modules/rules/ — rules and alerts (layer 1)#
The first layer-1 module (spec v0.1 §11): rules written as CEL conditions (open-questions Q10) over a context built from the log, each evaluation written back as a decision event (§7.7) that names the observations it read, the rule and its version, the condition as written, the conclusion and the trust level. Everything here runs as advisory: it says, it never acts.
The first rule is the frost watch: at dusk, will tonight's forecast low reach the farm's warning threshold? Backtested as §11 requires, on NOAA's forecasts for McAllister Farm against its SCAN station, October 2025 to April 2026, through CEL:
| warn at | frosts warned | missed | false alerts |
|---|---|---|---|
| +1 °C | 38 of 38 | 0 | 8 |
| 0 °C | 38 of 38 | 0 | 6 |
| −1 °C | 37 of 38 | 1 | 2 |
| −2.2 °C | 34 of 38 | 4 | 0 |
The same numbers the Python scoring gave when the threshold was chosen (docs/notes/forecasts.md) — the rule language changes how a rule is written and logged, not what it concludes.
pip install -r modules/rules/requirements.txt # cel-python and starlark-pyo3, Apache-2.0
python3 modules/rules/frost_backtest.py --cache NBM_CACHE --from 2025-10-15 --to 2026-04-30
cd modules/rules && python3 -m unittest discover -p 'test_*.py'
Regimens#
A condition is one expression. A regimen is a sequence: water bed 3, wait for it to soak
in, check the probe saw it. Q10 chose Starlark for these, and
regimen.py runs them on starlark-pyo3 (Apache-2.0), a binding to the Rust
implementation. A regimen is a file defining plan(ctx, params) that returns steps built
with say(...), wait(minutes) and check(condition, ...). Starlark plans, CEL checks,
and the runner keeps time. Each step's outcome is a decision (§7.7) carrying the regimen's
version and the SHA-256 of its source, and a run's plan is logged when it starts, so a
restarted runner carries on without planning again on data that has since changed.
A regimen cannot reach the world, and the tests hold it to that. open, load,
import, while, recursion and a hundred-million-step loop are each refused: the last by
an instruction budget of about a million. A plan that is not a plan is refused too: an
unknown step, a malformed message key, a wait into the past or past fourteen days, a
condition that does not compile as CEL. Nothing acts while commands wait on §6, so every
step is advisory. The first regimen is the soil advisory's
watering check.
A decision anyone can read#
A decision carries enough to be said and placed without the module that made it, so a notifier or a page can carry any module's alerts without importing any module:
- Its sentence.
message.keyis chosen when the rule is judged — a rule'smessage_variantspick the sentence that fits ("not refilling, and the stock are not drinking" has no hours left to give) — andmessage.paramsfill it. - What it is about.
subjectnames an entity andmessage.subject_paramthe slot its label fills, in the reader's language (rules.render(decision, language, state)). - The things it names. A parameter listed in
message.key_paramsholds a language-pack key, not a word, and is said in the reader's language when rendered: the sowing advisory's crop issowing.crop.bean, said "beans" or "frijoles". - Its units.
message.unitsnames the parameters that carry a unit, as UCUM codes.rules.render(decision, language, state, units="us")says them in the reader's system (UNIT_SYSTEMS: "metric", "us") when the module wrote a sentence for it, under its key with the system after a dot (sowing.cold.us,zoneflow.low.metric) and in the reader's own language; otherwise it says the message as written, so a number never meets another unit's word. - Which question it answers.
topicdefaults to the rule and its subject: "is the north trough empty?" The soil advisory answers "what does bed 3 need today?" whichever rule held, so it names its own. The latest decision on a topic is the standing answer (rules.standing), andvalid_untilsays when an answer goes stale: tonight's frost watch stands until dawn, a morning's soil advice for a day, a raised trough alert until the monitor clears it.
Code AGPL-3.0-or-later (LICENSE), like the reference server; strings in
packs/ under the rules. prefix. It imports layer zero (the SDK and
tools/nbm_verify.py for the backtest) and nothing in layer zero imports it.