diff --git a/docs/interactivity-concept.md b/docs/interactivity-concept.md index 1a62bf0..4b164f0 100644 --- a/docs/interactivity-concept.md +++ b/docs/interactivity-concept.md @@ -17,6 +17,13 @@ > only** — no implementation, no PR > plan. It extends, and stays inside, the architecture in `CONCEPT.md`. +Version 0.10 — **reference set complete.** Tracing the last three reference +Lumens (workflow / defrag / map) in real LX-AST added only **interaction-surface** +items, no core-model gaps — the expected convergence: per-event payload schema +(`lumens-spec.md` §4.1), the `dataRef` `projection` read shape (§1.1), a `lookup` +associative-read helper (§2.3), and effect `debounceMs`/`coalesceKey` (§6.4). All +five reference Lumens now trace end-to-end; the only standing risk is LLM +emit-valid-LX reliability, measurable on a built L1 interpreter, not on paper. Version 0.9 — **acceptance-gate trace.** Hand-authoring the **full** arcade + ordering reference Lumens in real LX-AST (`docs/protocol/lumen-walkthroughs.md`) surfaced two load-bearing gaps invisible to fragment-level review — they only diff --git a/docs/lumens-spec.md b/docs/lumens-spec.md index 4c7d672..111f036 100644 --- a/docs/lumens-spec.md +++ b/docs/lumens-spec.md @@ -78,6 +78,16 @@ is a spike deliverable; where prose and schema disagree, the **schema wins**. > `list`/`record` (§1.1), transition result is a **delta-merge** with `{set:{}}` > the no-op (§2.5), and multi-field `{set:{a,b,…}}` (§2.2). +> **Rev 3.4 (reference set complete).** Tracing the remaining three reference +> Lumens (workflow / defrag / map) added only **interaction-surface** items — no +> core-model gaps, the expected convergence: per-event **payload schema** (§4.1 — +> `tap.hitId`, `drag.dropTarget`, `pan.dx/dy`, …); the **`dataRef` projection** +> shape + read rule (§1.1 — `loadData` fills a declared read-only projection, +> `{state:ref}` yields the value); a **`lookup`** associative-read std-lib (§2.3 — +> the dict access a `record` lacks); and effect **`debounceMs`/`coalesceKey`** +> (§6.4) for hot-path effects like persist-on-pan. All five reference Lumens are +> now expressible end-to-end ([`protocol/lumen-walkthroughs.md`](protocol/lumen-walkthroughs.md)). + A Lumen is the Omadia answer to "an interactive artifact": **declarative data, not code**, run by a small deterministic interpreter on Tier 1, generated and brokered agentically on Tiers 2/3, safe to share and to save as a preset @@ -178,17 +188,26 @@ type StateSchema = { | { type: 'list', of: StateLeaf, maxLen: number, init: unknown[] } | { type: 'record', fields: StateSchema, init: object } | { type: 'grid', w: number, h: number, of: StateLeaf, init?: unknown } // bounded 2D — boards, defrag cells - | { type: 'dataRef', init?: DataRef }; // §6.1 read-only projection handle + | { type: 'dataRef', projection: StateLeaf, init?: unknown }; // §6.1; loadData fills the declared projection }; ``` -A **`StateLeaf`** (the `of` of a `list`/`grid`, and the `fields` values of a -`record`) is **any** of the leaf shapes above — **including a nested `list`, -`record` or `grid`**. Nesting is therefore first-class: a board is a -`list>`, a shape table a `list>>`. Nesting depth and -every `maxLen`/`w`/`h` are bounded, so total size stays statically capped. -`ConstSchema` (§1.2) uses the identical leaf grammar with `value` in place of -`init`. +A **`StateLeaf`** (the `of` of a `list`/`grid`, the `fields` values of a `record`, +and the `projection` of a `dataRef`) is **any** of the leaf shapes above — +**including a nested `list`, `record` or `grid`**. Nesting is therefore +first-class: a board is a `list>`, a shape table a +`list>>`. Nesting depth and every `maxLen`/`w`/`h` are bounded, +so total size stays statically capped. `ConstSchema` (§1.2) uses the identical +leaf grammar with `value` in place of `init`. + +A **`dataRef` leaf** declares the **read-only `projection`** shape that +`loadData` (§6) fills with a size-capped projection of the underlying `DataRef`. +LX reads it **as an ordinary value of that shape** — `{state: ref}` yields the +projected `list`/`record` directly (the runtime resolves the handle; LX sees the +data); before `loadData` resolves it reads as the declared `init`/empty. A +`dataRef` projection is **read-only** — no `set`/`setAt` may target it; local +edits live in a separate `state` field merged in the `view` (the §6.3 optimistic +overlay idiom). Total serialised `state` size is capped (initial default **256 KB**, spike-tunable). `state` persists in canvas-state (`CONCEPT.md` §"State Model"); @@ -279,11 +298,15 @@ pick **`list`** when rows are added/removed (Tetris line-clear: clean ### 2.3 Standard library (whitelist, bounded, first-order) Scalar/collection helpers callable as `{call:name,…}`: `range` `len` `min` -`max` `clamp` `abs` `floor` `round` `mod` `concat` `flatten` `slice` `contains` -`indexOf` `keys` `values`, string ops (`upper` `lower` `pad` `fmt` `split` -`join`) and a small math set. `flatten` (one level) turns nested iteration into a -flat `list` — e.g. a board's per-row node lists into one `scene` draw-list — -without a `fold`/`concat` accumulator. Iteration is **not** here — it is the +`max` `clamp` `abs` `floor` `round` `mod` `pow` `concat` `flatten` `slice` +`contains` `indexOf` `lookup` `keys` `values`, string ops (`upper` `lower` `pad` +`fmt` `split` `join`) and a small math set. `flatten` (one level) turns nested +iteration into a flat `list` — e.g. a board's per-row node lists into one `scene` +draw-list — without a `fold`/`concat` accumulator. `lookup(list, keyField, key, +default)` is **associative read** — find the first record whose `keyField` +equals `key`, else `default`; it is the dictionary access a `record` lacks (a +`record` has only static-path read), so `override[id]` / `menu[sku]` is one call, +not an open-coded `fold`-find. Iteration is **not** here — it is the dedicated `map`/`filter`/`fold` binder nodes (§2.2), bounded by `state`/`const` size, so the gas bound stays a *static* property. **No `while`, no general recursion, no first-class functions.** `random()` and `now()` read host-seeded context values @@ -554,6 +577,30 @@ Rules (normative): The handshake (§13) reports **input modalities** (`touch`/`mouse`/`keyboard`/ `pen`) so Tier 2 composes the right affordances. +### 4.1 Event payload — the fields `{event:…}` reads + +Each event type delivers a **typed payload** to its transition; `{event: field}` +(§2.2) reads from it. The runtime computes the payload (hit-testing, gesture +arbitration) before the pure transition runs — the transition stays a pure +function of `(state, event)`. + +| Event | Payload fields | +|---|---| +| `tap` / `longPress` | `hitId` (topmost hit scene-node / primitive id, §3), `x`,`y` (buffer-native) | +| `drag` | `item` (the dragged element id), `dropTarget` (id under the release point, or `""`), `dx`,`dy` (total delta, buffer-native), `phase` (`start`∣`move`∣`end`) | +| `swipe` | `dir` (`up`∣`down`∣`left`∣`right`), `vx`,`vy` | +| `pinch` | `scale` (ratio since gesture start), `cx`,`cy` (focus) | +| `pointerMove` | `x`,`y` | +| `key` | `key` (the declared key) | +| `tick` / `timer` | `dtMs` (ms since last fire), `seq` | +| `wire` | the wired value (`{event:"value"}`) | + +`hitId`/`dropTarget` resolve via the same hit-testing as §3 (pointer → buffer +coords → topmost node `id`), so a `tap` on a marker or a `drag` onto a column +needs **no** per-node listener — one binding plus the payload's `hitId`/ +`dropTarget`. All fields are host-supplied and deterministic for a given recorded +input (replay-safe, §13.5). + --- ## 5. Cadence & motion @@ -702,11 +749,13 @@ capability call. ```ts type EffectBinding = { - on: TransitionName | { when: LXNode }; // fire after this transition runs, or when this state predicate flips true - call: CapabilityName; // a declared §6 capability - args: LXNode; // pure LX over (post-transition) state → the request payload - onResult?: TransitionName; // the brokered result re-enters here as an event - onError?: TransitionName; // failure / denial path (optimistic rollback) + on: TransitionName | { when: LXNode }; // fire after this transition runs, or when this state predicate flips true + call: CapabilityName; // a declared §6 capability + args: LXNode; // pure LX over (post-transition) state → the request payload + onResult?: TransitionName; // the brokered result re-enters here as an event + onError?: TransitionName; // failure / denial path (optimistic rollback) + debounceMs?: number; // coalesce rapid fires (e.g. persist-on-pan) — fire on settle + coalesceKey?: LXNode; // collapse in-flight calls sharing this key to the latest }; ``` @@ -726,7 +775,10 @@ declares *which* effects exist (structure); the **grant** is still Tier-2 policy consent (§0.5) — an effect binding can *request* a capability, never self-grant it. Effect bindings are subject to the same egress bounds as any capability call (§6 broker bounds): an effect bound to a `tick`-driven transition is rate/quota/ -max-in-flight capped like any other. +max-in-flight capped like any other. An effect bound to a **high-frequency** +transition (e.g. `persist` on a 60 fps `pan`) declares `debounceMs` to fire on +settle and/or a `coalesceKey` to collapse superseded in-flight calls — the clean +authoring form on top of the broker's hard egress cap. --- @@ -872,7 +924,12 @@ governs the host regardless of a Lumen's palette choice. non-recursive pure functions (a DAG, fully inlinable; no new compute power). - **Effects:** the `effects` binding list (§6.4) — the declarative trigger by which a *pure* transition causes a brokered capability call; result re-enters as - an event. Reuses the §6 broker, bounds and consent; no new transport. + an event; `debounceMs`/`coalesceKey` for hot-path effects. Reuses the §6 broker, + bounds and consent; no new transport. +- **Event payloads & data:** per-event payload fields (§4.1 — `hitId`, + `dropTarget`, `dx`/`dy`, …) the runtime supplies to `{event:…}`; the `dataRef` + `projection` schema (§1.1) that `loadData` fills and `{state:ref}` reads; the + `lookup` associative-read std-lib (§2.3). Additive, all host-resolved. - **Ports & wires:** `ports` and `expose` (published read-only interface) on primitives/Lumens, `wires` at container/canvas level (§7) — additive tree content, Tier-1-resolved. @@ -935,12 +992,16 @@ like `walkthroughs.md`. **Hand-authoring the reference set is the acceptance gate *before* implementation budget is committed.** It is the cheapest test that the binder forms, the kernel cut, the invariant/golden-trace loop and the transaction patterns actually hold -against a non-trivial artifact — not only in prose. **Two of the five are now -traced** in [`protocol/lumen-walkthroughs.md`](protocol/lumen-walkthroughs.md) -(the arcade game and the ordering flow); doing so converted "argued watertight" -into "tested watertight" and surfaced the Rev-3.3 gaps (`defs` §2.8, effect -bindings §6.4) that *only* appear in full artifacts, not fragments. The remaining -three (workflow, defrag-viz, map) are still to be traced before L0–L9. +against a non-trivial artifact — not only in prose. **All five are now traced** in +[`protocol/lumen-walkthroughs.md`](protocol/lumen-walkthroughs.md) (arcade, +ordering, workflow, defrag-viz, map); doing so converted "argued watertight" into +"tested watertight" and surfaced, in order of depth, the Rev-3.3 core gaps (`defs` +§2.8, effect bindings §6.4 — visible only with reuse-across-sites and +effects-from-pure-code) then the Rev-3.4 interaction-surface items (event payloads +§4.1, `dataRef` projection §1.1, `lookup` §2.3, effect debounce §6.4). The +remaining unknown — **LLM reliability emitting valid LX** — is unprovable on paper +and waits on a built L1 interpreter; an independent adversarial review is the +recommended next check. **Golden-trace authoring gate.** Because behaviour is deterministic, cold authoring SHOULD emit, alongside the Lumen, a few example `(input events → diff --git a/docs/protocol/lumen-walkthroughs.md b/docs/protocol/lumen-walkthroughs.md index f1cdd01..e3c197f 100644 --- a/docs/protocol/lumen-walkthroughs.md +++ b/docs/protocol/lumen-walkthroughs.md @@ -365,6 +365,174 @@ authoritative; determinism intact (replay re-feeds the recorded result). --- +--- + +## C — Interactive workflow Lumen (Kanban triage) + +Stresses: **data-driven** view from `loadData`, **optimistic overlay** (loadData +is read-only), drag-and-drop, write-back via `effects` (§6.4). View is **ordinary +primitives**, not `scene` — confirming `view → primitive tree` (§1). + +`loadData` hands a **read-only** projection, so a card's status **cannot** be +mutated in place. The canonical pattern: keep edits in a **separate local overlay** +and merge them in the view (the §6.3 optimistic+reconcile idiom, made concrete). + +```jsonc +"capabilities": [ + { "cap":"loadData", "scope":{ "dataRef":"cards" } }, + { "cap":"writeData", "scope":{ "target":"issues", "writeCapabilities":["update"] } } +], +"state": { + "cards": { "type":"dataRef", "projection":{ "type":"list","maxLen":500, + "of":{"type":"record","fields":{ + "id":{"type":"string","maxLength":32}, + "title":{"type":"string","maxLength":120}, + "status":{"type":"string","maxLength":16} } } } }, // ← R2: projection shape + "override": { "type":"list","maxLen":500, + "of":{"type":"record","fields":{ + "id":{"type":"string","maxLength":32}, + "status":{"type":"string","maxLength":16} } }, "init":[] } // local optimistic edits +}, +"const": { "columns": { "type":"list","of":{"type":"string","maxLength":16},"value":["todo","doing","done"] } } +``` + +`effectiveStatus(id, fallback)` — overlay first, else the loaded value. It must +look up `override` by `id`; **records have no dynamic-key access**, so the lookup +is a `fold`-find over a list of pairs (R3): + +```jsonc +"defs": { + "effStatus": { "params":["id","fallback"], "body": + { "fold":{"state":"override"}, "as":"o", "acc":"r", "init":{"var":"fallback"}, + "body":{ "if":{ "==":[{"var":"o","path":"id"},{"var":"id"}] }, "then":{"var":"o","path":"status"}, "else":{"var":"r"} } } } +} +``` + +`moveCard` — a **drag-and-drop**: drag card `id` onto a column. This needs the +**dropped item** and the **drop target** from the event payload — neither is +defined in §4 (R1). Written here against the payload R1 *adds*: + +```jsonc +"moveCard": { // event payload: {item:, dropTarget:} + "set":{ "override": + { "concat":[ + { "filter":{"state":"override"}, "as":"o", "body":{ "!=":[{"var":"o","path":"id"},{"event":"item"}] } }, // drop old entry + { "list":[ { "record":{ "id":{"event":"item"}, "status":{"event":"dropTarget"} } } ] } ] } } // add new +}, +"effects": [ + { "on":"moveCard", "call":"writeData", + "args":{ "record":{ "target":{"lit":"issues"}, "op":{"lit":"update"}, + "id":{"event":"item"}, "status":{"event":"dropTarget"} } }, + "onResult":"moveConfirmed", "onError":"moveReverted" } +] +``` + +✅ `effects` (§6.4) carries the optimistic write; `onError` reverts the overlay. +✅ `view` is primitives: a `row` of `column`s, each a `filter` of cards whose +`effStatus` matches, mapped to `card` primitives (drag-source bound). +🔴 **R1 (pointer-event payload undefined):** `moveCard` reads `{event:"item"}` +and `{event:"dropTarget"}` — but §4 never says a `drag`/`drop` event carries them. +🟡 **R2 (loadData projection shape):** used `"projection":{…}` on the `dataRef` +leaf and read `{state:"cards"}` as the list directly — neither is in §1.1/§6. +🟡 **R3 (no dynamic map access):** `effStatus` is an O(n) `fold`-find because a +`record` has no dynamic-key read. + +## D — Defrag-style visualisation Lumen + +Stresses: `loadData` → `scene` grid, a `tick` animation cursor, many-colour fills. + +```jsonc +"capabilities": [ { "cap":"loadData", "scope":{ "dataRef":"extents" } } ], +"colorMode": "free", // a defrag map wants many distinct block colours (R-colour validated) +"state": { + "cells": { "type":"list","maxLen":4096, "of":{"type":"int","min":0,"max":255}, "init":[] }, // cell → fileIndex (0=free) + "cursor": { "type":"int","min":0,"max":4096,"init":0 }, + "extents":{ "type":"dataRef", "projection":{ "type":"list","of":{"type":"record","fields":{ + "file":{"type":"int"},"blocks":{"type":"int"} } } } } // R2 again +}, +"defs": { + "firstGap": { "params":["cells","upto"], "body": + { "fold":{ "call":"range","args":[{"var":"upto"}] }, "as":"i", "acc":"g", "init":{"var":"upto"}, + "body":{ "if":{ "and":[{ "==":[{"var":"g"},{"var":"upto"}] }, + { "==":[{ "at":{"var":"cells"},"index":[{"var":"i"}],"default":{"lit":1} },{"lit":0}] }] }, + "then":{"var":"i"}, "else":{"var":"g"} } } } +} +``` + +`tick` compacts one block per frame: move the highest used block into the first +gap (both are `fold` scans — bounded, no kernel): + +```jsonc +"tick": { + "let":{ "g":{ "apply":"firstGap","args":[{"state":"cells"},{"state":"cursor"}] } }, "in": + { "if":{ ">=":[{"var":"g"},{"state":"cursor"}] }, + "then":{ "set":{} }, // compacted: no-op + "else": + { "let":{ "src":{ "apply":"lastUsed","args":[{"state":"cells"},{"state":"cursor"}] } }, "in": + { "set":{ + "cells":{ "setAt": + { "setAt":{"state":"cells"}, "index":[{"var":"g"}], + "to":{ "at":{"state":"cells"},"index":[{"var":"src"}],"default":{"lit":0} } }, + "index":[{"var":"src"}], "to":{"lit":0} }, // two functional writes, nested + "cursor":{ "+":[{"var":"g"},{"lit":1}] } } } } } +} +``` + +✅ `setAt` nested (move = write dest then clear src), `fold`-scan helpers via +`defs`, `tick` cadence, `free` colour for many distinct file blocks. +🟡 **R2** recurs (the `extents` projection drives the initial `cells` layout). +✅ Otherwise fully expressible — gas trivial (two 4096-bounded folds per frame is +the worst case; well under budget). + +## E — Interactive map Lumen + +Stresses: `tiles` + `loadData` capabilities via **`effects`**, `scene` sprites, +camera pan/zoom, marker hit-testing, `persist`. + +```jsonc +"capabilities": [ + { "cap":"tiles", "scope":{ "provider":"osm" } }, + { "cap":"loadData", "scope":{ "dataRef":"places" } }, + { "cap":"persist", "scope":{ "key":"viewport" } } +], +"state": { + "view": { "type":"record","fields":{ "cx":{"type":"number"},"cy":{"type":"number"},"z":{"type":"int","min":1,"max":19} }, + "init":{"cx":0,"cy":0,"z":4} }, + "tiles": { "type":"list","maxLen":64, "of":{"type":"record","fields":{ + "x":{"type":"int"},"y":{"type":"int"},"img":{"type":"dataRef"} } }, "init":[] }, + "places": { "type":"dataRef", "projection":{ "type":"list","of":{"type":"record","fields":{ + "id":{"type":"string","maxLength":32},"lat":{"type":"number"},"lon":{"type":"number"} } } } }, + "sel": { "type":"string","maxLength":32,"init":"" } +}, +"transitions": { + "pan": { "set":{ "view":{ "record":{ "cx":{ "+":[{"state":"view","path":"cx"},{"event":"dx"}] }, + "cy":{ "+":[{"state":"view","path":"cy"},{"event":"dy"}] }, + "z":{"state":"view","path":"z"} } } } }, // R1: drag payload dx/dy + "selectMarker": { "set":{ "sel":{"event":"hitId"} } } // R1: tap payload hitId +}, +"events": [ + { "on":"drag", "run":"pan" }, + { "on":"pinch", "run":"zoom" }, + { "on":"tap", "run":"selectMarker" } // payload.hitId = topmost scene node hit (§3) — R1 +], +"effects": [ + { "on":"pan", "call":"tiles", "args":{ "apply":"tileRange","args":[{"state":"view"}] }, "onResult":"tilesReady" }, + { "on":"pan", "call":"persist", "args":{"state":"view"}, "debounceMs":500 } // R4: coalesce hot effect +] +``` + +✅ **`effects` validated in a second context** (tiles fetch on pan; result patched +into `state.tiles`). ✅ scene `sprite` tiles + marker hit-testing → `{event:"hitId"}` +→ `sel`. ✅ `tileRange(view)` is a `def` doing the z/x/y arithmetic (fiddly but +pure LX — `floor`/`pow`/`mod`; no kernel needed). +🔴 **R1** again — `pan` reads `{event:"dx/dy"}`, `selectMarker` reads +`{event:"hitId"}`; the payloads must be defined (§4). +🟡 **R4 (hot-effect coalescing):** `persist`-on-`pan` would fire every drag frame; +needs a declarative `debounceMs` (broker rate-limiting catches egress, but +debounce is the clean authoring form). + +--- + ## Findings — what the full test changed | # | Finding | Sev | Status | @@ -375,19 +543,27 @@ authoritative; determinism intact (replay re-feeds the recorded result). | **G4** | Transition **return semantics** unstated — full state vs delta-merge; how to express "no change". | 🟡 | **fixed** — §2 states delta-merge; `{set:{}}` = no-op | | **G5** | Multi-field `{set:{a,b,…}}` used everywhere; §2.2 showed only single-key. | 🟡 | **fixed** — §2.2 allows a path→expr map | | **G6** | `at`-then-field composition (`cart[i].qty`) undefined; bind-the-row works but is verbose. | 🟢 | noted; bind-row idiom documented, optional `at.path` later | +| **R1** | **Pointer-event payload schema undefined** — §4 declares event *types* but not the *fields* each carries (`tap`→hit node id + coords, `drag`/`drop`→source + drop-target + delta, `pinch`→scale/focus). `{event:field}` had no defined fields. Surfaced by every interactive Lumen (kanban drop, map pan/tap). | 🔴 high | **fixed** — §4.1 per-event payload schema | +| **R2** | **`loadData` projection read shape** — a `dataRef`/loadData state leaf is a "handle"; how LX reads it as a value, and its declared shape, were unstated. Every data-driven Lumen needs it. | 🟡 | **fixed** — §1.1 `dataRef` carries a `projection` schema; `{state:field}` yields the read-only value (empty until resolved); §6 | +| **R3** | **No dynamic map/dict access** — a `record` has only static-path read, so `override[id]`/`menu[sku]` lookups are O(n) `fold`-finds. | 🟡 | **fixed** — `lookup` std-lib (assoc-find over a list-of-pairs by key) §2.3; idiom documented | +| **R4** | **Hot-effect coalescing** — `persist`-on-`pan` fires every drag frame; broker rate-limiting catches egress but a declarative debounce is the clean authoring form. | 🟢 | **fixed** — optional `debounceMs`/`coalesceKey` on `EffectBinding` §6.4 | **What held (rev 3.x validated by the complete trace):** the `map`/`filter`/`fold` binder nodes + `{var}` (every transition), `at`/`setAt` over `list` (lock, -cart), `const` table (shapes), `idx` + `flatten` (render, cart-find), native -`aggregate` kernel (total), `colorMode:'brand'` + `palette` (kiosk), invariants, -§6.3 local-first (cart touches no capability). **Gas was never the constraint** — -the costliest frame (lock+clear) is a few hundred ops against the 50 000 budget. - -**Verdict.** The model expresses both the compute-heavy and the -capability-heavy reference cases — but only after **two** additions a -fragment-level review could not have found, because they only appear when logic is -**reused across sites** (G1) and when an effect must be **triggered from pure -code** (G2). With §2.8 and §6.4 added, the five-Lumen conformance set is -expressible; the remaining risk is LLM authoring reliability (mitigated by -`defs` reducing duplication, invariants, and the golden-trace gate), to be -measured on a built L1 interpreter. +cart, defrag move), `const` table (shapes/columns), `idx` + `flatten` (render), +native `aggregate` kernel (total), `colorMode` `theme`/`brand`/`free` (arcade / +kiosk / defrag), `defs` + `{apply}` and `effects` from rev 3.3 (every later +Lumen leans on them), `view`→**primitives** (kanban) as well as `scene` (map, +defrag), invariants, §6.3 local-first + optimistic overlay. **Gas was never the +constraint** across all five. + +**Verdict.** All five reference Lumens are expressible. The two compute/capability +axes (rev 3.3: `defs`, `effects`) were the deep gaps; the workflow/defrag/map pass +added only **interaction-surface** specifics — event payloads (R1), the data-read +shape (R2), a dict-lookup helper (R3), effect debounce (R4) — none touching the +core model. This is the expected **convergence**: each pass finds shallower things +(structural → naming → abstraction → payload/IO detail). The standing risk is +unchanged and unprovable on paper: **can an LLM emit valid LX reliably** — to be +measured on a built L1 interpreter, with `defs`/invariants/golden-trace as the +net. An independent adversarial pass (Codex, as for rev 2) is the recommended +next check before implementation budget.