diff --git a/docs/interactivity-concept.md b/docs/interactivity-concept.md index db3a263..1a62bf0 100644 --- a/docs/interactivity-concept.md +++ b/docs/interactivity-concept.md @@ -17,6 +17,17 @@ > only** — no implementation, no PR > plan. It extends, and stays inside, the architecture in `CONCEPT.md`. +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 +appear when logic is **reused across sites** and when an effect fires from **pure** +code: **`defs`/`apply`** (named, non-recursive pure helpers; `lumens-spec.md` +§2.8) and **effect bindings** (the declarative trigger by which a pure transition +invokes a capability — the output dual of `events`, realising the §9.1 "Lumen +output → capability" intent; `lumens-spec.md` §6.4). Plus clarifications: +`StateLeaf` includes nested `list`/`record`, transition result is a delta-merge, +multi-field `set`. Confirms the §13 discipline — *trace before you build*; both +gaps would otherwise have been found mid-implementation. Version 0.8 — **colour authority.** A Lumen's *own content* is **not** palette-locked: the agent picks `colorMode: 'theme'|'brand'|'free'` (+ a declared `palette`) from the **request + embedding context** (`lumens-spec.md` §3.1). diff --git a/docs/lumens-spec.md b/docs/lumens-spec.md index de25186..4c7d672 100644 --- a/docs/lumens-spec.md +++ b/docs/lumens-spec.md @@ -66,6 +66,18 @@ is a spike deliverable; where prose and schema disagree, the **schema wins**. > free-colour content is the author's responsibility (44 pt hit-targets and > reduced-motion still apply; those are interaction-safety, not colour). +> **Rev 3.3 (acceptance-gate trace).** Hand-authoring the **full** reference +> Lumens (an arcade game + a transactional ordering flow) in real LX-AST — +> [`protocol/lumen-walkthroughs.md`](protocol/lumen-walkthroughs.md) — surfaced +> two load-bearing gaps the earlier *fragment* passes could not, because they only +> appear when logic is **reused across sites** and when an effect must fire from +> **pure** code: (G1) no author-defined helper → add `defs` + `{apply}`, +> non-recursive pure functions (§2.8); (G2) no way for a pure transition to invoke +> a capability → add **effect bindings** (§6.4), the output dual of `events`. Plus +> clarifications the trace forced: `StateLeaf` defined to include nested +> `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). + 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 @@ -122,13 +134,15 @@ type Lumen = { id: string; // stable; patches/wires/beam target it state: StateSchema; // §1.1 — typed, bounded, serialisable (mutable memory) const?: ConstSchema; // §1.2 — typed, bounded, immutable author-time tables (not serialised) - transitions: Record; // §2 — pure (state,event)->state + defs?: Record;// §2.8 — named, parameterised, non-recursive pure helpers + transitions: Record; // §2 — pure (state,event)->state, delta-merge result view: LXNode; // §2,§3 — pure state -> primitive/scene tree events: EventBinding[]; // §4 — declared inputs -> transitions cadence?: CadenceSpec; // §5 — default "reactive" colorMode?: 'theme'|'brand'|'free';// §3.1 — default 'theme'; opens colour for THIS Lumen's content only palette?: PaletteSpec; // §3.1 — declared brand colours (used with colorMode 'brand') capabilities?: CapabilityRequest[]; // §6 — default-deny doors out + effects?: EffectBinding[]; // §6.4 — declared capability triggers (the output dual of `events`) ports?: PortSpec[]; // §7 — typed inputs/outputs for explicit wiring expose?: ExposeSpec[]; // §7 — published read-only view-state (the ambient-readable interface) invariants?: LXNode[]; // §2.7 — boolean assertions checked after every transition @@ -137,14 +151,17 @@ type Lumen = { ``` A Lumen is valid iff: its `state`/`const` conform to §1.1/§1.2, every `LXNode` in -`transitions`/`view`/`invariants` passes the §2 AST whitelist + static bounds -check (every `{call}` target in §2.3, every `{kernel}` target in §2.6, every -`{const}`/`{state}` path resolving against the declared schema), every -`EventBinding` names a declared transition and a §4 event, every -`CapabilityRequest` names a §6 catalog capability, every `invariants` entry is a -boolean `LXNode`, and every `PortSpec` / `ExposeSpec` is §7-typed. Any failure → -the Lumen is rejected wholesale with `surface_error` (scope = the Lumen `id`); it -never partially renders. +`transitions`/`view`/`invariants`/`defs` passes the §2 AST whitelist + static +bounds check (every `{call}` target in §2.3, every `{kernel}` target in §2.6, +every `{apply}` target a §2.8 `def` with matching arity, every +`{const}`/`{state}` path resolving against the declared schema), the `defs` call +graph is **acyclic** (§2.8), every `EventBinding` names a declared transition and +a §4 event, every `EffectBinding` names a declared transition (or carries a valid +`when` predicate) and a §6 catalog capability (§6.4), every `CapabilityRequest` +names a §6 catalog capability, every `invariants` entry is a boolean `LXNode`, and +every `PortSpec` / `ExposeSpec` is §7-typed. Any failure → the Lumen is rejected +wholesale with `surface_error` (scope = the Lumen `id`); it never partially +renders. ### 1.1 State schema @@ -165,6 +182,14 @@ type StateSchema = { }; ``` +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`. + Total serialised `state` size is capped (initial default **256 KB**, spike-tunable). `state` persists in canvas-state (`CONCEPT.md` §"State Model"); it is the *only* mutable memory a Lumen has. @@ -226,7 +251,8 @@ no prototypes, no functions-as-values beyond the named std-lib. | `if` | `{if:c, then:a, else:b}` | total conditional (both branches required) | | `match` | `{match:expr, cases:[{when,then}], else}` | total switch | | record/list ctor | `{record:{…}}` `{list:[…]}` | construction | -| `set` | `{set:{path: expr}}` | **functional** update at a *static* path → returns a new state (no mutation) | +| `set` | `{set:{path: expr, …}}` | **functional** update of one **or many** static paths (a `path→expr` map) → new state with those paths replaced; `{set:{}}` is the **no-op** (return state unchanged); no mutation | +| `apply` | `{apply:name, args:[…]}` | call a §2.8 author-defined `def` (named, non-recursive pure helper) | | `setAt` | `{setAt: coll, index:[xExpr] \| [xExpr,yExpr], to: expr}` | **functional** write at a computed index → new collection; 1-D indexes a `list`, 2-D `[x,y]` a `grid` **or** a `list` (as `coll[y][x]`); out-of-bounds is a **no-op** (total) | | `at` | `{at: coll, index:[xExpr] \| [xExpr,yExpr], default: expr}` | random-access **read** at a computed index → element or, on out-of-bounds, `default` (total); 1-D for `list`, 2-D `[x,y]` for `grid` **or** `list` (`coll[y][x]`); also the form for `{state, at:[…]}` with **expression** indices | | `map` | `{map: listExpr, as:"x", idx?:"i", body: expr}` | element-wise; binds item `x` (and optional index `i`) per item → new list | @@ -277,10 +303,17 @@ kernels** of §2.6. ### 2.5 Validation A Lumen's LX is accepted iff every node is in §2.2, every `call` target is in -§2.3, every `kernel` target is in §2.6, every `state`/`event` path resolves -against the declared schema, and a static pass proves iteration bounds and a gas -ceiling. `view` MUST return a valid primitive/scene tree (§3); `transitions` MUST -return a value conforming to `state`. Anything else → reject. +§2.3, every `kernel` target is in §2.6, every `apply` target is a §2.8 `def`, +every `state`/`const`/`event` path resolves against the declared schema, and a +static pass proves iteration bounds and a gas ceiling. `view` MUST return a valid +primitive/scene tree (§3); `transitions` MUST return a value conforming to +`state`. Anything else → reject. + +**Transition result = delta-merge.** A transition's value is the **current +`state` with its `set`/`setAt` paths applied** — unmentioned fields are carried +over unchanged. A transition does *not* reconstruct the whole record; `{set:{}}` +returns the state untouched (a no-op transition). This keeps transitions small +(touch only what changes) and patches local. ### 2.6 Native kernels — bounded algorithms the host owns @@ -344,6 +377,37 @@ generation bugs — the off-by-one the validator **cannot** catch because the Lumen is syntactically valid — from **silent-wrong** into a caught, loud failure the agent repairs by patch. They pair with the golden-trace authoring gate (§14). +### 2.8 Author-defined helpers (`defs` / `apply`) + +`let` binds a *value*; a transition cannot call another transition. So without a +helper facility, any logic reused across sites — a collision test called from +gravity / move / rotate, a `menuRow` lookup called per line item — must be +**inlined at every site**, multiplying bloat *and* the chance of an inconsistent +edit (a patch must touch every copy). `defs` removes that: + +```ts +type LXDef = { params: string[], body: LXNode }; // a named, parameterised pure expression +// call: { apply: name, args: [expr, …] } // binds params positionally, evaluates body +``` + +- **Pure & first-order.** A `def` body reads its **params** (via `{var}`) and + **`const`** (truly immutable), and may `call` std-lib, `kernel`, or **other + defs** via `apply`. It does **not** read `state`/`event` — those flow in as + args, so a def is referentially transparent and can be applied to an *in-flight + computed value* (e.g. a game-over check against a not-yet-committed board), not + only to current state. +- **Non-recursive — statically bounded.** `defs` form a **DAG**: a def may apply + only defs that do not (transitively) apply it. No self- or mutual recursion. + The whole call graph is therefore fully inlinable, so the §2.4 gas bound stays a + *static* property — `apply` is sugar over inlining, not a new unbounded power. +- **Validated.** Every `apply` target must exist with a matching arity; the DAG + must be acyclic; each `body` passes the §2 whitelist. Any failure → reject. + +`defs` are **agent-owned structure** (like `transitions`), patched by stable name. +They are also the natural shape the idiom library (§8) ships — a vetted +`collides` or `scaleAxis` def is reused, not re-emitted. This is purely additive: +no `defs` ⇒ identical behaviour to a Lumen that inlines everything. + --- ## 3. The `scene` primitive (editor-class, 1.1) @@ -625,6 +689,45 @@ it fluid without weakening the gate: A **transactional ordering Lumen** is therefore the right fifth conformance artifact (§14): it exercises the capability axis a game never does. +### 6.4 Effect bindings — how a pure transition invokes a capability + +§6 defines the capability **catalog**, the **broker**, and the **wire** event +(`surface_capability_request`, §12); it did **not** define the **authoring +trigger** — how declarative, *pure* `(state,event)→state` logic causes an effect +to fire. It cannot do so from inside a transition (that would make transitions +impure and break determinism/replay). Instead, the Lumen declares **effect +bindings** — the *output* dual of `events` (§4): `events` bind an input to a +transition; `effects` bind a transition (or a state predicate) to a brokered +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) +}; +``` + +Flow (the §6.3 ordering example): `placeOrder` is a **pure** transition that only +flips `stage` to `placing` (optimistic). An `effects` entry `{on:"placeOrder", +call:"writeData", args:, onResult:"orderPlaced", +onError:"orderFailed"}` makes the **runtime** — not the transition — emit the +brokered `external-effect` call (one consent gate), keep the deterministic loop +running while it is in flight (§6 async-by-default), and **re-enter the result as +an ordinary event** feeding `onResult`/`onError`. Determinism holds: the result is +an external input, recorded and re-fed on replay exactly like a capability result +today (§13.5). + +This realises the `interactivity-concept.md` §9.1 "Lumen output → capability" +intent with a concrete primitive, and respects the authority split: the agent +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. + --- ## 7. Ports & wires — cross-element interaction @@ -742,6 +845,8 @@ governs the host regardless of a Lumen's palette choice. | Arbitrary code in the renderer | None runs — LX is a validated AST walked by a shipped interpreter; CSP `default-src 'self'`, no `unsafe-eval` | | Runaway / DoS | Gas + frame ceiling + bounded iteration + state cap + **capped wakeup budget** (`tick` + `timer`, §4) → halt/reject with `surface_error`, never the canvas; capability **egress** is broker-bounded (rate/quota/max-in-flight/idempotent/backpressure, §6) so a tick-driven call cannot DoS Tier 2/3 | | Iterative compute (sort/pathfind/layout) | **Native kernels** (§2.6), not agent-authored loops: audited host code under a per-call **kernel-gas** ceiling, total on degenerate input, deterministic, no IO — the agent calls but never writes one, so "no arbitrary code" and "cannot hang" both hold | +| Author helpers (`defs`) hiding unbounded compute | `defs` are **non-recursive** (a validated DAG, §2.8) and read only params + `const` — fully inlinable, so the static gas bound holds; no new power over inlined LX | +| Effects firing from a hot loop | `effects` (§6.4) route through the **same §6 broker**: default-deny grant, consent gate, and egress bounds (rate/quota/max-in-flight) — an effect bound to a `tick` transition cannot self-grant or out-pace the broker | | Corrupt generated state (silent-wrong) | **Declared invariants** (§2.7) checked post-transition → rollback + `surface_error`; **golden-trace** author-time gate (§14) runs example traces before first render | | Data exfiltration | Default-deny capabilities; Lumen reads only own state + **wired/`expose`-published** ports; all egress brokered, allowlisted, confirmed, Trace-audited; **state/`DataRef`-derived** `fetch`/`writeData` classified `external-effect` (confirmed) unless pre-approved at grant (§6) | | Stale / poisoned assets | Content-addressed `DataRef` (id = content hash); HMAC-scoped fetch; explicit invalidation | @@ -763,6 +868,11 @@ governs the host regardless of a Lumen's palette choice. validated. - **Constants:** the immutable `const` section + `{const}` read node (§1.2) — agent-owned, bounded, not serialised; the unit the idiom/preset library ships. +- **Helpers:** the `defs` section + `{apply}` node (§2.8) — named, parameterised, + 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. - **Ports & wires:** `ports` and `expose` (published read-only interface) on primitives/Lumens, `wires` at container/canvas level (§7) — additive tree content, Tier-1-resolved. @@ -815,19 +925,22 @@ accordingly and idioms degrade gracefully — the same principle as ## 14. Conformance & open questions Conformance is the schema set in `schema/` (Lumen, LX-AST **incl. the -`map`/`filter`/`fold` binder nodes, `at`/`setAt`, and the §2.6 kernel -signatures**, scene, ports/wires/`expose`, invariants, capability manifest) + -accept/reject fixtures, plus **five** reference Lumens — an arcade game, -interactive workflow, **a transactional ordering flow (§6.3)**, defrag-viz, map — -each authored **by hand in real LX-AST** and traced end-to-end like -`walkthroughs.md`. +`map`/`filter`/`fold` binder nodes, `{var}`, `at`/`setAt`, `defs`/`{apply}`, and +the §2.6 kernel signatures**, scene, ports/wires/`expose`, invariants, `effects`, +capability manifest) + accept/reject fixtures, plus **five** reference Lumens — an +arcade game, interactive workflow, **a transactional ordering flow (§6.3)**, +defrag-viz, map — each authored **by hand in real LX-AST** and traced end-to-end +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. (Tracing a board-game-class -Lumen on paper is exactly what surfaced the Rev-3 gaps; doing the full set -converts "argued watertight" into "tested watertight".) +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. **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 new file mode 100644 index 0000000..f1cdd01 --- /dev/null +++ b/docs/protocol/lumen-walkthroughs.md @@ -0,0 +1,393 @@ +# Lumen reference walkthroughs — the §14 acceptance gate + +> **Purpose.** Hand-author the reference Lumens in **real LX-AST** and trace each +> transition against the [`lumens-spec.md`](../lumens-spec.md) grammar — the +> cheapest test that the spec holds against non-trivial artifacts *before* +> implementation budget (L0–L9) is committed (`lumens-spec.md` §14). This is a +> **manual** trace: there is no interpreter yet (L1 unbuilt), so "validates" means +> "every node form exists in the grammar, types resolve, it is total/bounded, and +> it computes the intended result". Where a node form is **missing**, it is marked +> 🔴 GAP; ergonomic friction is 🟡; confirmed-working is ✅. +> +> Two Lumens, chosen to stress the two orthogonal axes: +> - **A — falling-blocks arcade** (`colorMode: theme`): the compute axis — +> gameloop, collision, lock, line-clear, spawn, input, scene render. +> - **B — kiosk ordering flow** (`colorMode: brand`): the capability axis — +> local-first cart, kernel aggregation, the single `external-effect` commit. +> +> **Headline result.** Both are expressible *in shape*, and rev 3.x (binders, +> `var`, `at`/`setAt`, `const`, `idx`, `flatten`, kernels, colour authority) holds +> up. Writing them **complete** (not fragments, as in the earlier passes) surfaced +> **two load-bearing gaps the fragment passes missed**: no author-defined function +> abstraction (§Findings G1), and no specified mechanism for a *pure* transition +> to *invoke* a capability (§Findings G2). Both are now fixed in the spec +> (`defs`/`apply` §2.8; effect bindings §6.4). + +--- + +## A — Falling-blocks arcade Lumen + +### A.0 State, const, invariants + +`board` is a **`list`** (not `grid`) so line-clear is a clean +`filter`/`concat` (`lumens-spec.md` §2.2 guidance); the piece is `(type, rot, x, +y)` as indices into a `const` shape table. + +```jsonc +"state": { + "board": { "type":"list", "maxLen":20, + "of":{ "type":"list", "maxLen":10, "of":{ "type":"int","min":0,"max":7 } }, + "init":[[0,0,0,0,0,0,0,0,0,0], /* …20 empty rows… */] }, + "pType": { "type":"int","min":0,"max":6,"init":0 }, + "pRot": { "type":"int","min":0,"max":3,"init":0 }, + "pX": { "type":"int","min":-2,"max":11,"init":4 }, + "pY": { "type":"int","min":-2,"max":21,"init":0 }, + "score": { "type":"int","min":0,"init":0 }, + "frame": { "type":"int","min":0,"init":0 }, + "over": { "type":"bool","init":false } +} +``` + +The shape table — declared **once** in `const` (the §1.2 fix; without it this +112-cell table would be re-inlined into every transition that reads it): + +```jsonc +"const": { + "shapes": { + "type":"list","maxLen":7, + "of":{ "type":"list","maxLen":4, + "of":{ "type":"list","maxLen":4, + "of":{ "type":"record","fields":{ + "dx":{"type":"int","min":-2,"max":2}, + "dy":{"type":"int","min":-2,"max":2} } } } }, + "value": [ + /* piece 0 (O), all 4 rots identical */ + [ [{"dx":0,"dy":0},{"dx":1,"dy":0},{"dx":0,"dy":1},{"dx":1,"dy":1}], /* …×4… */ ], + /* piece 1 (I) rot 0 */ + [ [{"dx":-1,"dy":0},{"dx":0,"dy":0},{"dx":1,"dy":0},{"dx":2,"dy":0}], /* rot1,2,3… */ ] + /* pieces 2–6 (T,S,Z,J,L) — mechanical, elided; they stress nothing new */ + ] + } +} +``` + +✅ **§1.2 `const` validated** — the nested `list>>` is +expressible in the typed-leaf grammar and carries the table once. +🟡 **G3 (StateLeaf undefined):** §1.1 references `StateLeaf` in `list`/`grid` +`of` but never defines it. The board (`list`) and this table +(`list>>`) only validate if `StateLeaf` includes **nested +`list`/`record`**. The spec must say so explicitly (see Findings). + +Invariants (§2.7) — turn off-by-one generation bugs into loud errors: + +```jsonc +"invariants": [ + { ">=":[ {"state":"score"}, {"lit":0} ] }, + { "==":[ {"call":"len","args":[{"state":"board"}]}, {"lit":20} ] } // board never loses a row +] +``` + +### A.1 The shared helpers — and the gap that forced them + +Collision is needed from **four** transitions (gravity, move-left, move-right, +rotate), each against a *different* candidate `(x,y,rot)`. There is **no way to +define `collides(...)` once** in the rev-3.1 grammar — `let` binds a *value*, not +a *parameterised function*, and a transition cannot call another transition. The +only rev-3.1 option is to **inline the whole collision fold four times** with +shifted coordinates — bloat, and four chances for an inconsistent off-by-one. + +🔴 **G1 — no author-defined function abstraction.** Fixed by adding `defs` + +`{apply}` (`lumens-spec.md` §2.8). With it: + +```jsonc +"defs": { + "cellsOf": { "params":["t","r"], "body": + { "at":{ "at":{"const":"shapes"}, "index":[{"var":"t"}], "default":{"lit":[]} }, + "index":[{"var":"r"}], "default":{"lit":[]} } }, + + "collides": { "params":["board","t","px","py","pr"], "body": + { "fold":{ "apply":"cellsOf", "args":[{"var":"t"},{"var":"pr"}] }, + "as":"c", "acc":"hit", "init":{"lit":false}, + "body": + { "let":{ "bx":{ "+":[{"var":"px"},{"var":"c","path":"dx"}] } }, "in": + { "let":{ "by":{ "+":[{"var":"py"},{"var":"c","path":"dy"}] } }, "in": + { "or":[ + {"var":"hit"}, + { "<":[{"var":"bx"},{"lit":0}] }, + { ">":[{"var":"bx"},{"lit":9}] }, + { ">":[{"var":"by"},{"lit":19}] }, + { "and":[ + { ">=":[{"var":"by"},{"lit":0}] }, + { "!=":[ + { "at":{ "at":{"var":"board"},"index":[{"var":"by"}],"default":{"lit":[]} }, + "index":[{"var":"bx"}], "default":{"lit":0} }, + {"lit":0} ] } ] } + ] } } } } } +} +``` + +✅ `cellsOf`/`collides` read **only their params + `const`** (not `state`) — pure, +so a caller can pass either `{state:"board"}` *or* an in-flight computed board +(critical for the game-over test in A.2, which must check the *cleared* board, not +the stale state board). This purity rule is part of the G1 fix. +✅ **`at` totality** (every nested access carries a `default`) makes the +above-board region (`by < 0`) read as empty — collision stays total. + +### A.2 `tick` — gravity, lock, clear, spawn, game-over + +```jsonc +"tick": { + "if": {"state":"over"}, + "then": { "set":{} }, // ← no-op: return state unchanged (G4) + "else": + { "let":{ "f2":{ "+":[{"state":"frame"},{"lit":1}] } }, "in": + { "if": { "!=":[ {"call":"mod","args":[{"var":"f2"},{"apply":"gravity","args":[{"state":"score"}]}]}, {"lit":0} ] }, + "then": { "set":{ "frame":{"var":"f2"} } }, // not a gravity frame: only advance the counter + "else": + { "if": { "not":{ "apply":"collides","args":[ + {"state":"board"},{"state":"pType"},{"state":"pX"}, + { "+":[{"state":"pY"},{"lit":1}] },{"state":"pRot"} ] } }, + "then": { "set":{ "frame":{"var":"f2"}, "pY":{ "+":[{"state":"pY"},{"lit":1}] } } }, // fall one + "else": // landed → lock + clear + spawn + { "let":{ "locked":{ "apply":"lockPiece","args":[ + {"state":"board"},{"state":"pType"},{"state":"pX"},{"state":"pY"},{"state":"pRot"}] } }, "in": + { "let":{ "kept":{ "filter":{"var":"locked"}, "as":"row", + "body":{ "not":{ "fold":{"var":"row"},"as":"cell","acc":"full","init":{"lit":true}, + "body":{ "and":[{"var":"full"},{"!=":[{"var":"cell"},{"lit":0}]}] } } } } }, "in": + { "let":{ "nClear":{ "-":[{"lit":20},{"call":"len","args":[{"var":"kept"}]}] } }, "in": + { "let":{ "cleared":{ "concat":[ + { "map":{"call":"range","args":[{"var":"nClear"}]}, "as":"_", + "body":{"lit":[0,0,0,0,0,0,0,0,0,0]} }, + {"var":"kept"} ] } }, "in": + { "set":{ + "board": {"var":"cleared"}, + "score": { "+":[{"state":"score"},{"apply":"lineScore","args":[{"var":"nClear"}]}] }, + "pType": { "call":"mod","args":[{ "+":[{"state":"pType"},{"lit":1}]},{"lit":7}] }, // demo spawn + "pRot":0, "pX":4, "pY":0, "frame":{"var":"f2"}, + "over": { "apply":"collides","args":[ + {"var":"cleared"}, + { "call":"mod","args":[{ "+":[{"state":"pType"},{"lit":1}]},{"lit":7}] }, + {"lit":4},{"lit":0},{"lit":0} ] } + } } } } } } } } } } } +} +``` + +✅ **Line-clear** (`filter` full rows out, `concat` empty rows on top) — clean, +~12 nodes, **no kernel needed**. ✅ **`map(range)`** builds the empty rows. ✅ +**multi-field `set`** writes 7 fields from shared `let`s in one shot. ✅ The +game-over check calls `collides` against the **computed** `cleared` board — only +possible because `defs` are pure-in-params (A.1). +🟡 **G4 (transition return semantics):** `{set:{}}` is used for "unchanged". The +spec must state normatively that a transition returns **current state with +`set`/`setAt` applied** (delta-merge), and that `{set:{}}` is the no-op — without +this rule, "return unchanged" and "change only pX" have no defined meaning. +🟡 **G5 (multi-field `set`):** `{set:{a:…,b:…}}` (a map of several paths) is used +heavily; §2.2 showed only the single-key form. Must be explicitly allowed. + +`lockPiece` (folds `setAt` over the 4 cells — the rev-3.1 `setAt`-on-`list` +fix) and the tiny `gravity`/`lineScore`/`bag` defs are mechanical; elided. + +### A.3 Input & view + +```jsonc +"moveLeft": { "if": { "not":{ "apply":"collides","args":[ + {"state":"board"},{"state":"pType"}, + { "-":[{"state":"pX"},{"lit":1}] },{"state":"pY"},{"state":"pRot"} ] } }, + "then": { "set":{ "pX":{ "-":[{"state":"pX"},{"lit":1}] } } }, + "else": { "set":{} } }, + +"events": [ + { "on":"tick", "rate":60, "run":"tick" }, + { "on":"key", "key":"ArrowLeft", "run":"moveLeft" }, + { "on":"key", "key":"ArrowRight", "run":"moveRight" }, + { "on":"key", "key":"ArrowUp", "run":"rotate" }, + { "on":"swipe", "run":"moveLeft" } // touch equivalent (§4) +] +``` + +`view` renders the board with the `idx` binder (rev 3.1) + `flatten` instead of +the old `map(range)+at`/`fold`+`concat` detour: + +```jsonc +"view": { "type":"scene", "id":"b", "width":200, "height":400, "draw": + { "call":"flatten","args":[ + { "map":{"state":"board"}, "as":"row", "idx":"y", "body": + { "filter": + { "map":{"var":"row"}, "as":"cell", "idx":"x", "body": + { "record":{ "kind":{"lit":"rect"}, + "x":{ "*":[{"var":"x"},{"lit":20}] }, "y":{ "*":[{"var":"y"},{"lit":20}] }, + "w":{"lit":20}, "h":{"lit":20}, + "fill":{ "apply":"tokenOf","args":[{"var":"cell"}] }, // 0→bg, 1–7→accent tints (theme) + "id":{ "call":"fmt","args":[{"lit":"c-{}-{}"},{"var":"x"},{"var":"y"}] } } } }, + "as":"n", "body":{ "!=":[{"var":"n","path":"fill"},{"lit":"transparent"}] } } } ] + } } +``` + +✅ **`idx` binder + `flatten`** (rev 3.1) collapse the hot render path to a +readable nested map. ✅ token colour via a `tokenOf` def (theme mode — a falling +game is accent-tinted; the 7-distinct-colours limitation noted earlier stands for +`theme`, and is exactly what `colorMode:'free'` lifts when the author wants it). + +--- + +## B — Kiosk ordering Lumen (capability axis) + +### B.0 State, brand palette, capabilities + +```jsonc +"colorMode": "brand", +"palette": { "primary":"#DA291C", "ink":"#FFFFFF", "surface":"#1A1A1A", "pop":"#FFC72C" }, +"state": { + "menu": { "type":"dataRef" }, // filled by loadData (read-only projection) + "cart": { "type":"list","maxLen":50, + "of":{ "type":"record","fields":{ + "sku":{"type":"string","maxLength":32}, + "qty":{"type":"int","min":0,"max":99} } }, "init":[] }, + "stage": { "type":"enum","values":["browse","review","placing","done","failed"], "init":"browse" }, + "orderId": { "type":"string","maxLength":64,"init":"" } +}, +"capabilities": [ + { "cap":"loadData", "scope":{ "dataRef":"menu" } }, + { "cap":"writeData", "scope":{ "target":"orders", "writeCapabilities":["create"] } } +], +"invariants": [ { "<=":[ {"call":"len","args":[{"state":"cart"}]}, {"lit":50} ] } ] +``` + +✅ **§3.1 colour authority** — `brand` + declared `palette`; this Lumen's content +renders in the customer's red/gold, Omadia chrome stays Lume. + +### B.1 Cart edits — pure `state`, zero capabilities (the §6.3 proof) + +`addItem` increments an existing line or appends — using `fold`+**`idx`** to find +the line by `sku` (there is no `findIndex`-by-predicate; the `idx` binder makes +the fold do it): + +```jsonc +"addItem": { + "let":{ "i":{ "fold":{"state":"cart"}, "as":"it", "idx":"k", "acc":"f", "init":{"lit":-1}, + "body":{ "if":{ "==":[{"var":"it","path":"sku"},{"event":"sku"}] }, + "then":{"var":"k"}, "else":{"var":"f"} } } }, "in": + { "if": { ">=":[{"var":"i"},{"lit":0}] }, + "then": { "set":{ "cart":{ "setAt":{"state":"cart"}, "index":[{"var":"i"}], "to": + { "record":{ "sku":{"event":"sku"}, + "qty":{ "+":[ { "at":{"state":"cart"},"index":[{"var":"i"}],"default":{"record":{"qty":{"lit":0}}} ,"path":"qty"... } +``` + +🟡 **G6 (`at` + `path` composition):** reading `cart[i].qty` wants +`at`-then-field. `{var:…,path:…}` reads a field of a *binder*; `at` reads an +*index*. Composing index-then-field needs either nesting (`{"at":…}` wrapped so a +`path` applies to its result) or letting `at` take an optional `path`. The +grammar doesn't say which. Resolved here by binding the row first: + +```jsonc +"addItem": { + "let":{ "i": /* …fold-find as above… */ }, "in": + { "if": { ">=":[{"var":"i"},{"lit":0}] }, + "then": + { "let":{ "row":{ "at":{"state":"cart"}, "index":[{"var":"i"}], "default":{"lit":{"sku":"","qty":0}} } }, "in": + { "set":{ "cart":{ "setAt":{"state":"cart"}, "index":[{"var":"i"}], "to": + { "record":{ "sku":{"var":"row","path":"sku"}, "qty":{ "+":[{"var":"row","path":"qty"},{"lit":1}] } } } } } } }, + "else": + { "set":{ "cart":{ "concat":[ {"state":"cart"}, + { "list":[ { "record":{ "sku":{"event":"sku"}, "qty":{"lit":1} } } ] } ] } } } } +} +``` + +✅ **The entire cart flow touches *no* capability** — `addItem`, `setQty`, +`removeItem`, browse navigation are pure `state`, `reactive`, **zero modals**. +This is the §6.3 "local-first, commit-once" claim, validated concretely: the "20 +taps" never cross a gate. + +### B.2 Review view — the `aggregate` kernel + cart↔menu join + +```jsonc +"defs": { + "menuRow": { "params":["sku"], "body": + { "fold":{"state":"menu"}, "as":"m", "acc":"r", "init":{"lit":{"name":"?","price":0}}, + "body":{ "if":{ "==":[{"var":"m","path":"sku"},{"var":"sku"}] }, "then":{"var":"m"}, "else":{"var":"r"} } } }, + "lineItems": { "params":["cart"], "body": + { "map":{"var":"cart"}, "as":"it", "body": + { "let":{ "mr":{ "apply":"menuRow","args":[{"var":"it","path":"sku"}] } }, "in": + { "record":{ "name":{"var":"mr","path":"name"}, + "qty":{"var":"it","path":"qty"}, + "lineTotal":{ "*":[{"var":"it","path":"qty"},{"var":"mr","path":"price"}] } } } } } } +} +``` + +Total via the kernel (the rev-3 native-kernel mechanism, in its home use case): + +```jsonc +"total": { "kernel":"aggregate", + "args":[ {"apply":"lineItems","args":[{"state":"cart"}]}, {"record":{"op":{"lit":"sum"},"field":{"lit":"lineTotal"}}} ] } +``` + +✅ **`aggregate` kernel + `menuRow` def** — the join-by-sku is one reusable def +(reinforcing G1's value beyond the arcade), the sum is one kernel call. Note again +`defs` purity: `menuRow` reads `state.menu` here only because menu is fixed for +the turn — but to stay strictly pure-in-params it should take `menu` as an arg; +the spec rule (G1 fix) allows reading `state` only when the value is stable for +the evaluation. *Recommend menu passed as a param for cleanliness.* + +### B.3 Checkout — the single `external-effect`, and the gap it exposed + +The user taps "place order". The transition is **pure**: it can flip `stage` to +`placing` (optimistic), but **how does a pure transition actually fire the +`writeData` capability?** Nothing in §6/§6.3 defines the *trigger primitive* — §6 +describes the wire event (`surface_capability_request`) and §9.1 *gestures* at "a +Lumen output wired to a `writeData` capability", but no authoring form exists. + +🔴 **G2 — capability invocation from a pure transition is unspecified.** Fixed by +adding **effect bindings** (`lumens-spec.md` §6.4): the Lumen declares, like +`events` but in the output direction, "when transition X fires, broker capability +C with args ``, and feed the result to transition `then`". Purity +and determinism are preserved (the result re-enters as a recorded input, §13.5). + +```jsonc +"placeOrder": { "set":{ "stage":{"lit":"placing"} } }, // pure: just the optimistic flip + +"effects": [ + { "on":"placeOrder", // trigger = a transition firing + "call":"writeData", + "args":{ "record":{ "target":{"lit":"orders"}, + "op":{"lit":"create"}, + "lines":{ "apply":"lineItems","args":[{"state":"cart"}] } } }, + "onResult":"orderPlaced", "onError":"orderFailed" } +], + +"orderPlaced": { "set":{ "stage":{"lit":"done"}, "orderId":{"event":"id"} } }, // result re-enters as event +"orderFailed": { "set":{ "stage":{"lit":"failed"} } } // optimistic rollback +``` + +✅ With §6.4, the whole flow is: pure local cart → one `placeOrder` (optimistic +`placing`) → declared effect brokers the **single `external-effect`** (one consent +gate, §6.3) → `orderPlaced`/`orderFailed` patch the terminal state. Server stays +authoritative; determinism intact (replay re-feeds the recorded result). + +--- + +## Findings — what the full test changed + +| # | Finding | Sev | Status | +|---|---|---|---| +| **G1** | No author-defined function abstraction — `collides`/`menuRow` needed from many sites; `let` binds values, transitions can't call transitions. Forced inlining = bloat + LLM-reliability risk + patch-divergence. | 🔴 high | **fixed** — `defs` + `{apply}`, non-recursive (DAG), pure-in-params, §2.8 | +| **G2** | A **pure transition cannot invoke a capability** — §6 defined the wire event + policy but never the authoring trigger. Blocks every write-back / fetch / generateAsset. | 🔴 high | **fixed** — **effect bindings** (`effects: [{on,call,args,onResult,onError}]`), §6.4 | +| **G3** | `StateLeaf` referenced (list/grid `of`) but never defined; `list` boards and nested `const` tables depend on it. | 🟡 | **fixed** — §1.1 defines `StateLeaf` incl. nested `list`/`record` | +| **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 | + +**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.