From df6e7fbb8cbd1dea1e7ff1c1bd58be2b16544829 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 16:26:49 +0200 Subject: [PATCH 01/24] docs(brief): add M1.0.14 milestone brief --- briefs/M1.0.14-entity-scoped-events.md | 162 +++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 briefs/M1.0.14-entity-scoped-events.md diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md new file mode 100644 index 0000000..4d42787 --- /dev/null +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -0,0 +1,162 @@ +# M1.0.14 — Entity-scoped events + +> **Status:** PLANNED +> **Phase:** 1 +> **Branch:** `phase-1/etch/entity-scoped-events` +> **Planned tag:** `v0.10.14-entity-scoped-events` +> **Dependencies:** M1.0.2 (events + `EventStore` + `@on_event` drain — the per-tick store this milestone matches against), M1.0.11 (async suspension core — the `await`/`WakeCond` substrate), M1.0.12 (task pool + drive-by-origin discipline) +> **Opened:** 2026-07-04 +> **Closed:** — + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +Phase 1, M1.0 series (M1.0.0–M1.0.20) closing the EBNF v0.6 execution gaps for the tree-walking interpreter (criterion C1.6). `entity_event` is the last `await` target (besides `future`) that parses and type-checks but fails loud at runtime (`interp.zig`, two sites marked M1.0.14). This milestone realizes `await entity_event(e, T [{ … }])` and the optional payload filter on **both** event targets (`global_event` included — grammar §4.2 erratum applied to the KB at milestone open), per the normative entity-scoping convention fixed in `etch-reference-part1.md §9.4`: designated `Entity` field (`@entity_target` > single structural `Entity` field > `E0908`/`E0909`), equality filter captured once at suspension, resume with unit. After this milestone, `future` is the sole remaining fail-loud `await` target. + +## Scope + +Executed **gate by gate E1→E4**, in order. Each gate is pushed and reviewed on the real diff before the next opens (cf. § Notes › Gate protocol). + +**E1 — Parser + AST: payload filters.** +- `ast.zig`: `AwaitExpr` gains a filter run — `filter_start: u32`, `filter_len: u32` into the existing `struct_lit_fields` slab (the `EmitStmt` shape); `(0, 0)` when absent. +- `parser.zig`: parse the optional `struct_literal_body` filter on `entity_event(e, T { … })` — **remove** the M0.8-era rejection ("payload-filter body is not supported in M0.8 (T2 / Phase 2)") — and on `global_event(T { … })` (`etch-grammar.md §4.2`, both productions). Bare forms unchanged. Trailing comma accepted (struct-literal convention). +- Parser tests updated: the existing "payload-filter body rejected" test flips to a parse-success assertion on the recorded fields run. + +**E2 — Type-checker: event-target validation, designated field, diagnostics.** +- The `entity_event` entity operand is synthesized and must type `Entity` (currently never synthesized — reuse existing type-mismatch machinery, no new code). +- For **both** event targets, `T` must be a declared `event` (reuse the `emit` path's validation kind — fix-as-you-go: `global_event` currently accepts an undeclared `T` silently). +- **Designated-field resolution** (`etch-reference-part1.md §9.4`, normative order): field annotated `@entity_target` > the single `Entity`-typed field > diagnostic at the `await` site — `E0908 EventNotEntityScoped` (zero `Entity` fields) / `E0909 AmbiguousEventEntityTarget` (two or more, no annotation). Implemented as a **single shared `pub` helper** operating on the event declaration, called by the type-checker (to diagnose) and later by the interpreter (E3, to capture the field) — the policy lives in exactly one place. +- `@entity_target` validation at the event declaration: at most one per event; the annotated field must be of type `Entity`; misuse rejected through **existing** diagnostic kinds (no new codes). +- Filter field validation for both targets: each `IDENT : expression` names an existing field of `T` and type-checks against that field's declared type (reuse the `emit` field-validation path and its kinds). +- `diagnostics.zig`: new kinds mapping to `E0908` / `E0909` (codes pre-assigned in `etch-diagnostics.md §12`). + +**E3 — Runtime realization.** +- `WakeCond` carries the entity-event condition: event type + target `EntityId` + designated field (`StringId`) + captured filter values; the `global_event` condition gains the optional captured filter (representation is implementer's choice — extend the existing variant or add one; semantics fixed). Captured filter values live in interpreter-owned storage referenced by range (the `task_children` pattern) or equivalent pointer-stable storage. +- `evalAwaitTarget`: the `entity_event` arm evaluates the entity expression **once** (must yield an entity value — defensive fail-loud otherwise), resolves the designated field via the E2 shared helper, and evaluates each filter expression **once** in the live scope, capturing values; the `global_event` arm captures its filter likewise. Neither expression set is re-evaluated at later polls. +- `asyncWakeFired`: the entity-event condition scans the per-tick `EventStore` — type match, designated-field equality against the target entity, and equality on every captured filter field; the filtered `global_event` condition matches type + filter. Equality covers the legally admitted event-field value kinds (verify the admitted set — POD scalars and any non-POD kinds events accept since M1.0.2 — and reuse the interpreter's existing value-equality machinery). +- The two `entity_event` fail-loud sites are removed: `stepBodyStmt` routes `.entity_event` through the wake-condition arm (suspend, `pending_bind` delivers **unit** at resume — no implicit `event` binding); `evalAwaitTarget` handles it. `.future` stays fail-loud at both sites. +- A matched event is **not consumed**: the wake does not remove it from the store; observers and multiple awaiters can fire on the same event instance in the same tick. Producer-before-awaiter rule-order requirement unchanged (same as `global_event`). + +**E4 — Integration + partition boundary.** +- Cross-tick integration tests (see Acceptance criteria): multi-awaiter wake on one event, `@on_event` observer coexistence, per-tick store clearing (an event emitted at tick N never wakes a task suspended at tick N+1), filtered `global_event`, capture-once semantics. +- The M1.0.13 partition-boundary test ("await entity_event still fails loud") flips: `entity_event` executes; a new boundary assertion pins `future` as the **only** remaining fail-loud target. +- Byte-stability: every pre-existing M1.0.11/12/13 async and timer test passes untouched. + +## Out of scope + +- **`future` / `Future`** (`await ` on a Future — asset loading, non-inlined `async fn` returns) — the last remaining fail-loud target, a separate later gap. NOT this milestone. +- **Implicit `event` binding or payload delivery at resume** — normative §9.4: all wake-condition targets resume with unit. Payload consumption belongs to `@on_event` rules; pre-wake discrimination belongs to the filter. +- **Entity-keyed `EventStore` index** — the Phase-1 realization is predicate matching over the per-tick store; indexing belongs to the target-form typed bus (Phase 2). +- **Emit-side targeting** (`emit T { … } to e` or similar) — `emit_stmt` stays global and target-less (grammar v0.6 frozen; §9.4 shows a plain `emit` waking an `entity_event`). +- **Non-equality filter predicates** (ranges, comparisons, boolean expressions) — the filter is field-equality only (§4.2/§9.4). +- **Entity-bound `async rule`** — still a later milestone (unchanged M1.0.11/12 boundary). +- **New diagnostic codes beyond `E0908`/`E0909`** — annotation misuse and operand/filter type errors reuse existing kinds. If a genuinely new, unavoidable validation surfaces mid-gate, STOP for a Claude.ai round-trip before minting `E0910` (next free; do not invent a code from the code-map). + +## Specs to read first + +Mandatory before any production code; checked off in the LIVING SECTION. + +1. `etch-reference-part1.md` — §9.4 (the three normative blocks: entity-scoping convention, payload filter, resume value), §9.12 (the "Événements entity-scoped et filtres (M1.0.14)" realization block — the authoritative mechanism — plus the async-substrate paragraphs), §9.2 (the `entity_event` usage example). +2. `etch-grammar.md` — §4.2 (`await_target` productions + the event-target semantics bullets), §18.10 (`@entity_target` row + validation note), §5.10 (`event_decl`), §4.1 (`struct_literal_body`). +3. `etch-diagnostics.md` — §12 (E09xx block: `E0908`/`E0909` pre-assigned; the taken-code map). +4. `etch-resolver-types.md` — §9.2/§9.4 (effects algebra — `entity_event`/`global_event` carry `{async}` via the explicit `await`; context for the E2 validation surface). + +## Files to create or modify + +- `src/etch/ast.zig` — modify — E1: `AwaitExpr` filter run fields (`filter_start`/`filter_len` into `struct_lit_fields`). +- `src/etch/parser.zig` — modify — E1: optional filter body on both event targets; remove the M0.8 `entity_event` filter rejection; tests updated. +- `src/etch/types.zig` — modify — E2: entity-operand check, declared-event validation (both targets), shared designated-field resolution helper (`pub`), `@entity_target` declaration validation, filter field validation. +- `src/etch/diagnostics.zig` — modify — E2: `E0908`/`E0909` kinds + code/name mappings. +- `src/etch/interp.zig` — modify — E3/E4: `WakeCond` enrichment, `evalAwaitTarget` arms, `asyncWakeFired` predicate match, removal of the two fail-loud sites, tests. + +No new files. No changes outside `src/etch/`. + +## Acceptance criteria + +### Tests + +Tests are **inline** in the respective `src/etch/*.zig` files (repo convention), green in `debug` and `ReleaseSafe`. + +- `src/etch/parser.zig` — `test` — `entity_event(e, T)` bare parses (`filter_len == 0`); `entity_event(e, T { f: v })` parses with the fields run recorded; `global_event(T)` bare unchanged; `global_event(T { f: v })` parses; trailing comma in a filter body accepted. +- `src/etch/types.zig` — `test` — non-`Entity` first operand rejected; undeclared event type rejected on `entity_event` AND on `global_event`; zero-`Entity`-field event → `E0908`; two-`Entity`-field event without annotation → `E0909`; two-field event WITH `@entity_target` accepted; `@entity_target` on a non-`Entity` field rejected; duplicate `@entity_target` in one event rejected; filter naming an unknown field rejected; filter field-type mismatch rejected; filtered `global_event` type-checks. +- `src/etch/interp.zig` — `test` (E3) — awaiter wakes at its drive slot the tick a matching event is emitted by an earlier-ordered rule; no wake when the event's designated field carries a different entity; single-`Entity`-field structural designation works; `@entity_target` designation works on a two-`Entity`-field event (matches the annotated field, not the other); filter match wakes / filter mismatch does not; entity expression and filter expressions are evaluated exactly once at suspension (mutating their source locals after suspension does not alter the wake condition); `let x = await entity_event(…)` binds unit at resume. +- `src/etch/interp.zig` — `test` (E4) — two awaiters wake on one event instance; an `@on_event` observer drains the same event that wakes an awaiter (event not consumed by the wake); an event emitted at tick N does not wake a task that suspends at tick N+1 (per-tick store clearing); filtered `global_event` wakes cross-tick on the matching instance only; the partition-boundary test asserts `future` is the sole remaining fail-loud `await` target; all pre-existing M1.0.11/12/13 async + timer tests pass unmodified. + +### Benchmarks + +None (no perf target for this milestone). + +### Observable behavior + +- A test-harness scenario (inline, driven by repeated `stepOnce`), mirroring §9.2: an `async rule` emits `DialogueStart` then suspends on `await entity_event(npc, DialogueComplete)`. A producer rule emits `DialogueComplete` for a **different** entity → no resume. It then emits `DialogueComplete` for `npc` → the awaiter resumes at its slot that tick and emits `QuestStarted`, observable in the event store / via an `@on_event` sink. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix. +- `zig build test` green (`debug` + `ReleaseSafe`). +- `zig fmt --check` green. +- `zig build lint` green (once the custom linter exists). +- `commit-msg` hook green on every commit of the branch. + +## Conventions + +- **Branch:** `phase-1/etch/entity-scoped-events` +- **Final tag:** `v0.10.14-entity-scoped-events` +- **PR title:** `Phase 1 / Etch / Entity-scoped events` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Gate protocol (overrides Étape 3's "implement all at once").** `git push -u` at branch creation. Implement E1→E4 in order. After each gate's commits, **push**, then emit exactly `étape E terminée, prête pour review` and **stop** — do not open the next gate until Claude.ai returns a GO on the pushed diff. A STOP verdict returns a problem + fix; re-push and re-emit the same signal. The PR (Étape 5) opens only after the E4 GO. Reviews are on the real pushed diff, never on a CC summary. +- **Spec reconciliations already applied** to the KB at milestone open (Claude.ai re-upload): `etch-reference-part1.md` §9.4 (three normative blocks) + §9.12 (M1.0.14 realization block); `etch-grammar.md` §4.2 (`global_event` optional filter — erratum aligning the grammar on the §9.4 example — + event-target semantics) + §18.10 (`@entity_target`); `etch-diagnostics.md` §12 (`E0908`/`E0909`); `engine-phase-1-plan.md` (M1.0.x table re-synchronized on executed reality). **CC performs no spec authoring** — it reads the updated specs. +- **`entity_event` is `global_event` plus predicates** — same wake path, same store, same producer-before-awaiter ordering; no parallel mechanism. The `WakeCond` representation (extend the existing variant vs. add one) is implementer's choice; the capture-once and predicate semantics are not. +- **The designated-field policy lives in one place.** The E2 shared helper is the single implementation of "`@entity_target` > single `Entity` field"; the interpreter consumes it at suspension (where it always succeeds — the program passed `weld check`) and performs **no per-poll resolution**. +- **Capture-once (normative §9.4).** The entity expression and every filter expression are evaluated exactly once, at suspension, in the live scope; the wake condition holds captured values, never re-evaluates. +- **Event-field equality set.** Events have accepted non-POD scalar fields since the M1.0.2 fold-in; at E3, verify the actual admitted field-value kinds and make the filter/designated-field equality cover exactly that set, reusing the interpreter's existing value-equality machinery. If a kind has no equality today and cannot reuse existing machinery, STOP (design question). +- **Pre-existing gap fixed here (fix-as-you-go):** `await global_event(T)` with an undeclared `T` currently passes `weld check`; E2 closes it alongside the `entity_event` validation (same seam). +- **Fix-as-you-go (non-negotiable).** Any gap, inconsistency, or finding at any point in a gate is fixed within this milestone or explicitly refused by Guy — no parking, no deferred comment. A construct assigned to another milestone (`future`) is a scope boundary, not debt. +- **CLAUDE.md §3.4 update (E4 close, on the branch, `docs(claude-md): update for M1.0.14`).** Current-state table: `Last released tag` → `v0.10.14-entity-scoped-events` (post-merge wording per house style); `Next planned milestone` → M1.0.15 (test-runner + identity), with the await-family note updated (`entity_event` delivered; `future` = sole reserved await target). Tags table: +1 row for `v0.10.14-entity-scoped-events`. Open/deferred decisions: +1 M1.0.14 scope-boundary entry (designated-field convention, filter on both targets, resume-unit, predicate match without index; `future`/payload-delivery/emit-targeting/index out). `Last updated` date. Content provided by Claude.ai at closure; CC applies it on the branch. +- **Rejected alternatives.** Fixed field-name convention (`entity`) — killed by the corpus (`target`/`source`/`killer`). Emit-side targeting — grammar v0.6 frozen; §9.4 shows a plain `emit` waking the awaiter. First-declared-`Entity`-field positional rule — silently breaks on field reorder and picks `source` over `target` on `DamageTaken`. Entity-keyed store index — per-tick-cleared store, Phase-1 volumes; the indexed form is the Phase-2 typed bus. Payload delivery at resume — would fork the resume semantics of the shipped `global_event`; the filter carries pre-wake discrimination. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +*Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* + +- [ ] `etch-reference-part1.md` (§9.4, §9.12, §9.2) — read +- [ ] `etch-grammar.md` (§4.2, §18.10, §5.10, §4.1) — read +- [ ] `etch-diagnostics.md` (§12) — read +- [ ] `etch-resolver-types.md` (§9.2, §9.4) — read + +## Execution log + +*One entry per logical unit of work (typically: an objective met, a green test, a blocker). Chronological. Short — 1 to 3 lines per entry.* + +## Recorded deviations + +*Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* + +## Blockers encountered + +*Blocking points that required a return to Claude.ai (cf. `engine-development-workflow.md §2.4`). If 2+ distinct blockers: re-scope signal.* + +## Closing notes + +*Fill in at Status → CLOSED, just before opening the PR.* + +- **What worked:** +- **What deviated from the original spec:** +- **What to flag explicitly in review:** +- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): +- **Residual risks / tech debt left intentionally:** From c516af9815dd9d6b9a849d21579552a0f2fdfa88 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 16:28:49 +0200 Subject: [PATCH 02/24] docs(brief): confirm specs read for M1.0.14 --- briefs/M1.0.14-entity-scoped-events.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 4d42787..b00fc87 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -134,10 +134,10 @@ None (no perf target for this milestone). *Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* -- [ ] `etch-reference-part1.md` (§9.4, §9.12, §9.2) — read -- [ ] `etch-grammar.md` (§4.2, §18.10, §5.10, §4.1) — read -- [ ] `etch-diagnostics.md` (§12) — read -- [ ] `etch-resolver-types.md` (§9.2, §9.4) — read +- [x] `etch-reference-part1.md` (§9.4, §9.12, §9.2) — read 2026-07-04 16:28 +- [x] `etch-grammar.md` (§4.2, §18.10, §5.10, §4.1) — read 2026-07-04 16:28 +- [x] `etch-diagnostics.md` (§12) — read 2026-07-04 16:28 +- [x] `etch-resolver-types.md` (§9.2, §9.4) — read 2026-07-04 16:28 ## Execution log From 6efa2a7d6520317d12c0e528c3a3ebb3c3f64e7c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 16:28:56 +0200 Subject: [PATCH 03/24] docs(brief): activate M1.0.14 --- briefs/M1.0.14-entity-scoped-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index b00fc87..959f859 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -1,6 +1,6 @@ # M1.0.14 — Entity-scoped events -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1 > **Branch:** `phase-1/etch/entity-scoped-events` > **Planned tag:** `v0.10.14-entity-scoped-events` From a3783f90ce6e2cf11b6de7c55469a8f45246ce90 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 16:49:28 +0200 Subject: [PATCH 04/24] feat(etch): parse payload filter on event await targets (M1.0.14 E1) --- briefs/M1.0.14-entity-scoped-events.md | 4 + src/etch/ast.zig | 8 +- src/etch/parser.zig | 119 +++++++++++++++++++++++-- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 959f859..1bd8ddc 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -143,6 +143,10 @@ None (no perf target for this milestone). *One entry per logical unit of work (typically: an objective met, a green test, a blocker). Chronological. Short — 1 to 3 lines per entry.* +- 2026-07-04 — Code comprehension (pre-E1): mapped the 5 target surfaces. `AwaitExpr` (ast.zig:994) is a flat node with no field-run; `struct_lit_fields` (ast.zig:2570, `StructLitField{name,value}`) is the shared slab (`EmitStmt`/`StructLitExpr` `(start,len)` convention). Parser `parseAwaitExpr` (parser.zig:6543): entity_event arm rejects the `{`-body (M0.8), global_event has no filter path. Confirmed for later gates: diagnostics `E0908`/`E0909` absent (insert after `control_flow_escapes_task_branch`); interp `WakeCond.global_event: StringId` + `EventStore` (`EventVal{type_name, fields:[]StructField{name,Value}}`, per-tick `clear`) + `Value.eql` (entity_id/int/enum/string_id supported) + `task_children` range pattern + two fail-loud sites (interp.zig:3425, :2831); type-checker await arm returns `unknown` unvalidated for events, emit reuses an event field-iteration path (`event_decls`/`fields`), `@entity_target` unrecognized today (add `AnnotationKind.entity_target`). +- 2026-07-04 — E1 (parser + AST): `AwaitExpr` gains `filter_start`/`filter_len` into `struct_lit_fields` (`(0,0)` absent). New `parser.parseEventFilterBody` parses the optional `{ IDENT : expr, … }` equality-predicate run (trailing comma accepted, spread rejected); wired into BOTH the `entity_event` arm (M0.8 rejection removed) and the `global_event` arm. All 4 `addAwaitExpr` sites set the filter fields. +- 2026-07-04 — E1 tests green: flipped the old "payload-filter body rejected" parser test to a parse-success assertion (bare `filter_len == 0`; filtered `filter_len == 1` with the recorded field name/kind); added a `global_event` bare-vs-filtered + trailing-comma test. Full `zig build test` = 975/992 pass, 0 failed (17 pre-existing skips); `zig fmt --check` + `zig build lint` clean. + ## Recorded deviations *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 40c7d89..2b53dff 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -990,12 +990,18 @@ pub const AwaitTargetKind = enum { /// the duration/tick expr (`wait`/`wait_unscaled`) or the awaited future /// (`future`), else `NodeId.none`; `entity_expr` is the `entity_event` entity, /// else `NodeId.none`; `event_type` is the event type name for the two event -/// forms, else `0`. +/// forms, else `0`. `filter_start` / `filter_len` index a run of +/// `arena.struct_lit_fields` — the optional payload filter of the two event +/// forms (`entity_event` / `global_event`), each `IDENT : expression` an +/// equality predicate; `(0, 0)` when no filter body is present (M1.0.14, +/// `etch-grammar.md` §4.2). pub const AwaitExpr = struct { target_kind: AwaitTargetKind, arg_expr: NodeId, entity_expr: NodeId, event_type: StringId, + filter_start: u32, + filter_len: u32, }; /// One branch of a `race` / `sync` statement (M1.0.12 E2, `etch-grammar.md` diff --git a/src/etch/parser.zig b/src/etch/parser.zig index a7d073e..40cef1a 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -6532,6 +6532,35 @@ pub const Parser = struct { }); } + /// Parse the optional `{ field_init {"," field_init} [","] }` payload-filter + /// body of an event await target (`entity_event(e, T { … })` / + /// `global_event(T { … })`, M1.0.14, `etch-grammar.md` §4.2). Shares the + /// `struct_lit_fields` slab and the struct-literal loop shape: each + /// `IDENT : expression` is an equality predicate; the spread form `..base` + /// is rejected (the filter is field-equality only). The caller has + /// confirmed `peek() == {`. Returns the `(start, len)` run into + /// `arena.struct_lit_fields`; field values re-enable struct literals inside. + fn parseEventFilterBody(self: *Parser) ParseError!struct { start: u32, len: u32 } { + _ = try self.advance(); // '{' + const saved = self.no_struct_lit; + self.no_struct_lit = false; + defer self.no_struct_lit = saved; + const start: u32 = @intCast(self.arena.struct_lit_fields.items.len); + while (self.peek() != .rbrace and self.peek() != .eof) { + if (self.peek() == .dotdot) { + return self.parseErr(self.peekSpan(), "event payload-filter spread '..base' is not supported (the filter is field-equality only)"); + } + const fname = try self.expect(.ident, "expected field name in event payload filter"); + _ = try self.expect(.colon, "expected ':' after payload-filter field name"); + const value = try self.parseExpr(0); + try self.arena.struct_lit_fields.append(self.gpa, .{ .name = try self.internSlice(fname.span), .value = value }); + if (!try self.match(.comma)) break; + } + _ = try self.expect(.rbrace, "expected '}' to close the event payload filter"); + const len: u32 = @as(u32, @intCast(self.arena.struct_lit_fields.items.len)) - start; + return .{ .start = start, .len = len }; + } + /// Parse `await ` (M0.8 E3 sub-slice B, `etch-grammar.md` §4.2 /// `await_target`). `wait` / `wait_unscaled` / `entity_event` / /// `global_event` are contextual builtins recognised by lexeme when @@ -6555,6 +6584,8 @@ pub const Parser = struct { .arg_expr = arg, .entity_expr = NodeId.none, .event_type = 0, + .filter_start = 0, + .filter_len = 0, }, .{ .byte_start = kw_span.byte_start, .byte_end = closing.span.byte_end }); } if (std.mem.eql(u8, name, "entity_event")) { @@ -6564,10 +6595,14 @@ pub const Parser = struct { _ = try self.expect(.comma, "expected ',' between entity and event type in entity_event(...)"); const ty = try self.expect(.type_ident, "expected event type (TYPE_IDENT) in entity_event(...)"); const event_type = try self.internSlice(ty.span); - // The optional `entity_event(e, T { ... })` payload-filter body is - // a post-Phase-0 feature — reject it rather than silently drop it. + // Optional `entity_event(e, T { … })` payload filter (M1.0.14, + // §4.2): a field-equality predicate run into `struct_lit_fields`. + var filter_start: u32 = 0; + var filter_len: u32 = 0; if (self.peek() == .lbrace) { - return self.parseErr(self.peekSpan(), "entity_event payload-filter body is not supported in M0.8 (T2 / Phase 2)"); + const run = try self.parseEventFilterBody(); + filter_start = run.start; + filter_len = run.len; } const closing = try self.expect(.rparen, "expected ')' to close entity_event(...)"); return try self.arena.addAwaitExpr(self.gpa, .{ @@ -6575,6 +6610,8 @@ pub const Parser = struct { .arg_expr = NodeId.none, .entity_expr = entity, .event_type = event_type, + .filter_start = filter_start, + .filter_len = filter_len, }, .{ .byte_start = kw_span.byte_start, .byte_end = closing.span.byte_end }); } if (std.mem.eql(u8, name, "global_event")) { @@ -6582,12 +6619,23 @@ pub const Parser = struct { _ = try self.advance(); // '(' const ty = try self.expect(.type_ident, "expected event type (TYPE_IDENT) in global_event(...)"); const event_type = try self.internSlice(ty.span); + // Optional `global_event(T { … })` payload filter (M1.0.14, §4.2 + // erratum aligning the grammar on the §9.4 example). + var filter_start: u32 = 0; + var filter_len: u32 = 0; + if (self.peek() == .lbrace) { + const run = try self.parseEventFilterBody(); + filter_start = run.start; + filter_len = run.len; + } const closing = try self.expect(.rparen, "expected ')' to close global_event(...)"); return try self.arena.addAwaitExpr(self.gpa, .{ .target_kind = .global_event, .arg_expr = NodeId.none, .entity_expr = NodeId.none, .event_type = event_type, + .filter_start = filter_start, + .filter_len = filter_len, }, .{ .byte_start = kw_span.byte_start, .byte_end = closing.span.byte_end }); } } @@ -6599,6 +6647,8 @@ pub const Parser = struct { .arg_expr = fut, .entity_expr = NodeId.none, .event_type = 0, + .filter_start = 0, + .filter_len = 0, }, .{ .byte_start = kw_span.byte_start, .byte_end = fut_span.byte_end }); } @@ -8494,9 +8544,10 @@ test "parser: async rule parses with await wait + global_event targets (M0.8 E3 const aw1 = result.ast.awaitExpr(e1); try std.testing.expectEqual(ast_mod.AwaitTargetKind.global_event, aw1.target_kind); try std.testing.expectEqualStrings("Done", result.ast.strings.slice(aw1.event_type)); + try std.testing.expectEqual(@as(u32, 0), aw1.filter_len); // bare form: no payload filter } -test "parser: await entity_event(entity, T) parses; payload-filter body rejected (M0.8 E3 sub-slice B)" { +test "parser: await entity_event(entity, T) parses bare and with payload filter (M1.0.14 E1)" { const gpa = std.testing.allocator; var result = try parse(gpa, \\event Hit { } @@ -8517,16 +8568,66 @@ test "parser: await entity_event(entity, T) parses; payload-filter body rejected try std.testing.expectEqual(ast_mod.AwaitTargetKind.entity_event, aw.target_kind); try std.testing.expectEqualStrings("Hit", result.ast.strings.slice(aw.event_type)); try std.testing.expect(!aw.entity_expr.isNone()); + try std.testing.expectEqual(@as(u32, 0), aw.filter_len); // bare form: no payload filter - // The optional `entity_event(e, T { ... })` payload-filter body is rejected. - var bad = try parse(gpa, + // M1.0.14: the optional `entity_event(e, T { … })` payload-filter body now + // PARSES (the M0.8 rejection is lifted), recording an equality-predicate run. + var filtered = try parse(gpa, \\event Hit { } \\async rule watch(target: Entity) { - \\ await entity_event(target, Hit { x: 1 }) + \\ await entity_event(target, Hit { damage: 1 }) + \\} + ); + defer filtered.deinit(gpa); + if (filtered.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{filtered.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + const frule = filtered.ast.rule_decls.items[0]; + const fs0: ast_mod.NodeId = @bitCast(filtered.ast.extra.items[frule.body_start]); + const fe0: ast_mod.NodeId = @bitCast(filtered.ast.stmtData(fs0)); + const faw = filtered.ast.awaitExpr(fe0); + try std.testing.expectEqual(ast_mod.AwaitTargetKind.entity_event, faw.target_kind); + try std.testing.expectEqual(@as(u32, 1), faw.filter_len); + const ffield = filtered.ast.struct_lit_fields.items[faw.filter_start]; + try std.testing.expectEqualStrings("damage", filtered.ast.strings.slice(ffield.name)); + try std.testing.expectEqual(ast_mod.ExprKind.int_lit, filtered.ast.exprKind(ffield.value)); +} + +test "parser: await global_event(T) parses bare and with payload filter + trailing comma (M1.0.14 E1)" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\event Quest { } + \\async rule watch() { + \\ await global_event(Quest) + \\ await global_event(Quest { id: 1, tier: 2, }) \\} ); - defer bad.deinit(gpa); - try std.testing.expect(bad.diagnostics.len > 0); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + const rule = result.ast.rule_decls.items[0]; + try std.testing.expectEqual(@as(u32, 2), rule.body_len); + + // stmt 0: bare `global_event(Quest)` — no filter. + const s0: ast_mod.NodeId = @bitCast(result.ast.extra.items[rule.body_start]); + const e0: ast_mod.NodeId = @bitCast(result.ast.stmtData(s0)); + const aw0 = result.ast.awaitExpr(e0); + try std.testing.expectEqual(ast_mod.AwaitTargetKind.global_event, aw0.target_kind); + try std.testing.expectEqual(@as(u32, 0), aw0.filter_len); + + // stmt 1: `global_event(Quest { id: 1, tier: 2, })` — 2 filter fields, trailing comma accepted. + const s1: ast_mod.NodeId = @bitCast(result.ast.extra.items[rule.body_start + 1]); + const e1: ast_mod.NodeId = @bitCast(result.ast.stmtData(s1)); + const aw1 = result.ast.awaitExpr(e1); + try std.testing.expectEqual(ast_mod.AwaitTargetKind.global_event, aw1.target_kind); + try std.testing.expectEqual(@as(u32, 2), aw1.filter_len); + const f0 = result.ast.struct_lit_fields.items[aw1.filter_start]; + const f1 = result.ast.struct_lit_fields.items[aw1.filter_start + 1]; + try std.testing.expectEqualStrings("id", result.ast.strings.slice(f0.name)); + try std.testing.expectEqualStrings("tier", result.ast.strings.slice(f1.name)); } test "parser builds a data table declaration with entries + spread (M0.8 E4)" { From db7f5ff815566336aeac740bee0a27a0d26a8bb1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 17:42:43 +0200 Subject: [PATCH 05/24] feat(etch): validate entity-scoped event await targets (M1.0.14 E2) --- src/etch/ast.zig | 75 ++++++++++ src/etch/diagnostics.zig | 10 ++ src/etch/interp.zig | 22 ++- src/etch/types.zig | 307 +++++++++++++++++++++++++++++++++------ 4 files changed, 360 insertions(+), 54 deletions(-) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 2b53dff..79a8d66 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -403,6 +403,23 @@ pub const EventDecl = struct { annotations_len: u32, }; +/// Resolution of an event's designated `Entity` field for `await +/// entity_event` scoping (M1.0.14, `etch-reference-part1.md` §9.4): the field +/// annotated `@entity_target`, else the single `Entity`-typed field, else a +/// diagnostic condition. Produced by `AstArena.resolveEventEntityTarget` — the +/// single home of the designated-field policy, consumed by the type-checker +/// (to diagnose `E0908`/`E0909` at the `await` site) and by the interpreter +/// (to capture the field at suspension, where it always resolves — the program +/// has passed `weld check`). +pub const EntityTargetResolution = union(enum) { + /// The designated field: its ordinal in the event's field run and its name. + field: struct { index: u32, name: StringId }, + /// No `Entity`-typed field at all → `E0908 EventNotEntityScoped`. + none_entity, + /// Two or more `Entity` fields and no `@entity_target` → `E0909 AmbiguousEventEntityTarget`. + ambiguous, +}; + /// Side-slab entry for a `tags { ... }` hierarchical declaration (M0.8 E3, /// `etch-grammar.md` §5.11 `tags_decl`). The hierarchy is stored flat and /// parent-linked across `arena.tag_namespaces` + `arena.tag_leaves`, appended @@ -2342,6 +2359,10 @@ pub const AnnotationKind = enum { on_replaced, on_spawned, on_despawned, + // M1.0.14 E2 — @entity_target: designates the field matched by + // `await entity_event(e, T)` (§18.10). Field-level, event-only, + // Entity-typed, at most one per event (validated in the type-checker). + entity_target, pub fn fromName(name: []const u8) AnnotationKind { if (std.mem.eql(u8, name, "phase")) return .phase; @@ -2369,6 +2390,7 @@ pub const AnnotationKind = enum { if (std.mem.eql(u8, name, "on_replaced")) return .on_replaced; if (std.mem.eql(u8, name, "on_spawned")) return .on_spawned; if (std.mem.eql(u8, name, "on_despawned")) return .on_despawned; + if (std.mem.eql(u8, name, "entity_target")) return .entity_target; return .custom; } @@ -2966,6 +2988,58 @@ pub const AstArena = struct { return current; } + /// Whether `field` carries the builtin annotation `kind` (M1.0.14). + pub fn fieldHasAnnotation(self: *const AstArena, field: Field, kind: AnnotationKind) bool { + var i: u32 = 0; + while (i < field.annotations_len) : (i += 1) { + if (self.annot_pool.items[field.annotations_extra + i].kind == kind) return true; + } + return false; + } + + /// Whether `field`'s declared type is the builtin `Entity` (resolved + /// through the `type` alias chain). An optional (`Entity?`) or any other + /// shape is NOT `Entity` — the `await entity_event` target must be a bare + /// `Entity` (M1.0.14, §9.4). + pub fn fieldTypeIsEntity(self: *const AstArena, field: Field) bool { + if (self.typeNodeKind(field.type_node) != .named) return false; + const named = self.named_types.items[self.typeNodeData(field.type_node)]; + return std.mem.eql(u8, self.strings.slice(self.resolveTypeAliasName(named.name)), "Entity"); + } + + /// Resolve the event's designated `Entity` field for `await entity_event` + /// scoping (M1.0.14, §9.4 normative order): a field annotated + /// `@entity_target` wins; else the single `Entity`-typed field; else a + /// diagnostic condition (`none_entity` / `ambiguous`). The `@entity_target` + /// field is returned as written — its Entity-typing and uniqueness are + /// validated at the event declaration (E2), so a program reaching the + /// interpreter has already passed those checks. This is the ONE + /// implementation of the designated-field policy (`EntityTargetResolution`). + pub fn resolveEventEntityTarget(self: *const AstArena, decl: EventDecl) EntityTargetResolution { + // 1. `@entity_target`-annotated field wins. + var i: u32 = 0; + while (i < decl.fields_len) : (i += 1) { + const field = self.fields.items[decl.fields_start + i]; + if (self.fieldHasAnnotation(field, .entity_target)) { + return .{ .field = .{ .index = i, .name = field.name } }; + } + } + // 2. Else the single `Entity`-typed field. + var found_index: ?u32 = null; + var found_name: StringId = 0; + i = 0; + while (i < decl.fields_len) : (i += 1) { + const field = self.fields.items[decl.fields_start + i]; + if (self.fieldTypeIsEntity(field)) { + if (found_index != null) return .ambiguous; // ≥2 Entity fields → E0909 + found_index = i; + found_name = field.name; + } + } + if (found_index) |idx| return .{ .field = .{ .index = idx, .name = found_name } }; + return .none_entity; // zero Entity fields → E0908 + } + pub fn addStmt(self: *AstArena, gpa: std.mem.Allocator, kind: StmtKind, data: u32, span: SourceSpan) !NodeId { const idx: u28 = @intCast(self.stmts.len); try self.stmts.append(gpa, .{ .kind = kind, .data = data, .span = span }); @@ -3836,5 +3910,6 @@ test "AstArena timer statement round-trips through the timer_stmts slab (M1.0.13 test "AnnotationKind.fromName recognises builtin names" { try std.testing.expectEqual(AnnotationKind.phase, AnnotationKind.fromName("phase")); try std.testing.expectEqual(AnnotationKind.range, AnnotationKind.fromName("range")); + try std.testing.expectEqual(AnnotationKind.entity_target, AnnotationKind.fromName("entity_target")); try std.testing.expectEqual(AnnotationKind.custom, AnnotationKind.fromName("totally_unknown")); } diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 92486d0..b4a1baa 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -366,6 +366,8 @@ pub const DiagnosticCode = enum { unconsumed_async_effect, // M1.0.12 E3 — E0905 UnconsumedAsyncEffect (bare async call in an async context: neither awaited nor launched via spawn/branch/race/sync) illegal_return_in_concurrency_branch, // M1.0.12 E3 — E0906 IllegalReturnInConcurrencyBranch (return in a sync branch or a branch/spawn body; legal only in a race branch) control_flow_escapes_task_branch, // M1.0.12 E3 — E0907 ControlFlowEscapesTaskBranch (break/continue targeting a loop outside the concurrency construct) + event_not_entity_scoped, // M1.0.14 E2 — E0908 EventNotEntityScoped (`await entity_event(e, T)` where T has no `Entity` field) + ambiguous_event_entity_target, // M1.0.14 E2 — E0909 AmbiguousEventEntityTarget (T has multiple `Entity` fields with no `@entity_target`) /// Canonical short code, e.g. `"E0001"`. pub fn code(self: DiagnosticCode) []const u8 { @@ -557,6 +559,8 @@ pub const DiagnosticCode = enum { .unconsumed_async_effect => "E0905", .illegal_return_in_concurrency_branch => "E0906", .control_flow_escapes_task_branch => "E0907", + .event_not_entity_scoped => "E0908", + .ambiguous_event_entity_target => "E0909", }; } @@ -750,6 +754,8 @@ pub const DiagnosticCode = enum { .unconsumed_async_effect => "UnconsumedAsyncEffect", .illegal_return_in_concurrency_branch => "IllegalReturnInConcurrencyBranch", .control_flow_escapes_task_branch => "ControlFlowEscapesTaskBranch", + .event_not_entity_scoped => "EventNotEntityScoped", + .ambiguous_event_entity_target => "AmbiguousEventEntityTarget", }; } }; @@ -850,4 +856,8 @@ test "DiagnosticCode code and name are stable cross-version" { try std.testing.expectEqualStrings("E1210", DiagnosticCode.unknown_component_in_when.code()); try std.testing.expectEqualStrings("UnknownComponentInWhen", DiagnosticCode.unknown_component_in_when.name()); try std.testing.expectEqualStrings("E1213", DiagnosticCode.resource_expected_in_when.code()); + try std.testing.expectEqualStrings("E0908", DiagnosticCode.event_not_entity_scoped.code()); + try std.testing.expectEqualStrings("EventNotEntityScoped", DiagnosticCode.event_not_entity_scoped.name()); + try std.testing.expectEqualStrings("E0909", DiagnosticCode.ambiguous_event_entity_target.code()); + try std.testing.expectEqualStrings("AmbiguousEventEntityTarget", DiagnosticCode.ambiguous_event_entity_target.name()); } diff --git a/src/etch/interp.zig b/src/etch/interp.zig index aa58465..95f1d17 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -10087,14 +10087,24 @@ test "await entity_event still fails loud (partition boundary intact, M1.0.13 E5 const gpa = std.testing.allocator; // `wait_unscaled` graduated to a working await target with the M1.0.13 // time subsystem (E5); the remaining partition boundary is - // `entity_event` — needs entity-scoped events (M1.0.14). + // `entity_event` — needs entity-scoped events (M1.0.14). The program is + // now type-VALID (M1.0.14 E2 validates the target: `e` is an Entity and + // `Ev` has a single Entity field), so it reaches the interpreter, where + // `entity_event` still fails loud (execution lands in E3). A `seed` rule + // spawns the matched entity so the entity-based awaiter actually runs. try std.testing.expect((try asyncFailLoudCount(gpa, - \\event Ev { } - \\resource Out { n: int = 0 } - \\async rule r() - \\ when resource Out + \\component M { } + \\event Ev { who: Entity } + \\resource Boot { on: bool = true } + \\rule seed() + \\ when resource Boot + \\{ + \\ spawn(M { }) + \\} + \\async rule r(e: Entity) + \\ when e has M \\{ - \\ await entity_event(get(Out), Ev) + \\ await entity_event(e, Ev) \\} )) >= 1); // The M1.0.11 case — `await` on a stored non-TaskHandle value — is diff --git a/src/etch/types.zig b/src/etch/types.zig index c78c50e..e6112f8 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -350,6 +350,9 @@ fn annotationAppliesTo(kind: ast_mod.AnnotationKind, target: AnnotTarget) bool { .on_added, .on_removed, .on_replaced, .on_spawned, .on_despawned => target == .rule, .unit, .range, .hidden, .readonly, .replicated => target == .field, .networked => target == .event, + // M1.0.14 E2 — @entity_target decorates an event field; event-only + + // Entity-typed + uniqueness are enforced at the declaration (E0502). + .entity_target => target == .field, .shader_fn => target == .function, .loc => false, }; @@ -3297,6 +3300,8 @@ pub const TypeChecker = struct { // Field name uniqueness within parent: collect into a small set. var seen: std.AutoHashMapUnmanaged(StringId, void) = .empty; defer seen.deinit(self.gpa); + // M1.0.14 E2 — `@entity_target` is at most one per event (across this decl's fields). + var entity_target_seen = false; var i: u32 = 0; while (i < fields_len) : (i += 1) { @@ -3306,6 +3311,24 @@ pub const TypeChecker = struct { // Field-level annotation applicability (M0.8 D-S3-annot-applicability). try self.validateAnnotations(field.annotations_extra, field.annotations_len, .field); + // M1.0.14 E2 — `@entity_target` field-annotation placement (§18.10): + // it designates the `await entity_event` match field, so it is + // event-only, `Entity`-typed, and at most one per event. Misuse → + // E0502 (existing kind, no new codes). The designated-field POLICY + // lives in `arena.resolveEventEntityTarget` (consumed at the await site). + if (self.arena.fieldHasAnnotation(field, .entity_target)) { + const at_span = self.arena.typeNodeSpan(field.type_node); + if (origin != .event_) { + try self.emit(.annotation_misapplied, .error_, at_span, "annotation '@entity_target' is only valid on an event field", .{}); + } else if (!self.arena.fieldTypeIsEntity(field)) { + try self.emit(.annotation_misapplied, .error_, at_span, "annotation '@entity_target' must be on an Entity-typed field", .{}); + } + if (entity_target_seen) { + try self.emit(.annotation_misapplied, .error_, at_span, "at most one '@entity_target' per event", .{}); + } + entity_target_seen = true; + } + // Check uniqueness. const gop = try seen.getOrPut(self.gpa, field.name); if (gop.found_existing) { @@ -4185,6 +4208,60 @@ pub const TypeChecker = struct { } } + /// Validate a run of `struct_lit_fields[start .. start+len]` against event + /// `T`'s declared fields (M1.0.14 E2). Shared by `emit` and the + /// `entity_event` / `global_event` payload filter: each `IDENT : + /// expression` must name a field of `T` (`E1211 InvalidFieldFilter` else) + /// and its value must fit that field's declared type (`E0200 TypeMismatch` + /// else). `event_decl_index` indexes `arena.event_decls`. + fn checkEventFieldRun(self: *TypeChecker, event_decl_index: u32, start: u32, len: u32, ctx_opt: ?*RuleCtx) !void { + const decl = self.arena.event_decls.items[event_decl_index]; + var i: u32 = 0; + while (i < len) : (i += 1) { + const flit = self.arena.struct_lit_fields.items[start + i]; + var declared: ?ResolvedType = null; + var f_i: u32 = 0; + while (f_i < decl.fields_len) : (f_i += 1) { + const f = self.arena.fields.items[decl.fields_start + f_i]; + if (f.name == flit.name) { + declared = self.namedTypeToResolved(f.type_node); + break; + } + } + const actual = self.synthExpr(flit.value, ctx_opt); + if (declared) |d| { + if (d == .builtin and actual == .builtin and !self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "event field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); + } + } else { + try self.emit(.invalid_field_filter, .error_, self.arena.exprSpan(flit.value), "event '{s}' has no field '{s}'", .{ self.arena.strings.slice(decl.name), self.arena.strings.slice(flit.name) }); + } + } + } + + /// Type-check an `await entity_event` / `await global_event` target + /// (M1.0.14 E2). `T` must be a declared `event` (`E0102` — fix-as-you-go: + /// `global_event` previously accepted an undeclared `T` silently). For an + /// entity-scoped target the designated field must resolve (`E0908`/`E0909` + /// at the await site, via the shared `arena.resolveEventEntityTarget`). The + /// optional payload filter is validated for both targets. + fn checkEventTarget(self: *TypeChecker, await_id: NodeId, aw: ast_mod.AwaitExpr, entity_scoped: bool, ctx_opt: ?*RuleCtx) !void { + const sym = self.symbols.get(aw.event_type); + if (sym == null or sym.?.kind != .event_) { + try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(await_id), "'{s}' is not a declared event", .{self.arena.strings.slice(aw.event_type)}); + return; + } + const decl_index = self.arena.itemData(sym.?.item_id); + if (entity_scoped) { + switch (self.arena.resolveEventEntityTarget(self.arena.event_decls.items[decl_index])) { + .field => {}, + .none_entity => try self.emit(.event_not_entity_scoped, .error_, self.arena.exprSpan(await_id), "event '{s}' has no Entity field — 'await entity_event' requires a designated entity target", .{self.arena.strings.slice(aw.event_type)}), + .ambiguous => try self.emit(.ambiguous_event_entity_target, .error_, self.arena.exprSpan(await_id), "event '{s}' has multiple Entity fields — annotate the target field with '@entity_target'", .{self.arena.strings.slice(aw.event_type)}), + } + } + try self.checkEventFieldRun(decl_index, aw.filter_start, aw.filter_len, ctx_opt); + } + fn checkStmt(self: *TypeChecker, ctx: *RuleCtx, stmt_id: NodeId) !void { const kind = self.arena.stmtKind(stmt_id); const data = self.arena.stmtData(stmt_id); @@ -4491,28 +4568,9 @@ pub const TypeChecker = struct { if (sym == null or sym.?.kind != .event_) { try self.emit(.undefined_symbol, .error_, self.arena.stmtSpan(stmt_id), "'{s}' is not a declared event", .{self.arena.strings.slice(em.event_type)}); } else { - const decl = self.arena.event_decls.items[self.arena.itemData(sym.?.item_id)]; - var i: u32 = 0; - while (i < em.fields_len) : (i += 1) { - const flit = self.arena.struct_lit_fields.items[em.fields_start + i]; - var declared: ?ResolvedType = null; - var f_i: u32 = 0; - while (f_i < decl.fields_len) : (f_i += 1) { - const f = self.arena.fields.items[decl.fields_start + f_i]; - if (f.name == flit.name) { - declared = self.namedTypeToResolved(f.type_node); - break; - } - } - const actual = self.synthExpr(flit.value, ctx); - if (declared) |d| { - if (d == .builtin and actual == .builtin and !self.literalTypeFits(d.builtin, flit.value, actual.builtin)) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(flit.value), "emit field '{s}' value type does not match its declared type", .{self.arena.strings.slice(flit.name)}); - } - } else { - try self.emit(.invalid_field_filter, .error_, self.arena.stmtSpan(stmt_id), "event '{s}' has no field '{s}'", .{ self.arena.strings.slice(em.event_type), self.arena.strings.slice(flit.name) }); - } - } + // Field-value validation is shared with the `entity_event` / + // `global_event` payload filter (M1.0.14 E2). + try self.checkEventFieldRun(self.arena.itemData(sym.?.item_id), em.fields_start, em.fields_len, ctx); } }, .tag_mutation_stmt => { @@ -5002,33 +5060,50 @@ pub const TypeChecker = struct { try self.emit(.async_call_in_non_async_context, .error_, self.arena.exprSpan(id), "`await` is only allowed in an `async fn` or `async rule`", .{}); } const aw = self.arena.awaitExpr(id); - if (aw.target_kind == .future) { - // The direct call is CONSUMED by this await (M1.0.12 E3, - // §9.2) — exempt from E0905 while it is synthesized. Only - // the target itself is exempt: an async call among its - // ARGUMENTS is still bare. - const saved_awaited = self.awaited_call; - self.awaited_call = aw.arg_expr; - defer self.awaited_call = saved_awaited; - const t = try self.synthExprE(aw.arg_expr, ctx_opt); - const ak = self.arena.exprKind(aw.arg_expr); - if (ak == .fn_call or ak == .method_call) return t; - // Non-call target: the handle-await form (M1.0.12 E3, - // §9.8) — the target must be a TaskHandle. The result is - // unit in Phase 1 (spawn bodies have no value channel — - // brief Notes); `unknown` ≈ unit, the house convention. - if (t == .builtin and t.builtin == .task_handle) return ResolvedType.unknown; - // A `TimerHandle` is NOT awaitable (M1.0.13 E4, §9.10): - // a timer is not a task — no join semantics. Precise - // message ahead of the generic rejection below. - if (t == .builtin and t.builtin == .timer_handle) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.arg_expr), "a TimerHandle is not awaitable — a timer is not a task (its only operation is 'cancel()', par. 9.10)", .{}); + switch (aw.target_kind) { + // `wait` / `wait_unscaled` take a Duration LITERAL, validated + // at the interpreter (literal-only, M1.0.11/13) — nothing to + // synthesize here. + .wait, .wait_unscaled => {}, + .entity_event => { + // The entity operand (evaluated once at suspension in the + // interpreter) must type `Entity` — reuse E0200 (M1.0.14 E2; + // this operand was never synthesized before). + const et = self.synthExpr(aw.entity_expr, ctx_opt); + if (!(et == .builtin and et.builtin == .entity)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.entity_expr), "the first argument of entity_event(e, T) must be an Entity", .{}); + } + try self.checkEventTarget(id, aw, true, ctx_opt); + }, + .global_event => try self.checkEventTarget(id, aw, false, ctx_opt), + .future => { + // The direct call is CONSUMED by this await (M1.0.12 E3, + // §9.2) — exempt from E0905 while it is synthesized. Only + // the target itself is exempt: an async call among its + // ARGUMENTS is still bare. + const saved_awaited = self.awaited_call; + self.awaited_call = aw.arg_expr; + defer self.awaited_call = saved_awaited; + const t = try self.synthExprE(aw.arg_expr, ctx_opt); + const ak = self.arena.exprKind(aw.arg_expr); + if (ak == .fn_call or ak == .method_call) return t; + // Non-call target: the handle-await form (M1.0.12 E3, + // §9.8) — the target must be a TaskHandle. The result is + // unit in Phase 1 (spawn bodies have no value channel — + // brief Notes); `unknown` ≈ unit, the house convention. + if (t == .builtin and t.builtin == .task_handle) return ResolvedType.unknown; + // A `TimerHandle` is NOT awaitable (M1.0.13 E4, §9.10): + // a timer is not a task — no join semantics. Precise + // message ahead of the generic rejection below. + if (t == .builtin and t.builtin == .timer_handle) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.arg_expr), "a TimerHandle is not awaitable — a timer is not a task (its only operation is 'cancel()', par. 9.10)", .{}); + return ResolvedType.unknown; + } + if (t != .unknown) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.arg_expr), "await target must be a direct async call or a TaskHandle", .{}); + } return ResolvedType.unknown; - } - if (t != .unknown) { - try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.arg_expr), "await target must be a direct async call or a TaskHandle", .{}); - } - return ResolvedType.unknown; + }, } return ResolvedType.unknown; }, @@ -8095,6 +8170,142 @@ test "type-checker validates event declaration + emit (M0.8 E3)" { try expectAnyCode(not_event.diagnostics.items, .undefined_symbol); } +test "type-checker validates entity_event / global_event await targets (M1.0.14 E2)" { + const gpa = std.testing.allocator; + + // Non-Entity first operand of `entity_event` → E0200 (the operand was + // never synthesized before M1.0.14). `Ev` has an Entity field so E0908 does + // not also fire. + var bad_operand = try parseAndCheck(gpa, + \\component C { } + \\event Ev { who: Entity } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(5, Ev) + \\} + ); + defer bad_operand.deinit(gpa); + try expectAnyCode(bad_operand.diagnostics.items, .type_mismatch); + + // Undeclared event on `entity_event` → E0102. + var undecl_ent = try parseAndCheck(gpa, + \\component C { } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(e, Ghost) + \\} + ); + defer undecl_ent.deinit(gpa); + try expectAnyCode(undecl_ent.diagnostics.items, .undefined_symbol); + + // Undeclared event on `global_event` → E0102 (fix-as-you-go: previously + // accepted silently). + var undecl_glob = try parseAndCheck(gpa, + \\resource R { n: int = 0 } + \\async rule r() + \\ when resource R + \\{ + \\ await global_event(Ghost) + \\} + ); + defer undecl_glob.deinit(gpa); + try expectAnyCode(undecl_glob.diagnostics.items, .undefined_symbol); + + // Zero Entity fields → E0908 EventNotEntityScoped. + var no_entity = try parseAndCheck(gpa, + \\component C { } + \\event Ev { amount: int = 0 } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(e, Ev) + \\} + ); + defer no_entity.deinit(gpa); + try expectAnyCode(no_entity.diagnostics.items, .event_not_entity_scoped); + + // Two Entity fields, no `@entity_target` → E0909 AmbiguousEventEntityTarget. + var ambiguous = try parseAndCheck(gpa, + \\component C { } + \\event Ev { source: Entity, target: Entity } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(e, Ev) + \\} + ); + defer ambiguous.deinit(gpa); + try expectAnyCode(ambiguous.diagnostics.items, .ambiguous_event_entity_target); + + // Two Entity fields WITH `@entity_target` → clean (annotation disambiguates). + var annotated = try parseAndCheck(gpa, + \\component C { } + \\event Ev { source: Entity, @entity_target target: Entity } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(e, Ev) + \\} + ); + defer annotated.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), annotated.diagnostics.items.len); + + // `@entity_target` on a non-Entity field → E0502 (existing kind). + var at_non_entity = try parseAndCheck(gpa, + \\event Ev { @entity_target amount: int = 0, who: Entity } + ); + defer at_non_entity.deinit(gpa); + try expectAnyCode(at_non_entity.diagnostics.items, .annotation_misapplied); + + // Duplicate `@entity_target` in one event → E0502. + var at_dup = try parseAndCheck(gpa, + \\event Ev { @entity_target a: Entity, @entity_target b: Entity } + ); + defer at_dup.deinit(gpa); + try expectAnyCode(at_dup.diagnostics.items, .annotation_misapplied); + + // Filter naming a field the event does not declare → E1211. + var filter_unknown = try parseAndCheck(gpa, + \\component C { } + \\event Ev { who: Entity } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(e, Ev { nope: 1 }) + \\} + ); + defer filter_unknown.deinit(gpa); + try expectAnyCode(filter_unknown.diagnostics.items, .invalid_field_filter); + + // Filter value type does not match the field's declared type → E0200. + var filter_type = try parseAndCheck(gpa, + \\component C { } + \\event Ev { amount: int = 0, who: Entity } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(e, Ev { amount: true }) + \\} + ); + defer filter_type.deinit(gpa); + try expectAnyCode(filter_type.diagnostics.items, .type_mismatch); + + // Filtered `global_event` type-checks clean (filter on both targets §4.2). + var filtered_global = try parseAndCheck(gpa, + \\event Ev { amount: int = 0 } + \\resource R { n: int = 0 } + \\async rule r() + \\ when resource R + \\{ + \\ await global_event(Ev { amount: 3 }) + \\} + ); + defer filtered_global.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), filtered_global.diagnostics.items.len); +} + test "type-checker validates @networked on event, rejects @config on event (M0.8 E3)" { const gpa = std.testing.allocator; From 25c34bf2bf8ffe59d5bbff15ade2cc95824860b2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 17:42:52 +0200 Subject: [PATCH 06/24] docs(brief): journal E2 (type-checker validation) --- briefs/M1.0.14-entity-scoped-events.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 1bd8ddc..b2fa192 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -146,6 +146,9 @@ None (no perf target for this milestone). - 2026-07-04 — Code comprehension (pre-E1): mapped the 5 target surfaces. `AwaitExpr` (ast.zig:994) is a flat node with no field-run; `struct_lit_fields` (ast.zig:2570, `StructLitField{name,value}`) is the shared slab (`EmitStmt`/`StructLitExpr` `(start,len)` convention). Parser `parseAwaitExpr` (parser.zig:6543): entity_event arm rejects the `{`-body (M0.8), global_event has no filter path. Confirmed for later gates: diagnostics `E0908`/`E0909` absent (insert after `control_flow_escapes_task_branch`); interp `WakeCond.global_event: StringId` + `EventStore` (`EventVal{type_name, fields:[]StructField{name,Value}}`, per-tick `clear`) + `Value.eql` (entity_id/int/enum/string_id supported) + `task_children` range pattern + two fail-loud sites (interp.zig:3425, :2831); type-checker await arm returns `unknown` unvalidated for events, emit reuses an event field-iteration path (`event_decls`/`fields`), `@entity_target` unrecognized today (add `AnnotationKind.entity_target`). - 2026-07-04 — E1 (parser + AST): `AwaitExpr` gains `filter_start`/`filter_len` into `struct_lit_fields` (`(0,0)` absent). New `parser.parseEventFilterBody` parses the optional `{ IDENT : expr, … }` equality-predicate run (trailing comma accepted, spread rejected); wired into BOTH the `entity_event` arm (M0.8 rejection removed) and the `global_event` arm. All 4 `addAwaitExpr` sites set the filter fields. - 2026-07-04 — E1 tests green: flipped the old "payload-filter body rejected" parser test to a parse-success assertion (bare `filter_len == 0`; filtered `filter_len == 1` with the recorded field name/kind); added a `global_event` bare-vs-filtered + trailing-comma test. Full `zig build test` = 975/992 pass, 0 failed (17 pre-existing skips); `zig fmt --check` + `zig build lint` clean. +- 2026-07-04 — E2 (diagnostics + shared AST helper): `diagnostics.zig` gains `event_not_entity_scoped` (E0908) / `ambiguous_event_entity_target` (E0909) + code/name mappings + stability spot-checks. `ast.zig` (touched beyond the E2 file list — justified: the enum + a pure AST query belong here): `AnnotationKind.entity_target` + `fromName`; the SINGLE designated-field policy `AstArena.resolveEventEntityTarget` (`@entity_target` > single `Entity` field > `none_entity`/`ambiguous`) with `fieldHasAnnotation`/`fieldTypeIsEntity` — a pure AST query so the type-checker (diagnose) and the interpreter (E3 capture) share it with no interp→types dependency. +- 2026-07-04 — E2 (types.zig): `annotationAppliesTo(.entity_target, .field)`; `validateFieldsInDecl` validates `@entity_target` placement (event-only, `Entity`-typed, ≤1/event → E0502, existing kind). The await arm is now a target switch — entity operand synthesized + must be `Entity` (E0200, never synthesized before), `T` must be a declared `event` on BOTH targets (E0102, fix-as-you-go for `global_event`), designated field resolved for `entity_event` (E0908/E0909 at the await site via the shared helper), filter validated for both via the new shared `checkEventFieldRun` (emit refactored onto it — one field-run validation, E1211/E0200). +- 2026-07-04 — E2 fix-as-you-go (interp.zig, touched beyond the E2 file list — justified): the M1.0.13 partition-boundary test used a now-type-invalid program (`entity_event(get(Out), Ev)` — non-Entity operand + zero-Entity event); rewrote it to a type-valid `entity_event(e, Ev{who:Entity})` fed by a `seed` spawn so the entity awaiter runs and still hits the (untouched) interp fail-loud site. The E4 flip is unchanged. E2 test (10 sub-cases) green; full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` clean. ## Recorded deviations From 5589078ef00b145b0b51a92352fc43b157ef1b0d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 18:38:12 +0200 Subject: [PATCH 07/24] docs(brief): amend M1.0.14 scope after E2 round-trip --- briefs/M1.0.14-entity-scoped-events.md | 129 +++++++++++++------------ 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index b2fa192..38c24da 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -1,4 +1,4 @@ -# M1.0.14 — Entity-scoped events +# M1.0.14 — Entity-scoped events and entity-bound async rules > **Status:** ACTIVE > **Phase:** 1 @@ -16,63 +16,65 @@ ## Context -Phase 1, M1.0 series (M1.0.0–M1.0.20) closing the EBNF v0.6 execution gaps for the tree-walking interpreter (criterion C1.6). `entity_event` is the last `await` target (besides `future`) that parses and type-checks but fails loud at runtime (`interp.zig`, two sites marked M1.0.14). This milestone realizes `await entity_event(e, T [{ … }])` and the optional payload filter on **both** event targets (`global_event` included — grammar §4.2 erratum applied to the KB at milestone open), per the normative entity-scoping convention fixed in `etch-reference-part1.md §9.4`: designated `Entity` field (`@entity_target` > single structural `Entity` field > `E0908`/`E0909`), equality filter captured once at suspension, resume with unit. After this milestone, `future` is the sole remaining fail-loud `await` target. +Phase 1, M1.0 series (M1.0.0–M1.0.20) closing the EBNF v0.6 execution gaps for the tree-walking interpreter (criterion C1.6). `entity_event` is the last `await` target (besides `future`) that parses and type-checks but fails loud at runtime. This milestone realizes `await entity_event(e, T [{ … }])` and the optional payload filter on **both** event targets, per the normative convention in `etch-reference-part1.md §9.4` (designated `Entity` field: `@entity_target` > single structural field > `E0908`/`E0909`; equality filter captured once at suspension; resume with unit). **Amended after the E2 round-trip:** entity-bound `async rule` execution is absorbed into this milestone — the interpreter fails loud on any parameterized async rule (`runAsyncRule` first-reach guard), and no executable parameterless-body expression yields an `Entity`, so an end-to-end Etch `entity_event` awaiter is inexpressible without it. The four lifecycle rulings are normative in `etch-reference-part1.md §9.2`; the realization is `§9.12`. After this milestone, `future` is the sole remaining fail-loud `await` target. ## Scope -Executed **gate by gate E1→E4**, in order. Each gate is pushed and reviewed on the real diff before the next opens (cf. § Notes › Gate protocol). - -**E1 — Parser + AST: payload filters.** -- `ast.zig`: `AwaitExpr` gains a filter run — `filter_start: u32`, `filter_len: u32` into the existing `struct_lit_fields` slab (the `EmitStmt` shape); `(0, 0)` when absent. -- `parser.zig`: parse the optional `struct_literal_body` filter on `entity_event(e, T { … })` — **remove** the M0.8-era rejection ("payload-filter body is not supported in M0.8 (T2 / Phase 2)") — and on `global_event(T { … })` (`etch-grammar.md §4.2`, both productions). Bare forms unchanged. Trailing comma accepted (struct-literal convention). -- Parser tests updated: the existing "payload-filter body rejected" test flips to a parse-success assertion on the recorded fields run. - -**E2 — Type-checker: event-target validation, designated field, diagnostics.** -- The `entity_event` entity operand is synthesized and must type `Entity` (currently never synthesized — reuse existing type-mismatch machinery, no new code). -- For **both** event targets, `T` must be a declared `event` (reuse the `emit` path's validation kind — fix-as-you-go: `global_event` currently accepts an undeclared `T` silently). -- **Designated-field resolution** (`etch-reference-part1.md §9.4`, normative order): field annotated `@entity_target` > the single `Entity`-typed field > diagnostic at the `await` site — `E0908 EventNotEntityScoped` (zero `Entity` fields) / `E0909 AmbiguousEventEntityTarget` (two or more, no annotation). Implemented as a **single shared `pub` helper** operating on the event declaration, called by the type-checker (to diagnose) and later by the interpreter (E3, to capture the field) — the policy lives in exactly one place. -- `@entity_target` validation at the event declaration: at most one per event; the annotated field must be of type `Entity`; misuse rejected through **existing** diagnostic kinds (no new codes). -- Filter field validation for both targets: each `IDENT : expression` names an existing field of `T` and type-checks against that field's declared type (reuse the `emit` field-validation path and its kinds). -- `diagnostics.zig`: new kinds mapping to `E0908` / `E0909` (codes pre-assigned in `etch-diagnostics.md §12`). - -**E3 — Runtime realization.** -- `WakeCond` carries the entity-event condition: event type + target `EntityId` + designated field (`StringId`) + captured filter values; the `global_event` condition gains the optional captured filter (representation is implementer's choice — extend the existing variant or add one; semantics fixed). Captured filter values live in interpreter-owned storage referenced by range (the `task_children` pattern) or equivalent pointer-stable storage. -- `evalAwaitTarget`: the `entity_event` arm evaluates the entity expression **once** (must yield an entity value — defensive fail-loud otherwise), resolves the designated field via the E2 shared helper, and evaluates each filter expression **once** in the live scope, capturing values; the `global_event` arm captures its filter likewise. Neither expression set is re-evaluated at later polls. -- `asyncWakeFired`: the entity-event condition scans the per-tick `EventStore` — type match, designated-field equality against the target entity, and equality on every captured filter field; the filtered `global_event` condition matches type + filter. Equality covers the legally admitted event-field value kinds (verify the admitted set — POD scalars and any non-POD kinds events accept since M1.0.2 — and reuse the interpreter's existing value-equality machinery). -- The two `entity_event` fail-loud sites are removed: `stepBodyStmt` routes `.entity_event` through the wake-condition arm (suspend, `pending_bind` delivers **unit** at resume — no implicit `event` binding); `evalAwaitTarget` handles it. `.future` stays fail-loud at both sites. -- A matched event is **not consumed**: the wake does not remove it from the store; observers and multiple awaiters can fire on the same event instance in the same tick. Producer-before-awaiter rule-order requirement unchanged (same as `global_event`). - -**E4 — Integration + partition boundary.** -- Cross-tick integration tests (see Acceptance criteria): multi-awaiter wake on one event, `@on_event` observer coexistence, per-tick store clearing (an event emitted at tick N never wakes a task suspended at tick N+1), filtered `global_event`, capture-once semantics. -- The M1.0.13 partition-boundary test ("await entity_event still fails loud") flips: `entity_event` executes; a new boundary assertion pins `future` as the **only** remaining fail-loud target. +Executed **gate by gate E1→E5**, in order. Each gate is pushed and reviewed on the real diff before the next opens (cf. § Notes › Gate protocol). E1 and E2 are delivered (E1 GO; E2 GO pending the boundary-test fix below). + +**E1 — Parser + AST: payload filters.** (delivered) +- `AwaitExpr` filter run (`filter_start`/`filter_len` into `struct_lit_fields`, `(0,0)` when absent); optional filter parsed on `entity_event` (M0.8 rejection removed) and `global_event`; bare forms unchanged; trailing comma accepted; parser tests updated. + +**E2 — Type-checker: event-target validation, designated field, diagnostics.** (delivered, one fix pending) +- Entity operand synthesized and must type `Entity`; `T` must be a declared `event` on **both** targets (fix-as-you-go for `global_event`); designated-field resolution via the single shared helper (`AstArena.resolveEventEntityTarget`: `@entity_target` > single `Entity` field > `E0908`/`E0909` at the await site); `@entity_target` declaration validation (event-only, `Entity`-typed, at most one — `E0502`, existing kind); filter field validation shared with `emit` (`checkEventFieldRun`); `E0908`/`E0909` minted in `diagnostics.zig`. +- **Boundary-test fix (E2 STOP):** the partition-boundary test must pin the real `entity_event` fail-loud — a same-file test compiling a minimal type-valid program and calling the private machinery (`evalAwaitTarget` on the `.entity_event` await node), expecting `error.RuntimeFailure`. An Etch-program path cannot reach the site (the entity-bound async-rule guard fires first). The pin stands until the E4 lift. + +**E3 — Entity-bound async rules (absorbed; rulings in `etch-reference-part1.md §9.2`).** +- `runAsyncRule` moves from "one root task per async rule" to "**one root task per (rule, matched entity)**": at the rule's slot, every selected entity with no LIVE task for the `(rule, entity)` pair spawns a task, in the rule's deterministic selection order; the entity binding is copied into the task's root locals at creation. Drive-by-origin is unchanged. +- **Ruling 1 (re-arm):** after a task reaches a terminal state (done or canceled), the rule re-arms for that entity while it still matches — sync-form parity; pool husks never block re-arming. +- **Ruling 2 (when gates spawn, not life):** an entity that stops matching during a suspension does not interrupt its task. +- **Ruling 3 (despawn):** despawn during a suspension does not cancel the task; a later ECS access on the dead handle follows the existing dead-handle fail-loud semantics. Cancel-on-despawn is Phase 2+. +- **Ruling 4 (order):** entity iteration at spawn follows the rule's selection order (deterministic). +- The parameterless shape is byte-stable (one root task, unchanged). Any parameter shape outside {parameterless, entity-bound} keeps the existing fail-loud guard (e.g. `dt: float` — `dt` injection is a later milestone). + +**E4 — Runtime realization of `entity_event` + filters.** +- `WakeCond` carries the entity-event condition: event type + target `EntityId` + designated field (`StringId`) + captured filter values; the `global_event` condition gains the optional captured filter (representation is implementer's choice; semantics fixed). Captured values live in pointer-stable interpreter-owned storage. +- `evalAwaitTarget`: the `entity_event` arm evaluates the entity expression **once** (must yield an entity value — defensive fail-loud otherwise), resolves the designated field via the shared helper, and evaluates each filter expression **once** in the live scope; the `global_event` arm captures its filter likewise. Nothing is re-evaluated at later polls. +- `asyncWakeFired`: entity-event = per-tick `EventStore` scan — type match + designated-field equality + equality on every captured filter field; filtered `global_event` = type + filter. Equality covers the legally admitted event-field value kinds (verify the admitted set — POD scalars plus any non-POD kinds events accept since M1.0.2 — reusing the interpreter's existing value-equality machinery; STOP if a kind has no reusable equality). +- The two `entity_event` fail-loud sites are removed (`stepBodyStmt` routes `.entity_event` through the wake-condition arm — suspend, `pending_bind` delivers **unit** at resume, no implicit `event` binding; `evalAwaitTarget` handles it). `.future` stays fail-loud at both sites; the E2 boundary pin flips here to assert `future` alone. +- A matched event is **not consumed** (observers and multiple awaiters can fire on the same instance); producer-before-awaiter rule-order requirement unchanged. + +**E5 — Integration.** +- End-to-end cross-tick tests over the full chain (entity-bound awaiter + `entity_event`): multi-awaiter wake on one event, `@on_event` observer coexistence, per-tick store clearing, filtered `global_event` cross-tick, capture-once, the §9.2-mirror observable scenario. - Byte-stability: every pre-existing M1.0.11/12/13 async and timer test passes untouched. ## Out of scope -- **`future` / `Future`** (`await ` on a Future — asset loading, non-inlined `async fn` returns) — the last remaining fail-loud target, a separate later gap. NOT this milestone. -- **Implicit `event` binding or payload delivery at resume** — normative §9.4: all wake-condition targets resume with unit. Payload consumption belongs to `@on_event` rules; pre-wake discrimination belongs to the filter. -- **Entity-keyed `EventStore` index** — the Phase-1 realization is predicate matching over the per-tick store; indexing belongs to the target-form typed bus (Phase 2). -- **Emit-side targeting** (`emit T { … } to e` or similar) — `emit_stmt` stays global and target-less (grammar v0.6 frozen; §9.4 shows a plain `emit` waking an `entity_event`). -- **Non-equality filter predicates** (ranges, comparisons, boolean expressions) — the filter is field-equality only (§4.2/§9.4). -- **Entity-bound `async rule`** — still a later milestone (unchanged M1.0.11/12 boundary). -- **New diagnostic codes beyond `E0908`/`E0909`** — annotation misuse and operand/filter type errors reuse existing kinds. If a genuinely new, unavoidable validation surfaces mid-gate, STOP for a Claude.ai round-trip before minting `E0910` (next free; do not invent a code from the code-map). +- **`future` / `Future`** — the last remaining fail-loud target, a separate later gap. +- **Implicit `event` binding or payload delivery at resume** — normative §9.4: wake-condition targets resume with unit. +- **Entity-keyed `EventStore` index** — predicate matching over the per-tick store; indexing is the Phase-2 typed bus. +- **Emit-side targeting** — `emit_stmt` stays global and target-less (grammar v0.6 frozen). +- **Non-equality filter predicates** — the filter is field-equality only (§4.2/§9.4). +- **Cancel-on-despawn for entity-bound tasks** — Phase 2+ refinement (ruling 3). +- **`dt` (or any non-entity) parameter injection on rules** — later milestone; those shapes keep the fail-loud guard. +- **New diagnostic codes beyond `E0908`/`E0909`** — reuse existing kinds; STOP before minting `E0910`. ## Specs to read first -Mandatory before any production code; checked off in the LIVING SECTION. +Mandatory before any production code; checked off in the LIVING SECTION. (Amendment: re-read `etch-reference-part1.md` §9.2 — the entity-bound normative block is new.) -1. `etch-reference-part1.md` — §9.4 (the three normative blocks: entity-scoping convention, payload filter, resume value), §9.12 (the "Événements entity-scoped et filtres (M1.0.14)" realization block — the authoritative mechanism — plus the async-substrate paragraphs), §9.2 (the `entity_event` usage example). -2. `etch-grammar.md` — §4.2 (`await_target` productions + the event-target semantics bullets), §18.10 (`@entity_target` row + validation note), §5.10 (`event_decl`), §4.1 (`struct_literal_body`). -3. `etch-diagnostics.md` — §12 (E09xx block: `E0908`/`E0909` pre-assigned; the taken-code map). -4. `etch-resolver-types.md` — §9.2/§9.4 (effects algebra — `entity_event`/`global_event` carry `{async}` via the explicit `await`; context for the E2 validation surface). +1. `etch-reference-part1.md` — §9.2 (entity-bound async rule normative block — the four rulings), §9.4 (the three normative blocks), §9.12 (the M1.0.14 realization block, including the entity-bound bullet, plus the async-substrate paragraphs). +2. `etch-grammar.md` — §4.2 (`await_target` + event-target semantics), §18.10 (`@entity_target`), §5.10 (`event_decl`), §4.1 (`struct_literal_body`). +3. `etch-diagnostics.md` — §12 (E09xx block). +4. `etch-resolver-types.md` — §9.2/§9.4 (effects algebra — context). ## Files to create or modify -- `src/etch/ast.zig` — modify — E1: `AwaitExpr` filter run fields (`filter_start`/`filter_len` into `struct_lit_fields`). -- `src/etch/parser.zig` — modify — E1: optional filter body on both event targets; remove the M0.8 `entity_event` filter rejection; tests updated. -- `src/etch/types.zig` — modify — E2: entity-operand check, declared-event validation (both targets), shared designated-field resolution helper (`pub`), `@entity_target` declaration validation, filter field validation. -- `src/etch/diagnostics.zig` — modify — E2: `E0908`/`E0909` kinds + code/name mappings. -- `src/etch/interp.zig` — modify — E3/E4: `WakeCond` enrichment, `evalAwaitTarget` arms, `asyncWakeFired` predicate match, removal of the two fail-loud sites, tests. +- `src/etch/ast.zig` — modify — E1 (filter run) + E2 (annotation kind, shared designated-field helper). Delivered. +- `src/etch/parser.zig` — modify — E1. Delivered. +- `src/etch/types.zig` — modify — E2. Delivered. +- `src/etch/diagnostics.zig` — modify — E2 (`E0908`/`E0909`). Delivered. +- `src/etch/interp.zig` — modify — E2 fix (boundary pin) + E3 (per-entity `runAsyncRule`) + E4 (`WakeCond`, `evalAwaitTarget`, `asyncWakeFired`, fail-loud lift) + E5 (integration tests). No new files. No changes outside `src/etch/`. @@ -80,20 +82,20 @@ No new files. No changes outside `src/etch/`. ### Tests -Tests are **inline** in the respective `src/etch/*.zig` files (repo convention), green in `debug` and `ReleaseSafe`. +Tests are **inline** in the respective `src/etch/*.zig` files (repo convention), green in `debug` and `ReleaseSafe`. E1/E2 test items are delivered (see the pushed diffs); the E2 boundary-test fix replaces the rewritten partition test. -- `src/etch/parser.zig` — `test` — `entity_event(e, T)` bare parses (`filter_len == 0`); `entity_event(e, T { f: v })` parses with the fields run recorded; `global_event(T)` bare unchanged; `global_event(T { f: v })` parses; trailing comma in a filter body accepted. -- `src/etch/types.zig` — `test` — non-`Entity` first operand rejected; undeclared event type rejected on `entity_event` AND on `global_event`; zero-`Entity`-field event → `E0908`; two-`Entity`-field event without annotation → `E0909`; two-field event WITH `@entity_target` accepted; `@entity_target` on a non-`Entity` field rejected; duplicate `@entity_target` in one event rejected; filter naming an unknown field rejected; filter field-type mismatch rejected; filtered `global_event` type-checks. -- `src/etch/interp.zig` — `test` (E3) — awaiter wakes at its drive slot the tick a matching event is emitted by an earlier-ordered rule; no wake when the event's designated field carries a different entity; single-`Entity`-field structural designation works; `@entity_target` designation works on a two-`Entity`-field event (matches the annotated field, not the other); filter match wakes / filter mismatch does not; entity expression and filter expressions are evaluated exactly once at suspension (mutating their source locals after suspension does not alter the wake condition); `let x = await entity_event(…)` binds unit at resume. -- `src/etch/interp.zig` — `test` (E4) — two awaiters wake on one event instance; an `@on_event` observer drains the same event that wakes an awaiter (event not consumed by the wake); an event emitted at tick N does not wake a task that suspends at tick N+1 (per-tick store clearing); filtered `global_event` wakes cross-tick on the matching instance only; the partition-boundary test asserts `future` is the sole remaining fail-loud `await` target; all pre-existing M1.0.11/12/13 async + timer tests pass unmodified. +- `src/etch/interp.zig` — `test` (E2 fix) — direct call on the private machinery: `evalAwaitTarget` on an `.entity_event` await node returns `error.RuntimeFailure` (pin until E4). +- `src/etch/interp.zig` — `test` (E3) — an entity-bound async rule with `await wait` spawns one task per matching entity (two entities → two independent resumes); a completed task re-arms next tick while the entity still matches (ruling 1); an entity that stops matching mid-suspension still resumes (ruling 2); despawn mid-suspension → the resumed body's ECS access on the dead handle fails loud (ruling 3); spawn order follows selection order across two entities (ruling 4); the parameterless shape is byte-stable; a `dt: float` async rule still fails loud. +- `src/etch/interp.zig` — `test` (E4) — end-to-end: awaiter wakes at its slot the tick a matching event is emitted by an earlier-ordered rule; no wake when the designated field carries a different entity; single-`Entity`-field structural designation; `@entity_target` designation on a two-field event (matches the annotated field); filter match wakes / mismatch does not; entity + filter expressions evaluated exactly once at suspension; `let x = await entity_event(…)` binds unit; filtered `global_event` end-to-end (parameterless awaiter); the boundary pin flips — `future` is the sole remaining fail-loud target. +- `src/etch/interp.zig` — `test` (E5) — two awaiters wake on one event instance; an `@on_event` observer drains the same event that wakes an awaiter; an event emitted at tick N does not wake a task suspending at tick N+1; filtered `global_event` cross-tick on the matching instance only; all pre-existing M1.0.11/12/13 async + timer tests pass unmodified. ### Benchmarks -None (no perf target for this milestone). +None. ### Observable behavior -- A test-harness scenario (inline, driven by repeated `stepOnce`), mirroring §9.2: an `async rule` emits `DialogueStart` then suspends on `await entity_event(npc, DialogueComplete)`. A producer rule emits `DialogueComplete` for a **different** entity → no resume. It then emits `DialogueComplete` for `npc` → the awaiter resumes at its slot that tick and emits `QuestStarted`, observable in the event store / via an `@on_event` sink. +- A test-harness scenario (inline, driven by repeated `stepOnce`), mirroring §9.2: an entity-bound async awaiter on `npc` emits `DialogueStart` then suspends on `await entity_event(npc, DialogueComplete)`. A producer emits `DialogueComplete` for a **different** entity → no resume; then for `npc` → the awaiter resumes at its slot that tick and emits `QuestStarted`, observable via an `@on_event` sink. ### CI @@ -107,22 +109,23 @@ None (no perf target for this milestone). - **Branch:** `phase-1/etch/entity-scoped-events` - **Final tag:** `v0.10.14-entity-scoped-events` -- **PR title:** `Phase 1 / Etch / Entity-scoped events` +- **PR title:** `Phase 1 / Etch / Entity-scoped events and entity-bound async rules` - **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) - **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) ## Notes -- **Gate protocol (overrides Étape 3's "implement all at once").** `git push -u` at branch creation. Implement E1→E4 in order. After each gate's commits, **push**, then emit exactly `étape E terminée, prête pour review` and **stop** — do not open the next gate until Claude.ai returns a GO on the pushed diff. A STOP verdict returns a problem + fix; re-push and re-emit the same signal. The PR (Étape 5) opens only after the E4 GO. Reviews are on the real pushed diff, never on a CC summary. -- **Spec reconciliations already applied** to the KB at milestone open (Claude.ai re-upload): `etch-reference-part1.md` §9.4 (three normative blocks) + §9.12 (M1.0.14 realization block); `etch-grammar.md` §4.2 (`global_event` optional filter — erratum aligning the grammar on the §9.4 example — + event-target semantics) + §18.10 (`@entity_target`); `etch-diagnostics.md` §12 (`E0908`/`E0909`); `engine-phase-1-plan.md` (M1.0.x table re-synchronized on executed reality). **CC performs no spec authoring** — it reads the updated specs. -- **`entity_event` is `global_event` plus predicates** — same wake path, same store, same producer-before-awaiter ordering; no parallel mechanism. The `WakeCond` representation (extend the existing variant vs. add one) is implementer's choice; the capture-once and predicate semantics are not. -- **The designated-field policy lives in one place.** The E2 shared helper is the single implementation of "`@entity_target` > single `Entity` field"; the interpreter consumes it at suspension (where it always succeeds — the program passed `weld check`) and performs **no per-poll resolution**. -- **Capture-once (normative §9.4).** The entity expression and every filter expression are evaluated exactly once, at suspension, in the live scope; the wake condition holds captured values, never re-evaluates. -- **Event-field equality set.** Events have accepted non-POD scalar fields since the M1.0.2 fold-in; at E3, verify the actual admitted field-value kinds and make the filter/designated-field equality cover exactly that set, reusing the interpreter's existing value-equality machinery. If a kind has no equality today and cannot reuse existing machinery, STOP (design question). -- **Pre-existing gap fixed here (fix-as-you-go):** `await global_event(T)` with an undeclared `T` currently passes `weld check`; E2 closes it alongside the `entity_event` validation (same seam). -- **Fix-as-you-go (non-negotiable).** Any gap, inconsistency, or finding at any point in a gate is fixed within this milestone or explicitly refused by Guy — no parking, no deferred comment. A construct assigned to another milestone (`future`) is a scope boundary, not debt. -- **CLAUDE.md §3.4 update (E4 close, on the branch, `docs(claude-md): update for M1.0.14`).** Current-state table: `Last released tag` → `v0.10.14-entity-scoped-events` (post-merge wording per house style); `Next planned milestone` → M1.0.15 (test-runner + identity), with the await-family note updated (`entity_event` delivered; `future` = sole reserved await target). Tags table: +1 row for `v0.10.14-entity-scoped-events`. Open/deferred decisions: +1 M1.0.14 scope-boundary entry (designated-field convention, filter on both targets, resume-unit, predicate match without index; `future`/payload-delivery/emit-targeting/index out). `Last updated` date. Content provided by Claude.ai at closure; CC applies it on the branch. -- **Rejected alternatives.** Fixed field-name convention (`entity`) — killed by the corpus (`target`/`source`/`killer`). Emit-side targeting — grammar v0.6 frozen; §9.4 shows a plain `emit` waking the awaiter. First-declared-`Entity`-field positional rule — silently breaks on field reorder and picks `source` over `target` on `DamageTaken`. Entity-keyed store index — per-tick-cleared store, Phase-1 volumes; the indexed form is the Phase-2 typed bus. Payload delivery at resume — would fork the resume semantics of the shipped `global_event`; the filter carries pre-wake discrimination. +- **Gate protocol (overrides Étape 3's "implement all at once").** `git push -u` at branch creation. Implement E1→E5 in order. After each gate's commits, **push**, then emit exactly `étape E terminée, prête pour review` and **stop** — do not open the next gate until Claude.ai returns a GO on the pushed diff. A STOP verdict returns a problem + fix; re-push and re-emit the same signal. The PR (Étape 5) opens only after the E5 GO. Reviews are on the real pushed diff, never on a CC summary. +- **Amendment provenance.** This FROZEN SECTION supersedes the original after the E2 STOP round-trip: entity-bound async rules absorbed (Guy-ratified rulings 1–4, normative in `etch-reference-part1.md §9.2`); gates restructured E1→E5 (E3 = entity-bound BEFORE E4 = `entity_event`, so E4 tests are end-to-end from the start — no throwaway harness). KB re-uploaded at amendment: `etch-reference-part1.md` (§9.2 + §9.12), `engine-phase-1-plan.md` (M1.0.14 row). **CC performs no spec authoring.** +- **Spec reconciliations from milestone open remain in force** (`§9.4` normative blocks, `§4.2` erratum + semantics, `§18.10`, `E0908`/`E0909`, plan re-sync). +- **`entity_event` is `global_event` plus predicates** — same wake path, same store, same ordering; no parallel mechanism. +- **The designated-field policy lives in one place** (`AstArena.resolveEventEntityTarget`); the interpreter consumes it at suspension and performs no per-poll resolution. +- **Capture-once (normative §9.4).** Entity + filter expressions evaluated exactly once at suspension; the wake condition holds captured values. +- **Event-field equality set.** Verify the admitted event-field value kinds at E4 and cover exactly that set with the existing value-equality machinery; STOP if a kind has no reusable equality. +- **Per-entity task bookkeeping (E3).** `rule_tasks` (one slot per rule) must become per-(rule, entity) — representation is implementer's choice (e.g. a map keyed by entity on the rule slot); LIVE means `.suspended`; husks and terminal states never block re-arm. The monotonic pool grows with per-entity tasks — the M1.0.12 accepted characteristic, unchanged. +- **Fix-as-you-go (non-negotiable).** Any gap, inconsistency, or finding at any point in a gate is fixed within this milestone or explicitly refused by Guy. A construct assigned to another milestone (`future`) is a scope boundary, not debt. +- **CLAUDE.md §3.4 update (E5 close, on the branch, `docs(claude-md): update for M1.0.14`).** Current-state table: `Last released tag` → `v0.10.14-entity-scoped-events`; `Next planned milestone` → M1.0.15 (test-runner + identity); await-family note updated (`entity_event` delivered, `future` = sole reserved target; entity-bound async rules delivered — the M1.0.11/12 "entity-bound → later" out-list entries are superseded). Tags table: +1 row. Open/deferred decisions: +1 M1.0.14 scope-boundary entry. `Last updated` date. Content provided by Claude.ai at closure; CC applies it on the branch. +- **Rejected alternatives.** (Original set stands: fixed field name, emit-side targeting, positional first-field, entity-keyed index, payload delivery at resume.) Added: a separate entity-bound-async milestone (rejected — it is a de-facto dependency of this milestone's acceptance, and deferral would force throwaway mechanism-only harnesses at E4); `Entity.null` as a test vehicle (rejected — not realized in the executable surface; realizing it here would be unplanned scope). --- @@ -154,6 +157,8 @@ None (no perf target for this milestone). *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* +- AMENDSHA — E2 STOP round-trip: entity-bound async rules absorbed into M1.0.14 (an end-to-end Etch entity_event awaiter is inexpressible without an entity-scoped awaiter; no executable parameterless-body expression yields an Entity). Gates restructured E1→E5; four lifecycle rulings ratified (etch-reference-part1.md §9.2). Partition-boundary test contract corrected (direct pin of the private machinery until the E4 lift). + ## Blockers encountered *Blocking points that required a return to Claude.ai (cf. `engine-development-workflow.md §2.4`). If 2+ distinct blockers: re-scope signal.* From 19d32b3b7a29fa11fc64130370771f8ad5404461 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 18:48:01 +0200 Subject: [PATCH 08/24] fix(etch): pin entity_event fail-loud via evalAwaitTarget (M1.0.14 E2) --- src/etch/interp.zig | 65 +++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 95f1d17..6a6d7b8 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -10060,14 +10060,26 @@ test "await wait(1.0s) resumes at the fixed-timestep-equivalent tick count (60) try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); } -/// Compile + run `source` for 2 ticks and return the runtime-error count, after -/// asserting it parses and type-checks clean (M1.0.11 E3 fail-loud partition -/// helper). The async targets NOT owned by this milestone must surface a typed -/// `RuntimeFailure` (counted), never crash or silently no-op. -fn asyncFailLoudCount(gpa: std.mem.Allocator, source: []const u8) !u64 { +test "entity_event fails loud in evalAwaitTarget until the E4 lift (M1.0.14 E2 pin)" { + const gpa = std.testing.allocator; + // `entity_event` type-checks (M1.0.14 E2) but its runtime lands in E4. No + // Etch program can REACH the fail-loud site yet: a parameterized async rule + // hits the entity-bound `runAsyncRule` guard first (entity-bound async rules + // are E3), so a program-driven test would fail loud for the WRONG reason. + // The boundary is therefore pinned by calling the private machinery + // directly — `evalAwaitTarget` on the `.entity_event` await node must fail + // loud. This pin flips at E4 (assert `future` is then the sole target). var world = World.init(); defer world.deinit(gpa); - var pr = try parser_mod.parse(gpa, source); + var pr = try parser_mod.parse(gpa, + \\component C { } + \\event Ev { who: Entity } + \\async rule r(e: Entity) + \\ when e has C + \\{ + \\ await entity_event(e, Ev) + \\} + ); defer pr.deinit(gpa); try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; @@ -10077,40 +10089,19 @@ fn asyncFailLoudCount(gpa: std.mem.Allocator, source: []const u8) !u64 { } try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); defer interp.deinit(); - const report = try interp.runFor(&world, 2); - return report.runtime_errors; -} -test "await entity_event still fails loud (partition boundary intact, M1.0.13 E5)" { - const gpa = std.testing.allocator; - // `wait_unscaled` graduated to a working await target with the M1.0.13 - // time subsystem (E5); the remaining partition boundary is - // `entity_event` — needs entity-scoped events (M1.0.14). The program is - // now type-VALID (M1.0.14 E2 validates the target: `e` is an Entity and - // `Ev` has a single Entity field), so it reaches the interpreter, where - // `entity_event` still fails loud (execution lands in E3). A `seed` rule - // spawns the matched entity so the entity-based awaiter actually runs. - try std.testing.expect((try asyncFailLoudCount(gpa, - \\component M { } - \\event Ev { who: Entity } - \\resource Boot { on: bool = true } - \\rule seed() - \\ when resource Boot - \\{ - \\ spawn(M { }) - \\} - \\async rule r(e: Entity) - \\ when e has M - \\{ - \\ await entity_event(e, Ev) - \\} - )) >= 1); - // The M1.0.11 case — `await` on a stored non-TaskHandle value — is - // rejected at TYPE-CHECK since M1.0.12 E3 (E0200, "await target must be a - // direct async call or a TaskHandle"), so it never reaches the runtime: - // covered by the types.zig E3 tests. + // Navigate to the `await entity_event(e, Ev)` node in rule `r`'s body. + const rule = pr.ast.rule_decls.items[0]; + const stmt: NodeId = @bitCast(pr.ast.extra.items[rule.body_start]); + const await_id: NodeId = @bitCast(pr.ast.stmtData(stmt)); + try std.testing.expectEqual(ast_mod.ExprKind.await_expr, pr.ast.exprKind(await_id)); + try std.testing.expectEqual(ast_mod.AwaitTargetKind.entity_event, pr.ast.awaitExpr(await_id).target_kind); + + // The direct pin: `entity_event` is not yet realized → fail loud (until E4). + try std.testing.expectError(error.RuntimeFailure, interp.evalAwaitTarget(await_id)); } test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { From d2835972769631e76164a2679bf1a41ec7b60a67 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 18:48:02 +0200 Subject: [PATCH 09/24] docs(brief): journal E2 boundary-pin fix + resolve amendment sha --- briefs/M1.0.14-entity-scoped-events.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 38c24da..9c584ae 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -152,12 +152,13 @@ None. - 2026-07-04 — E2 (diagnostics + shared AST helper): `diagnostics.zig` gains `event_not_entity_scoped` (E0908) / `ambiguous_event_entity_target` (E0909) + code/name mappings + stability spot-checks. `ast.zig` (touched beyond the E2 file list — justified: the enum + a pure AST query belong here): `AnnotationKind.entity_target` + `fromName`; the SINGLE designated-field policy `AstArena.resolveEventEntityTarget` (`@entity_target` > single `Entity` field > `none_entity`/`ambiguous`) with `fieldHasAnnotation`/`fieldTypeIsEntity` — a pure AST query so the type-checker (diagnose) and the interpreter (E3 capture) share it with no interp→types dependency. - 2026-07-04 — E2 (types.zig): `annotationAppliesTo(.entity_target, .field)`; `validateFieldsInDecl` validates `@entity_target` placement (event-only, `Entity`-typed, ≤1/event → E0502, existing kind). The await arm is now a target switch — entity operand synthesized + must be `Entity` (E0200, never synthesized before), `T` must be a declared `event` on BOTH targets (E0102, fix-as-you-go for `global_event`), designated field resolved for `entity_event` (E0908/E0909 at the await site via the shared helper), filter validated for both via the new shared `checkEventFieldRun` (emit refactored onto it — one field-run validation, E1211/E0200). - 2026-07-04 — E2 fix-as-you-go (interp.zig, touched beyond the E2 file list — justified): the M1.0.13 partition-boundary test used a now-type-invalid program (`entity_event(get(Out), Ev)` — non-Entity operand + zero-Entity event); rewrote it to a type-valid `entity_event(e, Ev{who:Entity})` fed by a `seed` spawn so the entity awaiter runs and still hits the (untouched) interp fail-loud site. The E4 flip is unchanged. E2 test (10 sub-cases) green; full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` clean. +- 2026-07-04 — E2 STOP fix (round-trip `5589078`; FROZEN amended — entity-bound async rules absorbed, gates E1→E5): the spawn-seed rewrite above was wrong — a parameterized async rule fails loud at the entity-bound `runAsyncRule` guard BEFORE reaching `entity_event`, so it pinned the boundary for the wrong reason. Replaced with a DIRECT pin: compile a minimal type-valid program and assert `interp.evalAwaitTarget(<.entity_event node>)` returns `error.RuntimeFailure` (flips at E4). Removed the now-orphaned `asyncFailLoudCount` helper. Full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. ## Recorded deviations *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* -- AMENDSHA — E2 STOP round-trip: entity-bound async rules absorbed into M1.0.14 (an end-to-end Etch entity_event awaiter is inexpressible without an entity-scoped awaiter; no executable parameterless-body expression yields an Entity). Gates restructured E1→E5; four lifecycle rulings ratified (etch-reference-part1.md §9.2). Partition-boundary test contract corrected (direct pin of the private machinery until the E4 lift). +- `5589078` — E2 STOP round-trip: entity-bound async rules absorbed into M1.0.14 (an end-to-end Etch entity_event awaiter is inexpressible without an entity-scoped awaiter; no executable parameterless-body expression yields an Entity). Gates restructured E1→E5; four lifecycle rulings ratified (etch-reference-part1.md §9.2). Partition-boundary test contract corrected (direct pin of the private machinery until the E4 lift). ## Blockers encountered From 24dbd8bfc850edd2476de2c0f07f9c3bfddb2813 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 19:39:12 +0200 Subject: [PATCH 10/24] docs(brief): re-read amended reference-part1 for E3 --- briefs/M1.0.14-entity-scoped-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 9c584ae..63dd906 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -137,7 +137,7 @@ None. *Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* -- [x] `etch-reference-part1.md` (§9.4, §9.12, §9.2) — read 2026-07-04 16:28 +- [x] `etch-reference-part1.md` (§9.4, §9.12, §9.2) — read 2026-07-04 16:28; re-read amended §9.2 (entity-bound async rule + rulings 1–4) + §9.12 (entity-bound bullet) 2026-07-04 19:37 - [x] `etch-grammar.md` (§4.2, §18.10, §5.10, §4.1) — read 2026-07-04 16:28 - [x] `etch-diagnostics.md` (§12) — read 2026-07-04 16:28 - [x] `etch-resolver-types.md` (§9.2, §9.4) — read 2026-07-04 16:28 From 6687e37df490e6409320b5daaeeccc4ea0d49877 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 20:10:48 +0200 Subject: [PATCH 11/24] feat(etch): execute entity-bound async rules (M1.0.14 E3) --- src/etch/interp.zig | 392 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 371 insertions(+), 21 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 6a6d7b8..9d9a71a 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -1059,6 +1059,16 @@ pub const Interpreter = struct { /// `has_async`): `null` until the async rule first spawns, then the pool index /// of its task. A non-async rule's entry stays `null`. rule_tasks: []?u32 = &.{}, + /// Per-rule `(entity → live task index)` map for ENTITY-BOUND async rules + /// (M1.0.14 E3; parallel to `rule_descs`, allocated iff `has_async`). An + /// entry records the last task spawned for that `(rule, entity)` pair; a + /// LIVE task is `.suspended` — a terminal-state entry (husk) does not block + /// re-arming (ruling 2). A non-entity-bound rule's map stays empty. + entity_rule_tasks: []std.AutoHashMapUnmanaged(EntityId, u32) = &.{}, + /// Reusable buffer collecting an entity-bound rule's matched entities in + /// selection order before spawning their tasks (M1.0.14 E3) — filled by the + /// shared selection walk (`iterateSelection` collect mode). + entity_spawn_buf: std.ArrayListUnmanaged(EntityId) = .empty, /// Reusable cursor buffer for the multi-term (`or`) archetype-union merge /// (M1.0.0). Resized to the term count of the rule being iterated; capacity /// is retained across rules/ticks so the union path allocates at most once. @@ -1147,6 +1157,9 @@ pub const Interpreter = struct { self.timers.deinit(self.gpa); self.task_children.deinit(self.gpa); self.gpa.free(self.rule_tasks); + for (self.entity_rule_tasks) |*m| m.deinit(self.gpa); + self.gpa.free(self.entity_rule_tasks); + self.entity_spawn_buf.deinit(self.gpa); self.descriptors.deinit(self.gpa); self.merge_cursors.deinit(self.gpa); self.gpa.free(self.observer_ctxs); @@ -1469,6 +1482,13 @@ pub const Interpreter = struct { @memset(map, null); break :blk map; } else &.{}; + // Per-rule `(entity → live task)` maps for entity-bound async rules + // (M1.0.14 E3), parallel to `rule_descs`. Empty for non-entity-bound rules. + const entity_rule_tasks: []std.AutoHashMapUnmanaged(EntityId, u32) = if (any_async) blk: { + const maps = try gpa.alloc(std.AutoHashMapUnmanaged(EntityId, u32), slice.len); + for (maps) |*m| m.* = .empty; + break :blk maps; + } else &.{}; // Pass E — build the Level-B descriptors (M0.8 E4, build-structure // side of the serialized-IR differential). Fail-loud on any @@ -1495,6 +1515,7 @@ pub const Interpreter = struct { .has_changed = any_changed, .has_async = any_async, .rule_tasks = rule_tasks, + .entity_rule_tasks = entity_rule_tasks, .descriptors = descriptors, .world = world, .persistent_literals = persistent_literals, @@ -2055,7 +2076,7 @@ pub const Interpreter = struct { // match never goes stale. A single term iterates its matches directly // (ascending archetype-creation order, preserving the historical // direct-walk order → interp↔codegen parity); `or` unions the terms. - try self.iterateSelection(world, rd, &rule_matched, report); + try self.iterateSelection(world, rd, &rule_matched, report, null); if (rule_matched) report.rules_matched += 1; } @@ -2077,25 +2098,30 @@ pub const Interpreter = struct { /// `query.maybeRescan` returns the archetypes it scanned this call (0 in /// the steady state); summing them into `predicate_archetype_evals` keeps /// the tail-only-rescan observable the cache test asserts. - fn iterateSelection(self: *Interpreter, world: *World, rd: *RuleDesc, rule_matched: *bool, report: *RuntimeReport) !void { + /// Walk a rule's selected entities in deterministic order. When `collect` + /// is non-null the matched `EntityId`s are appended to it and NOTHING runs + /// (entity-bound async spawn, M1.0.14 E3); when null, the sync path runs + /// `execBody` per match. The order and `when`-guard semantics are identical + /// either way — the two consumers share this one walk. + fn iterateSelection(self: *Interpreter, world: *World, rd: *RuleDesc, rule_matched: *bool, report: *RuntimeReport, collect: ?*std.ArrayListUnmanaged(EntityId)) !void { const sel = rd.selection; if (sel.len == 0) return; if (sel.len == 1) { report.predicate_archetype_evals += sel[0].maybeRescan(); for (sel[0].matching.items) |arch| { - try self.iterateArchetype(world, rd, arch, rule_matched, report); + try self.iterateArchetype(world, rd, arch, rule_matched, report, collect); } return; } for (sel) |*q| report.predicate_archetype_evals += q.maybeRescan(); - try self.iterateUnion(world, rd, rule_matched, report); + try self.iterateUnion(world, rd, rule_matched, report, collect); } /// k-way merge of a multi-term (`or`) selection's matching lists, ascending /// by `archetype_id`, each archetype dispatched exactly once (M1.0.0). Uses /// the reusable `merge_cursors` buffer — one cursor per term — so the union /// path allocates at most once over the interpreter's lifetime. - fn iterateUnion(self: *Interpreter, world: *World, rd: *RuleDesc, rule_matched: *bool, report: *RuntimeReport) !void { + fn iterateUnion(self: *Interpreter, world: *World, rd: *RuleDesc, rule_matched: *bool, report: *RuntimeReport, collect: ?*std.ArrayListUnmanaged(EntityId)) !void { const sel = rd.selection; self.merge_cursors.clearRetainingCapacity(); try self.merge_cursors.appendNTimes(self.gpa, 0, sel.len); @@ -2120,13 +2146,15 @@ pub const Interpreter = struct { cursors[qi] += 1; } } - try self.iterateArchetype(world, rd, arch, rule_matched, report); + try self.iterateArchetype(world, rd, arch, rule_matched, report, collect); } } /// Walk one archetype's chunks/slots for an entity-bound rule — the - /// per-archetype body of the cached-matching-set selection (M1.0.0). - fn iterateArchetype(self: *Interpreter, world: *World, rd: *RuleDesc, arch: *DynamicArchetype, rule_matched: *bool, report: *RuntimeReport) !void { + /// per-archetype body of the cached-matching-set selection (M1.0.0). When + /// `collect` is non-null, matched entities are gathered instead of run + /// (M1.0.14 E3, entity-bound async spawn). + fn iterateArchetype(self: *Interpreter, world: *World, rd: *RuleDesc, arch: *DynamicArchetype, rule_matched: *bool, report: *RuntimeReport, collect: ?*std.ArrayListUnmanaged(EntityId)) !void { for (arch.chunks.items) |chunk| { const ids = arch.entityIdsConst(chunk); const count = chunk.header().entity_count; @@ -2150,10 +2178,16 @@ pub const Interpreter = struct { // codegen guards). if ((rd.expr_filters.len > 0 or rd.expr_conds.len > 0) and !(try self.exprGuardsPass(world, rd.*, entity_id, arch, chunk, slot))) continue; - report.entities_iterated += 1; - rd.matched_entities += 1; - rule_matched.* = true; - try self.execBody(world, rd.*, entity_id, null, report); + if (collect) |buf| { + // Entity-bound async spawn (M1.0.14 E3): gather the matched + // entity in selection order; the caller spawns its task. + try buf.append(self.gpa, entity_id); + } else { + report.entities_iterated += 1; + rd.matched_entities += 1; + rule_matched.* = true; + try self.execBody(world, rd.*, entity_id, null, report); + } } } } @@ -2312,27 +2346,41 @@ pub const Interpreter = struct { /// shape); an entity-bound async rule (one task per matching entity) is /// deferred and fails loud (counted once, then parked `.done`). fn runAsyncRule(self: *Interpreter, world: *World, idx: usize, report: *RuntimeReport) !void { - const rd = self.rule_descs[idx]; - if (self.rule_tasks[idx] == null) { - // First reach → spawn the rule-root task in the pool. - const rule = self.ast.rule_decls.items[rd.rule_idx]; - if (rule.params_len > 0 or rd.is_entity_bound) { + const rd = &self.rule_descs[idx]; + const rule = self.ast.rule_decls.items[rd.rule_idx]; + if (rd.is_entity_bound and rule.params_len == 1) { + // Entity-bound async rule (M1.0.14 E3): the clean single-`Entity`-param + // shape (`async rule r(e: Entity) when e has T`). One root task per (rule, + // matched entity). Each selected entity with no LIVE (`.suspended`) + // task spawns a task — the entity bound into its root locals — in + // the rule's deterministic selection order (ruling 4). The `when` + // gates the SPAWN only (ruling 1); a terminal task re-arms for a + // still-matching entity (ruling 2); despawn does not cancel — a + // later dead-handle ECS access fails loud (ruling 3). + try self.spawnEntityBoundTasks(world, idx, rd, rule, report); + } else if (rule.params_len > 0) { + // A non-entity parameter shape (e.g. `dt: float`) stays fail-loud — + // parameter injection beyond the entity binding is a later + // milestone. Park one done husk on first reach. + if (self.rule_tasks[idx] == null) { report.runtime_errors += 1; const ti = try self.newTask(@intCast(idx), null); self.async_tasks.items[ti].state = .done; self.rule_tasks[idx] = ti; - return; } + } else if (self.rule_tasks[idx] == null) { + // Parameterless async rule (byte-stable, unchanged): one root task, + // spawned on first reach. The fresh task's default wake + // (`wait_until = 0`) fires immediately, so it runs this tick. const ti = try self.newTask(@intCast(idx), null); self.rule_tasks[idx] = ti; - // The initial frame is the rule body as a linear run. The fresh - // task's default wake (`wait_until = 0`) fires immediately, so the - // drive-by-origin pass below runs it this tick. try self.async_tasks.items[ti].frames.append(self.gpa, .{ .run = .{ .block_start = rule.body_start, .block_len = rule.body_len, } }); } + // Drive-by-origin (UNCHANGED, M1.0.12): drive every suspended task of + // this rule whose wake fired, at the rule's position in the tick order. var drove_any = false; var ti: usize = 0; while (ti < self.async_tasks.items.len) : (ti += 1) { @@ -2346,6 +2394,34 @@ pub const Interpreter = struct { if (drove_any) report.rules_matched += 1; } + /// Spawn one root task per matched entity for an ENTITY-BOUND async rule + /// (M1.0.14 E3). Reuses the SYNC entity selection (collect mode) so the + /// order and `when` semantics are identical. Two passes: collect the + /// matched entities in selection order (nothing runs), then spawn a task + /// for each entity whose `(rule, entity)` map slot has no LIVE + /// (`.suspended`) task — a terminal-state husk does not block re-arming + /// (ruling 2). A matched entity that later stops matching keeps its live + /// task (ruling 1 — `when` gates the spawn, not the task's life). The fresh + /// task's default wake fires this tick, so the drive-by-origin pass runs it. + fn spawnEntityBoundTasks(self: *Interpreter, world: *World, idx: usize, rd: *RuleDesc, rule: ast_mod.RuleDecl, report: *RuntimeReport) !void { + self.entity_spawn_buf.clearRetainingCapacity(); + var dummy_matched = false; + try self.iterateSelection(world, rd, &dummy_matched, report, &self.entity_spawn_buf); + const map = &self.entity_rule_tasks[idx]; + for (self.entity_spawn_buf.items) |entity_id| { + if (map.get(entity_id)) |ti| { + if (self.async_tasks.items[ti].state == .suspended) continue; // LIVE → no re-arm + } + const ti = try self.newTask(@intCast(idx), null); + try bindParams(self.gpa, self.ast, rule, entity_id, &self.async_tasks.items[ti].locals); + try self.async_tasks.items[ti].frames.append(self.gpa, .{ .run = .{ + .block_start = rule.body_start, + .block_len = rule.body_len, + } }); + try map.put(self.gpa, entity_id, ti); + } + } + /// The active scope for the top frame (M1.0.11 E2): the nearest enclosing /// `async fn` call frame's own scope, or the task's root locals if none. The /// `&task.locals` fallback is stable across pool growth (M1.0.12 E1): the @@ -10104,6 +10180,280 @@ test "entity_event fails loud in evalAwaitTarget until the E4 lift (M1.0.14 E2 p try std.testing.expectError(error.RuntimeFailure, interp.evalAwaitTarget(await_id)); } +test "entity-bound async rule: one root task per matched entity, independent resumes (M1.0.14 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Tag { v: int = 0 } + \\resource Out { n: int = 0 } + \\async rule r(e: Entity) + \\ when e has Tag and resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\ await wait(0.04s) + \\ let o2 = get_mut(Out) + \\ o2.n = o2.n + 10 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const tag = world.registry.idOf("Tag").?; + const out = world.registry.idOf("Out").?; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{tag}); + _ = try world.spawnDynamic(gpa, &[_]ComponentId{tag}); + + // tick 1: one task spawns per matching entity (2) → each runs the prefix + // (n += 1 twice → 2) and suspends at `await wait(0.04s)` (2 ticks). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out)); + // ticks 2–3: both wake at the same tick and run the tail (n += 10 twice → 22). + _ = try interp.runFor(&world, 2); + try std.testing.expectEqual(@as(i64, 22), readResourceInt(&world, out)); +} + +test "entity-bound async rule: re-arms after a terminal state while the entity matches (M1.0.14 E3 ruling 2)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Tag { v: int = 0 } + \\resource Out { n: int = 0 } + \\async rule r(e: Entity) + \\ when e has Tag and resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\ await wait(0.02s) + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const tag = world.registry.idOf("Tag").?; + const out = world.registry.idOf("Out").?; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{tag}); + + // Each cycle: spawn → n += 1 → suspend 1 tick → resume → done → re-arm. + _ = try interp.runFor(&world, 1); // t1: spawn, n=1, suspend + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); + _ = try interp.runFor(&world, 2); // t2 resume→done; t3 re-arm, n=2, suspend + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out)); + _ = try interp.runFor(&world, 2); // t4 resume→done; t5 re-arm, n=3 + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out)); +} + +test "entity-bound async rule: when gates the spawn, not the task life (M1.0.14 E3 ruling 1)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Tag { v: int = 0 } + \\component Keep { k: int = 0 } + \\resource Out { n: int = 0 } + \\async rule r(e: Entity) + \\ when e has Tag and resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\ await wait(0.04s) + \\ let o2 = get_mut(Out) + \\ o2.n = o2.n + 100 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const tag = world.registry.idOf("Tag").?; + const keep = world.registry.idOf("Keep").?; + const out = world.registry.idOf("Out").?; + // Two components so removing Tag leaves the entity non-empty (keeps Keep). + const e = try world.spawnDynamic(gpa, &[_]ComponentId{ tag, keep }); + + _ = try interp.runFor(&world, 1); // t1: spawn, n=1, suspend (wake t3) + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); + // The entity stops matching mid-suspension — its live task must survive. + try world.removeComponentDynamic(gpa, e, tag); + _ = try interp.runFor(&world, 2); // t2 no wake; t3 resume → n += 100 → 101 + try std.testing.expectEqual(@as(i64, 101), readResourceInt(&world, out)); + // No re-arm: the entity no longer matches, so nothing spawns again. + _ = try interp.runFor(&world, 2); + try std.testing.expectEqual(@as(i64, 101), readResourceInt(&world, out)); +} + +test "entity-bound async rule: despawn mid-suspension → dead-handle fail-loud on resume (M1.0.14 E3 ruling 3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Tag { hp: int = 7 } + \\resource Out { n: int = 0 } + \\async rule r(e: Entity) + \\ when e has Tag and resource Out + \\{ + \\ await wait(0.04s) + \\ let o = get_mut(Out) + \\ o.n = e.get(Tag).hp + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const tag = world.registry.idOf("Tag").?; + const out = world.registry.idOf("Out").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{tag}); + + _ = try interp.runFor(&world, 1); // t1: spawn, suspend at await (no e access yet) + // Despawn mid-suspension: the task is NOT canceled (ruling 3). + try world.despawn(gpa, e); + // t2 no wake; t3 resume → `e.get(Tag)` on the dead handle fails loud. + const rep = try interp.runFor(&world, 2); + try std.testing.expect(rep.runtime_errors >= 1); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out)); // tail never ran +} + +test "entity-bound async rule: spawn follows selection order (M1.0.14 E3 ruling 4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\component Tag { mark: int = 0 } + \\resource Out { seq: int = 0 } + \\async rule r(e: Entity) + \\ when e has Tag and resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.seq = o.seq * 10 + e.get(Tag).mark + \\ await wait(0.02s) + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const tag = world.registry.idOf("Tag").?; + const out = world.registry.idOf("Out").?; + // Spawn order = selection order (slot 0, then slot 1). mark 1 then mark 2. + var m1: i64 = 1; + _ = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{tag}, &[_][]const u8{std.mem.asBytes(&m1)}); + var m2: i64 = 2; + _ = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{tag}, &[_][]const u8{std.mem.asBytes(&m2)}); + + // tick 1: tasks spawn + drive in selection order → seq = (0*10+1)*10+2 = 12. + // A reversed order would yield 21. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 12), readResourceInt(&world, out)); +} + +test "async rule shape guard: parameterless byte-stable, dt param fail-loud (M1.0.14 E3)" { + const gpa = std.testing.allocator; + + // Parameterless async rule: one root task, runs once, never re-arms (the + // pre-M1.0.14 behavior — byte-stable). + { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\ await wait(0.02s) + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + const rep = try interp.runFor(&world, 8); + try std.testing.expectEqual(@as(u64, 0), rep.runtime_errors); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // ran once, no re-arm + } + + // A non-entity parameter shape (`dt: float`) stays fail-loud: the body + // never runs and a runtime error is surfaced. + { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\resource Out { n: int = 0 } + \\async rule r(dt: float) + \\ when resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = 1 + \\ await wait(0.02s) + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + const rep = try interp.runFor(&world, 2); + try std.testing.expect(rep.runtime_errors >= 1); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out)); // body never ran + } +} + test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { const gpa = std.testing.allocator; var world = World.init(); From 3081d376cf6a13ad115b0ea31d91f4d7a74343a6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 20:10:49 +0200 Subject: [PATCH 12/24] docs(brief): journal E3 (entity-bound async rules) --- briefs/M1.0.14-entity-scoped-events.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 63dd906..0cd362f 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -153,6 +153,8 @@ None. - 2026-07-04 — E2 (types.zig): `annotationAppliesTo(.entity_target, .field)`; `validateFieldsInDecl` validates `@entity_target` placement (event-only, `Entity`-typed, ≤1/event → E0502, existing kind). The await arm is now a target switch — entity operand synthesized + must be `Entity` (E0200, never synthesized before), `T` must be a declared `event` on BOTH targets (E0102, fix-as-you-go for `global_event`), designated field resolved for `entity_event` (E0908/E0909 at the await site via the shared helper), filter validated for both via the new shared `checkEventFieldRun` (emit refactored onto it — one field-run validation, E1211/E0200). - 2026-07-04 — E2 fix-as-you-go (interp.zig, touched beyond the E2 file list — justified): the M1.0.13 partition-boundary test used a now-type-invalid program (`entity_event(get(Out), Ev)` — non-Entity operand + zero-Entity event); rewrote it to a type-valid `entity_event(e, Ev{who:Entity})` fed by a `seed` spawn so the entity awaiter runs and still hits the (untouched) interp fail-loud site. The E4 flip is unchanged. E2 test (10 sub-cases) green; full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` clean. - 2026-07-04 — E2 STOP fix (round-trip `5589078`; FROZEN amended — entity-bound async rules absorbed, gates E1→E5): the spawn-seed rewrite above was wrong — a parameterized async rule fails loud at the entity-bound `runAsyncRule` guard BEFORE reaching `entity_event`, so it pinned the boundary for the wrong reason. Replaced with a DIRECT pin: compile a minimal type-valid program and assert `interp.evalAwaitTarget(<.entity_event node>)` returns `error.RuntimeFailure` (flips at E4). Removed the now-orphaned `asyncFailLoudCount` helper. Full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-04 — E3 (entity-bound async rules, interp.zig): `runAsyncRule` restructured — the clean single-`Entity`-param shape (`is_entity_bound and params_len == 1`) takes a new per-(rule,entity) path (`spawnEntityBoundTasks`): a task per matched entity, entity bound into root locals, in selection order. Bookkeeping = a per-rule `entity_rule_tasks[idx]` map (`entity → task`), LIVE = `.suspended`, terminal husks re-arm (ruling 2); `when` gates the spawn only (ruling 1); despawn → dead-handle fail-loud on resume (ruling 3, free via `dynamicLocation`); order preserved by REUSING the sync `iterateSelection`/`iterateUnion`/`iterateArchetype` walk via a threaded nullable `collect` buffer (ruling 4 — identical order to sync). Non-entity param shapes (e.g. `dt: float`, or `e + dt`) keep the fail-loud guard; the parameterless shape is byte-stable (unchanged branch). Drive-by-origin untouched. +- 2026-07-04 — E3 tests: 6 tests (per-entity spawn + independent resumes; rulings 1–4; parameterless byte-stable; `dt: float` fail-loud) green. Ruling-1 test gives the entity a second `Keep` component because `world.removeComponentDynamic` asserts ≥2 components (cannot empty an entity). Full `zig build test` = 982/999 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. ## Recorded deviations From 222ef836b30de79871b5bc17795e8eba003debb8 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 20:48:27 +0200 Subject: [PATCH 13/24] docs(etch): renumber E3 rulings to match the brief (M1.0.14) --- briefs/M1.0.14-entity-scoped-events.md | 4 ++-- src/etch/interp.zig | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 0cd362f..6378394 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -153,8 +153,8 @@ None. - 2026-07-04 — E2 (types.zig): `annotationAppliesTo(.entity_target, .field)`; `validateFieldsInDecl` validates `@entity_target` placement (event-only, `Entity`-typed, ≤1/event → E0502, existing kind). The await arm is now a target switch — entity operand synthesized + must be `Entity` (E0200, never synthesized before), `T` must be a declared `event` on BOTH targets (E0102, fix-as-you-go for `global_event`), designated field resolved for `entity_event` (E0908/E0909 at the await site via the shared helper), filter validated for both via the new shared `checkEventFieldRun` (emit refactored onto it — one field-run validation, E1211/E0200). - 2026-07-04 — E2 fix-as-you-go (interp.zig, touched beyond the E2 file list — justified): the M1.0.13 partition-boundary test used a now-type-invalid program (`entity_event(get(Out), Ev)` — non-Entity operand + zero-Entity event); rewrote it to a type-valid `entity_event(e, Ev{who:Entity})` fed by a `seed` spawn so the entity awaiter runs and still hits the (untouched) interp fail-loud site. The E4 flip is unchanged. E2 test (10 sub-cases) green; full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` clean. - 2026-07-04 — E2 STOP fix (round-trip `5589078`; FROZEN amended — entity-bound async rules absorbed, gates E1→E5): the spawn-seed rewrite above was wrong — a parameterized async rule fails loud at the entity-bound `runAsyncRule` guard BEFORE reaching `entity_event`, so it pinned the boundary for the wrong reason. Replaced with a DIRECT pin: compile a minimal type-valid program and assert `interp.evalAwaitTarget(<.entity_event node>)` returns `error.RuntimeFailure` (flips at E4). Removed the now-orphaned `asyncFailLoudCount` helper. Full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. -- 2026-07-04 — E3 (entity-bound async rules, interp.zig): `runAsyncRule` restructured — the clean single-`Entity`-param shape (`is_entity_bound and params_len == 1`) takes a new per-(rule,entity) path (`spawnEntityBoundTasks`): a task per matched entity, entity bound into root locals, in selection order. Bookkeeping = a per-rule `entity_rule_tasks[idx]` map (`entity → task`), LIVE = `.suspended`, terminal husks re-arm (ruling 2); `when` gates the spawn only (ruling 1); despawn → dead-handle fail-loud on resume (ruling 3, free via `dynamicLocation`); order preserved by REUSING the sync `iterateSelection`/`iterateUnion`/`iterateArchetype` walk via a threaded nullable `collect` buffer (ruling 4 — identical order to sync). Non-entity param shapes (e.g. `dt: float`, or `e + dt`) keep the fail-loud guard; the parameterless shape is byte-stable (unchanged branch). Drive-by-origin untouched. -- 2026-07-04 — E3 tests: 6 tests (per-entity spawn + independent resumes; rulings 1–4; parameterless byte-stable; `dt: float` fail-loud) green. Ruling-1 test gives the entity a second `Keep` component because `world.removeComponentDynamic` asserts ≥2 components (cannot empty an entity). Full `zig build test` = 982/999 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-04 — E3 (entity-bound async rules, interp.zig): `runAsyncRule` restructured — the clean single-`Entity`-param shape (`is_entity_bound and params_len == 1`) takes a new per-(rule,entity) path (`spawnEntityBoundTasks`): a task per matched entity, entity bound into root locals, in selection order. Bookkeeping = a per-rule `entity_rule_tasks[idx]` map (`entity → task`), LIVE = `.suspended`, terminal husks re-arm (ruling 1); `when` gates the spawn only (ruling 2); despawn → dead-handle fail-loud on resume (ruling 3, free via `dynamicLocation`); order preserved by REUSING the sync `iterateSelection`/`iterateUnion`/`iterateArchetype` walk via a threaded nullable `collect` buffer (ruling 4 — identical order to sync). Non-entity param shapes (e.g. `dt: float`, or `e + dt`) keep the fail-loud guard; the parameterless shape is byte-stable (unchanged branch). Drive-by-origin untouched. +- 2026-07-04 — E3 tests: 6 tests (per-entity spawn + independent resumes; rulings 1–4; parameterless byte-stable; `dt: float` fail-loud) green. The when-gates test (ruling 2) gives the entity a second `Keep` component because `world.removeComponentDynamic` asserts ≥2 components (cannot empty an entity). Full `zig build test` = 982/999 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. ## Recorded deviations diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 9d9a71a..9148beb 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -1063,7 +1063,7 @@ pub const Interpreter = struct { /// (M1.0.14 E3; parallel to `rule_descs`, allocated iff `has_async`). An /// entry records the last task spawned for that `(rule, entity)` pair; a /// LIVE task is `.suspended` — a terminal-state entry (husk) does not block - /// re-arming (ruling 2). A non-entity-bound rule's map stays empty. + /// re-arming (ruling 1). A non-entity-bound rule's map stays empty. entity_rule_tasks: []std.AutoHashMapUnmanaged(EntityId, u32) = &.{}, /// Reusable buffer collecting an entity-bound rule's matched entities in /// selection order before spawning their tasks (M1.0.14 E3) — filled by the @@ -2354,8 +2354,8 @@ pub const Interpreter = struct { // matched entity). Each selected entity with no LIVE (`.suspended`) // task spawns a task — the entity bound into its root locals — in // the rule's deterministic selection order (ruling 4). The `when` - // gates the SPAWN only (ruling 1); a terminal task re-arms for a - // still-matching entity (ruling 2); despawn does not cancel — a + // gates the SPAWN only (ruling 2); a terminal task re-arms for a + // still-matching entity (ruling 1); despawn does not cancel — a // later dead-handle ECS access fails loud (ruling 3). try self.spawnEntityBoundTasks(world, idx, rd, rule, report); } else if (rule.params_len > 0) { @@ -2400,8 +2400,8 @@ pub const Interpreter = struct { /// matched entities in selection order (nothing runs), then spawn a task /// for each entity whose `(rule, entity)` map slot has no LIVE /// (`.suspended`) task — a terminal-state husk does not block re-arming - /// (ruling 2). A matched entity that later stops matching keeps its live - /// task (ruling 1 — `when` gates the spawn, not the task's life). The fresh + /// (ruling 1). A matched entity that later stops matching keeps its live + /// task (ruling 2 — `when` gates the spawn, not the task's life). The fresh /// task's default wake fires this tick, so the drive-by-origin pass runs it. fn spawnEntityBoundTasks(self: *Interpreter, world: *World, idx: usize, rd: *RuleDesc, rule: ast_mod.RuleDecl, report: *RuntimeReport) !void { self.entity_spawn_buf.clearRetainingCapacity(); @@ -10222,7 +10222,7 @@ test "entity-bound async rule: one root task per matched entity, independent res try std.testing.expectEqual(@as(i64, 22), readResourceInt(&world, out)); } -test "entity-bound async rule: re-arms after a terminal state while the entity matches (M1.0.14 E3 ruling 2)" { +test "entity-bound async rule: re-arms after a terminal state while the entity matches (M1.0.14 E3 ruling 1)" { const gpa = std.testing.allocator; var world = World.init(); defer world.deinit(gpa); @@ -10261,7 +10261,7 @@ test "entity-bound async rule: re-arms after a terminal state while the entity m try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out)); } -test "entity-bound async rule: when gates the spawn, not the task life (M1.0.14 E3 ruling 1)" { +test "entity-bound async rule: when gates the spawn, not the task life (M1.0.14 E3 ruling 2)" { const gpa = std.testing.allocator; var world = World.init(); defer world.deinit(gpa); From 3923dcc4ea85744a011e61f5c6c838dd340e4240 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 22:49:22 +0200 Subject: [PATCH 14/24] feat(etch): realize entity_event + payload filters (M1.0.14 E4) --- src/etch/interp.zig | 591 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 559 insertions(+), 32 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 9148beb..00ad8af 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -644,6 +644,11 @@ fn durationLiteralSeconds(text: []const u8) ?f64 { /// The condition that resumes a suspended `async rule` (M0.8 E3 sub-slice B). /// The tree-walker is its own runtime (`etch-reference-part1.md §9`): an /// `await` suspends the rule as a task-record, polled each tick in `stepOnce`. +/// A `(start, len)` window into `Interpreter.captured_filters` — an event +/// await target's payload filter, captured by value at suspension (M1.0.14 E4). +/// `len == 0` means no filter body. +const FilterRange = struct { start: u32, len: u32 }; + const WakeCond = union(enum) { /// Resume once the GAME clock accumulator reaches this deadline, in /// tick units (M1.0.13 E5): `await wait(d)` reached with the game clock @@ -657,10 +662,19 @@ const WakeCond = union(enum) { /// the UNSCALED clock, which advances by 1 per tick unconditionally — /// the wait keeps running under `paused` and ignores `time_scale`. wait_until_unscaled: f64, - /// Resume once an event of this type is present in the per-tick EventStore - /// (M0.8 E3 sub-slice B — `await global_event(T)`). The producer must run - /// before the awaiter in the rule order, same as the observer drain. - global_event: StringId, + /// Resume once an event of `type_name` matching the optional payload + /// `filter` is present in the per-tick EventStore (`await global_event(T + /// [{…}])`, M0.8 E3 + M1.0.14 E4 filter). The producer must run before the + /// awaiter in the rule order, same as the observer drain. The matched event + /// is NOT consumed (observers and other awaiters see it too). + global_event: struct { type_name: StringId, filter: FilterRange }, + /// Resume once an event of `type_name` whose designated `Entity` field + /// (`field_name`, matched BY NAME — emit stores fields in emit-site order) + /// equals `entity`, and matching the optional payload `filter`, is present + /// this tick (`await entity_event(e, T [{…}])`, M1.0.14 E4). `entity` and + /// the filter values are captured ONCE at suspension (§9.4); the designated + /// field is the compile-time decision from `resolveEventEntityTarget`. + entity_event: struct { type_name: StringId, entity: EntityId, field_name: StringId, filter: FilterRange }, /// A `race` parent (M1.0.12 E1): fires when at least one child in /// `task_children[start .. start+len]` is `.done` (a winner exists) — OR when /// no child remains `.suspended` (every branch failed/canceled: no winner, @@ -929,6 +943,10 @@ pub const Interpreter = struct { /// `enum` declarations keyed by name (M0.8 E2 block 3 tranche B), for /// resolving enum values + match-arm variant indices. enum_decls: std.AutoHashMapUnmanaged(StringId, ast_mod.EnumDecl) = .empty, + /// Declared events keyed by name (M1.0.14 E4) — resolves an await event + /// target's designated field + enum field types at suspension, and enum + /// event field values at `emit`. Mirrors `enum_decls`. + event_decls: std.AutoHashMapUnmanaged(StringId, ast_mod.EventDecl) = .empty, /// Trait-impl methods keyed by `methodKey(type_name, method_name)` (M0.8 E2 /// block 3 tranche C), each resolved to the impl-provided method or the /// trait's default. Consulted AFTER `methods` (inherent) per §5.5. @@ -1069,6 +1087,13 @@ pub const Interpreter = struct { /// selection order before spawning their tasks (M1.0.14 E3) — filled by the /// shared selection walk (`iterateSelection` collect mode). entity_spawn_buf: std.ArrayListUnmanaged(EntityId) = .empty, + /// Append-only store of event payload-filter values captured by value at + /// await suspension (M1.0.14 E4); a suspended `global_event`/`entity_event` + /// wake owns a `{start,len}` window (`FilterRange`). Never compacted (husk + /// discipline, like `task_children`); freed wholesale in deinit. Filter + /// values are always literals (`.int_`/`.float_`/`.bool_`/`.string_id`/ + /// `.enum_value`/`.entity_id`) — copy-stable across ticks. + captured_filters: std.ArrayListUnmanaged(StructField) = .empty, /// Reusable cursor buffer for the multi-term (`or`) archetype-union merge /// (M1.0.0). Resized to the term count of the rule being iterated; capacity /// is retained across rules/ticks so the union path allocates at most once. @@ -1138,6 +1163,7 @@ pub const Interpreter = struct { self.methods.deinit(self.gpa); self.struct_decls.deinit(self.gpa); self.enum_decls.deinit(self.gpa); + self.event_decls.deinit(self.gpa); self.trait_methods.deinit(self.gpa); self.tag_table.deinit(self.gpa); self.pending_tags.deinit(self.gpa); @@ -1160,6 +1186,7 @@ pub const Interpreter = struct { for (self.entity_rule_tasks) |*m| m.deinit(self.gpa); self.gpa.free(self.entity_rule_tasks); self.entity_spawn_buf.deinit(self.gpa); + self.captured_filters.deinit(self.gpa); self.descriptors.deinit(self.gpa); self.merge_cursors.deinit(self.gpa); self.gpa.free(self.observer_ctxs); @@ -1393,6 +1420,8 @@ pub const Interpreter = struct { errdefer struct_decls.deinit(gpa); var enum_decls: std.AutoHashMapUnmanaged(StringId, ast_mod.EnumDecl) = .empty; errdefer enum_decls.deinit(gpa); + var event_decls: std.AutoHashMapUnmanaged(StringId, ast_mod.EventDecl) = .empty; + errdefer event_decls.deinit(gpa); var trait_methods: std.AutoHashMapUnmanaged(u64, ast_mod.FnDecl) = .empty; errdefer trait_methods.deinit(gpa); // Trait declarations by name (M0.8 E2 block 3 tranche C) — a local index @@ -1423,6 +1452,10 @@ pub const Interpreter = struct { const decl = ast.enum_decls.items[data]; try enum_decls.put(gpa, decl.name, decl); }, + .event_decl => { + const decl = ast.event_decls.items[data]; + try event_decls.put(gpa, decl.name, decl); + }, .trait_decl => { const decl = ast.trait_decls.items[data]; try trait_decls.put(gpa, decl.name, decl); @@ -1505,6 +1538,7 @@ pub const Interpreter = struct { .methods = methods, .struct_decls = struct_decls, .enum_decls = enum_decls, + .event_decls = event_decls, .trait_methods = trait_methods, .tag_table = tag_table, .tagset_id = tagset_id, @@ -2898,13 +2932,23 @@ pub const Interpreter = struct { }, } }, - .wait, .wait_unscaled, .global_event => { + .wait, .wait_unscaled => { task.wake = try self.evalAwaitTarget(site.await_id); cursor.* += 1; task.pending_bind = site.ret; return .suspended; }, - .entity_event => return error.RuntimeFailure, + .global_event, .entity_event => { + // M1.0.14 E4: build the (filtered / entity-scoped) event wake + // in the LIVE scope — the entity operand and each filter + // expression are captured by value ONCE here, never + // re-evaluated at later polls (§9.4). Resume delivers unit + // (no implicit `event` binding, §9.4). + task.wake = try self.evalEventWake(world, scope, site.await_id); + cursor.* += 1; + task.pending_bind = site.ret; + return .suspended; + }, } } const sk = self.ast.stmtKind(stmt); @@ -3474,16 +3518,129 @@ pub const Interpreter = struct { } } + /// Evaluate an event field / payload-filter value with enum-field awareness + /// (M1.0.14 E4): a bare `.variant` shorthand resolves against the event + /// field's declared enum type (the struct-literal precedent), else via + /// `evalExpr`. Shared by `emit` execution and payload-filter capture so both + /// sides of an enum equality produce the same `.enum_value`. + fn evalEventFieldValue(self: *Interpreter, world: *World, locals: *Locals, decl: ast_mod.EventDecl, flit: ast_mod.StructLitField) StmtError!Value { + if (self.ast.exprKind(flit.value) == .tag_path) { + var fi: u32 = 0; + while (fi < decl.fields_len) : (fi += 1) { + const f = self.ast.fields.items[decl.fields_start + fi]; + if (f.name == flit.name) { + if (self.enumFieldShorthand(f, flit.value)) |ev| return ev; + break; + } + } + } + return try self.evalExpr(world, locals, flit.value); + } + + /// Capture an await event target's payload filter BY VALUE into the + /// append-only `captured_filters` buffer (M1.0.14 E4, capture-once §9.4): + /// each filter field is evaluated once in the live scope; the returned + /// range is re-scanned at each poll, never re-evaluated. + fn captureEventFilter(self: *Interpreter, world: *World, locals: *Locals, decl: ast_mod.EventDecl, filter_start: u32, filter_len: u32) StmtError!FilterRange { + const start: u32 = @intCast(self.captured_filters.items.len); + var i: u32 = 0; + while (i < filter_len) : (i += 1) { + const flit = self.ast.struct_lit_fields.items[filter_start + i]; + const v = try self.evalEventFieldValue(world, locals, decl, flit); + try self.captured_filters.append(self.gpa, .{ .name = flit.name, .value = v }); + } + return .{ .start = start, .len = filter_len }; + } + + /// Build the wake condition for an `await global_event` / `entity_event` + /// target (M1.0.14 E4). The entity operand (entity_event) and the payload + /// filter are evaluated ONCE, here, in the live scope — never re-evaluated + /// at later polls (§9.4). The designated entity field is the compile-time + /// decision from `resolveEventEntityTarget` (§9.4), matched BY NAME at poll. + fn evalEventWake(self: *Interpreter, world: *World, locals: *Locals, await_id: NodeId) StmtError!WakeCond { + const aw = self.ast.awaitExpr(await_id); + const decl = self.event_decls.get(aw.event_type) orelse return error.RuntimeFailure; + const filter = try self.captureEventFilter(world, locals, decl, aw.filter_start, aw.filter_len); + switch (aw.target_kind) { + .global_event => return .{ .global_event = .{ .type_name = aw.event_type, .filter = filter } }, + .entity_event => { + const field_name = switch (self.ast.resolveEventEntityTarget(decl)) { + .field => |f| f.name, + else => return error.RuntimeFailure, // defensive: E2 guarantees a designated field + }; + const ev = try self.evalExpr(world, locals, aw.entity_expr); + if (ev != .entity_id) return error.RuntimeFailure; // defensive: E2 types the operand Entity + return .{ .entity_event = .{ .type_name = aw.event_type, .entity = ev.entity_id, .field_name = field_name, .filter = filter } }; + }, + else => return error.RuntimeFailure, // only the two event targets route here + } + } + + /// An entity gate for `entity_event` wake matching: the designated field's + /// name and the awaited entity (M1.0.14 E4). + const EntityGate = struct { name: StringId, id: EntityId }; + + /// The value of `ev`'s field named `name`, or null if absent — a linear scan + /// (emit stores fields in emit-site order; lookup is by interned name). + fn eventFieldByName(ev: EventVal, name: StringId) ?Value { + for (ev.fields.items) |f| { + if (f.name == name) return f.value; + } + return null; + } + + /// True iff some queued event of `type_name` this tick satisfies the payload + /// `filter` and (for `entity_event`) has its designated field == the gate + /// entity (M1.0.14 E4). The matched event is NOT consumed — observers and + /// other awaiters see it too. + fn anyEventMatches(self: *const Interpreter, type_name: StringId, filter: FilterRange, gate: ?EntityGate) bool { + for (self.events.list.items) |ev| { + if (ev.type_name != type_name) continue; + if (self.eventMatches(ev, filter, gate)) return true; + } + return false; + } + + fn eventMatches(self: *const Interpreter, ev: EventVal, filter: FilterRange, gate: ?EntityGate) bool { + if (gate) |g| { + const fv = eventFieldByName(ev, g.name) orelse return false; + if (fv != .entity_id or fv.entity_id != g.id) return false; + } + var i = filter.start; + while (i < filter.start + filter.len) : (i += 1) { + const want = self.captured_filters.items[i]; + const fv = eventFieldByName(ev, want.name) orelse return false; + if (!self.eventValueEql(want.value, fv)) return false; + } + return true; + } + + /// Equality for an event-field comparison (M1.0.14 E4). Strings compare by + /// BYTES (a filter literal is `.string_id`, but an emitted string may be + /// `.string_run` / `.string_persistent` — `Value.eql` only matches same tag + /// + same pool index); every other admitted kind (int/float/bool/entity/ + /// enum) uses `Value.eql`. + fn eventValueEql(self: *const Interpreter, a: Value, b: Value) bool { + if (self.stringBytes(a)) |ab| { + const bb = self.stringBytes(b) orelse return false; + return std.mem.eql(u8, ab, bb); + } + return a.eql(b); + } + /// Resolve a wake-condition `await` target to a `WakeCond` (M1.0.11 E3). Only - /// `wait` / `wait_unscaled` / `global_event` reach here (`future` is handled - /// by `beginAsyncCall` upstream). `wait` and `wait_unscaled` take a `Duration` + /// `wait` / `wait_unscaled` reach here (`global_event` / `entity_event` build + /// their wake via `evalEventWake`; `future` via `beginAsyncCall`). `wait` and + /// `wait_unscaled` take a `Duration` /// (final API, §9.4): a Duration LITERAL → whole ticks via `round(seconds * /// 60)`, scheduled on the game clock (`wait` — scaled, pausable) or the /// unscaled clock (`wait_unscaled` — M1.0.13 E5, fires under pause). Both /// keep the M1.0.11 literal-only restriction: a non-literal Duration (const / /// arithmetic) fails loud — only the TIMER family evaluates a full Duration - /// expression (E6). `global_event(T)` waits for an event of type `T`; - /// `entity_event` (M1.0.14) fails loud (defensive; filtered upstream). + /// expression (E6). `global_event` / `entity_event` build their wake in the + /// live scope via `evalEventWake` (M1.0.14 E4 — filter/entity capture) and + /// never reach this resolver; `future` is unrealized. All three are a + /// defensive fail-loud here. fn evalAwaitTarget(self: *Interpreter, await_id: NodeId) StmtError!WakeCond { const aw = self.ast.awaitExpr(await_id); switch (aw.target_kind) { @@ -3497,8 +3654,7 @@ pub const Interpreter = struct { else => .{ .wait_until_unscaled = self.unscaled_clock_ticks + ticks }, }; }, - .global_event => return .{ .global_event = aw.event_type }, - .entity_event, .future => return error.RuntimeFailure, + .global_event, .entity_event, .future => return error.RuntimeFailure, } } @@ -3509,7 +3665,11 @@ pub const Interpreter = struct { return switch (wake) { .wait_until => |t| self.game_clock_ticks >= t, .wait_until_unscaled => |t| self.unscaled_clock_ticks >= t, - .global_event => |type_name| self.events.count(type_name) > 0, + // M1.0.14 E4 — a queued event of the type this tick that also + // satisfies the payload filter (global_event) and the designated + // entity field (entity_event). The event is NOT consumed. + .global_event => |g| self.anyEventMatches(g.type_name, g.filter, null), + .entity_event => |e| self.anyEventMatches(e.type_name, e.filter, .{ .name = e.field_name, .id = e.entity }), // Race parent: a winner exists (some child `.done`) — or no child // remains `.suspended` (every branch failed/canceled → no winner; // the race completes and the parent resumes after the statement, E4). @@ -3958,12 +4118,25 @@ pub const Interpreter = struct { // tree-walker; `@on_event` observers drain this store (deferred // to the E3 observer tranche, resolver-types §12). const em = self.ast.emit_stmts.items[data]; + // The event decl (for enum-shorthand field resolution) is known + // only for MAIN-arena events; a re-parsed extension hook + // (`execHookText`) runs against a transient arena whose event + // type ids are not in `event_decls`, so fall back to plain + // `evalExpr` there — its emit fields are the M1.0.9 cookable + // subset, which has no enum shorthand. + const edecl_opt = self.event_decls.get(em.event_type); var fields: std.ArrayListUnmanaged(StructField) = .empty; errdefer fields.deinit(self.gpa); var i: u32 = 0; while (i < em.fields_len) : (i += 1) { const flit = self.ast.struct_lit_fields.items[em.fields_start + i]; - const v = try self.evalExpr(world, locals, flit.value); + // Resolve enum-shorthand field values against the declared + // enum type (M1.0.14 E4 fix-as-you-go — a bare `.Variant` + // event field was fail-loud in generic `evalExpr` before). + const v = if (edecl_opt) |edecl| + try self.evalEventFieldValue(world, locals, edecl, flit) + else + try self.evalExpr(world, locals, flit.value); try fields.append(self.gpa, .{ .name = flit.name, .value = v }); } try self.events.enqueue(self.gpa, em.event_type, fields); @@ -8821,6 +8994,20 @@ fn writeI64Field(world: *World, comp_id: ComponentId, entity: CoreEntityId, valu @memcpy(cslot[0..@sizeOf(i64)], std.mem.asBytes(&v)); } +/// Read the first `int` (`i64`) field of `comp_id` on `entity` (test helper) — +/// the read counterpart of `writeI64Field`, used by the M1.0.14 E4 tests to +/// observe a per-entity wake counter. +fn readI64Field(world: *World, comp_id: ComponentId, entity: CoreEntityId) i64 { + const loc = world.dynamicLocation(entity).?; + const arch = world.dynamicArchetype(loc.archetype_idx); + const chunk = arch.chunks.items[loc.chunk_idx]; + const cidx = arch.componentIndex(comp_id).?; + const cslot = arch.componentSlot(chunk, cidx, loc.slot); + var v: i64 = 0; + @memcpy(std.mem.asBytes(&v), cslot[0..@sizeOf(i64)]); + return v; +} + /// Read the `changed_tick` change-detection sidecar of `comp_id`'s slot on /// `entity` (test helper) — the value a `changed` filter compares against /// `last_run_tick` (`engine-ecs-internals.md` §5). Surfaces the spawn-tick @@ -10136,24 +10323,28 @@ test "await wait(1.0s) resumes at the fixed-timestep-equivalent tick count (60) try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); } -test "entity_event fails loud in evalAwaitTarget until the E4 lift (M1.0.14 E2 pin)" { +test "future is the sole await target the wake-resolver rejects (M1.0.14 E4 — boundary pin flipped)" { const gpa = std.testing.allocator; - // `entity_event` type-checks (M1.0.14 E2) but its runtime lands in E4. No - // Etch program can REACH the fail-loud site yet: a parameterized async rule - // hits the entity-bound `runAsyncRule` guard first (entity-bound async rules - // are E3), so a program-driven test would fail loud for the WRONG reason. - // The boundary is therefore pinned by calling the private machinery - // directly — `evalAwaitTarget` on the `.entity_event` await node must fail - // loud. This pin flips at E4 (assert `future` is then the sole target). + // The M1.0.13/E2 pin asserted `entity_event` failed loud in the wake-resolver. + // M1.0.14 E4 FLIPS it: `entity_event` and `global_event` are now realized — + // their wake is built in the live scope by `evalEventWake` (see the E4 + // resume/filter tests above). `evalAwaitTarget` handles only `wait` / + // `wait_unscaled`, so `future` (a genuine `Future` / non-inlined async + // return) is the SOLE `await` target it still rejects — the residual + // unrealized target after this milestone. var world = World.init(); defer world.deinit(gpa); var pr = try parser_mod.parse(gpa, - \\component C { } - \\event Ev { who: Entity } - \\async rule r(e: Entity) - \\ when e has C + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out \\{ - \\ await entity_event(e, Ev) + \\ let h = spawn { + \\ await wait(0.02s) + \\ let s = get_mut(Out) + \\ s.n = 7 + \\ } + \\ await h \\} ); defer pr.deinit(gpa); @@ -10169,14 +10360,14 @@ test "entity_event fails loud in evalAwaitTarget until the E4 lift (M1.0.14 E2 p var interp = try Interpreter.compile(gpa, &pr.ast, &world); defer interp.deinit(); - // Navigate to the `await entity_event(e, Ev)` node in rule `r`'s body. + // Navigate to the `await h` node (2nd statement) in rule `r`'s body — the + // handle-await form is a `.future` target. const rule = pr.ast.rule_decls.items[0]; - const stmt: NodeId = @bitCast(pr.ast.extra.items[rule.body_start]); + const stmt: NodeId = @bitCast(pr.ast.extra.items[rule.body_start + 1]); const await_id: NodeId = @bitCast(pr.ast.stmtData(stmt)); try std.testing.expectEqual(ast_mod.ExprKind.await_expr, pr.ast.exprKind(await_id)); - try std.testing.expectEqual(ast_mod.AwaitTargetKind.entity_event, pr.ast.awaitExpr(await_id).target_kind); - - // The direct pin: `entity_event` is not yet realized → fail loud (until E4). + try std.testing.expectEqual(ast_mod.AwaitTargetKind.future, pr.ast.awaitExpr(await_id).target_kind); + // `future` is the residual fail-loud target of the wake-resolver. try std.testing.expectError(error.RuntimeFailure, interp.evalAwaitTarget(await_id)); } @@ -10454,6 +10645,342 @@ test "async rule shape guard: parameterless byte-stable, dt param fail-loud (M1. } } +test "entity_event: wakes for the matching designated entity, not a different one (M1.0.14 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // `producer` emits `Done { who: e }` for every Trig entity; the entity-bound + // `watch` awaits `entity_event(e, Done)` — the designated field is the single + // structural `Entity` field `who`. e1 has Trig (so a Done{who:e1} is emitted); + // e2 does not. Only watch(e1) must wake. + var pr = try parser_mod.parse(gpa, + \\component NPC { v: int = 0 } + \\component Woke { n: int = 0 } + \\component Trig { v: int = 0 } + \\event Done { who: Entity } + \\rule producer(e: Entity) + \\ when e has Trig + \\{ + \\ emit Done { who: e } + \\} + \\async rule watch(e: Entity) + \\ when e has NPC and e has Woke + \\{ + \\ await entity_event(e, Done) + \\ let w = e.get_mut(Woke) + \\ w.n = w.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const npc = world.registry.idOf("NPC").?; + const woke = world.registry.idOf("Woke").?; + const trig = world.registry.idOf("Trig").?; + const e1 = try world.spawnDynamic(gpa, &[_]ComponentId{ npc, woke, trig }); + const e2 = try world.spawnDynamic(gpa, &[_]ComponentId{ npc, woke }); + _ = try interp.runFor(&world, 6); + try std.testing.expect(readI64Field(&world, woke, e1) >= 1); // matching designated entity woke + try std.testing.expectEqual(@as(i64, 0), readI64Field(&world, woke, e2)); // a different entity never woke +} + +test "global_event payload filter: int match/mismatch + string byte-equality (M1.0.14 E4)" { + const gpa = std.testing.allocator; + + // A) int filter MATCH — producer emits Q{id:7}, awaiter filters {id:7} → wakes once. + { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\event Q { id: int } + \\resource Out { n: int = 0 } + \\rule producer() + \\ when resource Out + \\{ + \\ emit Q { id: 7 } + \\} + \\async rule watch() + \\ when resource Out + \\{ + \\ await global_event(Q { id: 7 }) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + _ = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // matched (parameterless: no re-arm) + } + + // B) int filter MISMATCH — producer emits Q{id:9}, awaiter filters {id:7} → never wakes. + { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\event Q { id: int } + \\resource Out { n: int = 0 } + \\rule producer() + \\ when resource Out + \\{ + \\ emit Q { id: 9 } + \\} + \\async rule watch() + \\ when resource Out + \\{ + \\ await global_event(Q { id: 7 }) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + _ = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out)); // filter never matched + } + + // C) string filter across DIFFERENT string tags — the emitted `text` reads a + // resource string (`.string_persistent`, persistent-heap-backed), the filter + // literal is a `.string_id`. They match only because the comparator + // BYTE-compares strings (`Value.eql` tag-mismatches string_persistent vs + // string_id → false). This pins the E4 `stringBytes` path. (An emitted + // runtime-CONCAT string would be a body-scoped `.string_run` and is a + // pre-existing emit/string-lifetime limitation, orthogonal to E4.) + { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, + \\event Msg { text: string } + \\resource Out { n: int = 0 } + \\resource Cfg { name: string = "go" } + \\rule producer() + \\ when resource Out and resource Cfg + \\{ + \\ emit Msg { text: get(Cfg).name } + \\} + \\async rule watch() + \\ when resource Out + \\{ + \\ await global_event(Msg { text: "go" }) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + _ = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // byte-equal string matched + } +} + +test "entity_event: @entity_target designates the matched field on a two-Entity event (M1.0.14 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // `Hit` has two Entity fields; `@entity_target target` is the designated one. + // `producer` (on the Link entity) emits Hit{source: itself, target: its + // Link.other} — so source != target. The awaiter is on the `target` entity; + // it must wake because the designation is `target`, NOT the structurally-first + // `source` (which would be the producer's own entity, never the awaiter). + var pr = try parser_mod.parse(gpa, + \\component NPC { v: int = 0 } + \\component Woke { n: int = 0 } + \\component Link { other: Entity } + \\event Hit { source: Entity, @entity_target target: Entity } + \\rule producer(e: Entity) + \\ when e has Link + \\{ + \\ emit Hit { source: e, target: e.get(Link).other } + \\} + \\async rule watch(e: Entity) + \\ when e has NPC and e has Woke + \\{ + \\ await entity_event(e, Hit) + \\ let w = e.get_mut(Woke) + \\ w.n = w.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const npc = world.registry.idOf("NPC").?; + const woke = world.registry.idOf("Woke").?; + const link = world.registry.idOf("Link").?; + const e_target = try world.spawnDynamic(gpa, &[_]ComponentId{ npc, woke }); + var target_bits: u64 = @bitCast(e_target); + _ = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{link}, &[_][]const u8{std.mem.asBytes(&target_bits)}); + _ = try interp.runFor(&world, 6); + try std.testing.expect(readI64Field(&world, woke, e_target) >= 1); // woke via the @entity_target field +} + +test "entity_event: payload filter match/mismatch + `let x = await` binds unit (M1.0.14 E4)" { + const gpa = std.testing.allocator; + const prog = + \\component NPC {{ v: int = 0 }} + \\component Woke {{ n: int = 0 }} + \\component Trig {{ v: int = 0 }} + \\event Dmg {{ who: Entity, amount: int }} + \\rule producer(e: Entity) + \\ when e has Trig + \\{{ + \\ emit Dmg {{ who: e, amount: 10 }} + \\}} + \\async rule watch(e: Entity) + \\ when e has NPC and e has Woke + \\{{ + \\ let x = await entity_event(e, Dmg {{ amount: {d} }}) + \\ let w = e.get_mut(Woke) + \\ w.n = w.n + 1 + \\}} + ; + + // MATCH — filter {amount: 10} equals the emitted amount; `let x = await …` + // binds unit and the body runs (the resume delivers unit, not the event). + { + var world = World.init(); + defer world.deinit(gpa); + const src = try std.fmt.allocPrint(gpa, prog, .{10}); + defer gpa.free(src); + var pr = try parser_mod.parse(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const npc = world.registry.idOf("NPC").?; + const woke = world.registry.idOf("Woke").?; + const trig = world.registry.idOf("Trig").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{ npc, woke, trig }); + _ = try interp.runFor(&world, 6); + try std.testing.expect(readI64Field(&world, woke, e) >= 1); // filter matched → resumed (x = unit) + } + + // MISMATCH — filter {amount: 99} never equals the emitted amount 10. + { + var world = World.init(); + defer world.deinit(gpa); + const src = try std.fmt.allocPrint(gpa, prog, .{99}); + defer gpa.free(src); + var pr = try parser_mod.parse(gpa, src); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const npc = world.registry.idOf("NPC").?; + const woke = world.registry.idOf("Woke").?; + const trig = world.registry.idOf("Trig").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{ npc, woke, trig }); + _ = try interp.runFor(&world, 6); + try std.testing.expectEqual(@as(i64, 0), readI64Field(&world, woke, e)); // filter never matched + } +} + +test "global_event filter is captured once at suspension, not re-evaluated at poll (M1.0.14 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // The filter reads `get(Cfg).want`, initially 7. `producer` emits Q{id:7}; + // `bump` sets Cfg.want = 9 AFTER the awaiter suspends (rule order: producer, + // watch, bump). The wake uses the CAPTURED 7 → it fires. If the filter were + // re-evaluated at the poll (== 9), Q{id:7} would never match → n stays 0. + var pr = try parser_mod.parse(gpa, + \\event Q { id: int } + \\resource Out { n: int = 0 } + \\resource Cfg { want: int = 7 } + \\rule producer() + \\ when resource Cfg + \\{ + \\ emit Q { id: 7 } + \\} + \\async rule watch() + \\ when resource Out and resource Cfg + \\{ + \\ await global_event(Q { id: get(Cfg).want }) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + \\rule bump() + \\ when resource Cfg + \\{ + \\ let c = get_mut(Cfg) + \\ c.want = 9 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + _ = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // captured 7 matched despite want→9 +} + test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { const gpa = std.testing.allocator; var world = World.init(); From c9da9feac258a5b3152210f53ab26cc1136e8f86 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 22:49:23 +0200 Subject: [PATCH 15/24] docs(brief): journal E4 (entity_event runtime + filters) --- briefs/M1.0.14-entity-scoped-events.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 6378394..3a6b5b6 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -155,6 +155,10 @@ None. - 2026-07-04 — E2 STOP fix (round-trip `5589078`; FROZEN amended — entity-bound async rules absorbed, gates E1→E5): the spawn-seed rewrite above was wrong — a parameterized async rule fails loud at the entity-bound `runAsyncRule` guard BEFORE reaching `entity_event`, so it pinned the boundary for the wrong reason. Replaced with a DIRECT pin: compile a minimal type-valid program and assert `interp.evalAwaitTarget(<.entity_event node>)` returns `error.RuntimeFailure` (flips at E4). Removed the now-orphaned `asyncFailLoudCount` helper. Full `zig build test` = 976/993 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-04 — E3 (entity-bound async rules, interp.zig): `runAsyncRule` restructured — the clean single-`Entity`-param shape (`is_entity_bound and params_len == 1`) takes a new per-(rule,entity) path (`spawnEntityBoundTasks`): a task per matched entity, entity bound into root locals, in selection order. Bookkeeping = a per-rule `entity_rule_tasks[idx]` map (`entity → task`), LIVE = `.suspended`, terminal husks re-arm (ruling 1); `when` gates the spawn only (ruling 2); despawn → dead-handle fail-loud on resume (ruling 3, free via `dynamicLocation`); order preserved by REUSING the sync `iterateSelection`/`iterateUnion`/`iterateArchetype` walk via a threaded nullable `collect` buffer (ruling 4 — identical order to sync). Non-entity param shapes (e.g. `dt: float`, or `e + dt`) keep the fail-loud guard; the parameterless shape is byte-stable (unchanged branch). Drive-by-origin untouched. - 2026-07-04 — E3 tests: 6 tests (per-entity spawn + independent resumes; rulings 1–4; parameterless byte-stable; `dt: float` fail-loud) green. The when-gates test (ruling 2) gives the entity a second `Keep` component because `world.removeComponentDynamic` asserts ≥2 components (cannot empty an entity). Full `zig build test` = 982/999 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-04 — E4 (runtime realization, interp.zig): `WakeCond` extended — filtered `global_event {type_name, filter}` + new `entity_event {type_name, entity, field_name, filter}`; `filter` is a `FilterRange` into a new append-only `captured_filters` buffer (mirrors `task_children`). The two event await targets build their wake in the LIVE scope via `evalEventWake` (routed from `stepBodyStmt`, NOT the wait-resolver): the entity operand and each filter field are evaluated ONCE at suspension (capture-once §9.4); the designated field is resolved via the shared `AstArena.resolveEventEntityTarget` and matched BY NAME at poll (emit stores fields in emit-site order, not decl order). `asyncWakeFired` scans the per-tick store (type + designated-entity + per-filter-field equality) via `anyEventMatches`/`eventMatches`/`eventValueEql`. Both `entity_event` fail-loud sites removed; `evalAwaitTarget` now handles only `wait`/`wait_unscaled` (defensive fail-loud for `global_event`/`entity_event`/`future`); the matched event is NOT consumed. +- 2026-07-04 — E4 equality-set verdict (the milestone's flagged risk): CLEAR, no STOP. int/float/bool/entity/enum compare via `Value.eql`; STRINGS byte-compare (reusing `stringBytes`) because a filter literal is `.string_id` while an emitted value may be `.string_persistent` / `.string_run` — `Value.eql` only matches same tag + same pool index. Fix-as-you-go: `emit` now resolves enum field values through `evalEnumShorthandFor` (a bare `.Variant` event field was fail-loud in generic `evalExpr` before), fallback-safe to plain `evalExpr` when the event decl isn't in the new runtime `event_decls` map (re-parsed extension hooks — the M1.0.9 execHookText path). `event_decls` (mirrors `enum_decls`) also backs the designated-field + enum-field resolution. +- 2026-07-04 — E4 tests (5) + pin flip: (1) entity_event realized + structural single-`Entity` designation + no-wake-on-a-different-entity; (2) `@entity_target` designation on a two-`Entity` event (Link cross-ref → source ≠ target); (3) `global_event` filter int match/mismatch + string byte-equality (emitted `.string_persistent` resource string vs `.string_id` filter — proves the byte-compare is necessary); (4) entity_event filter match/mismatch + `let x = await` binds unit; (5) `global_event` filter captured-once (a `bump` rule mutates the source after suspension; the wake uses the captured value). The M1.0.13/E2 boundary pin FLIPPED: `future` is the sole `await` target `evalAwaitTarget` rejects. Full `zig build test` = 987/1004 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-04 — E4 residual (pre-existing, out of scope, noted not fixed): a runtime-produced emitted string (a concat `.string_run`) lives in the emitter's per-body `run_strings` and does NOT survive the body boundary, so it dangles for ANY later reader (an awaiter's filter poll OR an `@on_event` observer) — an emit/string-lifetime limitation orthogonal to `entity_event` (it predates this milestone and affects observers equally). The E4 comparator byte-compares the STABLE string kinds (`.string_id` AST-pool / `.string_persistent` persistent-heap); a filter literal is always a stable `.string_id`. Persisting emitted runtime strings is a separate concern. ## Recorded deviations From 21a77bed16893ca63ac03804ba1e4c788275a97e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 23:53:29 +0200 Subject: [PATCH 16/24] fix(etch): stabilize runtime strings in events/filters (M1.0.14 E4) --- src/etch/interp.zig | 144 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 5 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 00ad8af..e60e192 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -465,15 +465,28 @@ const EventVal = struct { /// `Lifetime.tick` drain cadence (`src/core/events/lifetime.zig`). const EventStore = struct { list: std.ArrayListUnmanaged(EventVal) = .empty, + /// Store-owned deep copies of runtime-produced (`.string_run`) event field + /// bytes (M1.0.14 E4). A `.string_run` lives in the emitter's per-body + /// `run_strings` and is freed at the body boundary (its handle may even be + /// reused), so it cannot back an event that outlives the emitter's body + /// (an `@on_event` observer, or an awaiter's cross-tick filter poll). Such a + /// field value is deep-copied here at emit and re-tagged `.string_persistent` + /// over the copy; the copies are freed together with the event queue at the + /// per-tick `clear`. + owned_strings: std.ArrayListUnmanaged([]u8) = .empty, fn deinit(self: *EventStore, gpa: std.mem.Allocator) void { for (self.list.items) |*e| e.fields.deinit(gpa); self.list.deinit(gpa); + for (self.owned_strings.items) |s| gpa.free(s); + self.owned_strings.deinit(gpa); } fn clear(self: *EventStore, gpa: std.mem.Allocator) void { for (self.list.items) |*e| e.fields.deinit(gpa); self.list.clearRetainingCapacity(); + for (self.owned_strings.items) |s| gpa.free(s); + self.owned_strings.clearRetainingCapacity(); } /// Enqueue an event of `type_name`, taking ownership of `fields`. @@ -481,6 +494,17 @@ const EventStore = struct { try self.list.append(gpa, .{ .type_name = type_name, .fields = fields }); } + /// Deep-copy `bytes` into store-owned memory and return a stable + /// `.string_persistent` view over the copy (freed at `clear`). Stabilizes a + /// runtime `.string_run` event field value that would otherwise dangle when + /// the emitter's body ends (M1.0.14 E4). + fn ownRuntimeString(self: *EventStore, gpa: std.mem.Allocator, bytes: []const u8) !Value { + const dup = try gpa.dupe(u8, bytes); + errdefer gpa.free(dup); + try self.owned_strings.append(gpa, dup); + return Value{ .string_persistent = .{ .ptr = @intFromPtr(dup.ptr), .len = @intCast(dup.len) } }; + } + /// Number of queued events of `type_name` (test / inspection helper). fn count(self: *const EventStore, type_name: StringId) usize { var n: usize = 0; @@ -1090,10 +1114,17 @@ pub const Interpreter = struct { /// Append-only store of event payload-filter values captured by value at /// await suspension (M1.0.14 E4); a suspended `global_event`/`entity_event` /// wake owns a `{start,len}` window (`FilterRange`). Never compacted (husk - /// discipline, like `task_children`); freed wholesale in deinit. Filter - /// values are always literals (`.int_`/`.float_`/`.bool_`/`.string_id`/ - /// `.enum_value`/`.entity_id`) — copy-stable across ticks. + /// discipline, like `task_children`); freed wholesale in deinit. POD filter + /// values (`.int_`/`.float_`/`.bool_`/`.string_id`/`.enum_value`/ + /// `.entity_id`) are copy-stable across ticks; a COMPUTED string filter + /// (`prefix + "!"`) is a `.string_run` whose per-body bytes are freed at the + /// body boundary — it is deep-copied into `captured_filter_strings` at + /// capture and re-tagged `.string_persistent` over the copy. captured_filters: std.ArrayListUnmanaged(StructField) = .empty, + /// Buffer-owned deep copies of computed (`.string_run`) filter-value bytes + /// (M1.0.14 E4). Parallel to `captured_filters`, same husk lifetime: freed + /// wholesale in deinit (never per-tick — a suspended wake keeps its window). + captured_filter_strings: std.ArrayListUnmanaged([]u8) = .empty, /// Reusable cursor buffer for the multi-term (`or`) archetype-union merge /// (M1.0.0). Resized to the term count of the rule being iterated; capacity /// is retained across rules/ticks so the union path allocates at most once. @@ -1187,6 +1218,8 @@ pub const Interpreter = struct { self.gpa.free(self.entity_rule_tasks); self.entity_spawn_buf.deinit(self.gpa); self.captured_filters.deinit(self.gpa); + for (self.captured_filter_strings.items) |s| self.gpa.free(s); + self.captured_filter_strings.deinit(self.gpa); self.descriptors.deinit(self.gpa); self.merge_cursors.deinit(self.gpa); self.gpa.free(self.observer_ctxs); @@ -3546,7 +3579,17 @@ pub const Interpreter = struct { var i: u32 = 0; while (i < filter_len) : (i += 1) { const flit = self.ast.struct_lit_fields.items[filter_start + i]; - const v = try self.evalEventFieldValue(world, locals, decl, flit); + const v0 = try self.evalEventFieldValue(world, locals, decl, flit); + // Stabilize a COMPUTED string (`.string_run`): its per-body bytes are + // freed at the body boundary, but the captured filter is re-scanned + // at later-tick polls, so deep-copy into `captured_filter_strings` + // (M1.0.14 E4). Literal / persistent / POD values are copy-stable. + const v = if (v0 == .string_run) blk: { + const dup = try self.gpa.dupe(u8, self.run_strings.items[v0.string_run]); + errdefer self.gpa.free(dup); + try self.captured_filter_strings.append(self.gpa, dup); + break :blk Value{ .string_persistent = .{ .ptr = @intFromPtr(dup.ptr), .len = @intCast(dup.len) } }; + } else v0; try self.captured_filters.append(self.gpa, .{ .name = flit.name, .value = v }); } return .{ .start = start, .len = filter_len }; @@ -4137,7 +4180,15 @@ pub const Interpreter = struct { try self.evalEventFieldValue(world, locals, edecl, flit) else try self.evalExpr(world, locals, flit.value); - try fields.append(self.gpa, .{ .name = flit.name, .value = v }); + // Stabilize a runtime string (`.string_run`): its per-body + // `run_strings` bytes are freed at the body boundary, but the + // enqueued event outlives the body (observers / cross-tick + // awaiters), so deep-copy into the store (M1.0.14 E4). + const stable = if (v == .string_run) + try self.events.ownRuntimeString(self.gpa, self.run_strings.items[v.string_run]) + else + v; + try fields.append(self.gpa, .{ .name = flit.name, .value = stable }); } try self.events.enqueue(self.gpa, em.event_type, fields); }, @@ -10981,6 +11032,89 @@ test "global_event filter is captured once at suspension, not re-evaluated at po try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // captured 7 matched despite want→9 } +test "emit stabilizes a computed string so an @on_event observer reads it safely (M1.0.14 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // `producer` emits a COMPUTED string ("go" + "!" → a body-scoped `.string_run`). + // Without the E4 deep-copy into the event store, the emitter's body reset would + // free those bytes before the `@on_event` observer (a later body, same tick) + // reads them → use-after-free. `event.text.len()` touches the copied bytes. + var pr = try parser_mod.parse(gpa, + \\event Msg { text: string } + \\resource Out { n: int = 0 } + \\rule producer() + \\ when resource Out + \\{ + \\ emit Msg { text: "go" + "!" } + \\} + \\@on_event(Msg) + \\rule reader() + \\ when resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = event.text.len() + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + const rep = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), rep.runtime_errors); // no use-after-free + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out)); // "go!".len(), from the stable copy +} + +test "a computed string filter survives to a cross-tick global_event poll (M1.0.14 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // The filter value `get(Cfg).prefix + "!"` is a COMPUTED string (a body-scoped + // `.string_run`) captured at suspension and re-scanned at the NEXT tick's poll. + // Without the E4 deep-copy into `captured_filter_strings`, the awaiter's body + // reset would free those bytes before the poll → use-after-free / false match. + var pr = try parser_mod.parse(gpa, + \\event Msg { text: string } + \\resource Out { n: int = 0 } + \\resource Cfg { prefix: string = "go" } + \\rule producer() + \\ when resource Out + \\{ + \\ emit Msg { text: "go!" } + \\} + \\async rule watch() + \\ when resource Out and resource Cfg + \\{ + \\ await global_event(Msg { text: get(Cfg).prefix + "!" }) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + const rep = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(u64, 0), rep.runtime_errors); // no use-after-free at the cross-tick poll + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // captured "go!" matched cross-tick +} + test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { const gpa = std.testing.allocator; var world = World.init(); From a6f45508280c65f05c96cc5d147bc6eb9555c377 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 23:53:30 +0200 Subject: [PATCH 17/24] docs(brief): journal E4 STOP fix (runtime-string stabilization) --- briefs/M1.0.14-entity-scoped-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index 3a6b5b6..c0c2292 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -158,7 +158,7 @@ None. - 2026-07-04 — E4 (runtime realization, interp.zig): `WakeCond` extended — filtered `global_event {type_name, filter}` + new `entity_event {type_name, entity, field_name, filter}`; `filter` is a `FilterRange` into a new append-only `captured_filters` buffer (mirrors `task_children`). The two event await targets build their wake in the LIVE scope via `evalEventWake` (routed from `stepBodyStmt`, NOT the wait-resolver): the entity operand and each filter field are evaluated ONCE at suspension (capture-once §9.4); the designated field is resolved via the shared `AstArena.resolveEventEntityTarget` and matched BY NAME at poll (emit stores fields in emit-site order, not decl order). `asyncWakeFired` scans the per-tick store (type + designated-entity + per-filter-field equality) via `anyEventMatches`/`eventMatches`/`eventValueEql`. Both `entity_event` fail-loud sites removed; `evalAwaitTarget` now handles only `wait`/`wait_unscaled` (defensive fail-loud for `global_event`/`entity_event`/`future`); the matched event is NOT consumed. - 2026-07-04 — E4 equality-set verdict (the milestone's flagged risk): CLEAR, no STOP. int/float/bool/entity/enum compare via `Value.eql`; STRINGS byte-compare (reusing `stringBytes`) because a filter literal is `.string_id` while an emitted value may be `.string_persistent` / `.string_run` — `Value.eql` only matches same tag + same pool index. Fix-as-you-go: `emit` now resolves enum field values through `evalEnumShorthandFor` (a bare `.Variant` event field was fail-loud in generic `evalExpr` before), fallback-safe to plain `evalExpr` when the event decl isn't in the new runtime `event_decls` map (re-parsed extension hooks — the M1.0.9 execHookText path). `event_decls` (mirrors `enum_decls`) also backs the designated-field + enum-field resolution. - 2026-07-04 — E4 tests (5) + pin flip: (1) entity_event realized + structural single-`Entity` designation + no-wake-on-a-different-entity; (2) `@entity_target` designation on a two-`Entity` event (Link cross-ref → source ≠ target); (3) `global_event` filter int match/mismatch + string byte-equality (emitted `.string_persistent` resource string vs `.string_id` filter — proves the byte-compare is necessary); (4) entity_event filter match/mismatch + `let x = await` binds unit; (5) `global_event` filter captured-once (a `bump` rule mutates the source after suspension; the wake uses the captured value). The M1.0.13/E2 boundary pin FLIPPED: `future` is the sole `await` target `evalAwaitTarget` rejects. Full `zig build test` = 987/1004 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. -- 2026-07-04 — E4 residual (pre-existing, out of scope, noted not fixed): a runtime-produced emitted string (a concat `.string_run`) lives in the emitter's per-body `run_strings` and does NOT survive the body boundary, so it dangles for ANY later reader (an awaiter's filter poll OR an `@on_event` observer) — an emit/string-lifetime limitation orthogonal to `entity_event` (it predates this milestone and affects observers equally). The E4 comparator byte-compares the STABLE string kinds (`.string_id` AST-pool / `.string_persistent` persistent-heap); a filter literal is always a stable `.string_id`. Persisting emitted runtime strings is a separate concern. +- 2026-07-04 — E4 STOP fix (round-trip; runtime-string use-after-free): a COMPUTED string (`prefix + "!"`) is a `.string_run` whose per-body `run_strings` bytes are `gpa.free`d at the body boundary (handle reusable), so the NEW `captured_filters` (re-scanned at later-tick polls) AND the emit-side value (read by a later-body `@on_event` observer) pointed at freed/rewritten memory — a use-after-free / silent false match, and the "filter values are always literals — copy-stable" doc-comment was wrong. FIX: deep-copy `.string_run` bytes at BOTH escape boundaries and re-tag `.string_persistent` over the copy — (i) at `EventStore` entry on emit (store-owned `owned_strings`, freed at the per-tick `clear`), (ii) at `captured_filters` entry on capture (buffer-owned `captured_filter_strings`, freed wholesale at deinit, husk discipline). Also fixed a regression from the E4 emit change: the `event_decls` lookup at emit is now OPTIONAL (fallback to plain `evalExpr`) so a re-parsed extension hook (`execHookText`, transient arena) still emits. Doc-comments corrected. Two tests added: a computed string emitted → read by an `@on_event` observer (emit-side); a computed-string filter that matches cross-tick (capture-side). Verified the E4 set covers "filtered `global_event` end-to-end" (the int-match block + the new cross-tick test — parameterless awaiter). Full `zig build test` = 989/1006 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. ## Recorded deviations From bec269e2be381059c915feda7fbb29a0999cec53 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 5 Jul 2026 00:06:50 +0200 Subject: [PATCH 18/24] fix(etch): stabilize all non-AST strings in event filters (M1.0.14 E4) --- src/etch/interp.zig | 131 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 103 insertions(+), 28 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index e60e192..4a8a7ba 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -465,14 +465,16 @@ const EventVal = struct { /// `Lifetime.tick` drain cadence (`src/core/events/lifetime.zig`). const EventStore = struct { list: std.ArrayListUnmanaged(EventVal) = .empty, - /// Store-owned deep copies of runtime-produced (`.string_run`) event field - /// bytes (M1.0.14 E4). A `.string_run` lives in the emitter's per-body - /// `run_strings` and is freed at the body boundary (its handle may even be - /// reused), so it cannot back an event that outlives the emitter's body - /// (an `@on_event` observer, or an awaiter's cross-tick filter poll). Such a - /// field value is deep-copied here at emit and re-tagged `.string_persistent` - /// over the copy; the copies are freed together with the event queue at the - /// per-tick `clear`. + /// Store-owned deep copies of NON-AST string event field bytes (M1.0.14 E4): + /// a `.string_run` (per-body `run_strings`, freed at the body boundary, its + /// handle reusable) OR a borrowed `.string_persistent` (a view over resource + /// storage, released when the resource string field is reassigned — M1.0.3). + /// Neither survives an event that outlives the emitter's body (an `@on_event` + /// observer or an awaiter's cross-tick poll), nor a mutation of its source, + /// so such a field value is deep-copied here at emit and re-tagged + /// `.string_persistent` over the copy; the copies are freed with the event + /// queue at the per-tick `clear`. (`.string_id` — the immortal AST table — is + /// stable and never copied.) owned_strings: std.ArrayListUnmanaged([]u8) = .empty, fn deinit(self: *EventStore, gpa: std.mem.Allocator) void { @@ -496,9 +498,10 @@ const EventStore = struct { /// Deep-copy `bytes` into store-owned memory and return a stable /// `.string_persistent` view over the copy (freed at `clear`). Stabilizes a - /// runtime `.string_run` event field value that would otherwise dangle when - /// the emitter's body ends (M1.0.14 E4). - fn ownRuntimeString(self: *EventStore, gpa: std.mem.Allocator, bytes: []const u8) !Value { + /// non-AST string event field value (a per-body `.string_run` or a borrowed + /// `.string_persistent`) that would otherwise dangle when the emitter's body + /// ends or the source resource string is reassigned (M1.0.14 E4). + fn ownEscapingString(self: *EventStore, gpa: std.mem.Allocator, bytes: []const u8) !Value { const dup = try gpa.dupe(u8, bytes); errdefer gpa.free(dup); try self.owned_strings.append(gpa, dup); @@ -1116,14 +1119,16 @@ pub const Interpreter = struct { /// wake owns a `{start,len}` window (`FilterRange`). Never compacted (husk /// discipline, like `task_children`); freed wholesale in deinit. POD filter /// values (`.int_`/`.float_`/`.bool_`/`.string_id`/`.enum_value`/ - /// `.entity_id`) are copy-stable across ticks; a COMPUTED string filter - /// (`prefix + "!"`) is a `.string_run` whose per-body bytes are freed at the - /// body boundary — it is deep-copied into `captured_filter_strings` at - /// capture and re-tagged `.string_persistent` over the copy. + /// `.entity_id`) are copy-stable across ticks; a NON-AST string filter — a + /// computed `.string_run` (`prefix + "!"`) or a borrowed `.string_persistent` + /// (`get(R).s`, released on resource reassignment) — is deep-copied into + /// `captured_filter_strings` at capture and re-tagged `.string_persistent` + /// over the copy (which also enforces capture-once §9.4). captured_filters: std.ArrayListUnmanaged(StructField) = .empty, - /// Buffer-owned deep copies of computed (`.string_run`) filter-value bytes - /// (M1.0.14 E4). Parallel to `captured_filters`, same husk lifetime: freed - /// wholesale in deinit (never per-tick — a suspended wake keeps its window). + /// Buffer-owned deep copies of non-AST (`.string_run` / borrowed + /// `.string_persistent`) filter-value bytes (M1.0.14 E4). Parallel to + /// `captured_filters`, same husk lifetime: freed wholesale in deinit (never + /// per-tick — a suspended wake keeps its window). captured_filter_strings: std.ArrayListUnmanaged([]u8) = .empty, /// Reusable cursor buffer for the multi-term (`or`) archetype-union merge /// (M1.0.0). Resized to the term count of the rule being iterated; capacity @@ -3580,12 +3585,15 @@ pub const Interpreter = struct { while (i < filter_len) : (i += 1) { const flit = self.ast.struct_lit_fields.items[filter_start + i]; const v0 = try self.evalEventFieldValue(world, locals, decl, flit); - // Stabilize a COMPUTED string (`.string_run`): its per-body bytes are - // freed at the body boundary, but the captured filter is re-scanned - // at later-tick polls, so deep-copy into `captured_filter_strings` - // (M1.0.14 E4). Literal / persistent / POD values are copy-stable. - const v = if (v0 == .string_run) blk: { - const dup = try self.gpa.dupe(u8, self.run_strings.items[v0.string_run]); + // Stabilize a NON-AST string (a per-body `.string_run` or a borrowed + // `.string_persistent` over resource storage): its bytes are freed at + // the body boundary / on resource-string reassignment, but the + // captured filter is re-scanned at later-tick polls — deep-copy into + // `captured_filter_strings`, which ALSO enforces capture-once §9.4 + // (the value cannot move under a later mutation). `.string_id` (AST + // table) and POD values are copy-stable (M1.0.14 E4). + const v = if (stringNeedsOwning(v0)) blk: { + const dup = try self.gpa.dupe(u8, self.stringBytes(v0).?); errdefer self.gpa.free(dup); try self.captured_filter_strings.append(self.gpa, dup); break :blk Value{ .string_persistent = .{ .ptr = @intFromPtr(dup.ptr), .len = @intCast(dup.len) } }; @@ -4180,12 +4188,13 @@ pub const Interpreter = struct { try self.evalEventFieldValue(world, locals, edecl, flit) else try self.evalExpr(world, locals, flit.value); - // Stabilize a runtime string (`.string_run`): its per-body - // `run_strings` bytes are freed at the body boundary, but the + // Stabilize a non-AST string (a per-body `.string_run` or a + // borrowed `.string_persistent`): its bytes are freed at the + // body boundary / on resource-string reassignment, but the // enqueued event outlives the body (observers / cross-tick // awaiters), so deep-copy into the store (M1.0.14 E4). - const stable = if (v == .string_run) - try self.events.ownRuntimeString(self.gpa, self.run_strings.items[v.string_run]) + const stable = if (stringNeedsOwning(v)) + try self.events.ownEscapingString(self.gpa, self.stringBytes(v).?) else v; try fields.append(self.gpa, .{ .name = flit.name, .value = stable }); @@ -4902,6 +4911,21 @@ pub const Interpreter = struct { self.run_strings.clearRetainingCapacity(); } + /// Whether a string `Value`'s bytes must be deep-copied to outlive the + /// current body OR survive a mutation of their source (M1.0.14 E4). Only + /// `.string_id` (the immortal AST string table) is stable; a `.string_run` + /// (per-body `run_strings`, freed at the body boundary) and a NON-EMPTY + /// borrowed `.string_persistent` (a view over resource storage, released when + /// the resource string field is reassigned — M1.0.3) are NOT. The empty + /// `.string_persistent` sentinel (`ptr == 0`, `len == 0`) has no bytes to own. + fn stringNeedsOwning(v: Value) bool { + return switch (v) { + .string_run => true, + .string_persistent => |s| s.len > 0, + else => false, + }; + } + /// The bytes of a string value — an AST-table literal (`string_id`), a /// runtime-produced string (`string_run`), or a borrowed resource-string /// view (`string_persistent`, M1.0.3 E2). Null for any non-string value. @@ -11115,6 +11139,57 @@ test "a computed string filter survives to a cross-tick global_event poll (M1.0. try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // captured "go!" matched cross-tick } +test "a borrowed resource-string filter is captured once and survives reassignment (M1.0.14 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // The filter value `get(Cfg).s` is a BORROWED `.string_persistent` view over + // the resource string, captured at suspension. `bump` reassigns `Cfg.s` + // during the suspension — which RELEASES the old bytes (M1.0.3). Without the + // E4 deep-copy the captured view would dangle (use-after-free) AND drift to + // the new value; with it, the poll matches the OLD captured "old" + // (capture-once §9.4). `producer` always emits "old", so a match proves the + // captured value stuck to "old" (a re-evaluation would want "new" ≠ "old"). + var pr = try parser_mod.parse(gpa, + \\event Msg { text: string } + \\resource Out { n: int = 0 } + \\resource Cfg { s: string = "old" } + \\rule producer() + \\ when resource Cfg + \\{ + \\ emit Msg { text: "old" } + \\} + \\async rule watch() + \\ when resource Out and resource Cfg + \\{ + \\ await global_event(Msg { text: get(Cfg).s }) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + \\rule bump() + \\ when resource Cfg + \\{ + \\ let c = get_mut(Cfg) + \\ c.s = "new" + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + const rep = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(u64, 0), rep.runtime_errors); // no use-after-free + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // matched the captured OLD "old" +} + test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { const gpa = std.testing.allocator; var world = World.init(); From 9ac8cf657f6bed5768f0233d110c4c2e2dfca0b1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 5 Jul 2026 00:06:51 +0200 Subject: [PATCH 19/24] docs(brief): journal E4 STOP fix 2 (string_persistent stabilization) --- briefs/M1.0.14-entity-scoped-events.md | 1 + 1 file changed, 1 insertion(+) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index c0c2292..e72af1c 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -159,6 +159,7 @@ None. - 2026-07-04 — E4 equality-set verdict (the milestone's flagged risk): CLEAR, no STOP. int/float/bool/entity/enum compare via `Value.eql`; STRINGS byte-compare (reusing `stringBytes`) because a filter literal is `.string_id` while an emitted value may be `.string_persistent` / `.string_run` — `Value.eql` only matches same tag + same pool index. Fix-as-you-go: `emit` now resolves enum field values through `evalEnumShorthandFor` (a bare `.Variant` event field was fail-loud in generic `evalExpr` before), fallback-safe to plain `evalExpr` when the event decl isn't in the new runtime `event_decls` map (re-parsed extension hooks — the M1.0.9 execHookText path). `event_decls` (mirrors `enum_decls`) also backs the designated-field + enum-field resolution. - 2026-07-04 — E4 tests (5) + pin flip: (1) entity_event realized + structural single-`Entity` designation + no-wake-on-a-different-entity; (2) `@entity_target` designation on a two-`Entity` event (Link cross-ref → source ≠ target); (3) `global_event` filter int match/mismatch + string byte-equality (emitted `.string_persistent` resource string vs `.string_id` filter — proves the byte-compare is necessary); (4) entity_event filter match/mismatch + `let x = await` binds unit; (5) `global_event` filter captured-once (a `bump` rule mutates the source after suspension; the wake uses the captured value). The M1.0.13/E2 boundary pin FLIPPED: `future` is the sole `await` target `evalAwaitTarget` rejects. Full `zig build test` = 987/1004 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-04 — E4 STOP fix (round-trip; runtime-string use-after-free): a COMPUTED string (`prefix + "!"`) is a `.string_run` whose per-body `run_strings` bytes are `gpa.free`d at the body boundary (handle reusable), so the NEW `captured_filters` (re-scanned at later-tick polls) AND the emit-side value (read by a later-body `@on_event` observer) pointed at freed/rewritten memory — a use-after-free / silent false match, and the "filter values are always literals — copy-stable" doc-comment was wrong. FIX: deep-copy `.string_run` bytes at BOTH escape boundaries and re-tag `.string_persistent` over the copy — (i) at `EventStore` entry on emit (store-owned `owned_strings`, freed at the per-tick `clear`), (ii) at `captured_filters` entry on capture (buffer-owned `captured_filter_strings`, freed wholesale at deinit, husk discipline). Also fixed a regression from the E4 emit change: the `event_decls` lookup at emit is now OPTIONAL (fallback to plain `evalExpr`) so a re-parsed extension hook (`execHookText`, transient arena) still emits. Doc-comments corrected. Two tests added: a computed string emitted → read by an `@on_event` observer (emit-side); a computed-string filter that matches cross-tick (capture-side). Verified the E4 set covers "filtered `global_event` end-to-end" (the int-match block + the new cross-tick test — parameterless awaiter). Full `zig build test` = 989/1006 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-05 — E4 STOP fix (round-trip 2, `.string_persistent`): round-trip 1 stabilized only `.string_run`; a borrowed `.string_persistent` (a view over resource storage) is ALSO unstable — reassigning a resource string field RELEASES the old bytes (M1.0.3 "the previous value is released"), so a captured/emitted `get(R).s` dangled under a later mutation AND (for a captured filter) violated capture-once §9.4 (the value would drift). FIX: generalized the deep-copy at BOTH boundaries to every non-AST string kind via a `stringNeedsOwning` predicate (`.string_run` OR non-empty `.string_persistent`; `.string_id` stays — immortal AST table; the empty `ptr == 0` sentinel is left as-is). `EventStore.ownRuntimeString` → `ownEscapingString`; both sites now copy `stringBytes(v)`. Doc-comments corrected. New test: a `get(R).s` filter captured, `r.s` reassigned during the suspension → the poll matches the OLD captured value with no use-after-free (capture-once). Full `zig build test` = 990/1007 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. ## Recorded deviations From bf72122f9beb221bf94d656fdcaf974a21d96950 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 5 Jul 2026 00:49:19 +0200 Subject: [PATCH 20/24] test(etch): add M1.0.14 E5 integration tests (entity_event end-to-end) --- src/etch/interp.zig | 239 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 4a8a7ba..23da933 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -11190,6 +11190,245 @@ test "a borrowed resource-string filter is captured once and survives reassignme try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // matched the captured OLD "old" } +test "two awaiters wake on a single global_event instance (M1.0.14 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // One `Ping` is NOT consumed by a wake — both awaiters see the same instance. + var pr = try parser_mod.parse(gpa, + \\event Ping { } + \\resource A { n: int = 0 } + \\resource B { n: int = 0 } + \\rule producer() + \\ when resource A + \\{ + \\ emit Ping { } + \\} + \\async rule wa() + \\ when resource A + \\{ + \\ await global_event(Ping) + \\ let a = get_mut(A) + \\ a.n = a.n + 1 + \\} + \\async rule wb() + \\ when resource B + \\{ + \\ await global_event(Ping) + \\ let b = get_mut(B) + \\ b.n = b.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const a = world.registry.idOf("A").?; + const b = world.registry.idOf("B").?; + _ = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, a)); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, b)); +} + +test "an @on_event observer and an awaiter both fire on the same event (M1.0.14 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // The event is not consumed: the observer drains it AND the awaiter wakes on + // the very same instance (the awaiter is polled after the observer's slot). + var pr = try parser_mod.parse(gpa, + \\event Ping { } + \\resource Aw { n: int = 0 } + \\resource Ob { n: int = 0 } + \\rule producer() + \\ when resource Aw + \\{ + \\ emit Ping { } + \\} + \\@on_event(Ping) + \\rule obs() + \\ when resource Ob + \\{ + \\ let o = get_mut(Ob) + \\ o.n = o.n + 1 + \\} + \\async rule watch() + \\ when resource Aw + \\{ + \\ await global_event(Ping) + \\ let a = get_mut(Aw) + \\ a.n = a.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const aw = world.registry.idOf("Aw").?; + const ob = world.registry.idOf("Ob").?; + _ = try interp.runFor(&world, 4); + try std.testing.expect(readResourceInt(&world, ob) >= 1); // observer drained + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, aw)); // awaiter woke on the same event +} + +test "an event emitted at tick N does not wake a task that suspends at tick N+1 (M1.0.14 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // `producer` emits `Ping` ONCE (tick 1). `watch` is still on `await wait` at + // tick 1 and only reaches `await global_event(Ping)` at tick 2 — by which the + // per-tick store has cleared the tick-1 `Ping`. It must never wake. + var pr = try parser_mod.parse(gpa, + \\event Ping { } + \\resource Out { n: int = 0 } + \\resource Once { fired: bool = false } + \\rule producer() + \\ when resource Once + \\{ + \\ if not get(Once).fired { + \\ emit Ping { } + \\ let f = get_mut(Once) + \\ f.fired = true + \\ } + \\} + \\async rule watch() + \\ when resource Out + \\{ + \\ await wait(0.02s) + \\ await global_event(Ping) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + _ = try interp.runFor(&world, 6); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out)); // the tick-1 Ping was cleared +} + +test "a filtered global_event wakes only on the matching instance across ticks (M1.0.14 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // `producer` emits Q{id:1}, Q{id:2}, Q{id:3}, … (one per tick). The awaiter + // filters {id:7} — it must wake on exactly the tick that emits Q{id:7} and + // ignore every other instance. + var pr = try parser_mod.parse(gpa, + \\event Q { id: int } + \\resource Out { n: int = 0 } + \\resource Step { t: int = 0 } + \\rule producer() + \\ when resource Step + \\{ + \\ let s = get_mut(Step) + \\ s.t = s.t + 1 + \\ emit Q { id: s.t } + \\} + \\async rule watch() + \\ when resource Out + \\{ + \\ await global_event(Q { id: 7 }) + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out = world.registry.idOf("Out").?; + _ = try interp.runFor(&world, 10); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out)); // woke once, on Q{id:7} only +} + +test "9.2 mirror: entity awaiter resumes on its own event and its emit is observed (M1.0.14 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // Mirrors `etch-reference-part1.md` §9.2: an entity-bound async rule on the + // hero emits `DialogueStart`, suspends on `await entity_event(e, + // DialogueComplete)`, and resumes on the `DialogueComplete` whose designated + // `npc` field is the hero — emitting `QuestStarted`, observed by an + // `@on_event` sink. A decoy entity's `DialogueComplete { npc: decoy }` + // coexists in the store and does NOT wake the hero's awaiter. + var pr = try parser_mod.parse(gpa, + \\component Hero { v: int = 0 } + \\component Marker { v: int = 0 } + \\event DialogueStart { who: Entity } + \\event DialogueComplete { npc: Entity } + \\event QuestStarted { by: Entity } + \\resource Sink { n: int = 0 } + \\rule producer(e: Entity) + \\ when e has Marker + \\{ + \\ emit DialogueComplete { npc: e } + \\} + \\async rule converse(e: Entity) + \\ when e has Hero + \\{ + \\ emit DialogueStart { who: e } + \\ await entity_event(e, DialogueComplete) + \\ emit QuestStarted { by: e } + \\} + \\@on_event(QuestStarted) + \\rule sink() + \\ when resource Sink + \\{ + \\ let s = get_mut(Sink) + \\ s.n = s.n + 1 + \\} + ); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const hero_c = world.registry.idOf("Hero").?; + const marker_c = world.registry.idOf("Marker").?; + const sink = world.registry.idOf("Sink").?; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{ hero_c, marker_c }); // the hero (also Marker → gets its own DC) + _ = try world.spawnDynamic(gpa, &[_]ComponentId{marker_c}); // a decoy (its DC must not wake the hero) + _ = try interp.runFor(&world, 6); + try std.testing.expect(readResourceInt(&world, sink) >= 1); // hero resumed on its DC → QuestStarted observed +} + test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { const gpa = std.testing.allocator; var world = World.init(); From ce9a8c3e463ef63d38022496898200234cd966b8 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 5 Jul 2026 00:49:20 +0200 Subject: [PATCH 21/24] docs(brief): journal E5 (integration tests) --- briefs/M1.0.14-entity-scoped-events.md | 1 + 1 file changed, 1 insertion(+) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index e72af1c..f27f78f 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -160,6 +160,7 @@ None. - 2026-07-04 — E4 tests (5) + pin flip: (1) entity_event realized + structural single-`Entity` designation + no-wake-on-a-different-entity; (2) `@entity_target` designation on a two-`Entity` event (Link cross-ref → source ≠ target); (3) `global_event` filter int match/mismatch + string byte-equality (emitted `.string_persistent` resource string vs `.string_id` filter — proves the byte-compare is necessary); (4) entity_event filter match/mismatch + `let x = await` binds unit; (5) `global_event` filter captured-once (a `bump` rule mutates the source after suspension; the wake uses the captured value). The M1.0.13/E2 boundary pin FLIPPED: `future` is the sole `await` target `evalAwaitTarget` rejects. Full `zig build test` = 987/1004 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-04 — E4 STOP fix (round-trip; runtime-string use-after-free): a COMPUTED string (`prefix + "!"`) is a `.string_run` whose per-body `run_strings` bytes are `gpa.free`d at the body boundary (handle reusable), so the NEW `captured_filters` (re-scanned at later-tick polls) AND the emit-side value (read by a later-body `@on_event` observer) pointed at freed/rewritten memory — a use-after-free / silent false match, and the "filter values are always literals — copy-stable" doc-comment was wrong. FIX: deep-copy `.string_run` bytes at BOTH escape boundaries and re-tag `.string_persistent` over the copy — (i) at `EventStore` entry on emit (store-owned `owned_strings`, freed at the per-tick `clear`), (ii) at `captured_filters` entry on capture (buffer-owned `captured_filter_strings`, freed wholesale at deinit, husk discipline). Also fixed a regression from the E4 emit change: the `event_decls` lookup at emit is now OPTIONAL (fallback to plain `evalExpr`) so a re-parsed extension hook (`execHookText`, transient arena) still emits. Doc-comments corrected. Two tests added: a computed string emitted → read by an `@on_event` observer (emit-side); a computed-string filter that matches cross-tick (capture-side). Verified the E4 set covers "filtered `global_event` end-to-end" (the int-match block + the new cross-tick test — parameterless awaiter). Full `zig build test` = 989/1006 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-05 — E4 STOP fix (round-trip 2, `.string_persistent`): round-trip 1 stabilized only `.string_run`; a borrowed `.string_persistent` (a view over resource storage) is ALSO unstable — reassigning a resource string field RELEASES the old bytes (M1.0.3 "the previous value is released"), so a captured/emitted `get(R).s` dangled under a later mutation AND (for a captured filter) violated capture-once §9.4 (the value would drift). FIX: generalized the deep-copy at BOTH boundaries to every non-AST string kind via a `stringNeedsOwning` predicate (`.string_run` OR non-empty `.string_persistent`; `.string_id` stays — immortal AST table; the empty `ptr == 0` sentinel is left as-is). `EventStore.ownRuntimeString` → `ownEscapingString`; both sites now copy `stringBytes(v)`. Doc-comments corrected. New test: a `get(R).s` filter captured, `r.s` reassigned during the suspension → the poll matches the OLD captured value with no use-after-free (capture-once). Full `zig build test` = 990/1007 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-05 — E5 (integration tests, interp.zig): 5 end-to-end tests — (1) two awaiters wake on ONE `global_event` instance (not consumed); (2) an `@on_event` observer AND an awaiter both fire on the same event; (3) an event emitted at tick N does NOT wake a task that suspends at tick N+1 (per-tick store clearing — a one-shot `emit` + an awaiter that reaches `global_event` only after an intervening `await wait`); (4) a filtered `global_event` (`{id:7}`) wakes only on the matching instance across ticks emitting `id = 1,2,3,…`; (5) the §9.2-mirror scenario — an entity-bound awaiter emits `DialogueStart`, suspends on `entity_event(e, DialogueComplete)`, resumes on its OWN `DialogueComplete` (a decoy entity's DC coexists in the store without waking it), emits `QuestStarted`, observed by an `@on_event` sink. Renamed the mirror rule `dialogue`→`converse` (`dialogue` is a reserved construct keyword — a rule name must be an identifier). Byte-stability: all pre-existing M1.0.11/12/13 async + timer tests pass UNMODIFIED. Full `zig build test` = 995/1012 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. ## Recorded deviations From 72fb9dd25b7c56d83710ead3926abbda587995d9 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 5 Jul 2026 01:06:40 +0200 Subject: [PATCH 22/24] docs(claude-md): update for M1.0.14 --- CLAUDE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef99ed8..0503045 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,9 +11,9 @@ knowledge base — see § Quick links spec. |---|---| | Phase | 1 (Etch ↔ ECS) | | Current milestone | (none — between milestones) | -| Last released tag | `v0.10.13-time-and-timers` | +| Last released tag | `v0.10.14-entity-scoped-events` | | Active branch | `main` | -| Next planned milestone | M1.0.14 — entity-scoped events (`await entity_event`, the last reserved await target). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.13 closed; M1.0.14–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), Etch-closure tag at M1.0.20. Await-family partition: `wait_unscaled` + the timer family delivered in M1.0.13 (time subsystem); `entity_event` → M1.0.14. `every`/`after_unscaled` graduated with M1.0.13; `override` stays the last reserved top-level construct keyword, and `quantize` stays reserved in `non_s3_keywords` (its beat/bar musical clock is a later Sequencer-adjacent milestone). | +| Next planned milestone | M1.0.15 — test-runner + identity (execute `test "X" { }` blocks + a separate symbol namespace resolving the `test "Foo"`/`component Foo` collision → `E0101`). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.14 closed; M1.0.15–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), Etch-closure tag at M1.0.20. Await-family partition: `entity_event` + payload filters (both event targets) delivered in M1.0.14, alongside entity-bound `async rule` execution — `future` (`await ` on a `Future`: asset loading, non-inlined `async fn` returns) is now the **sole** reserved await target. `override` stays the last reserved top-level construct keyword, and `quantize` stays reserved in `non_s3_keywords` (its beat/bar musical clock is a later Sequencer-adjacent milestone). | ## Tags @@ -51,6 +51,7 @@ knowledge base — see § Quick links spec. | `v0.10.11-async-core` | 2026-07-01 | M1.0.11 — Async suspension core (`await` family + `async fn`/`async method` execution) | The per-rule `AsyncSlot` becomes a dynamic `AsyncTask` pool with a **resume frame-stack** (`run`/`loop_`/`while_`/`for_`/`try_`/`call` frames, innermost last); a statement-head `await` suspends at any depth (incl. `for`/`try` bodies) and resumes without re-running prefixes (no double `emit`); a `throw` post-resume routes to the enclosing `try_` frame. `async fn`/`async method` **execute via frame inlining** (`await f()` pushes the callee body + heap-boxed scope + `RetTarget` on the caller task; `return` resolves at the await site; a SYNC call to an `async` fn stays fail-loud — `callFn`/`callMethod` `is_async` gate). Owned `await` targets: `wait` as a **`Duration`** (fixed 60 Hz → ticks; non-literal fail-loud), `global_event`, direct-call `future`. Placement (Phase-1): `await` must be a statement's full RHS on the frame-driven spine — a sub-expression `await` or one in a synchronously-evaluated VALUE block → **`E0904 AwaitNotStatementHead`**. Coloring (§9.3): an `async` call / `await` in a non-async `fn`/`rule` → **`E0901 AsyncCallInNonAsyncContext`**. `parser.zig`/`token.zig`/`ast.zig` untouched. | | `v0.10.12-concurrency-algebra` | 2026-07-03 | M1.0.12 — Concurrency algebra (`race`/`sync`/`branch`/`spawn { }` + cancellation + `TaskHandle` await) | Closes the concurrency chapter of EBNF v0.6 (C1.6). `race`/`sync` graduate (`kw_race`/`kw_sync`); the four statements parse (`ConcurrencyBranch` shared slab + `RaceStmt`/`SyncStmt`/`BranchStmt`/`SpawnStmt`; `spawn (`/`spawn {` token disambiguation kept local; `let h = spawn { }` IS the SpawnStmt binding form, not a let-stmt). Type-check: E0901 on the four forms outside async; **`E0905 UnconsumedAsyncEffect`** — `await` is the SOLE call-grain consumer (§9.2 **revision 2**, STOP round-trip: the constructs relocate the suspension, their bodies are ordinary async contexts); **`E0906`** `return` race-branch-only (winner-return propagation); **`E0907`** break/continue must not cross the task boundary (per-branch loop depth + label window); `TaskHandle` builtin (non-POD, field-rejected), `h.cancel()`, `await h` typed (unit Phase 1). Execution: heap-record pointer-stable monotonic task pool (husk parking — no reuse, index = Phase-1 handle identity, no generations); drive-by-origin (children interleave at the origin rule's position, creation order, mid-pass pickup); `race`/`sync` = child task per admitted branch (guards evaluated first in the live parent scope; per-branch scope SNAPSHOT copy) with parent suspended on `children_any`/`children_all` (zero admitted → same-tick passthrough; one-tick construct latency otherwise — parent precedes children in pool order, so losers are canceled before their wakes can fire); race = first-`.done`-in-declaration-order winner, losers `cancelTask`'d (NON-transitive), winner-`return` re-raised at the race site; failed tasks park `.canceled` (never a winner, never block a join, `await` on them fails loud). `branch`/`spawn` = detached tasks (`Value.task_handle` = pool index); `await h` on done = immediate resume delivering the parked result, on canceled(-while-awaited) = fail-loud. Fix-as-you-go: `return await ` no longer drops its return at resume (M1.0.11 gap). | | `v0.10.13-time-and-timers` | 2026-07-04 | M1.0.13 — Time subsystem and timers | First builtin engine resources (`GameTime`/`UnscaledTime`/`RealTime`), auto-registered from a single `pub const` descriptor table in `types.zig` (the `TagSet` injection-point precedent; no `.etch` prelude) and known to the type-checker by name (`get(GameTime).dt`, ambient — no `when resource` gate). `stepOnce` populates them at the tick head (fixed 1/60 timestep): two internal f64 clock accumulators in TICK units (game `+= time_scale`, 0 under `paused`; unscaled `+= 1` — E5 recorded deviation vs the brief's seconds framing, accepted: exact integer f64 sums at `scale = 1` make the byte-identical wake guarantee robust), seconds derived at publication. `await wait` re-plumbed onto the game clock (freezes under `paused`, scales with `time_scale`), `await wait_unscaled` executes against the unscaled clock (fires under pause) — both keep the M1.0.11 literal-only restriction; byte-identical wake ticks at `scale = 1` (all M1.0.11/12 tick-precise tests untouched); `async_tick` demoted to the frame counter backing `GameTime.frame`. Timer family graduated + executes: `kw_every`/`kw_after_unscaled`, `TimerStmt` AST slab, `timer_stmt` parses (unbound at statement head + `let t = after(d) { }` in `parseLetStmt`, the spawn precedent), `TimerHandle` builtin type-checks (`cancel()` only, not awaitable, non-POD field-rejected), timer arg = full `Duration` expression, timer body = synchronous context (`await`/async call inside → E0901). Runtime timer registry: heap records, monotone pointer-stable + husk (a `TimerHandle` is a bare index), scope SNAPSHOT at scheduling (`cloneLocalsInto`, handle bound in the parent scope after), fired at the head of `stepOnce` in registration order (deterministic), one-shots husk / `every` re-arms at a fixed period (no drift correction), `cancel()` idempotent with a mid-fire latch. `Value` gains `timer_handle` + `duration` (runtime Duration seconds). `quantize` stays reserved (fail-loud re-pointed at a later Sequencer-adjacent milestone). | +| `v0.10.14-entity-scoped-events` | 2026-07-05 | M1.0.14 — Entity-scoped events and entity-bound async rules | `await entity_event(e, T [{…}])` executes: designated `Entity` field resolved at compile time (`@entity_target` > single structural `Entity` field > `E0908`/`E0909`, single shared policy helper `resolveEventEntityTarget`), matched by name over the per-tick `EventStore` (predicate scan, no entity index; the matched event is NOT consumed — observers and other awaiters coexist). Payload filter (`struct_literal_body`) executes on BOTH event targets (`global_event` erratum §4.2): field-equality only, entity operand + every filter expression captured BY VALUE once at suspension (capture-once §9.4), resume delivers unit (no implicit `event` binding). Non-AST strings (`.string_run`, borrowed `.string_persistent`) deep-copied at the two escape boundaries (emit → store-owned per-tick; filter capture → husk-lifetime buffer) — fixes a pre-existing use-after-free for `@on_event` observers reading computed-string event fields. Entity-bound `async rule` executes (absorbed at the E2 round-trip): one root task per (rule, matched entity) via the SHARED sync selection walk (collect mode — identical order + `when` semantics), four rulings (§9.2): re-arm after terminal while matching; `when` gates the spawn, not the task's life; despawn does not cancel (dead-handle fail-loud on access); spawn follows selection order. Parameterless shape byte-stable; other param shapes stay fail-loud (`dt` injection later). `emit` enum-shorthand event fields fixed (fail-loud before). `future` is the sole remaining fail-loud await target. | ## Hypotheses validated by spikes @@ -87,6 +88,7 @@ knowledge base — see § Quick links spec. - **M1.0.11 scope boundary (async suspension core)** — the Phase-1 tree-walker async model (NOT the Phase-2 bytecode state machine, `etch-bytecode.md §9`) is a dynamic `AsyncTask` pool + resume frame-stack reproducing the §9 observable semantics; documented in `etch-reference-part1.md §9.12` (Claude.ai KB re-upload). **Recorded deviations (Claude.ai round-trips):** §9.12 placement broadened to `for`/`try`/`catch` bodies (E1) and to reject `await` in VALUE-position blocks (E3, `E0904`) — both real EBNF v0.6 statements, C1.6; the `programs/` integration file dropped (that corpus is the interp↔codegen differential, rejects async — Observable behavior covered by inline cross-tick tests). **Owned here:** `await` family (`wait` Duration/60 Hz, `global_event`, direct-call `future`) + `async fn`/`method` execution + coloring `E0901` + placement `E0904`. **Out (owned by later milestones, NOT debt):** `race`/`sync`/`branch`/`spawn { }` + cancellation + `await` on a `TaskHandle` → M1.0.12; `await wait_unscaled` + timers (need the scaled/unscaled time subsystem) → M1.0.13; `await entity_event` (needs entity-scoped events) → M1.0.14; entity-bound `async rule` → later; the Phase-2 bytecode async lowering. **Open decision — async effect-consumption completeness:** a BARE `async` call in an `async` context (no `await`, no wrapper) is illegal per `etch-resolver-types.md §9.2` (the `{async}` effect is consumed by `await` OR `spawn`/`branch`/`race`/`sync`) but is **not** `E0901` (the non-async-context case) and currently **fails loud at runtime** (`is_async` gate) — the compile-time check completes with the consumption constructs in **M1.0.12**. - **M1.0.12 scope boundary (concurrency algebra)** — the four constructs (`race`/`sync`/`branch`/`spawn { }`) parse, type-check, and execute as CHILD TASKS in the interpreter's pointer-stable monotonic pool (heap records, husk parking, no slot reuse — a pool index IS the Phase-1 `TaskHandle`, no generations), driven by-origin at the creating rule's position in creation order. **Recorded deviation (STOP round-trip 2026-07-02, `etch-resolver-types.md` §9.2 revision 2):** the brief's "calls inside the four construct bodies count as consumed launch sites" is SUPERSEDED — `await` is the SOLE call-grain consumer of the `{async}` effect (`E0905` applies recursively inside the construct bodies; the constructs relocate the suspension into a child task, they do not replace the `await`). Return asymmetry (Guy's ruling 2026-07-02): `return` legal only in a `race` branch (winner-return re-raised at the race site); `sync`/`branch`/`spawn` bodies reject it (`E0906`). Documented one-tick construct latency: a race/sync parent precedes its children in pool-creation order, so it resumes the tick AFTER its wake fires — which guarantees losers are canceled before their own wakes can fire (zero-admitted constructs have no latency). Cancellation is NON-transitive (Phase 1, `etch-bytecode.md` §9.5). Failed tasks (uncaught `throw` / runtime failure) park `.canceled` — never a race winner, never block a `sync` join, `await`ing them fails loud (§9.8 amended). Fix-as-you-go: `return await ` dropped its return at resume (M1.0.11 gap) — fixed. **Out (later):** `wait_unscaled` + timers (M1.0.13), `entity_event` (M1.0.14), entity-bound `async rule`, Phase-2 bytecode lowering (`etch-bytecode.md` §9.4), transitive cancellation (Phase 2+), task-pool slot reuse / generations (Phase-2 refcounted model), value-producing spawn bodies (no EBNF v0.6 value channel). - **M1.0.13 scope boundary (time subsystem and timers)** — the three builtin time resources (`GameTime`/`UnscaledTime`/`RealTime`) are auto-registered from the single `pub const builtin_resources` descriptor table in `types.zig` (the `TagSet` injection-point precedent — no `.etch` prelude, no separate module) and resolve AMBIENTLY (`get(GameTime).dt` needs no `when resource` clause — the spec examples read them without one). `await wait` re-plumbed onto scaled game time and `await wait_unscaled` onto unscaled time with **byte-identical wake ticks at `time_scale = 1`** — realized by the E5 recorded deviation: the two internal clock accumulators advance in TICK units (game `+= time_scale`, 0 under `paused`; unscaled `+= 1`; deadlines `clock + round(secs * 60)`), seconds derived only at resource publication (exact integer f64 sums at `scale = 1`; a seconds accumulator would accrue rounding that can shift a wake by one tick). Both waits KEEP the M1.0.11 literal-only Duration restriction; only the timer family evaluates a full `Duration` expression. The timer registry mirrors the M1.0.12 task-pool discipline (heap records, monotone pointer-stable, husk parking, `TimerHandle` = bare index, no generations) but is a DISTINCT mechanism — a timer is not a task: `cancel()` only, not awaitable, body is a synchronous context (E0901 on `await`/async calls inside). Timer callbacks fire at the head of `stepOnce` (after the clock advance and event-store clear, before rule dispatch) in registration order; `every` re-arms at a fixed period (no drift correction Phase 1). `Value` gained `timer_handle` + `duration` (`value.zig` — outside the brief's per-file list, justified: `Value.timer_handle` is the E6 deliverable and the `task_handle` precedent lives there). **Out (later, not debt):** `quantize` (stays reserved; needs the Sequencer/Pulse beat/bar clock — later Sequencer-adjacent milestone), `time.*` stdlib sugar (later stdlib milestone; `etch-stdlib.md §20` reconciliation deferred with it), `dt` as an injected rule parameter, per-entity `TimeDilation` / selective pause / `WorldClock` (need the phase scheduler), lifting `wait`'s literal-only restriction, Phase-2 bytecode lowering. +- **M1.0.14 scope boundary (entity-scoped events + entity-bound async rules)** — `entity_event` = `global_event` + predicates (same wake path, same per-tick store, producer-before-awaiter order unchanged). Designated-field convention, filter semantics (equality-only, capture-once), and resume-unit are normative in `etch-reference-part1.md §9.4`; the four entity-bound lifecycle rulings in `§9.2`; realization in `§9.12` (Claude.ai KB re-uploads at open + at the E2 amendment). The M1.0.11/12 out-list mentions "entity-bound `async rule` → later" are **superseded** (delivered here — absorbed at the E2 round-trip: the construct is inexpressible end-to-end without an entity-scoped awaiter). String stabilization at the event/filter escape boundaries fixed a PRE-EXISTING observer-side use-after-free (fix-as-you-go, two STOP round-trips). **Out (later, NOT debt):** `future`/`Future` (the last await gap); implicit `event` binding / payload delivery at resume (unit is normative); entity-keyed event index (Phase-2 typed bus); emit-side targeting (grammar v0.6 frozen); non-equality filter predicates; cancel-on-despawn (Phase 2+); `dt`/non-entity rule-parameter injection; Phase-2 bytecode async lowering. ## Non-negotiable rules @@ -220,4 +222,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-07-04 +Last updated: 2026-07-05 From 00627462975f4b2219aba8970648a3940bcc4dd1 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 5 Jul 2026 01:15:44 +0200 Subject: [PATCH 23/24] docs(etch): correct stale runAsyncRule header comment (M1.0.14) --- src/etch/interp.zig | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 23da933..5ce8d90 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -2414,9 +2414,10 @@ pub const Interpreter = struct { /// created before its children (lower index), so a suspended `race` parent /// resumes — and cancels its losers — before any loser could resume. /// - /// One root task per async rule (the §9.2 parameterless, non-entity-bound - /// shape); an entity-bound async rule (one task per matching entity) is - /// deferred and fails loud (counted once, then parked `.done`). + /// Dispatch by rule shape: the §9.2 parameterless, non-entity-bound shape + /// gets one root task; an entity-bound async rule (single `Entity` param) + /// gets one task per matching entity (M1.0.14 E3); any other parameter + /// shape fails loud (counted once, then parked `.done`). fn runAsyncRule(self: *Interpreter, world: *World, idx: usize, report: *RuntimeReport) !void { const rd = &self.rule_descs[idx]; const rule = self.ast.rule_decls.items[rd.rule_idx]; From 6345f14c74f4ebb357cb94f8a5eeb52b6e86a6c0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 5 Jul 2026 01:15:45 +0200 Subject: [PATCH 24/24] docs(brief): close M1.0.14 --- briefs/M1.0.14-entity-scoped-events.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/briefs/M1.0.14-entity-scoped-events.md b/briefs/M1.0.14-entity-scoped-events.md index f27f78f..a39d86b 100644 --- a/briefs/M1.0.14-entity-scoped-events.md +++ b/briefs/M1.0.14-entity-scoped-events.md @@ -1,12 +1,12 @@ # M1.0.14 — Entity-scoped events and entity-bound async rules -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/etch/entity-scoped-events` > **Planned tag:** `v0.10.14-entity-scoped-events` > **Dependencies:** M1.0.2 (events + `EventStore` + `@on_event` drain — the per-tick store this milestone matches against), M1.0.11 (async suspension core — the `await`/`WakeCond` substrate), M1.0.12 (task pool + drive-by-origin discipline) > **Opened:** 2026-07-04 -> **Closed:** — +> **Closed:** 2026-07-05 --- @@ -157,10 +157,11 @@ None. - 2026-07-04 — E3 tests: 6 tests (per-entity spawn + independent resumes; rulings 1–4; parameterless byte-stable; `dt: float` fail-loud) green. The when-gates test (ruling 2) gives the entity a second `Keep` component because `world.removeComponentDynamic` asserts ≥2 components (cannot empty an entity). Full `zig build test` = 982/999 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-04 — E4 (runtime realization, interp.zig): `WakeCond` extended — filtered `global_event {type_name, filter}` + new `entity_event {type_name, entity, field_name, filter}`; `filter` is a `FilterRange` into a new append-only `captured_filters` buffer (mirrors `task_children`). The two event await targets build their wake in the LIVE scope via `evalEventWake` (routed from `stepBodyStmt`, NOT the wait-resolver): the entity operand and each filter field are evaluated ONCE at suspension (capture-once §9.4); the designated field is resolved via the shared `AstArena.resolveEventEntityTarget` and matched BY NAME at poll (emit stores fields in emit-site order, not decl order). `asyncWakeFired` scans the per-tick store (type + designated-entity + per-filter-field equality) via `anyEventMatches`/`eventMatches`/`eventValueEql`. Both `entity_event` fail-loud sites removed; `evalAwaitTarget` now handles only `wait`/`wait_unscaled` (defensive fail-loud for `global_event`/`entity_event`/`future`); the matched event is NOT consumed. - 2026-07-04 — E4 equality-set verdict (the milestone's flagged risk): CLEAR, no STOP. int/float/bool/entity/enum compare via `Value.eql`; STRINGS byte-compare (reusing `stringBytes`) because a filter literal is `.string_id` while an emitted value may be `.string_persistent` / `.string_run` — `Value.eql` only matches same tag + same pool index. Fix-as-you-go: `emit` now resolves enum field values through `evalEnumShorthandFor` (a bare `.Variant` event field was fail-loud in generic `evalExpr` before), fallback-safe to plain `evalExpr` when the event decl isn't in the new runtime `event_decls` map (re-parsed extension hooks — the M1.0.9 execHookText path). `event_decls` (mirrors `enum_decls`) also backs the designated-field + enum-field resolution. -- 2026-07-04 — E4 tests (5) + pin flip: (1) entity_event realized + structural single-`Entity` designation + no-wake-on-a-different-entity; (2) `@entity_target` designation on a two-`Entity` event (Link cross-ref → source ≠ target); (3) `global_event` filter int match/mismatch + string byte-equality (emitted `.string_persistent` resource string vs `.string_id` filter — proves the byte-compare is necessary); (4) entity_event filter match/mismatch + `let x = await` binds unit; (5) `global_event` filter captured-once (a `bump` rule mutates the source after suspension; the wake uses the captured value). The M1.0.13/E2 boundary pin FLIPPED: `future` is the sole `await` target `evalAwaitTarget` rejects. Full `zig build test` = 987/1004 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-04 — E4 tests (5) + pin flip: (1) entity_event realized + structural single-`Entity` designation + no-wake-on-a-different-entity; (2) `@entity_target` designation on a two-`Entity` event (Link cross-ref → source ≠ target); (3) `global_event` filter int match/mismatch + string byte-equality (emitted `.string_persistent` resource string vs `.string_id` filter — proves the byte-compare is necessary); (4) entity_event filter match/mismatch + `let x = await` binds unit; (5) `global_event` filter captured-once (a `bump` rule mutates the source after suspension; the wake uses the captured value). The M1.0.13/E2 boundary pin FLIPPED: `future` is the sole `await` target still unrealized end-to-end (`evalAwaitTarget` keeps a defensive reject arm for `global_event`/`entity_event` too, but `stepBodyStmt` routes those through `evalEventWake` before it — only `future` reaches the resolver's reject). Full `zig build test` = 987/1004 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-04 — E4 STOP fix (round-trip; runtime-string use-after-free): a COMPUTED string (`prefix + "!"`) is a `.string_run` whose per-body `run_strings` bytes are `gpa.free`d at the body boundary (handle reusable), so the NEW `captured_filters` (re-scanned at later-tick polls) AND the emit-side value (read by a later-body `@on_event` observer) pointed at freed/rewritten memory — a use-after-free / silent false match, and the "filter values are always literals — copy-stable" doc-comment was wrong. FIX: deep-copy `.string_run` bytes at BOTH escape boundaries and re-tag `.string_persistent` over the copy — (i) at `EventStore` entry on emit (store-owned `owned_strings`, freed at the per-tick `clear`), (ii) at `captured_filters` entry on capture (buffer-owned `captured_filter_strings`, freed wholesale at deinit, husk discipline). Also fixed a regression from the E4 emit change: the `event_decls` lookup at emit is now OPTIONAL (fallback to plain `evalExpr`) so a re-parsed extension hook (`execHookText`, transient arena) still emits. Doc-comments corrected. Two tests added: a computed string emitted → read by an `@on_event` observer (emit-side); a computed-string filter that matches cross-tick (capture-side). Verified the E4 set covers "filtered `global_event` end-to-end" (the int-match block + the new cross-tick test — parameterless awaiter). Full `zig build test` = 989/1006 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-05 — E4 STOP fix (round-trip 2, `.string_persistent`): round-trip 1 stabilized only `.string_run`; a borrowed `.string_persistent` (a view over resource storage) is ALSO unstable — reassigning a resource string field RELEASES the old bytes (M1.0.3 "the previous value is released"), so a captured/emitted `get(R).s` dangled under a later mutation AND (for a captured filter) violated capture-once §9.4 (the value would drift). FIX: generalized the deep-copy at BOTH boundaries to every non-AST string kind via a `stringNeedsOwning` predicate (`.string_run` OR non-empty `.string_persistent`; `.string_id` stays — immortal AST table; the empty `ptr == 0` sentinel is left as-is). `EventStore.ownRuntimeString` → `ownEscapingString`; both sites now copy `stringBytes(v)`. Doc-comments corrected. New test: a `get(R).s` filter captured, `r.s` reassigned during the suspension → the poll matches the OLD captured value with no use-after-free (capture-once). Full `zig build test` = 990/1007 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. - 2026-07-05 — E5 (integration tests, interp.zig): 5 end-to-end tests — (1) two awaiters wake on ONE `global_event` instance (not consumed); (2) an `@on_event` observer AND an awaiter both fire on the same event; (3) an event emitted at tick N does NOT wake a task that suspends at tick N+1 (per-tick store clearing — a one-shot `emit` + an awaiter that reaches `global_event` only after an intervening `await wait`); (4) a filtered `global_event` (`{id:7}`) wakes only on the matching instance across ticks emitting `id = 1,2,3,…`; (5) the §9.2-mirror scenario — an entity-bound awaiter emits `DialogueStart`, suspends on `entity_event(e, DialogueComplete)`, resumes on its OWN `DialogueComplete` (a decoy entity's DC coexists in the store without waking it), emits `QuestStarted`, observed by an `@on_event` sink. Renamed the mirror rule `dialogue`→`converse` (`dialogue` is a reserved construct keyword — a rule name must be an identifier). Byte-stability: all pre-existing M1.0.11/12/13 async + timer tests pass UNMODIFIED. Full `zig build test` = 995/1012 pass, 0 failed (17 skips); `zig fmt --check` + `zig build lint` green. +- 2026-07-05 — Close (Étape 4): CLAUDE.md §3.4 updated (`docs(claude-md)`: tag → `v0.10.14-entity-scoped-events`, next milestone M1.0.15, +1 Tags row, +1 open-decisions entry). Language audit EN-strict: PASS (the sole French is the FROZEN protocol block — process step labels + the mandated signal string, Guy-authored). Drift audit §3.6.1 (10 adversarial verifiers over CLAUDE.md + brief vs code): 8/10 CONFIRMED verbatim; fixed a stale `runAsyncRule` header comment (`docs(etch)`) + one log-entry :160 wording imprecision (`evalAwaitTarget` rejects all three event/future arms defensively — only `future` is unrouted); the "E4 fixes absent from Recorded deviations" flag is by-design (that section is scoped to FROZEN-SECTION changes; the E4 fixes are bug fixes journalled here in the Execution log). ## Recorded deviations @@ -176,8 +177,8 @@ None. *Fill in at Status → CLOSED, just before opening the PR.* -- **What worked:** -- **What deviated from the original spec:** -- **What to flag explicitly in review:** -- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): -- **Residual risks / tech debt left intentionally:** +- **What worked:** `entity_event` realized as `global_event` + predicates over the SAME per-tick `EventStore` and the M1.0.11/12 wake/task substrate — no parallel mechanism. The single shared designated-field policy (`AstArena.resolveEventEntityTarget`) let the type-checker (diagnose) and the interpreter (capture) agree with zero interp→types dependency. Reusing the synchronous selection walk in collect mode (a threaded nullable buffer) gave entity-bound task spawning identical selection order + `when` semantics for free (ruling 4). Byte-identical wake ticks preserved — every pre-existing M1.0.11/12/13 async + timer test passes UNMODIFIED. +- **What deviated from the original spec:** entity-bound `async rule` execution was absorbed at the E2 STOP round-trip (FROZEN amendment, `5589078`) — an end-to-end Etch `entity_event` awaiter is inexpressible without an entity-scoped awaiter, and no parameterless body expression yields an `Entity`. Ruling numbering follows the BRIEF (1 = re-arm, 2 = when-gates-spawn) vs spec §9.2's inverted 1/2 (E3 STOP renumber — comment/test-name only, no logic change). +- **What to flag explicitly in review:** the two E4 string-stabilization fixes closed a PRE-EXISTING (not milestone-introduced) use-after-free — an `@on_event` observer reading a computed (`.string_run`) or borrowed-resource (`.string_persistent`) event field dangled before this milestone; the deep-copy at both escape boundaries (emit → per-tick store-owned; filter capture → husk buffer, gated by `stringNeedsOwning`) fixes it and enforces capture-once for string filters. The matched event is NOT consumed (multi-awaiter + observer coexistence — E5 tests 1–2). A post-close drift audit (10 adversarial verifiers over CLAUDE.md + brief vs code) confirmed 8/10 claims verbatim and surfaced one stale `runAsyncRule` header comment (fixed here, `docs(etch)` — behavior was already correct) plus one wording imprecision in log entry :160 (corrected); the other flagged item (E4 STOP fixes not in Recorded deviations) is by-design — that section is scoped to FROZEN-SECTION changes, and the E4 fixes are bug fixes journalled in the Execution log. +- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): no benchmark gate (brief §Benchmarks: None). The event-wake scan is the existing per-tick linear predicate scan (same complexity as the pre-existing `global_event` path; no entity index added). Test count 975 → 995 passing (+20 tests across E1–E5); full suite 995/1012 pass, 0 failed, 17 pre-existing skips, 255/255 build steps; `zig fmt --check` + `zig build lint` green; pre-push gate (debug + ReleaseSafe, ×2) green. +- **Residual risks / tech debt left intentionally:** `future`/`Future` is the sole remaining fail-loud `await` target (the last await gap). Event matching is a linear per-tick scan — an entity-keyed event index is deferred to the Phase-2 typed bus. Cancel-on-despawn deferred (Phase 2+): a task whose entity despawns mid-suspension fails loud on the next dead-handle access (ruling 3). Implicit `event` binding / payload delivery at resume not implemented (unit is normative, §9.4). Non-equality filter predicates, emit-side targeting, and `dt`/non-entity rule-parameter injection are out of grammar/scope for EBNF v0.6.