Aiming for Jarvis, Creating D.A.N.I.

Tutorial 14: Rulesets and dynamic rules (or, How to teach your system to think for itself)

If you've been following along, you've got the basics of the language down. But let’s be real: at some point, you don't want to write a thousand if-else statements to control your hardware or your home automation. You want the system to be... aware. You want rules. In Skink, we handle this with ruleset and the RuleSource template. This is how I try to keep automated systems from doing something entirely stupid.

Static rules: The "When this, do that" approach

A ruleset is essentially a background loop that evaluates your logic continuously. It’s perfect for when you need a system to watch for changes without you having to manually poll every sensor every five milliseconds.

module main

import "std/time"

var temp: float = 20.0

ruleset ClimateRules {
    rule hot when temp > 30.0 {
        action: { print("hot") }
        priority: 1
    }
    rule cold when temp < 10.0 {
        action: { print("cold") }
        priority: 2
    }
}

fn main() -> int {
    ClimateRules.start()
    temp = 35.0
    
    // We need a brief nap to let the background loop catch the update
    time.SleepMs(50)
    ClimateRules.stop()
    return 0
}

A quick note on priority: keep the numbers low for the things that need to fire first. And yes, the ruleset runs in the background, so remember to start() and stop() it—otherwise, it'll happily keep eating your CPU cycles long after you've finished your coffee.

Dynamic sources: When rules go rogue (in a good way)

Static rules are fine, but what if your rules depend on objects that come and go? That's where the RuleSource template comes in. It lets you register an object as a "source" of rules, letting the system interact with things it didn't even know existed when you compiled it.

import "std/rules"
import "std/sync"
import "std/time"

struct SensorSource {
    mu: *sync.Mutex
    reading: float
    threshold: float

    pub fn NewSensorSource(threshold: float) -> SensorSource {
        return SensorSource{
            mu: sync.NewMutex(),
            reading: 0.0,
            threshold: threshold,
        }
    }

    pub fn SetReading(self: *SensorSource, r: float) {
        self.mu.Lock()
        self.reading = r
        self.mu.Unlock()
    }

    // The RuleSource contract
    pub fn name(self: *SensorSource) -> string {
        return "sensor"
    }
    pub fn start(self: *SensorSource) {}
    pub fn stop(self: *SensorSource) {}
    
    pub fn triggered(self: *SensorSource) -> bool {
        self.mu.Lock()
        r := self.reading
        self.mu.Unlock()
        return r > self.threshold
    }
    
    pub fn Action(self: *SensorSource) {
        print("sensor triggered")
    }
    
    pub fn Priority(self: *SensorSource) -> int {
        return 5
    }
}

Yes, it’s a bit more typing. But it’s the price we pay for decoupling our sensors from our main logic. Just remember that Action and Priority are capitalized—the ruleset expects specific method names because it's looking for that formal contract.

Lifecycle management

Treat your rulesets like you treat your house: don't leave the lights on when you aren't there.

start(): Fire up the engine.

stop(): Kill the background loop.

restart(): For when things get weird and you just need a fresh start.

reset(): Clear the board.

Exercises

  1. The Normal Zone: Add a rule that fires when temp is sitting comfortably between 10.0 and 30.0.
  2. Double Agent: Implement a second RuleSource and register both. See how they play together in the same ruleset.
  3. The Clean Slate: Try using ClimateRules.reset() after a stop() to make sure you're actually clearing the state.

Next

When you're ready to get into the really low-level stuff—handling the race conditions that keep us all up at night—we'll talk about Mutexes and waitgroups.