Skip to content

Smart Farm Walkthrough

A distributed smart-farm system where soil, weather, and crop sensor agents stream observations into one shared derived model. A controller derives irrigation candidates by joining facts across the three sensor namespaces, and a single named invariant prevents irrigating any plot under frost risk. Independently authored sensor agents coordinate stigmergically — through the derived facts they share — without orchestration glue or workflow code.

This walkthrough is the cleanest demonstration of the shared-derived-model coordination pattern with a named-invariant safety boundary:

sensor.soil + sensor.weather + sensor.crop
-> soil.dry, weather.frost_risk, crop.high_demand (monotonic enrichment)
-> irrigation.candidate (cross-agent join)
-> intent.irrigate (gated by no_irrigate_during_frost)

It covers the full JacqOS pipeline with a focus on CALM-monotonic distributed derivation:

  1. Observations arrive as JSON events (sensor.soil, sensor.weather, sensor.crop, effect.receipt)
  2. Mappers project sensor payloads into typed atoms with no side effects
  3. Rules derive monotonic enrichment per namespace, monotonic cross-agent joins, and a small non-monotonic irrigation decision
  4. Invariants enforce no_irrigate_during_frost as a structural backstop the controller can never bypass
  5. Intents derive bounded irrigation calls only when the safety boundary holds
  6. Fixtures prove safe irrigation, frost-blocked irrigation, and ambiguous-effect reconciliation under retracted telemetry
jacqos-smart-farm/
jacqos.toml
ontology/
schema.dh # 16 relations across 4 namespaces
rules.dh # Stratified derivation + 1 named invariant
intents.dh # intent.irrigate
mappings/
inbound.rhai # 4 observation kinds -> atoms
fixtures/
happy-path.jsonl
happy-path.expected.json
frost-path.jsonl
frost-path.expected.json
contradiction-path.jsonl
contradiction-path.expected.json
generated/
... # verification, graph, and export artifacts

jacqos.toml declares the app identity, the irrigation API binding under recorded replay, and Studio metadata:

app_id = "jacqos-smart-farm"
app_version = "0.1.0"
[paths]
ontology = ["ontology/*.dh"]
mappings = ["mappings/*.rhai"]
prompts = ["prompts/*.md"]
schemas = ["schemas/*.json"]
fixtures = ["fixtures/*.jsonl"]
helpers = ["helpers/*.rhai"]
[capabilities]
http_clients = ["irrigation_api"]
models = []
timers = false
blob_store = false
[capabilities.intents]
"intent.irrigate" = { capability = "http.fetch", resource = "irrigation_api" }
[resources.http.irrigation_api]
base_url = "https://farm-controller.example.invalid"
credential_ref = "IRRIGATION_API_TOKEN"
replay = "record"

There is no LLM in this app. Every observation is structured sensor telemetry, every intent is a bounded HTTP call, and the safety property holds entirely from the rule shape and one named invariant.

The schema partitions relations into four namespaces, each owned by a different agent. The owner of a namespace is the only producer of facts in it:

-- Soil agent — runs on field sensor nodes.
relation soil.reading(zone: text, moisture_pct: int, ph: int)
relation soil.dry(zone: text)
relation soil.acidic(zone: text)
relation soil.healthy(zone: text)
-- Weather agent — runs on weather station nodes.
relation weather.reading(station: text, temp_c: int, humidity_pct: int, wind_kph: int)
relation weather.rainfall(station: text, mm: int)
relation weather.hot(station: text)
relation weather.dry_period(station: text)
relation weather.frost_risk(station: text)
-- Crop agent — runs on camera/scanner nodes.
relation crop.scan(zone: text, crop_type: text, growth_stage: text)
relation crop.water_demand(zone: text, crop_type: text, demand: text)
relation crop.frost_sensitive(zone: text)
relation crop.high_demand(zone: text)
-- Irrigation agent — runs on central hub. Cross-agent joins here.
relation irrigation.candidate(zone: text)
relation irrigation.frost_protect(zone: text)
relation irrigation.skip(zone: text)
relation irrigation.irrigated(zone: text)
relation irrigation.unsafe_frost_irrigate(zone: text)
relation intent.irrigate(zone: text)

