204 lines
8.7 KiB
Markdown
204 lines
8.7 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: the subject registry owns the naming decision, the
|
|
register is law, per
|
|
[register.md, Subject registry](./register.md). Short subjects
|
|
(`gate.summary`, `action.call.<target>`) are internal identifiers;
|
|
their wire names resolve through the registry map (today: `nimmerverse.`
|
|
prefix, live streams already follow it). Code never hard-codes a
|
|
subject string.
|
|
|
|
### 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.13 | Updated: 2026-11-06
|