Files
nimmerworld.eachpath.local/architecture/lemniscate.md
T

201 lines
8.6 KiB
Markdown

# Runtime Engine: Lemniscate
Scope: the runtime loop for the agent (Nyx). It is a server, not a UI and not a borrowed coding harness.
## Runtime decision
- Interactive harnesses (pi-agent-core, omp) are hosts: they wait for a human, end on session close, and do not run unattended.
- Nimmerworld implements its own loop (this engine) and runs indefinitely.
- Harnesses host nimmer-lib only. Clients never implement the agent brain; the runtime never borrows one.
## Topology
Figure-eight with two lobes and two crossings:
```
Gate Summary In Action Result
| |
v Second Crossing v
+--------------+ +--------------+
| LEFT LOBE | | RIGHT LOBE |
| (intake) | | (effect) |
+--------------+ +--------------+
^ First Crossing ^
| |
Gate Feedback Action Call (tool/actuator)
```
### NATS vocabulary (label map)
| Lemniscate label | NATS role | Transport | Delivery guarantee |
|---|---|---|---|
| sensor `pub <sensor.subject>` | publisher | core NATS | fire-and-forget: only subscribers open at publish time get a copy; late subscribers get nothing |
| `gate.summary`, `gate.decision` | subject (fan-in at L1, fan-out to gate layer) | core NATS | ephemeral, regenerated every pulse; nothing survives the cycle |
| L1 (LLM) | subscriber on `gate.summary`, publisher on `gate.decision` | core NATS client | a swappable bus client, any language |
| `action.call.<target>` | stream (JetStream) | durable | at-least-once delivery, replayable |
| actuator:`<t>` | own JetStream consumer on its subject | durable | tracks its own position; a dead actuator stalls no one |
| `action.result` | stream (JetStream) | durable | causality: `event_uuid -> cycle_uuid -> decision_uuid` |
| client:`<name>` | own durable consumer on `zone.state.>` / `action.result.>` | durable | replay-on-wake: reconnect and read what it missed |
Rules in NATS words: fan-out is the subject matcher, not code (wildcard
`*` = one token, `>` = one or more). Fan-in means many publishers onto
one subject; each consumer position is independent, consumers never
share state or corrupt each other. Idempotency is enforced by us, via
dedupe on `decision_uuid`/`cycle_uuid`, because JetStream guarantees
*at-least-once*, never *exactly-once*.
Subject naming (open): the engine above uses short subjects
(`gate.summary`, `action.call.<target>`). Our live bus streams use
`nimmerverse.register.>` / `nimmerverse.cycle.>`. Before any engine
traffic flows, pick ONE naming convention and map it here.
### Bus transport (NATS / JetStream)
The engine is host-agnostic: it never speaks to a model or an actuator directly,
everything crosses the bus as structured JSON.
```
sensor/camera -> pub <sensor.subject> (raw events; gates subscribe via catalog)
gate layer -> pub gate.summary (composer renders summary table, once per cycle)
L1 (LLM) -> sub gate.summary / pub gate.decision (core NATS, ephemeral, cycle-scoped)
L2 composer -> sub gate.decision
validate targets vs drives (table lookup, once, upstream of fan-out)
then pub action.call.<target> per validated action
actuator:<t> -> own JetStream consumer on action.call.<t>.>
client:<name> -> own durable consumer on zone.state.> / action.result.> (replay-on-wake)
```
Placement rules:
- `gate.summary` / `gate.decision` stay on core NATS: ephemeral, regenerated
every pulse; never block the pulse loop on storage I/O.
- Consequence subjects (`action.call.>`, `action.result`, causal records) are
JetStream streams: at-least-once, survive restart, replayable via
`event_uuid -> cycle_uuid -> decision_uuid`.
- Fan-out is the subject matcher, not code: the L2 composer publishes to
`action.call.<target>` from the catalog; each actuator consumes its own
subject and tracks its own position; a dead actuator stalls no one.
- Validation happens once, in the composer, before the fan-out; consumers
execute or nack, they never re-decide.
- Idempotency rides the envelope: every action carries `decision_uuid` +
`cycle_uuid`; actuators dedupe on them so redelivery cannot double-fire.
- The LLM endpoint is a swappable bus client. vLLM today, harness tomorrow,
zero engine changes.
- Left lobe: intake. Gate summaries, evidence pulls, thresholds.
- Right lobe: effect. Synthesized answer, action calls, client output.
- L1 (first crossing): admission decision. Open/hold gates; shift thresholds; request evidence.
- L2 (second crossing): synthesis. Produce answer and action-call list from the admitted content.
- The crossings are the only synchronous points. All state resolves atomically there.
## Loop semantics
- One traversal = one cycle. The lemniscate closes exactly once: L1 then L2.
- Between crossings, traversal is local; only the crossing touches shared state.
- The cycle is a transaction: no new input lands while it is open.
- Feedback: the L2 answer is the salience signal for the next gate summary. It is not a gate.
## Data flow
L1 inputs:
- gate summary table: gate, impulses, threshold, last_opened, drives, last_decision_cycle, tier.
- request parameters (evidence pull, k frames, probe query).
L1 outputs:
- gate decision: decision_uuid, opened gate list, parameters, threshold deltas.
L2 inputs:
- composed content from the opened gates (evidence rows, frames, text).
- catalog of valid output gates from each opened gate's `drives`.
L2 outputs:
- answer text.
- action-call list: action_uuid, target actuator/tool, parameters, tier.
- gesture accumulator update.
- salience signal for next summary.
Validation:
- output gate must be in the union of opened input gates' `drives`.
- invalid decision: cycle re-runs. Membership is a table lookup.
Gate summary envelope (what the model actually reads at L1):
```json
{
"cycle_uuid": "c-042",
"gates": [
{"gate": "k8s_alert", "impulses": 4, "threshold": 10,
"last_opened": 12, "drives": ["k8s_action"], "tier": "T1",
"last_decision_cycle": "c-041"}
],
"request_params": {"k_frames": 3, "probe": null}
}
```
L1 replies exactly:
```json
{
"decision_uuid": "d-117",
"opened": ["k8s_alert"],
"params": {"k_frames": 5},
"threshold_deltas": {"k8s_alert": -2}
}
```
## Pulse clock
```
elapsed += dt
while elapsed >= interval:
crossing()
elapsed -= interval
```
- Interval = mean(L1_RTT) + mean(L2_RTT) + safety_margin.
- RTT is derived from v7 timestamps (impulse to decision), not from wall-clock probes.
- Sliding-mean estimator with hysteresis. A single slow cycle must not stretch the interval.
- Probe latencies are metadata on their gates; they never enter the cycle interval.
- dt >= 1/pulse_rate would fire every tick once pulse_rate > 62; the accumulator prevents that.
## Map
| Lemniscate | Nimmerworld |
|---|---|
| Director | runtime |
| Zone / EmergentZone | bounded event episode |
| Crossing L1 | gate summary read; gate open/hold |
| Crossing L2 | composed input synthesized; answer + action call |
| Slot tokens | participants |
| Gesture accumulator | nimmer-lib trait algebra |
| update_all_zones | NATS event loop |
| Gate / Sensor | threshold channel upstream |
| UUID causality | event_uuid -> cycle_uuid -> decision_uuid |
| Intake tiers | summary poll + evidence pull |
| Policy layers | hard floor (operator) > presence policy (harness) > attention policy (runtime) |
| Gate tags | T0-T3 declared importance |
| Adaptive timing | round-trips regulate interval and animation duration |
| Register | declared sensors/gates/actuators/clients and `drives` links |
## Engine corrections
1. `_should_crossing` must accumulate time:
- `elapsed += dt; while elapsed >= interval: crossing`.
- `dt >= 1/pulse_rate` fires every tick once pulse_rate > 62 and makes the pulse rate meaningless.
2. Loops A/B must carry distinct semantics:
- the crossing callback must know which loop it is on.
- symmetric loops degrade the figure-eight into round-robin.
- resolved by the L1/L2 split (intake vs effect).
3. `Director.update_all_zones` must call `zone.execute_turn(dt)`, not `zone.run_crossing()`:
- otherwise the state machine never ticks.
- terminal reports must be collected, not dropped.
## Interfaces (v1)
- Core NATS (ephemeral): `gate.summary`, `gate.decision`.
- JetStream (durable, at-least-once): `action.call.<target>`, `action.result`, causal records.
- Register/catalog: sensors, gates, actuators, clients, `drives`. Catalog entry declares the class path; runtime imports an explicit registry and fails loud on a missing class.
- Clients hold own durable consumers on `zone.state.>` and `action.result.>` (replay-on-wake).
Version: 0.12 | Updated: 2026-11-06