The namespace partition is the architecture. Every soil node only writes soil.* facts. Weather nodes only write weather.*. The hub joins them. Under the CALM theorem, every cross-agent join in the monotonic strata is consistent under set-union without coordination.

Each sensor agent emits a different observation kind and the mapper projects it into the corresponding namespace atoms:

fn map_observation(obs) {
let body = parse_json(obs.payload);
if obs.kind == "sensor.soil" {
return [
atom("soil.zone", body.zone),
atom("soil.moisture_pct", body.moisture_pct),
atom("soil.ph", body.ph),
];
}
if obs.kind == "sensor.weather" {
let atoms = [
atom("weather.station", body.station),
atom("weather.temp_c", body.temp_c),
atom("weather.humidity_pct", body.humidity_pct),
atom("weather.wind_kph", body.wind_kph),
];
if body.contains("rainfall_mm") {
atoms.push(atom("weather.rainfall_mm", body.rainfall_mm));
}
return atoms;
}
if obs.kind == "sensor.crop" {
return [
atom("crop.zone", body.zone),
atom("crop.type", body.crop_type),
atom("crop.growth_stage", body.growth_stage),
];
}
if obs.kind == "effect.receipt" {
return [
atom("effect.kind", body.kind),
atom("effect.zone", body.zone),
atom("effect.result", body.result),
];
}
[]
}

The mapper is a pure function. It has no shared state, no network access, and no view of previously derived facts — exactly the properties that let edge nodes evaluate their own namespace and sync results to the hub by set union.

Step 4: Derive Sensor Enrichment, Cross-Agent Joins, And Irrigation

Section titled “Step 4: Derive Sensor Enrichment, Cross-Agent Joins, And Irrigation”

Stratum 0 lifts atoms into typed relations. Stratum 1 enriches each namespace independently:

rule soil.dry(zone) :-
soil.reading(zone, moisture, _),
moisture < 30.
rule weather.hot(station) :-
weather.reading(station, temp, _, _),
temp > 32.
rule weather.frost_risk(station) :-
weather.reading(station, temp, _, _),
temp < 3.
rule crop.water_demand(zone, crop_type, "high") :-
crop.scan(zone, crop_type, "flowering").
rule crop.water_demand(zone, crop_type, "high") :-
crop.scan(zone, crop_type, "fruiting").
rule crop.frost_sensitive(zone) :-
crop.scan(zone, _, "flowering").
rule crop.high_demand(zone) :-
crop.water_demand(zone, _, "high").

Every stratum-1 rule joins facts only inside its own namespace. A soil node can derive soil.dry knowing nothing about weather. A weather node can derive weather.frost_risk knowing nothing about crops. The CALM theorem says these monotonic derivations remain consistent across distributed nodes without a coordinator.

Stratum 2 is the stigmergic join — the hub reads facts from every namespace and writes new facts to its own:

rule irrigation.candidate(zone) :-
soil.dry(zone),
crop.high_demand(zone).
rule irrigation.frost_protect(zone) :-
crop.frost_sensitive(zone),
weather.frost_risk("main").

Notice that irrigation.candidate is still monotonic — it only adds facts. Independent edge nodes can sync their soil.dry and crop.high_demand facts to the hub at any pace and the irrigation hub will derive a consistent set of candidates as facts arrive.

Stratum 3 is the only place negation appears, and it is exactly two rules:

rule irrigation.skip(zone) :-
irrigation.candidate(zone),
weather.rainfall("main", mm),
mm >= 15.
rule assert irrigation.irrigated(zone) :-
atom(obs, "effect.kind", "irrigate"),
atom(obs, "effect.zone", zone),
atom(obs, "effect.result", "succeeded").

The unsafe-condition relation and the named invariant close the safety loop:

rule irrigation.unsafe_frost_irrigate(zone) :-
irrigation.candidate(zone),
weather.frost_risk("main"),
not irrigation.frost_protect(zone).
invariant no_irrigate_during_frost(zone) :-
count irrigation.unsafe_frost_irrigate(zone) <= 0.

If the world ever derives an irrigation candidate while frost risk is active and no frost-protection intent is in flight, the invariant fails and the evaluator halts. The unsafe combination is structurally impossible to execute.

Step 5: Derive Outbound Effects Only From Stable State

Section titled “Step 5: Derive Outbound Effects Only From Stable State”

Irrigation is the only outbound effect, and it is gated on the negation-bearing skip relation and on whether the hub has already irrigated:

rule intent.irrigate(zone) :-
irrigation.candidate(zone),
not irrigation.skip(zone),
not irrigation.irrigated(zone).

There is no path from a single sensor reading directly to intent.irrigate. Every irrigation effect requires a soil-dry fact, a crop-high-demand fact, the absence of a recent-rainfall skip, and no prior irrigation receipt — and even then the named invariant adds a frost-risk veto.

This example ships three fixtures, each exercising a different facet of the pipeline.

Three zones report sensor readings. Two are dry with high-demand crops; one is wet with low-demand crops. The hub derives two irrigation.candidate facts, fires two intent.irrigate actions, and receives two effect receipts. No invariant fires.

A dry zone with a frost-sensitive crop reports a sub-3°C reading. Irrigation would otherwise be a candidate, but weather.frost_risk("main") flips, irrigation.unsafe_frost_irrigate(zone) fires, and no_irrigate_during_frost blocks the intent. The fixture’s expected output records the invariant violation alongside the candidate fact, proving the boundary held.

A weather sensor reports a hot, dry reading and is later retracted by a wet, cool reading from the same station. The contradiction surfaces in the worldview without halting derivation, demonstrating ambiguous-effect reconciliation: the operator inspects the contradiction in Studio, decides which reading is canonical, and replays from there. The earlier reading remains visible as contradiction history with full provenance — this is how distributed sensor systems handle clock skew, network reorderings, and node disagreement without losing the evidence trail.

Open the demo with jacqos studio --lineage smart-farm and the bundled happy-path fixture loads. Switch fixtures from the timeline picker to walk every scenario:

  • Two zones irrigated -> the Done tab shows two rows like north-field: irrigated, succeeded and greenhouse: irrigated, succeeded. Drill in and the inspector walks back through intent.irrigate, the irrigation.candidate join, the soil.dry fact, and the original sensor.soil observation.
  • Frost-blocked irrigation -> the Blocked tab shows the no_irrigate_during_frost invariant violation. The drill inspector names the candidate fact, the active weather.frost_risk, and the missing irrigation.frost_protect. No irrigation effect fires.
  • Contradicted telemetry -> the contradicted weather reading appears in the timeline with its retraction. Activity surfaces the supersession; no irrigation fires for the contradicted zone until the reconciliation completes.

This is stigmergic coordination at its smallest:

  • three sensor agents own three namespaces and never reference each other’s code
  • the hub joins their facts and contributes to a fourth namespace
  • every monotonic stratum is CALM-safe — edge nodes can run independently and merge by set union
  • one named invariant is the entire safety surface for the irrigation decision

That is how you build a distributed control loop where adding a new sensor type means writing one mapper and one stratum-1 rule — never editing the controller.

The smart-farm pattern fits every distributed sensor-fusion or multi-source enrichment domain:

  • Energy-grid load balancing — solar, wind, and load sensor agents feed a dispatch controller; named invariants prevent dispatching power above grid capacity
  • Warehouse robotics — bay sensors, conveyor sensors, and inventory scanners feed a routing controller; named invariants prevent collisions and overloads
  • Healthcare device monitoring — vitals, infusion pumps, and lab feeds enrich a unified patient model; named invariants prevent contraindicated medication actions
  • Smart-building HVAC — temperature, humidity, occupancy, and weather agents feed a comfort controller; named invariants prevent freeze-ups and humidity excursions

To start building, scaffold a starter app:

Terminal window
jacqos scaffold --pattern multi-agent my-farm-app