From c5792e0a57e52d2e4ee534f882cf6065cbb67070 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:46:24 -0400 Subject: [PATCH 1/7] docs: propose P0 event-loop and language benchmark contract --- benchmarks/language/P0-GATE-REVIEW.md | 162 + benchmarks/language/README.md | 338 +++ benchmarks/language/actors/echo.events.json | 29 + benchmarks/language/actors/echo.json | 143 + benchmarks/language/contract.test.ts | 1360 +++++++++ benchmarks/language/corpus.json | 209 ++ .../language/invalid/defer-with-writes.json | 150 + .../language/invalid/duplicate-event.json | 143 + .../language/invalid/event-bytes-limit.json | 143 + .../language/invalid/malformed-digest.json | 143 + .../language/invalid/output-count-limit.json | 143 + .../invalid/unknown-behavior-key.json | 144 + .../invalid/unknown-event-reference.json | 143 + .../invalid/unknown-expression-key.json | 144 + benchmarks/language/invalid/unknown-root.json | 144 + .../language/invalid/wrong-contract.json | 143 + .../language/projects/echo.project.json | 16 + benchmarks/language/results/smoke.json | 2658 +++++++++++++++++ benchmarks/language/span.test.ts | 18 + benchmarks/language/span.ts | 82 + spec/v1/README.md | 59 + spec/v1/event-loop.md | 340 +++ 22 files changed, 6854 insertions(+) create mode 100644 benchmarks/language/P0-GATE-REVIEW.md create mode 100644 benchmarks/language/README.md create mode 100644 benchmarks/language/actors/echo.events.json create mode 100644 benchmarks/language/actors/echo.json create mode 100644 benchmarks/language/contract.test.ts create mode 100644 benchmarks/language/corpus.json create mode 100644 benchmarks/language/invalid/defer-with-writes.json create mode 100644 benchmarks/language/invalid/duplicate-event.json create mode 100644 benchmarks/language/invalid/event-bytes-limit.json create mode 100644 benchmarks/language/invalid/malformed-digest.json create mode 100644 benchmarks/language/invalid/output-count-limit.json create mode 100644 benchmarks/language/invalid/unknown-behavior-key.json create mode 100644 benchmarks/language/invalid/unknown-event-reference.json create mode 100644 benchmarks/language/invalid/unknown-expression-key.json create mode 100644 benchmarks/language/invalid/unknown-root.json create mode 100644 benchmarks/language/invalid/wrong-contract.json create mode 100644 benchmarks/language/projects/echo.project.json create mode 100644 benchmarks/language/results/smoke.json create mode 100644 benchmarks/language/span.test.ts create mode 100644 benchmarks/language/span.ts create mode 100644 spec/v1/README.md create mode 100644 spec/v1/event-loop.md diff --git a/benchmarks/language/P0-GATE-REVIEW.md b/benchmarks/language/P0-GATE-REVIEW.md new file mode 100644 index 00000000..dc05cbbc --- /dev/null +++ b/benchmarks/language/P0-GATE-REVIEW.md @@ -0,0 +1,162 @@ +# P0 contract gate review, 2026-10-10 + +Checkpoint status before the CI handoff: the listed local fixture repairs pass +their focused tests; the P0 integration gate has not passed. This is a proposal +on `codex/p0-contract-freeze-20261009`, based on +`bc6ddae6e426b2727a3ce7e07158b248bae74309`. At this checkpoint the +worktree was partly staged and partly unstaged; nothing was committed or +published. There was no P0 pull request, independent positive review, or +PR-head `Required` result. P1 and P2 remain blocked. For later publication +and check status, consult the pull request rather than this historical record. + +## Repair and evidence scope + +- The checked `unknown` corpus now supplies expected identities to the result + parser. Accepted actor and project cases carry their respective *observed* + artifact, interface, event, project, and closure digests. Each is parsed as + a digest and compared with the pinned corpus expectation. The fixture test + also recomputes these values from the input files. Altered observed digests + fail. These synthetic values are not observations of a compiler or runtime. +- The ten invalid JSON fixtures emit a failure code with a structured path. + A byte scanner locates that path in the source file; the test compares the + emitted code and exact UTF-8 span to the corpus rather than only checking + that the expected span fits inside the file. Four previously placeholder + spans now point to the offending token. Missing paths, excessive source + bytes, depth, and path length fail in tests. +- The smoke result still has 12 cases, 360 synthetic samples, and 24 derived + aggregates. Its checked corpus names a synthetic machine profile. A real + scorecard requires a reviewed corpus revision and actual measurements. + Source bytes and a compiler mapping are not retained, so the source digest + is a claim, not evidence of origin. No event-loop runtime was changed. + +## Local validation on the repaired worktree + +- `bun install --frozen-lockfile`, focused contract tests (20 tests, 221 + assertions), `bun run docs:check`, `bun run typecheck`, `bun run lint`, both + Git diff whitespace checks, `cargo fmt --all -- --check`, `cargo clippy + --workspace --all-targets --locked -- -D warnings`, and `cargo build + --locked`: passed. The task-owned Bun install cache was removed after + installation because `eslint .` scans it as repository source. +- `bun scripts/native-parity.ts`, `bun scripts/process-parity.ts`, `bun + scripts/store-parity.ts`, and `bun scripts/cli-parity.ts`: passed against + the built native binary. These compare existing runtime surfaces, not an + event-loop implementation. +- `bun run check`: timed out at 600 seconds. Its typecheck and lint stages + passed, but the test stage reported loopback `EADDRINUSE` in + `src/vendor-cli.test.ts`. The aggregate gate is unresolved. +- `cargo test --workspace --locked`: compiled and ran many tests, then failed + in the HTTP integration test. A focused run found both tests unable to bind + a loopback socket (`Operation not permitted`). Git-backed dependencies + could be fetched with `CARGO_NET_GIT_FETCH_WITH_CLI=true`; the default + libgit2 fetch failed with SSL handshake `-9808`. +- `bun scripts/application-parity.ts`: timed out at 600 seconds without a + result. Neither it nor `bun run check` is counted as passed. The optional + `check:copy` script is not present in this repository. + +## Native executor revalidation, 2026-10-10 + +On the same branch and base commit, `bun install --frozen-lockfile --offline` +passed. The contract fixture test passed (20 tests, 221 assertions), as did +`bun run docs:check` and `bun run typecheck`. The first `bun run lint` failed +only on files inside the task-owned `.cache/bun` populated by the install. +After removing that cache, `bun run lint` passed. No tracked runtime code was +changed during this pass. + +A direct Bun listener on `127.0.0.1` with port `0` failed with `EPERM` in this +native executor. This reproduces a loopback permission failure before any +repository server starts. It does not diagnose the earlier `EADDRINUSE` result +as a project defect. This pass did not rerun `bun run check`, +`cargo test --workspace --locked`, or `bun scripts/application-parity.ts`; +focused fixture checks do not replace those gates. + +## Independent P0 review revalidation, 2026-10-10 + +The independent reviewer reran `bun test benchmarks/language/contract.test.ts` +(20 passed, 221 assertions) and `cargo fmt --all -- --check` (passed). +`bun run check` timed out after 600 seconds during `bun test`: typecheck and +lint passed, then `src/vendor-cli.test.ts` reported loopback `EADDRINUSE`. The +aggregate gate is unresolved. Default `cargo test --workspace --locked`, +`cargo clippy --workspace --all-targets --locked -- -D warnings`, and +`cargo build --locked` could not fetch `apple-foundation` through libgit2 +(SSL handshake `-9808`). With `CARGO_NET_GIT_FETCH_WITH_CLI=true`, build and +clippy passed; workspace tests failed in `tests/http.rs`. A focused +`cargo test -p algal --test http --locked -- --nocapture` with the same Git +setting failed both tests at the loopback bind (`Operation not permitted`). + +Against the newly built native binary, `bun scripts/native-parity.ts` passed +(86 examples and 12 habitat-budget cases), `bun scripts/process-parity.ts` +passed (58 steps), `bun scripts/store-parity.ts` passed (15 steps), and +`bun scripts/cli-parity.ts` passed (65 cases). These are incumbent surfaces, +not event-loop parity. `bun scripts/application-parity.ts` again timed out at +600 seconds with no result. Do not count either timeout as a pass. + +The review found a separate contract-fixture gap: `checkEventLoopBytes` calls +`JSON.parse` before its `FixtureError` handling, so malformed JSON or invalid +UTF-8 throws a native exception instead of the documented `EVENT_LOOP_PARSE` +code; the ten invalid files are structurally invalid but syntactically valid. +Normalize that failure and add a deterministic malformed-byte test before +claiming all malformed inputs return stable codes. The one bounded repair in +this review is this evidence update, which makes the independent failures and +missing syntax coverage explicit; it does not repair the parser. + +At that review checkpoint, PR-head and merged-main checks were unavailable +without publication; publication was outside that reviewer's bounded task. +Do not integrate this draft or begin P1/P2 until the remaining checks pass and +an independent positive review and PR-head gate are satisfied. This record is +contract-fixture evidence, not performance, source-provenance, event-loop +runtime parity, or cumulative-skill evidence. + +## Malformed-byte repair and bounded revalidation, 2026-10-10 + +The one diagnosed P0 repair cycle normalized malformed JSON and invalid UTF-8 +before parsing a behavior. The byte-fixture checker now reports +`EVENT_LOOP_PARSE` with a span over the complete 1 to 16,384 byte source; +non-object JSON roots receive the same span. Empty and oversized files still +fail the source-byte bound without a span. This choice is agent-decided, owner +may revisit. Tests cover malformed syntax, trailing text, invalid and truncated +UTF-8, scalars, a nested non-object root, both source-size failures, and the +accepted fixture. The ten pinned invalid files remain valid JSON and have +unchanged expected diagnostic paths and digests. The earlier independent +review finding about malformed bytes is repaired, but no independent positive +review of these new bytes has occurred. + +On `codex/p0-contract-freeze-20261009` at local HEAD `bc6ddae6e426`, +`bun install --frozen-lockfile` passed. The final focused test passed with +21 tests and 233 assertions. `bun run docs:check`, `bun run typecheck`, +`bun run lint`, both Git whitespace checks, and `cargo fmt --all -- --check` +passed. With Git-backed Cargo fetch through the CLI and the rustup toolchain, +`cargo clippy --workspace --all-targets --locked -- -D warnings` and +`cargo build --locked` passed. After that build, native parity passed +(86 examples, 12 habitat-budget cases), process parity passed (58 steps), +store parity passed (15 steps), and CLI parity passed (65 cases). The last +small test edit checked deeply nested non-object roots and did not change the +native runtime or any parity input. + +**P0_CONTRACT_GATE_FAILED.** `bun run check` timed out at 600 seconds during +Bun tests after `src/vendor-cli.test.ts` reported loopback `EADDRINUSE`; +its typecheck and lint stages passed. The Cargo workspace test failed in +both HTTP integration tests; a focused `cargo test -p algal --test http +--locked -- --nocapture` diagnosed loopback bind `Operation not permitted`. +`bun scripts/application-parity.ts` timed out at 600 seconds without output. +The stopped commands settled as timeouts and did not add tracked changes. +These are unresolved gates, not passing evidence; no P0 PR-head `Required` +result or independent positive review existed at that checkpoint. Keep P1/P2 +blocked. The no-separate-runtime choice remains agent-decided, owner may +revisit; the synthetic smoke timings are not measured performance. No commit, +push, PR, or integration occurred in this repair. + +## Delivery handoff checkpoint, 2026-10-10 + +A later reviewer added non-empty, quote-inclusive spans for empty JSON keys +and values, with a separate `span.test.ts` and a corrected benchmark README. +On the uncommitted draft, `bun test benchmarks/language/span.test.ts +benchmarks/language/contract.test.ts` passed (23 tests, 240 assertions), as +did `bun run docs:check`, `bun run typecheck`, `bun run lint`, and +`git diff HEAD --check`. This does not resolve the full Bun, Cargo HTTP, or +application-parity gates. The local executor cannot bind loopback, so the +authorized delivery handoff may publish a **draft** pull request to run CI +on its exact head. A green `Required` would close the CI gate, not establish +source provenance, measured performance, event-loop runtime parity, broader +coverage, or independent positive review. No auto-merge is authorized by this +failed P0 verification; leave the draft blocked until a new verification +recommends integration. diff --git a/benchmarks/language/README.md b/benchmarks/language/README.md new file mode 100644 index 00000000..eb80033a --- /dev/null +++ b/benchmarks/language/README.md @@ -0,0 +1,338 @@ +# ALGAL language benchmark contract + +This directory is the P0 baseline for the proposed event-loop contract. It +pins input bytes, expected structural outcomes, identity rules, and a +repeatable timing protocol. It does not contain a compiler, a scheduler, a +provider call, or a language-quality claim. The measured result record is +`algal.language-benchmark.v1`; the checked corpus manifest is +`algal.language-benchmark.corpus.v1`. + +The shared contract index is [`spec/v1/README.md`](../../spec/v1/README.md). +The result parser fixture is `results/smoke.json`; the colocated test parses it +from `unknown` alongside the checked corpus, recomputes both digests, joins every +case identity and expectation to that corpus, and checks raw samples against +summaries. + +The event-loop shape is specified in [`spec/v1/event-loop.md`](../../spec/v1/event-loop.md). +The fixture test parses foreign JSON as `unknown`, rejects unknown keys, checks +all fixture digests, and exercises the listed failure codes: + +```sh +bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts +``` + +## Baseline layout + +```text +benchmarks/language/ + README.md + corpus.json + contract.test.ts + span.ts + span.test.ts + actors/ + echo.json + echo.events.json + projects/ + echo.project.json + invalid/ + *.json + results/ + smoke.json +``` + +`actors/echo.json` is the one valid `algal.event-loop.v1` behavior fixture. +`actors/echo.events.json` is its deterministic input vector. The project fixture +pins both files as one closure. The ten invalid behavior fixtures each record a +specific parser failure. Colocated tests also mutate copies of the valid +fixtures to test digest, payload, byte-limit, and transition rejections. The +fixture count is deliberately small for the P0 contract gate. It is not the 100-program corpus target in the north-star plan. + +## Architecture vocabulary + +The event-loop contract defines an actor as a logical mailbox and state machine. +A supervisor is the host-owned durable owner that admits a behavior revision, +assigns delivery identities, enforces limits, and records pending outcomes. A +habitat is the named host or deployment boundary that supplies supervisor +storage, capacity, and admission policy. Neither term introduces a second +scheduler semantic or a provider permission. + +A behavior revision is identified by its behavior id, revision number, and +root artifact digest. The nested behavior digest covers metadata only. A source +digest claims an identity for exact UTF-8 bytes. The smoke actor retains no source +file, so this corpus cannot verify that claim. A runtime identity identifies the implementation used for a check or measurement. A replay identity +covers the message, transition, state, and output identities. These identities +remain separate from benchmark durations and execution receipts. + +## Corpus manifest + +`corpus.json` is a closed +`algal.language-benchmark.corpus.v1` record: + +```json +{ + "contract": "algal.language-benchmark.corpus.v1", + "kind": "corpus", + "id": "language-smoke", + "revision": 1, + "identities": { + "encoding": "utf-8", + "digest": "sha256-canonical-json", + "compiler": { "id": "algal-event-loop-contract", "version": "proposal" }, + "runtimes": ["bun", "rust"], + "machine": { "os": "fixture", "arch": "fixture", "cpu": "synthetic-contract-fixture" } + }, + "protocol": { + "warmupSamples": 3, + "measuredSamples": 15, + "phases": ["cold", "warm"], + "order": "path", + "quantile": "nearest-rank", + "outliers": "none", + "regressionPercent": 20 + }, + "fixtures": { + "actors": [], + "projects": [], + "invalid": [] + }, + "digest": "sha256:<64 lowercase hex>" +} +``` + +The root digest covers the canonical root without `digest`. The `identities` +record says which identity vocabulary a result must use. `proposal` is a +contract status, not a compiler version that can qualify a runtime. The +`machine` profile is explicitly synthetic in this corpus. It is checked against +the result environment, not presented as a measured computer. + +Each actor fixture entry is: + +```json +{ + "id": "counter-actor", + "path": "actors/echo.json", + "kind": "actor", + "sha256": "sha256:", + "expected": { + "check": "accepted", + "artifactDigest": "sha256:", + "interfaceDigest": "sha256:", + "eventPath": "actors/echo.events.json", + "eventSha256": "sha256:", + "eventDigest": "sha256:" + } +} +``` + +A project entry uses the same `id`, `path`, `kind`, and `sha256` fields. Its +`expected` object contains `check: "accepted"`, `projectDigest`, and +`closureDigest`. `projectDigest` covers the project record without `digest`. +`closureDigest` covers the canonical ordered `files` array. Every project path +is relative, uses `/`, and is at most 512 UTF-8 bytes. + +An invalid entry has `kind: "invalid"` and this expected object: + +```json +{ + "check": "rejected", + "code": "EVENT_LOOP_UNKNOWN_KEY", + "span": { "start": 0, "end": 1 } +} +``` + +The span is a byte range in the named file, with `0 <= start < end <= 16,384`. +For a nonempty string value or unknown property name it covers the UTF-8 bytes +inside the quotes; an empty string has no interior bytes, so its span includes +both quote bytes. For a number it covers the number token. The fixture checker +emits a failure code and a structured JSON path. `span.ts` locates that path in +the source bytes, and the test compares the emitted code and span with the corpus. +It fails if a structural failure has no path. Malformed JSON and invalid +UTF-8 have no structured token to locate: the checker reports `EVENT_LOOP_PARSE` +with the span covering the complete source file. A valid JSON root that is +not an object also receives a complete-source span. An empty file or a file +above 16,384 bytes fails the input bound before a span can be reported. The ten pinned invalid +fixtures are valid JSON; the malformed-byte cases are tested in memory. +Changing a pinned fixture diagnostic requires a corpus revision. + +## Identity rules + +All identity strings use `sha256:` and 64 lowercase hexadecimal characters. +Canonical digests use the repository's sorted-key canonical JSON encoder and +UTF-8 bytes. + +- `sha256` on a fixture entry hashes the exact file bytes, including no path or + line-ending normalization. +- `artifactDigest` is the `algal.event-loop.v1` root digest. +- `interfaceDigest` is the digest of a canonical object containing the behavior's + event declarations and actor ids plus state schemas. It excludes transition + expressions and initial event values. +- `eventDigest` is the `algal.event-loop.events.v1` root digest. +- `projectDigest` and `closureDigest` follow the project rules above. +- `corpus.json` has a digest over its manifest and expected identities, without + its own digest field. +- A language-benchmark result has a digest over all fields before its digest, + including raw samples and environment identities. + +A source digest declares bytes, but without retained source bytes and a checked +source-to-artifact mapping the smoke fixture cannot establish provenance. It does +not identify a compiler, imply semantic quality, or grant source-file access. +An artifact digest identifies the canonical data consumed by a future checker. These identities must not be +substituted for one another. + +## Measurement protocol + +P2 may produce a measured result only from a clean checkout whose +`corpusDigest` matches `corpus.json`. It must use a separately reviewed corpus +revision with an actual pinned OS, architecture, and CPU profile. The `fixture` +profile and durations in this smoke record are synthetic test data, not a real +baseline. + +1. Load the corpus and verify every raw fixture digest before running it. +2. Run cases in lexicographic `path` order. A case has one declared operation: + actor check, project check, or invalid check. +3. For each case, discard exactly three warmup samples. Record fifteen measured + samples in each of the `cold` and `warm` phases. A cold sample starts a fresh + process. A warm sample reuses one process and does not reuse a result. Compare + the checker output with the expected diagnostic or digest on every sample + before recording its outcome. The case observation is the common output for + those samples; a disagreement rejects the measurement. +4. Record only elapsed duration in integer nanoseconds. Do not record start or + end timestamps, host clock readings, process ids, or execution receipts in a + benchmark result. +5. Retain every sample. Compute p50 and p95 with nearest-rank over the fifteen + samples in each phase. `outliers: "none"` means no sample is removed. +6. Report a regression only when the comparison uses the same case ids, corpus + digest, toolchain identity, runtime identity, machine profile, phase, and + sample count. The 20 percent value is a review threshold, not a claim about + current performance. + +A result is measurement evidence. It does not establish that the language is +fast, that an event-loop program is representative, that Bun and Rust have +matching semantics, or that a later runtime improves cumulative skill. + +## `algal.language-benchmark.v1` result + +A result is a closed object with these root fields: + +```json +{ + "contract": "algal.language-benchmark.v1", + "kind": "result", + "corpusDigest": "sha256:", + "protocol": { + "warmupSamples": 3, + "measuredSamples": 15, + "phases": ["cold", "warm"], + "order": "path", + "quantile": "nearest-rank", + "outliers": "none" + }, + "toolchain": { + "compiler": "algal-event-loop-contract@proposal", + "runtime": "bun@1.3.14", + "native": "rust@" + }, + "environment": { + "os": "fixture", + "arch": "fixture", + "cpu": "synthetic-contract-fixture" + }, + "cases": [], + "aggregate": [], + "digest": "sha256:<64 lowercase hex>" +} +``` + +`protocol` repeats the checked corpus protocol except `regressionPercent`, +which is a comparison policy rather than a property of one run. The compiler +identity, runtime names, and exact machine profile must also match the checked +corpus. `toolchain` and `environment` identify the execution inputs. They are bounded descriptive +strings, not credentials. + +Each `cases` entry contains: + +```json +{ + "id": "counter-actor", + "kind": "actor", + "sourceDigest": "sha256:", + "expected": "accepted", + "observed": { + "artifactDigest": "sha256:", + "interfaceDigest": "sha256:", + "eventDigest": "sha256:" + }, + "samples": [ + { "phase": "cold", "index": 0, "durationNs": 1000, "outcome": "accepted" } + ], + "summary": { + "cold": { "count": 15, "p50Ns": 1000, "p95Ns": 1200 }, + "warm": { "count": 15, "p50Ns": 400, "p95Ns": 500 } + } +} +``` + +An accepted actor case has `observed` with `artifactDigest`, `interfaceDigest`, +and `eventDigest`. An accepted project case has `observed` with `projectDigest` +and `closureDigest`. These checked outputs must equal the corpus expectations. +The synthetic smoke result copies the values computed from its fixtures; it is +not a measured run of a compiler or runtime. An invalid case uses `expected: +"rejected"`, `code`, and `span` at the case level. The case object is closed: +accepted cases have no diagnostic fields, and rejected cases have no +`observed` field. A sample outcome must equal the case's expected outcome. Each phase has exactly one sample for every +index from zero through `measuredSamples - 1`. + +`aggregate` contains one entry per case and phase with `count`, `p50Ns`, and +`p95Ns`; it is derived data and is checked against the retained samples. The +result must contain exactly one case per corpus fixture and every sample, +not only its aggregates. The parser rejects invented or missing cases, changed +raw-byte digests, kinds, expected outcomes, observed accepted-case digests, +diagnostic codes or spans, a different corpus digest, and mismatched protocol, compiler, runtime names, or +machine profile. A joined digest is not proof of source provenance or measured +performance. + +## Decision record: no separate runtime + +The baseline measures contract checks and replay evidence, not a new language +runtime. TypeScript is the reference implementation boundary and Rust/WASM is a +parity boundary. Both must consume the same event-loop records and produce the +same canonical identities before a runtime comparison is meaningful. The +benchmark result records runtime identity so a comparison cannot silently mix +implementations, but it does not select or publish a runtime. + +This is a P0 architecture decision. A scheduler, supervisor, habitat adapter, +compiler, and provider integration remain later lanes. The smoke corpus is +review evidence for strict parsing and measurement shape, not evidence of speed, +semantic parity, or cumulative skill. + +## Bounds and strict parsing + +The corpus and result parsers reject unknown keys at every contract-owned object. +They parse all external input as `unknown` and validate before returning a typed +record. Event payloads match declared schemas and include source and +sequence in their byte limit. Expression programs remain foreign values governed by their own +contract. Caller-owned `json` state and event fields still enforce the expression +value limits before their enclosing state and event byte budgets are checked. + +| Value | Bound | +| --- | --- | +| corpus id, case id, operation, toolchain and environment string | 1 to 128 UTF-8 bytes | +| corpus fixtures | 1 to 256 total entries; at most 128 invalid entries | +| source file | 1 to 16,384 bytes | +| project closure | 1 to 262,144 bytes and at most 64 files | +| nested `json` field | at most 32 value levels (scalar at depth 0), 1,024 items per list, 256 keys per object, 65,536 UTF-8 bytes per string or key, and 262,144 canonical UTF-8 bytes per value | +| corpus paths | 1 to 512 UTF-8 bytes | +| warmup samples | 0 to 100, with three in this baseline | +| measured samples per phase | 1 to 1,000, with fifteen in this baseline | +| phases | exactly `cold`, `warm` in this baseline, at most 2 | +| elapsed duration | 0 to 86,400,000,000,000 nanoseconds | +| cases and their summaries | at most 256 entries | +| aggregate entries | at most 512, exactly two per case | +| pinned machine profile | `os`, `arch`, and `cpu`: 1 to 128 UTF-8 bytes each | +| result digest and every referenced digest | `sha256:` plus 64 lowercase hex characters | + +The parser rejects a zero measured sample count, an unknown phase, a duration over +one day, a mismatched sample count, an invalid quantile, an unknown diagnostic +field, a duplicate id, or a result whose digest does not recompute. These checks +are deterministic and do not depend on the host clock. diff --git a/benchmarks/language/actors/echo.events.json b/benchmarks/language/actors/echo.events.json new file mode 100644 index 00000000..93e2b972 --- /dev/null +++ b/benchmarks/language/actors/echo.events.json @@ -0,0 +1,29 @@ +{ + "contract": "algal.event-loop.events.v1", + "actor": "counter", + "events": [ + { + "source": "fixture", + "sequence": 0, + "type": "increment", + "payload": { + "amount": 2 + } + }, + { + "source": "fixture", + "sequence": 1, + "type": "increment", + "payload": { + "amount": 3 + } + }, + { + "source": "fixture", + "sequence": 2, + "type": "reset", + "payload": {} + } + ], + "digest": "sha256:1869ff89ddc5785172505954b7b19278ee716b96c2e86c538b2cd6756e9dbce9" +} diff --git a/benchmarks/language/actors/echo.json b/benchmarks/language/actors/echo.json new file mode 100644 index 00000000..4e49c92e --- /dev/null +++ b/benchmarks/language/actors/echo.json @@ -0,0 +1,143 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/contract.test.ts b/benchmarks/language/contract.test.ts new file mode 100644 index 00000000..17056715 --- /dev/null +++ b/benchmarks/language/contract.test.ts @@ -0,0 +1,1360 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { checkProgram, EXPR_BOUNDS } from "../../src/expr"; +import { canonicalize, isJsonValue, type JsonValue } from "../../src/values"; +import { locateJsonSpan, type ByteSpan } from "./span"; + +const ROOT = import.meta.dir; +const DIGEST = /^sha256:[0-9a-f]{64}$/; +const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const TYPES = new Set(["null", "boolean", "number", "string", "json"]); + +type RecordValue = Record; +type ErrorCode = + | "EVENT_LOOP_PARSE" + | "EVENT_LOOP_CONTRACT" + | "EVENT_LOOP_UNKNOWN_KEY" + | "EVENT_LOOP_ID" + | "EVENT_LOOP_DIGEST" + | "EVENT_LOOP_BOUNDS" + | "EVENT_LOOP_SCHEMA" + | "EVENT_LOOP_REFERENCE" + | "EVENT_LOOP_SEMANTIC"; + +const ERROR_CODES: ReadonlySet = new Set([ + "EVENT_LOOP_PARSE", + "EVENT_LOOP_CONTRACT", + "EVENT_LOOP_UNKNOWN_KEY", + "EVENT_LOOP_ID", + "EVENT_LOOP_DIGEST", + "EVENT_LOOP_BOUNDS", + "EVENT_LOOP_SCHEMA", + "EVENT_LOOP_REFERENCE", + "EVENT_LOOP_SEMANTIC", +]); +const MAX_ERROR_CODE_BYTES = 22; + +class FixtureError extends Error { + constructor(readonly code: ErrorCode, message: string, readonly at?: readonly (string | number)[]) { + super(message); + } +} + +function fail(code: ErrorCode, message: string, at?: readonly (string | number)[]): never { + throw new FixtureError(code, message, at); +} + +function object(value: unknown, what: string): RecordValue { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail("EVENT_LOOP_PARSE", `${what} must be an object`); + } + return value as RecordValue; +} + +function exact(value: RecordValue, keys: readonly string[], what: string, at?: readonly (string | number)[]): void { + const allowed = new Set(keys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail("EVENT_LOOP_UNKNOWN_KEY", `${what}.${key}`, at && [...at, key]); + } + for (const key of keys) { + if (!Object.hasOwn(value, key)) fail("EVENT_LOOP_PARSE", `${what}.${key} is missing`); + } +} + +function text(value: unknown, what: string, maxBytes = 128): string { + if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maxBytes) { + fail("EVENT_LOOP_PARSE", `${what} must be a non-empty bounded string`); + } + return value; +} + +function identifier(value: unknown, what: string): string { + const id = text(value, what, 64); + if (!ID.test(id)) fail("EVENT_LOOP_ID", `${what} is not lower kebab case`); + return id; +} + +function integer(value: unknown, what: string, min: number, max: number, at?: readonly (string | number)[]): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) { + fail("EVENT_LOOP_BOUNDS", `${what} is outside ${min}..${max}`, at); + } + return value; +} + +function digest(value: unknown, what: string, at?: readonly (string | number)[]): string { + if (typeof value !== "string" || !DIGEST.test(value)) fail("EVENT_LOOP_DIGEST", `${what} is not a digest`, at); + return value; +} + +// Apply the expression value limits to caller-owned JSON too, before +// canonicalization or a recursive unbounded isJsonValue walk. +function json(value: unknown, what: string): JsonValue { + const encoder = new TextEncoder(); + const ancestors = new Set(); + const maxBytes = EXPR_BOUNDS.maxValueBytes; + function size(node: unknown, depth: number): number { + if (depth > EXPR_BOUNDS.maxValueDepth || + (node !== null && typeof node === "object" && depth === EXPR_BOUNDS.maxValueDepth)) { + fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value depth`); + } + if (typeof node === "string" && encoder.encode(node).byteLength > EXPR_BOUNDS.maxStringBytes) { + fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON string bytes`); + } + if (Array.isArray(node)) { + if (node.length > EXPR_BOUNDS.maxListLen) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON list length`); + if (ancestors.has(node)) fail("EVENT_LOOP_PARSE", `${what} is cyclic`); + ancestors.add(node); + let bytes = 2; + for (let i = 0; i < node.length; i++) { + if (!Object.hasOwn(node, i)) fail("EVENT_LOOP_PARSE", `${what} has a sparse list`); + bytes += Number(i > 0) + size(node[i], depth + 1); + if (bytes > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`); + } + ancestors.delete(node); + return bytes; + } + if (node !== null && typeof node === "object") { + const proto = Object.getPrototypeOf(node); + if (proto !== Object.prototype && proto !== null) fail("EVENT_LOOP_PARSE", `${what} is not a JSON object`); + if (ancestors.has(node)) fail("EVENT_LOOP_PARSE", `${what} is cyclic`); + const keys = Object.keys(node); + if (keys.length > EXPR_BOUNDS.maxObjectKeys) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON object keys`); + ancestors.add(node); + let bytes = 2; + for (const [index, key] of keys.entries()) { + if (encoder.encode(key).byteLength > EXPR_BOUNDS.maxStringBytes) { + fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON key bytes`); + } + bytes += Number(index > 0) + encoder.encode(JSON.stringify(key)).byteLength + 1 + + size((node as Record)[key], depth + 1); + if (bytes > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`); + } + ancestors.delete(node); + return bytes; + } + if (node !== null && typeof node !== "boolean" && + !(typeof node === "number" && Number.isFinite(node)) && typeof node !== "string") { + fail("EVENT_LOOP_PARSE", `${what} is not canonical JSON`); + } + const scalar = JSON.stringify(node); + if (typeof scalar !== "string") fail("EVENT_LOOP_PARSE", `${what} is not canonical JSON`); + return encoder.encode(scalar).byteLength; + } + if (size(value, 0) > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`); + return value as JsonValue; +} + +function canonicalBytes(value: unknown, what: string): number { + return new TextEncoder().encode(canonicalize(json(value, what))).byteLength; +} + +function jsonBytes(value: unknown, what: string): number { + const size = canonicalBytes(value, what); + if (size > EXPR_BOUNDS.maxProgramBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds expression bytes`); + return size; +} + +// Include quoted data in the depth check: the evaluator's static walk skips +// quote payloads, but they still contribute to the program's JSON depth. +function expressionShape(value: unknown, what: string): void { + let nodes = 0; + function visit(node: unknown, depth: number): void { + nodes++; + if (nodes > EXPR_BOUNDS.maxProgramNodes || depth > EXPR_BOUNDS.maxProgramDepth) { + fail("EVENT_LOOP_BOUNDS", `${what} exceeds expression nodes or depth`); + } + if (Array.isArray(node)) { + for (const child of node) visit(child, depth + 1); + } else if (node !== null && typeof node === "object") { + for (const child of Object.values(node)) visit(child, depth + 1); + } + } + visit(value, 1); +} + +function expression(value: unknown, what: string, at?: readonly (string | number)[]): void { + const record = object(value, what); + exact(record, ["contract", "program"], what, at); + if (record.contract !== "algal.expr.v1") fail("EVENT_LOOP_CONTRACT", `${what}.contract`); + expressionShape(record.program, `${what}.program`); + const program = json(record.program, `${what}.program`); + jsonBytes(program, `${what}.program`); + const checked = checkProgram(program, ["state", "event"]); + if (!checked.ok) { + if (checked.err.code === "EXPR_BOUNDS") fail("EVENT_LOOP_BOUNDS", `${what}.program exceeds expression bounds`); + if (checked.err.code === "EXPR_PATH") fail("EVENT_LOOP_REFERENCE", `${what}.program names an undeclared value`); + fail("EVENT_LOOP_PARSE", `${what}.program is not a valid expression`); + } +} + +function matchesType(value: unknown, type: string): boolean { + switch (type) { + case "null": + return value === null; + case "boolean": + return typeof value === "boolean"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "string": + return typeof value === "string"; + case "json": + return isJsonValue(value); + default: + return false; + } +} + +function parseSchema(value: unknown, withInitial: boolean, what: string): Map { + const record = object(value, what); + exact(record, ["kind", "fields"], what); + if (record.kind !== "record" || !Array.isArray(record.fields) || record.fields.length > 64) { + fail("EVENT_LOOP_SCHEMA", `${what} must be a bounded record`); + } + const fields = new Map(); + for (const [index, raw] of record.fields.entries()) { + const field = object(raw, `${what}.fields[${index}]`); + const keys = withInitial ? ["name", "type", "initial"] : ["name", "type"]; + exact(field, keys, `${what}.fields[${index}]`); + const name = identifier(field.name, `${what}.fields[${index}].name`); + if (fields.has(name)) fail("EVENT_LOOP_ID", `${what} duplicates ${name}`); + const type = field.type; + if (typeof type !== "string" || !TYPES.has(type)) { + fail("EVENT_LOOP_SCHEMA", `${what}.fields[${index}].type`); + } + fields.set(name, type); + if (withInitial) { + json(field.initial, `${what}.fields[${index}].initial`); + if (!matchesType(field.initial, type)) { + fail("EVENT_LOOP_SCHEMA", `${what}.fields[${index}].initial does not match ${type}`); + } + } + } + return fields; +} + +function parseEvent(value: unknown, index: number): string { + const record = object(value, `events[${index}]`); + exact(record, ["id", "payload"], `events[${index}]`); + const id = identifier(record.id, `events[${index}].id`); + parseSchema(record.payload, false, `events[${index}].payload`); + return id; +} + +function parseResult(value: unknown, writes: number, outputs: number, what: string, at?: readonly (string | number)[]): void { + const record = object(value, what); + if (typeof record.kind !== "string") fail("EVENT_LOOP_SEMANTIC", `${what}.kind`); + if (record.kind === "continue" || record.kind === "quiet") { + exact(record, ["kind"], what); + } else if (record.kind === "defer") { + exact(record, ["kind", "key"], what); + expression(record.key, `${what}.key`); + } else if (record.kind === "escalate") { + exact(record, ["kind", "request"], what); + expression(record.request, `${what}.request`); + } else { + fail("EVENT_LOOP_SEMANTIC", `${what}.kind`); + } + if ((record.kind === "defer" || record.kind === "escalate") && (writes !== 0 || outputs !== 0)) { + fail("EVENT_LOOP_SEMANTIC", `${what} cannot commit writes or outputs`, at && [...at, "kind"]); + } + if (record.kind === "quiet" && outputs !== 0) fail("EVENT_LOOP_SEMANTIC", `${what} quiet cannot emit outputs`); +} + +function parseActor( + value: unknown, + index: number, + eventIds: Set, + actorIds: Set, + maxOutputs: number, + maxStateBytes: number, +): string { + const record = object(value, `actors[${index}]`); + exact(record, ["id", "state", "transitions"], `actors[${index}]`); + const actorId = identifier(record.id, `actors[${index}].id`); + const stateFields = parseSchema(record.state, true, `actors[${index}].state`); + const state = object(record.state, `actors[${index}].state`); + const initial: Record = {}; + for (const raw of state.fields as unknown[]) { + const field = object(raw, "state field"); + initial[identifier(field.name, "state field name")] = json(field.initial, "initial state field"); + } + if (canonicalBytes(initial, `actors[${index}].initial`) > maxStateBytes) { + fail("EVENT_LOOP_BOUNDS", `actors[${index}].initial exceeds maxStateBytes`); + } + if (!Array.isArray(record.transitions) || record.transitions.length < 1 || record.transitions.length > 256) { + fail("EVENT_LOOP_BOUNDS", `actors[${index}].transitions`); + } + const transitionIds = new Set(); + for (const [transitionIndex, raw] of record.transitions.entries()) { + const transition = object(raw, `actors[${index}].transitions[${transitionIndex}]`); + exact(transition, ["id", "on", "when", "writes", "outputs", "result"], `actors[${index}].transitions[${transitionIndex}]`); + const transitionId = identifier(transition.id, "transition.id"); + if (transitionIds.has(transitionId)) fail("EVENT_LOOP_ID", `duplicate transition ${transitionId}`); + transitionIds.add(transitionId); + if (typeof transition.on !== "string" || !eventIds.has(transition.on)) { + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId} event`, ["actors", index, "transitions", transitionIndex, "on"]); + } + if (transition.when !== null) expression(transition.when, `transition ${transitionId}.when`, ["actors", index, "transitions", transitionIndex, "when"]); + if (!Array.isArray(transition.writes) || transition.writes.length > 64) fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.writes`); + for (const [writeIndex, rawWrite] of transition.writes.entries()) { + const write = object(rawWrite, `transition ${transitionId}.writes[${writeIndex}]`); + exact(write, ["path", "value"], `transition ${transitionId}.writes[${writeIndex}]`); + if (!Array.isArray(write.path) || write.path.length !== 1) { + fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.path`); + } + if (typeof write.path[0] !== "string" || !stateFields.has(write.path[0])) { + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.state field`); + } + for (const [pathIndex, segment] of write.path.entries()) identifier(segment, `transition ${transitionId}.path[${pathIndex}]`); + expression(write.value, `transition ${transitionId}.value`); + } + if (!Array.isArray(transition.outputs) || transition.outputs.length > maxOutputs) { + fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.outputs`, ["limits", "maxOutputEvents"]); + } + for (const [outputIndex, rawOutput] of transition.outputs.entries()) { + const output = object(rawOutput, `transition ${transitionId}.outputs[${outputIndex}]`); + exact(output, ["kind", "target", "event", "payload"], `transition ${transitionId}.outputs[${outputIndex}]`); + if (output.kind !== "notify") fail("EVENT_LOOP_SEMANTIC", `transition ${transitionId}.output kind`); + if (output.target !== "self" && (typeof output.target !== "string" || !actorIds.has(output.target))) { + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.output target`); + } + if (typeof output.event !== "string" || !eventIds.has(output.event)) { + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.output event`); + } + expression(output.payload, `transition ${transitionId}.output payload`); + } + parseResult(transition.result, transition.writes.length, transition.outputs.length, `transition ${transitionId}.result`, ["actors", index, "transitions", transitionIndex, "result"]); + } + return actorId; +} + +function parseEventLoop(value: unknown): RecordValue { + const root = object(value, "event-loop"); + exact(root, ["contract", "behavior", "limits", "events", "actors", "digest"], "event-loop", []); + if (root.contract !== "algal.event-loop.v1") fail("EVENT_LOOP_CONTRACT", "event-loop.contract", ["contract"]); + const behavior = object(root.behavior, "behavior"); + exact(behavior, ["id", "revision", "sourceDigest", "digest"], "behavior", ["behavior"]); + identifier(behavior.id, "behavior.id"); + integer(behavior.revision, "behavior.revision", 1, 2_147_483_647); + digest(behavior.sourceDigest, "behavior.sourceDigest", ["behavior", "sourceDigest"]); + verifyDigest(behavior, "behavior"); + const limits = object(root.limits, "limits"); + exact(limits, ["maxEventBytes", "maxStateBytes", "maxOutputEvents", "maxTransitionFuel", "maxMailboxDepth"], "limits"); + integer(limits.maxEventBytes, "limits.maxEventBytes", 1, 65_536, ["limits", "maxEventBytes"]); + integer(limits.maxStateBytes, "limits.maxStateBytes", 1, 262_144); + integer(limits.maxOutputEvents, "limits.maxOutputEvents", 0, 64); + integer(limits.maxTransitionFuel, "limits.maxTransitionFuel", 1, 100_000); + integer(limits.maxMailboxDepth, "limits.maxMailboxDepth", 1, 1_024); + if (!Array.isArray(root.events) || root.events.length < 1 || root.events.length > 128) fail("EVENT_LOOP_BOUNDS", "events"); + const eventIds = new Set(); + for (const [index, raw] of root.events.entries()) { + const id = parseEvent(raw, index); + if (eventIds.has(id)) fail("EVENT_LOOP_ID", `duplicate event ${id}`, ["events", index, "id"]); + eventIds.add(id); + } + if (!Array.isArray(root.actors) || root.actors.length < 1 || root.actors.length > 64) fail("EVENT_LOOP_BOUNDS", "actors"); + const actorIds = new Set(); + for (const [index, raw] of root.actors.entries()) { + const actor = object(raw, `actors[${index}]`); + exact(actor, ["id", "state", "transitions"], `actors[${index}]`); + const id = identifier(actor.id, `actors[${index}].id`); + if (actorIds.has(id)) fail("EVENT_LOOP_ID", `duplicate actor ${id}`); + actorIds.add(id); + } + for (const [index, raw] of root.actors.entries()) parseActor(raw, index, eventIds, actorIds, limits.maxOutputEvents as number, limits.maxStateBytes as number); + verifyDigest(root, "event-loop"); + return root; +} + +function parseEvents(value: unknown, behaviorValue: unknown): RecordValue { + const behavior = parseEventLoop(behaviorValue); + const limits = object(behavior.limits, "behavior.limits"); + const declarations = new Map>(); + for (const raw of behavior.events as unknown[]) { + const declaration = object(raw, "event declaration"); + const id = identifier(declaration.id, "event declaration id"); + declarations.set(id, parseSchema(declaration.payload, false, `event ${id}.payload`)); + } + const root = object(value, "events"); + exact(root, ["contract", "actor", "events", "digest"], "events"); + if (root.contract !== "algal.event-loop.events.v1") fail("EVENT_LOOP_CONTRACT", "events.contract"); + const actor = identifier(root.actor, "events.actor"); + if (!(behavior.actors as RecordValue[]).some((raw) => raw.id === actor)) fail("EVENT_LOOP_REFERENCE", "events.actor"); + if (!Array.isArray(root.events) || root.events.length < 1 || root.events.length > 256) fail("EVENT_LOOP_BOUNDS", "events.events"); + for (const [index, raw] of root.events.entries()) { + const event = object(raw, `events.events[${index}]`); + exact(event, ["source", "sequence", "type", "payload"], `events.events[${index}]`); + identifier(event.source, `events.events[${index}].source`); + integer(event.sequence, `events.events[${index}].sequence`, 0, 2_147_483_647); + const type = identifier(event.type, `events.events[${index}].type`); + const schema = declarations.get(type); + if (!schema) fail("EVENT_LOOP_REFERENCE", `events.events[${index}].type`); + const payload = object(event.payload, `events.events[${index}].payload`); + exact(payload, [...schema.keys()], `events.events[${index}].payload`); + for (const [name, fieldType] of schema) { + json(payload[name], `events.events[${index}].payload.${name}`); + if (!matchesType(payload[name], fieldType)) fail("EVENT_LOOP_SCHEMA", `events.events[${index}].payload.${name}`); + } + if (canonicalBytes(event, `events.events[${index}]`) > (limits.maxEventBytes as number)) { + fail("EVENT_LOOP_BOUNDS", `events.events[${index}] exceeds maxEventBytes`); + } + } + verifyDigest(root, "events"); + return root; +} + +function path(value: unknown, what: string): string { + const result = text(value, what, 512); + if (result.startsWith("/") || result.includes("\\") || result.split("/").some((part) => part.length === 0 || part === "." || part === "..")) { + fail("EVENT_LOOP_PARSE", `${what} is not a relative POSIX path`); + } + return result; +} + +function parseProject(value: unknown): RecordValue { + const root = object(value, "project"); + exact(root, ["contract", "id", "entry", "files", "digest"], "project"); + if (root.contract !== "algal.language-project.v1") fail("EVENT_LOOP_CONTRACT", "project.contract"); + identifier(root.id, "project.id"); + const entry = path(root.entry, "project.entry"); + if (!Array.isArray(root.files) || root.files.length < 1 || root.files.length > 64) fail("EVENT_LOOP_BOUNDS", "project.files"); + const paths = new Set(); + for (const [index, raw] of root.files.entries()) { + const file = object(raw, `project.files[${index}]`); + exact(file, ["path", "digest"], `project.files[${index}]`); + const filePath = path(file.path, `project.files[${index}].path`); + if (paths.has(filePath)) fail("EVENT_LOOP_ID", `duplicate project path ${filePath}`); + paths.add(filePath); + digest(file.digest, `project.files[${index}].digest`); + } + if (!paths.has(entry)) fail("EVENT_LOOP_REFERENCE", "project.entry"); + verifyDigest(root, "project"); + return root; +} + +function parseFixtureEntry(value: unknown, expectedKind: "actor" | "project" | "invalid"): void { + const entry = object(value, "fixture"); + exact(entry, ["id", "path", "kind", "sha256", "expected"], "fixture"); + identifier(entry.id, "fixture.id"); + path(entry.path, "fixture.path"); + if (entry.kind !== expectedKind) fail("EVENT_LOOP_PARSE", "fixture.kind"); + digest(entry.sha256, "fixture.sha256"); + const expected = object(entry.expected, "fixture.expected"); + if (expectedKind === "actor") { + exact(expected, ["check", "artifactDigest", "interfaceDigest", "eventPath", "eventSha256", "eventDigest"], "fixture.expected"); + if (expected.check !== "accepted") fail("EVENT_LOOP_PARSE", "fixture.expected.check"); + digest(expected.artifactDigest, "fixture.expected.artifactDigest"); + digest(expected.interfaceDigest, "fixture.expected.interfaceDigest"); + path(expected.eventPath, "fixture.expected.eventPath"); + digest(expected.eventSha256, "fixture.expected.eventSha256"); + digest(expected.eventDigest, "fixture.expected.eventDigest"); + } else if (expectedKind === "project") { + exact(expected, ["check", "projectDigest", "closureDigest"], "fixture.expected"); + if (expected.check !== "accepted") fail("EVENT_LOOP_PARSE", "fixture.expected.check"); + digest(expected.projectDigest, "fixture.expected.projectDigest"); + digest(expected.closureDigest, "fixture.expected.closureDigest"); + } else { + exact(expected, ["check", "code", "span"], "fixture.expected"); + if (expected.check !== "rejected") fail("EVENT_LOOP_PARSE", "fixture.expected.check"); + const code = text(expected.code, "fixture.expected.code", MAX_ERROR_CODE_BYTES); + if (!ERROR_CODES.has(code)) fail("EVENT_LOOP_PARSE", "fixture.expected.code"); + const span = object(expected.span, "fixture.expected.span"); + exact(span, ["start", "end"], "fixture.expected.span"); + const start = integer(span.start, "fixture.expected.span.start", 0, 16_383); + integer(span.end, "fixture.expected.span.end", start + 1, 16_384); + } +} + +function parseCorpus(value: unknown): RecordValue { + const root = object(value, "corpus"); + exact(root, ["contract", "kind", "id", "revision", "identities", "protocol", "fixtures", "digest"], "corpus"); + if (root.contract !== "algal.language-benchmark.corpus.v1" || root.kind !== "corpus") fail("EVENT_LOOP_CONTRACT", "corpus identity"); + identifier(root.id, "corpus.id"); + integer(root.revision, "corpus.revision", 1, 2_147_483_647); + const identities = object(root.identities, "corpus.identities"); + exact(identities, ["encoding", "digest", "compiler", "runtimes", "machine"], "corpus.identities"); + if (identities.encoding !== "utf-8" || identities.digest !== "sha256-canonical-json") fail("EVENT_LOOP_CONTRACT", "corpus.identities"); + const compiler = object(identities.compiler, "corpus.identities.compiler"); + exact(compiler, ["id", "version"], "corpus.identities.compiler"); + text(compiler.id, "corpus.identities.compiler.id"); + text(compiler.version, "corpus.identities.compiler.version"); + if (!Array.isArray(identities.runtimes) || identities.runtimes.length !== 2) fail("EVENT_LOOP_BOUNDS", "corpus.identities.runtimes"); + const runtimes = new Set(); + for (const [index, runtime] of identities.runtimes.entries()) { + const id = text(runtime, `corpus.identities.runtimes[${index}]`); + if (runtimes.has(id)) fail("EVENT_LOOP_ID", `duplicate runtime ${id}`); + runtimes.add(id); + } + const machine = object(identities.machine, "corpus.identities.machine"); + exact(machine, ["os", "arch", "cpu"], "corpus.identities.machine"); + for (const key of ["os", "arch", "cpu"] as const) text(machine[key], `corpus.identities.machine.${key}`); + const protocol = object(root.protocol, "corpus.protocol"); + exact(protocol, ["warmupSamples", "measuredSamples", "phases", "order", "quantile", "outliers", "regressionPercent"], "corpus.protocol"); + integer(protocol.warmupSamples, "corpus.protocol.warmupSamples", 0, 100); + integer(protocol.measuredSamples, "corpus.protocol.measuredSamples", 1, 1_000); + if (!Array.isArray(protocol.phases) || protocol.phases.length !== 2 || protocol.phases[0] !== "cold" || protocol.phases[1] !== "warm") fail("EVENT_LOOP_CONTRACT", "corpus.protocol.phases"); + if (protocol.order !== "path" || protocol.quantile !== "nearest-rank" || protocol.outliers !== "none") fail("EVENT_LOOP_CONTRACT", "corpus.protocol"); + integer(protocol.regressionPercent, "corpus.protocol.regressionPercent", 0, 100); + const fixtures = object(root.fixtures, "corpus.fixtures"); + exact(fixtures, ["actors", "projects", "invalid"], "corpus.fixtures"); + if (!Array.isArray(fixtures.actors) || !Array.isArray(fixtures.projects) || !Array.isArray(fixtures.invalid)) fail("EVENT_LOOP_PARSE", "corpus.fixtures"); + if (fixtures.actors.length + fixtures.projects.length + fixtures.invalid.length < 1 || fixtures.actors.length + fixtures.projects.length + fixtures.invalid.length > 256 || fixtures.invalid.length > 128) fail("EVENT_LOOP_BOUNDS", "corpus.fixtures"); + const fixtureIds = new Set(); + const fixturePaths = new Set(); + const parseFixtureGroup = (entries: unknown[], kind: "actor" | "project" | "invalid"): void => { + for (const entry of entries) { + parseFixtureEntry(entry, kind); + const record = object(entry, "fixture"); + const id = identifier(record.id, "fixture.id"); + const fixturePath = path(record.path, "fixture.path"); + if (fixtureIds.has(id)) fail("EVENT_LOOP_ID", `duplicate fixture ${id}`); + if (fixturePaths.has(fixturePath)) fail("EVENT_LOOP_ID", `duplicate fixture path ${fixturePath}`); + fixtureIds.add(id); + fixturePaths.add(fixturePath); + } + }; + parseFixtureGroup(fixtures.actors, "actor"); + parseFixtureGroup(fixtures.projects, "project"); + parseFixtureGroup(fixtures.invalid, "invalid"); + verifyDigest(root, "corpus"); + return root; +} + +function canonicalDigest(value: unknown): string { + return `sha256:${createHash("sha256").update(canonicalize(json(value, "digest value"))).digest("hex")}`; +} + +function rawDigest(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function withoutDigest(value: unknown): JsonValue { + const copy = structuredClone(json(value, "record")) as RecordValue; + delete copy.digest; + return json(copy, "record without digest"); +} + +function verifyDigest(record: RecordValue, what: string): void { + if (digest(record.digest, `${what}.digest`) !== canonicalDigest(withoutDigest(record))) { + fail("EVENT_LOOP_DIGEST", `${what}.digest does not match canonical content`); + } +} + +function eventIdentity(artifactDigest: unknown, actor: unknown, value: unknown): string { + const event = object(value, "event identity"); + return canonicalDigest({ + behavior: digest(artifactDigest, "behavior artifact digest"), + actor: identifier(actor, "actor id"), + source: identifier(event.source, "event source"), + sequence: integer(event.sequence, "event sequence", 0, 2_147_483_647), + type: identifier(event.type, "event type"), + payload: json(event.payload, "event payload"), + }); +} + +async function load(path: string): Promise { + return JSON.parse(await readFile(join(ROOT, path), "utf8")) as unknown; +} + +function errorCode(fn: () => unknown): string { + try { + fn(); + } catch (error) { + if (error instanceof FixtureError) return error.code; + throw error; + } + return "ACCEPTED"; +} + +// The diagnostic is emitted from the checked source bytes and a structured +// failure path, never copied from the expected corpus entry. +function checkEventLoopBytes(bytes: Uint8Array): { code: "ACCEPTED" } | { code: ErrorCode; span: ByteSpan } { + if (bytes.byteLength < 1 || bytes.byteLength > 16_384) { + throw new Error("fixture source exceeds 1..16384 bytes"); + } + let source: unknown; + try { + source = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch (error) { + // Malformed JSON and UTF-8 have no structured path. Point at the entire + // bounded source rather than inventing a token location. + if (error instanceof SyntaxError || error instanceof TypeError) { + return { code: "EVENT_LOOP_PARSE", span: { start: 0, end: bytes.byteLength } }; + } + throw error; + } + if (source === null || typeof source !== "object" || Array.isArray(source)) { + return { code: "EVENT_LOOP_PARSE", span: { start: 0, end: bytes.byteLength } }; + } + try { + parseEventLoop(source); + return { code: "ACCEPTED" }; + } catch (error) { + if (!(error instanceof FixtureError) || !error.at) throw error; + return { code: error.code, span: locateJsonSpan(bytes, error.at, error.code === "EVENT_LOOP_UNKNOWN_KEY") }; + } +} + +type BenchmarkPhase = "cold" | "warm"; +type BenchmarkOutcome = "accepted" | "rejected"; + +function parseBenchmarkProtocol(value: unknown, what: string): { measuredSamples: number } { + const protocol = object(value, what); + exact(protocol, ["warmupSamples", "measuredSamples", "phases", "order", "quantile", "outliers"], what); + integer(protocol.warmupSamples, `${what}.warmupSamples`, 0, 100); + const measuredSamples = integer(protocol.measuredSamples, `${what}.measuredSamples`, 1, 1_000); + if (!Array.isArray(protocol.phases) || protocol.phases.length !== 2 || protocol.phases[0] !== "cold" || protocol.phases[1] !== "warm") { + fail("EVENT_LOOP_CONTRACT", `${what}.phases`); + } + if (protocol.order !== "path" || protocol.quantile !== "nearest-rank" || protocol.outliers !== "none") { + fail("EVENT_LOOP_CONTRACT", what); + } + return { measuredSamples }; +} + +function parseBenchmarkSample(value: unknown, index: number, what: string): { phase: BenchmarkPhase; sampleIndex: number; durationNs: number; outcome: BenchmarkOutcome } { + const sample = object(value, `${what}[${index}]`); + exact(sample, ["phase", "index", "durationNs", "outcome"], `${what}[${index}]`); + if (sample.phase !== "cold" && sample.phase !== "warm") fail("EVENT_LOOP_CONTRACT", `${what}[${index}].phase`); + const sampleIndex = integer(sample.index, `${what}[${index}].index`, 0, 999); + const durationNs = integer(sample.durationNs, `${what}[${index}].durationNs`, 0, 86_400_000_000_000); + if (sample.outcome !== "accepted" && sample.outcome !== "rejected") fail("EVENT_LOOP_CONTRACT", `${what}[${index}].outcome`); + return { phase: sample.phase, sampleIndex, durationNs, outcome: sample.outcome }; +} + +function nearestRank(values: readonly number[], fraction: number): number { + const ordered = [...values].sort((a, b) => a - b); + const rank = Math.max(1, Math.ceil(ordered.length * fraction)); + return ordered[rank - 1]!; +} + +function parseBenchmarkSummary(value: unknown, values: readonly number[], measuredSamples: number, what: string): void { + const summary = object(value, what); + exact(summary, ["count", "p50Ns", "p95Ns"], what); + if (integer(summary.count, `${what}.count`, 1, 1_000) !== measuredSamples) fail("EVENT_LOOP_BOUNDS", `${what}.count`); + if (integer(summary.p50Ns, `${what}.p50Ns`, 0, 86_400_000_000_000) !== nearestRank(values, 0.5)) fail("EVENT_LOOP_SEMANTIC", `${what}.p50Ns`); + if (integer(summary.p95Ns, `${what}.p95Ns`, 0, 86_400_000_000_000) !== nearestRank(values, 0.95)) fail("EVENT_LOOP_SEMANTIC", `${what}.p95Ns`); +} + +function parseBenchmarkCase(value: unknown, caseIndex: number, measuredSamples: number): { id: string; summaries: Map } { + const raw = object(value, `result.cases[${caseIndex}]`); + const expected = raw.expected; + if (expected !== "accepted" && expected !== "rejected") fail("EVENT_LOOP_CONTRACT", `result.cases[${caseIndex}].expected`); + const keys = expected === "accepted" + ? ["id", "kind", "sourceDigest", "expected", "observed", "samples", "summary"] + : ["id", "kind", "sourceDigest", "expected", "code", "span", "samples", "summary"]; + exact(raw, keys, `result.cases[${caseIndex}]`); + const id = identifier(raw.id, `result.cases[${caseIndex}].id`); + if (raw.kind !== "actor" && raw.kind !== "project" && raw.kind !== "invalid") fail("EVENT_LOOP_CONTRACT", `result.cases[${caseIndex}].kind`); + if ((expected === "rejected") !== (raw.kind === "invalid")) fail("EVENT_LOOP_SEMANTIC", `result.cases[${caseIndex}] kind and outcome`); + digest(raw.sourceDigest, `result.cases[${caseIndex}].sourceDigest`); + if (expected === "accepted") { + const observed = object(raw.observed, `result.cases[${caseIndex}].observed`); + const keys = raw.kind === "actor" ? ["artifactDigest", "interfaceDigest", "eventDigest"] : ["projectDigest", "closureDigest"]; + exact(observed, keys, `result.cases[${caseIndex}].observed`); + for (const key of keys) digest(observed[key], `result.cases[${caseIndex}].observed.${key}`); + } + if (expected === "rejected") { + const code = text(raw.code, `result.cases[${caseIndex}].code`, MAX_ERROR_CODE_BYTES); + if (!ERROR_CODES.has(code)) fail("EVENT_LOOP_PARSE", `result.cases[${caseIndex}].code`); + const span = object(raw.span, `result.cases[${caseIndex}].span`); + exact(span, ["start", "end"], `result.cases[${caseIndex}].span`); + const start = integer(span.start, `result.cases[${caseIndex}].span.start`, 0, 16_383); + integer(span.end, `result.cases[${caseIndex}].span.end`, start + 1, 16_384); + } + if (!Array.isArray(raw.samples) || raw.samples.length !== measuredSamples * 2 || raw.samples.length > 2_000) { + fail("EVENT_LOOP_BOUNDS", `result.cases[${caseIndex}].samples`); + } + const seen = new Set(); + const byPhase = new Map(); + for (const [sampleIndex, rawSample] of raw.samples.entries()) { + const sample = parseBenchmarkSample(rawSample, sampleIndex, `result.cases[${caseIndex}].samples`); + if (sample.sampleIndex >= measuredSamples) fail("EVENT_LOOP_BOUNDS", `result.cases[${caseIndex}].samples[${sampleIndex}].index`); + const key = `${sample.phase}:${sample.sampleIndex}`; + if (seen.has(key)) fail("EVENT_LOOP_ID", `duplicate benchmark sample ${key}`); + seen.add(key); + if (sample.outcome !== expected) fail("EVENT_LOOP_SEMANTIC", `result.cases[${caseIndex}].samples[${sampleIndex}].outcome`); + const values = byPhase.get(sample.phase) ?? []; + values.push(sample.durationNs); + byPhase.set(sample.phase, values); + } + for (const phase of ["cold", "warm"] as const) { + const values = byPhase.get(phase); + if (!values || values.length !== measuredSamples) fail("EVENT_LOOP_BOUNDS", `result.cases[${caseIndex}].samples.${phase}`); + } + const summary = object(raw.summary, `result.cases[${caseIndex}].summary`); + exact(summary, ["cold", "warm"], `result.cases[${caseIndex}].summary`); + const summaries = new Map(); + for (const phase of ["cold", "warm"] as const) { + const values = byPhase.get(phase)!; + parseBenchmarkSummary(summary[phase], values, measuredSamples, `result.cases[${caseIndex}].summary.${phase}`); + const parsed = object(summary[phase], `result.cases[${caseIndex}].summary.${phase}`); + summaries.set(phase, { count: parsed.count as number, p50Ns: parsed.p50Ns as number, p95Ns: parsed.p95Ns as number }); + } + return { id, summaries }; +} + +function parseBenchmarkResult(value: unknown, corpusValue: unknown): RecordValue { + const corpus = parseCorpus(corpusValue); + const root = object(value, "result"); + exact(root, ["contract", "kind", "corpusDigest", "protocol", "toolchain", "environment", "cases", "aggregate", "digest"], "result"); + if (root.contract !== "algal.language-benchmark.v1" || root.kind !== "result") fail("EVENT_LOOP_CONTRACT", "result.identity"); + if (digest(root.corpusDigest, "result.corpusDigest") !== corpus.digest) fail("EVENT_LOOP_REFERENCE", "result.corpusDigest does not name the checked corpus"); + const protocol = parseBenchmarkProtocol(root.protocol, "result.protocol"); + const corpusProtocol = object(corpus.protocol, "corpus.protocol"); + const resultProtocol = object(root.protocol, "result.protocol"); + for (const key of ["warmupSamples", "measuredSamples", "phases", "order", "quantile", "outliers"] as const) { + if (canonicalize(json(resultProtocol[key], `result.protocol.${key}`)) !== + canonicalize(json(corpusProtocol[key], `corpus.protocol.${key}`))) { + fail("EVENT_LOOP_REFERENCE", `result.protocol.${key} differs from checked corpus`); + } + } + const toolchain = object(root.toolchain, "result.toolchain"); + exact(toolchain, ["compiler", "runtime", "native"], "result.toolchain"); + text(toolchain.compiler, "result.toolchain.compiler"); + text(toolchain.runtime, "result.toolchain.runtime"); + text(toolchain.native, "result.toolchain.native"); + const identities = object(corpus.identities, "corpus.identities"); + const compiler = object(identities.compiler, "corpus.identities.compiler"); + if (toolchain.compiler !== `${compiler.id}@${compiler.version}`) fail("EVENT_LOOP_REFERENCE", "result.toolchain.compiler"); + const runtimeIds = new Set(identities.runtimes as string[]); + const measuredRuntimeIds = new Set(); + for (const key of ["runtime", "native"] as const) { + const value = toolchain[key] as string; + const separator = value.indexOf("@"); + if (separator < 1 || separator === value.length - 1 || value.indexOf("@", separator + 1) !== -1) { + fail("EVENT_LOOP_REFERENCE", `result.toolchain.${key}`); + } + const name = identifier(value.slice(0, separator), `result.toolchain.${key} id`); + if (!runtimeIds.has(name) || measuredRuntimeIds.has(name)) fail("EVENT_LOOP_REFERENCE", `result.toolchain.${key}`); + measuredRuntimeIds.add(name); + } + const environment = object(root.environment, "result.environment"); + exact(environment, ["os", "arch", "cpu"], "result.environment"); + text(environment.os, "result.environment.os"); + text(environment.arch, "result.environment.arch"); + text(environment.cpu, "result.environment.cpu"); + const machine = object(identities.machine, "corpus.identities.machine"); + for (const key of ["os", "arch", "cpu"] as const) { + if (environment[key] !== machine[key]) fail("EVENT_LOOP_REFERENCE", `result.environment.${key}`); + } + const fixtures = object(corpus.fixtures, "corpus.fixtures"); + const fixtureCases = new Map(); + for (const kind of ["actors", "projects", "invalid"] as const) { + for (const raw of fixtures[kind] as unknown[]) { + const fixture = object(raw, `corpus.fixtures.${kind}`); + fixtureCases.set(fixture.id as string, { + kind: fixture.kind as string, + path: fixture.path as string, + sha256: fixture.sha256 as string, + expected: object(fixture.expected, "fixture.expected"), + }); + } + } + if (!Array.isArray(root.cases) || root.cases.length < 1 || root.cases.length > 256) fail("EVENT_LOOP_BOUNDS", "result.cases"); + if (root.cases.length !== fixtureCases.size) fail("EVENT_LOOP_REFERENCE", "result.cases must cover the checked corpus exactly"); + const orderedFixtures = [...fixtureCases.entries()].sort((a, b) => + a[1].path < b[1].path ? -1 : a[1].path > b[1].path ? 1 : 0); + const cases = new Map }>(); + for (const [index, rawCase] of root.cases.entries()) { + const parsed = parseBenchmarkCase(rawCase, index, protocol.measuredSamples); + if (cases.has(parsed.id)) fail("EVENT_LOOP_ID", `duplicate benchmark case ${parsed.id}`); + const fixture = fixtureCases.get(parsed.id); + if (!fixture || orderedFixtures[index]?.[0] !== parsed.id) fail("EVENT_LOOP_REFERENCE", `result.cases[${index}].id or path order`); + const value = object(rawCase, `result.cases[${index}]`); + if (value.kind !== fixture.kind || value.sourceDigest !== fixture.sha256 || + value.expected !== fixture.expected.check) { + fail("EVENT_LOOP_REFERENCE", `result.cases[${index}] differs from checked fixture`); + } + if (fixture.kind !== "invalid") { + const observed = object(value.observed, `result.cases[${index}].observed`); + const keys = fixture.kind === "actor" ? ["artifactDigest", "interfaceDigest", "eventDigest"] : ["projectDigest", "closureDigest"]; + for (const key of keys) { + if (observed[key] !== fixture.expected[key]) fail("EVENT_LOOP_REFERENCE", `result.cases[${index}].observed.${key} differs from checked fixture`); + } + } else { + const span = object(value.span, `result.cases[${index}].span`); + const pinned = object(fixture.expected.span, "fixture.expected.span"); + if (value.code !== fixture.expected.code || span.start !== pinned.start || span.end !== pinned.end) { + fail("EVENT_LOOP_REFERENCE", `result.cases[${index}] diagnostic differs from checked fixture`); + } + } + cases.set(parsed.id, parsed); + } + for (const id of fixtureCases.keys()) { + if (!cases.has(id)) fail("EVENT_LOOP_REFERENCE", `result.cases missing ${id}`); + } + if (!Array.isArray(root.aggregate) || root.aggregate.length !== cases.size * 2 || root.aggregate.length > 512) fail("EVENT_LOOP_BOUNDS", "result.aggregate"); + const aggregateIds = new Set(); + for (const [index, rawAggregate] of root.aggregate.entries()) { + const aggregate = object(rawAggregate, `result.aggregate[${index}]`); + exact(aggregate, ["id", "phase", "count", "p50Ns", "p95Ns"], `result.aggregate[${index}]`); + const id = identifier(aggregate.id, `result.aggregate[${index}].id`); + if (aggregate.phase !== "cold" && aggregate.phase !== "warm") fail("EVENT_LOOP_CONTRACT", `result.aggregate[${index}].phase`); + const key = `${id}:${aggregate.phase}`; + if (aggregateIds.has(key)) fail("EVENT_LOOP_ID", `duplicate benchmark aggregate ${key}`); + aggregateIds.add(key); + const parsedCase = cases.get(id); + if (!parsedCase) fail("EVENT_LOOP_REFERENCE", `result.aggregate[${index}].id`); + const expected = parsedCase.summaries.get(aggregate.phase); + if (!expected) fail("EVENT_LOOP_REFERENCE", `result.aggregate[${index}].phase`); + if (integer(aggregate.count, `result.aggregate[${index}].count`, 1, 1_000) !== expected.count) fail("EVENT_LOOP_SEMANTIC", `result.aggregate[${index}].count`); + if (integer(aggregate.p50Ns, `result.aggregate[${index}].p50Ns`, 0, 86_400_000_000_000) !== expected.p50Ns) fail("EVENT_LOOP_SEMANTIC", `result.aggregate[${index}].p50Ns`); + if (integer(aggregate.p95Ns, `result.aggregate[${index}].p95Ns`, 0, 86_400_000_000_000) !== expected.p95Ns) fail("EVENT_LOOP_SEMANTIC", `result.aggregate[${index}].p95Ns`); + } + for (const [id] of cases) { + for (const phase of ["cold", "warm"] as const) { + if (!aggregateIds.has(`${id}:${phase}`)) fail("EVENT_LOOP_REFERENCE", `missing aggregate ${id}:${phase}`); + } + } + verifyDigest(root, "result"); + return root; +} + +describe("language benchmark contract fixtures", () => { + test("valid actor, event vector, project, and corpus records are strict", async () => { + const actor = await load("actors/echo.json"); + const events = await load("actors/echo.events.json"); + const project = await load("projects/echo.project.json"); + const corpus = await load("corpus.json"); + parseEventLoop(actor); + parseEvents(events, actor); + parseProject(project); + parseCorpus(corpus); + const actorRecord = object(actor, "actor"); + const behavior = object(actorRecord.behavior, "behavior"); + expect(canonicalDigest(withoutDigest(behavior))).toBe(digest(behavior.digest, "behavior.digest")); + expect(canonicalDigest(withoutDigest(actor))).toBe(digest(actorRecord.digest, "actor.digest")); + const eventRecord = object(events, "events"); + expect(canonicalDigest(withoutDigest(events))).toBe(digest(eventRecord.digest, "events.digest")); + const projectRecord = object(project, "project"); + expect(canonicalDigest(withoutDigest(project))).toBe(digest(projectRecord.digest, "project.digest")); + const corpusRecord = object(corpus, "corpus"); + expect(canonicalDigest(withoutDigest(corpus))).toBe(digest(corpusRecord.digest, "corpus.digest")); + }); + + test("valid language benchmark result is strict and sample-derived", async () => { + const result = await load("results/smoke.json"); + const corpus = await load("corpus.json"); + parseBenchmarkResult(result, corpus); + const resultRecord = object(result, "result"); + const corpusRecord = object(corpus, "corpus"); + expect(resultRecord.corpusDigest).toBe(corpusRecord.digest); + expect(canonicalDigest(withoutDigest(result))).toBe(digest(resultRecord.digest, "result.digest")); + }); + + test("benchmark result rejects unknown keys and over-limit samples", async () => { + const corpus = await load("corpus.json"); + const result = structuredClone(await load("results/smoke.json")) as RecordValue; + result.unexpected = true; + expect(errorCode(() => parseBenchmarkResult(result, corpus))).toBe("EVENT_LOOP_UNKNOWN_KEY"); + const tooLarge = structuredClone(await load("results/smoke.json")) as RecordValue; + const cases = tooLarge.cases; + if (!Array.isArray(cases)) throw new Error("benchmark cases did not parse"); + const first = object(cases[0], "result.cases[0]"); + const samples = first.samples; + if (!Array.isArray(samples)) throw new Error("benchmark samples did not parse"); + const firstSample = object(samples[0], "result.cases[0].samples[0]"); + firstSample.durationNs = 86_400_000_000_001; + expect(errorCode(() => parseBenchmarkResult(tooLarge, corpus))).toBe("EVENT_LOOP_BOUNDS"); + const invalidCode = structuredClone(await load("results/smoke.json")) as RecordValue; + const invalidCase = object((invalidCode.cases as unknown[])[0], "result.cases[0]"); + invalidCase.kind = "invalid"; + invalidCase.expected = "rejected"; + invalidCase.code = "x".repeat(MAX_ERROR_CODE_BYTES + 1); + invalidCase.span = { start: 0, end: 1 }; + delete invalidCase.observed; + expect(errorCode(() => parseBenchmarkResult(invalidCode, corpus))).toBe("EVENT_LOOP_PARSE"); + }); + + test("corpus fixture entries pin raw bytes and expected identities", async () => { + const corpus = object(await load("corpus.json"), "corpus"); + const fixtures = object(corpus.fixtures, "corpus.fixtures"); + for (const group of [fixtures.actors, fixtures.projects, fixtures.invalid]) { + if (!Array.isArray(group)) throw new Error("corpus fixture group did not parse"); + for (const raw of group) { + const entry = object(raw, "fixture"); + const bytes = await readFile(join(ROOT, text(entry.path, "fixture.path", 512))); + expect(bytes.byteLength).toBeGreaterThan(0); + expect(bytes.byteLength).toBeLessThanOrEqual(16_384); + expect(rawDigest(bytes)).toBe(digest(entry.sha256, "fixture.sha256")); + } + } + const actorEntry = object((fixtures.actors as unknown[])[0], "actor fixture"); + const actorExpected = object(actorEntry.expected, "actor expected"); + const actor = object(await load(path(actorEntry.path, "actor fixture path")), "actor"); + parseEventLoop(actor); + expect(actorExpected.artifactDigest).toBe(actor.digest); + const actorSchemas = (actor.actors as unknown[]).map((raw) => { + const record = object(raw, "actor schema"); + return { id: record.id, state: record.state }; + }); + expect(actorExpected.interfaceDigest).toBe(canonicalDigest({ events: actor.events, actors: actorSchemas })); + const eventPath = path(actorExpected.eventPath, "event fixture path"); + const eventVector = object(await load(eventPath), "event vector"); + parseEvents(eventVector, actor); + expect(actorExpected.eventDigest).toBe(eventVector.digest); + expect(actorExpected.eventSha256).toBe(rawDigest(await readFile(join(ROOT, eventPath)))); + const projectEntry = object((fixtures.projects as unknown[])[0], "project fixture"); + const projectExpected = object(projectEntry.expected, "project expected"); + const project = object(await load(path(projectEntry.path, "project fixture path")), "project"); + parseProject(project); + expect(projectExpected.projectDigest).toBe(project.digest); + expect(projectExpected.closureDigest).toBe(canonicalDigest(project.files)); + let closureBytes = 0; + for (const raw of project.files as unknown[]) { + const file = object(raw, "project file"); + const bytes = await readFile(join(ROOT, path(file.path, "project file path"))); + closureBytes += bytes.byteLength; + expect(rawDigest(bytes)).toBe(digest(file.digest, "project file digest")); + } + expect(closureBytes).toBeGreaterThan(0); + expect(closureBytes).toBeLessThanOrEqual(262_144); + const result = object(await load("results/smoke.json"), "result"); + const cases = result.cases as unknown[]; + const actorObserved = object(object(cases.find((raw) => object(raw, "case").id === actorEntry.id), "actor case").observed, "actor observed"); + expect(actorObserved.artifactDigest).toBe(actor.digest); + expect(actorObserved.interfaceDigest).toBe(canonicalDigest({ events: actor.events, actors: actorSchemas })); + expect(actorObserved.eventDigest).toBe(eventVector.digest); + const projectObserved = object(object(cases.find((raw) => object(raw, "case").id === projectEntry.id), "project case").observed, "project observed"); + expect(projectObserved.projectDigest).toBe(project.digest); + expect(projectObserved.closureDigest).toBe(canonicalDigest(project.files)); + }); + + test("every invalid fixture fails with its recorded code and bounded span", async () => { + const corpus = object(await load("corpus.json"), "corpus"); + const fixtures = object(corpus.fixtures, "corpus.fixtures"); + if (!Array.isArray(fixtures.invalid)) throw new Error("invalid fixture group did not parse"); + for (const raw of fixtures.invalid) { + const entry = object(raw, "fixture"); + const expected = object(entry.expected, "fixture.expected"); + const bytes = await readFile(join(ROOT, text(entry.path, "fixture.path", 512))); + expect(checkEventLoopBytes(bytes), text(entry.id, "fixture.id")).toEqual({ + code: expected.code, span: expected.span, + }); + } + }); + + test("diagnostic spans use UTF-8 offsets and reject unlocated or oversized source", () => { + const bytes = new TextEncoder().encode('{"méta": ["x", {"value": "é"}]}'); + expect(locateJsonSpan(bytes, ["méta"], true)).toEqual({ start: 2, end: 7 }); + expect(locateJsonSpan(bytes, ["méta", 1, "value"])).toEqual({ start: 27, end: 29 }); + expect(() => locateJsonSpan(bytes, ["méta", 2])).toThrow("path not found"); + expect(locateJsonSpan(new TextEncoder().encode("0"), [])).toEqual({ start: 0, end: 1 }); + expect(() => locateJsonSpan(new Uint8Array(), [])).toThrow("exceeds its limit"); + const maxSource = new TextEncoder().encode('{"x":0}' + " ".repeat(16_384 - 7)); + expect(locateJsonSpan(maxSource, ["x"])).toEqual({ start: 5, end: 6 }); + expect(() => locateJsonSpan(new Uint8Array(16_385), ["méta"])).toThrow("exceeds its limit"); + let source = "0"; + for (let i = 0; i < 64; i++) source = `[${source}]`; + expect(locateJsonSpan(new TextEncoder().encode(source), Array(64).fill(0))).toEqual({ start: 64, end: 65 }); + expect(() => locateJsonSpan(bytes, Array(65).fill("x"))).toThrow("exceeds its limit"); + expect(() => locateJsonSpan(new TextEncoder().encode(`[${source}]`), [0])).toThrow("depth or node limit"); + }); + + test("malformed JSON, invalid UTF-8, and non-object roots return located parse errors", async () => { + const valid = await readFile(join(ROOT, "actors/echo.json")); + expect(checkEventLoopBytes(valid)).toEqual({ code: "ACCEPTED" }); + const invalidSources = [ + new TextEncoder().encode('{"contract":'), + new TextEncoder().encode("{}x"), + Uint8Array.of(123, 34, 255, 34, 125), + Uint8Array.of(195), + ]; + for (const bytes of invalidSources) { + expect(checkEventLoopBytes(bytes)).toEqual({ + code: "EVENT_LOOP_PARSE", span: { start: 0, end: bytes.byteLength }, + }); + } + for (const input of ["null", "0", "[]"]) { + const bytes = new TextEncoder().encode(input); + expect(checkEventLoopBytes(bytes)).toEqual({ + code: "EVENT_LOOP_PARSE", span: { start: 0, end: bytes.byteLength }, + }); + } + const scalar = new TextEncoder().encode('"text"'); + expect(checkEventLoopBytes(scalar)).toEqual({ + code: "EVENT_LOOP_PARSE", span: { start: 0, end: scalar.byteLength }, + }); + let nested = "null"; + for (let i = 0; i < 65; i++) nested = `[${nested}]`; + const deepArray = new TextEncoder().encode(nested); + expect(checkEventLoopBytes(deepArray)).toEqual({ + code: "EVENT_LOOP_PARSE", span: { start: 0, end: deepArray.byteLength }, + }); + expect(() => checkEventLoopBytes(new Uint8Array())).toThrow("1..16384 bytes"); + expect(() => checkEventLoopBytes(new Uint8Array(16_385))).toThrow("1..16384 bytes"); + }); + + test("fixture error codes are closed and limited to 22 UTF-8 bytes", async () => { + const corpus = structuredClone(await load("corpus.json")) as RecordValue; + const fixtures = object(corpus.fixtures, "corpus.fixtures"); + const invalid = fixtures.invalid; + if (!Array.isArray(invalid)) throw new Error("invalid fixtures did not parse"); + const entry = object(invalid[0], "fixture"); + const expected = object(entry.expected, "fixture.expected"); + expect(expected.code).toBe("EVENT_LOOP_UNKNOWN_KEY"); + expect(new TextEncoder().encode(expected.code as string).byteLength).toBe(MAX_ERROR_CODE_BYTES); + expect(errorCode(() => parseCorpus(corpus))).toBe("ACCEPTED"); + const duplicateRuntime = structuredClone(corpus) as RecordValue; + const duplicateIdentities = object(duplicateRuntime.identities, "corpus.identities"); + duplicateIdentities.runtimes = ["bun", "bun"]; + expect(errorCode(() => parseCorpus(duplicateRuntime))).toBe("EVENT_LOOP_ID"); + const duplicateFixture = structuredClone(corpus) as RecordValue; + const duplicateFixtures = object(duplicateFixture.fixtures, "corpus.fixtures"); + const duplicateProject = object((duplicateFixtures.projects as unknown[])[0], "duplicate project"); + const firstActorFixture = object((duplicateFixtures.actors as unknown[])[0], "first actor fixture"); + duplicateProject.id = firstActorFixture.id; + expect(errorCode(() => parseCorpus(duplicateFixture))).toBe("EVENT_LOOP_ID"); + for (const code of ["", "NOT_A_CODE", "x".repeat(MAX_ERROR_CODE_BYTES + 1)]) { + expected.code = code; + corpus.digest = canonicalDigest(withoutDigest(corpus)); + expect(errorCode(() => parseCorpus(corpus)), `invalid code ${JSON.stringify(code)}`).toBe("EVENT_LOOP_PARSE"); + } + }); + + test("expression names use only state and event, including write and output programs", async () => { + const actor = structuredClone(await load("actors/echo.json")) as RecordValue; + const firstActor = object((actor.actors as unknown[])[0], "actor"); + const transition = object((firstActor.transitions as unknown[])[0], "transition"); + const guard = object(transition.when, "guard"); + guard.program = ["get", "undeclared"]; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_REFERENCE"); + guard.program = ["get", "event", "amount"]; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("ACCEPTED"); + const write = object((transition.writes as unknown[])[0], "write"); + const value = object(write.value, "write value"); + value.program = ["get", "undeclared"]; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_REFERENCE"); + value.program = ["get", "state", "count"]; + const output = object((transition.outputs as unknown[])[0], "output"); + const payload = object(output.payload, "output payload"); + payload.program = ["get", "undeclared"]; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_REFERENCE"); + }); + + test("expression node and depth limits include quoted data", () => { + const envelope = (program: JsonValue): RecordValue => ({ contract: "algal.expr.v1", program }); + const atMost512 = ["list", ...Array(510).fill(0)]; + expect(errorCode(() => expression(envelope(atMost512), "expression"))).toBe("ACCEPTED"); + expect(errorCode(() => expression(envelope([...atMost512, 0]), "expression"))).toBe("EVENT_LOOP_BOUNDS"); + let program: JsonValue = true; + for (let depth = 1; depth <= 15; depth++) program = ["not", program]; + expect(errorCode(() => expression(envelope(program), "expression"))).toBe("ACCEPTED"); + expect(errorCode(() => expression(envelope(["not", program]), "expression"))).toBe("EVENT_LOOP_BOUNDS"); + let quoted: JsonValue = true; + for (let depth = 1; depth <= 14; depth++) quoted = [quoted]; + expect(errorCode(() => expression(envelope(["quote", quoted]), "expression"))).toBe("ACCEPTED"); + expect(errorCode(() => expression(envelope(["quote", [quoted]]), "expression"))).toBe("EVENT_LOOP_BOUNDS"); + }); + + test("unknown keys and first out-of-range limits are rejected", async () => { + const actor = object(await load("actors/echo.json"), "actor"); + const unknown = structuredClone(actor) as RecordValue; + unknown.unexpected = true; + expect(errorCode(() => parseEventLoop(unknown))).toBe("EVENT_LOOP_UNKNOWN_KEY"); + const tooLarge = structuredClone(actor) as RecordValue; + const limits = object(tooLarge.limits, "limits"); + limits.maxEventBytes = 65_537; + expect(errorCode(() => parseEventLoop(tooLarge))).toBe("EVENT_LOOP_BOUNDS"); + }); + + test("all record parsers reject well-formed but incorrect digests", async () => { + const actor = await load("actors/echo.json"); + const events = await load("actors/echo.events.json"); + const awaitCorpus = await load("corpus.json"); + const records: [unknown, (value: unknown) => unknown][] = [ + [actor, parseEventLoop], + [events, (value) => parseEvents(value, actor)], + [await load("projects/echo.project.json"), parseProject], + [await load("corpus.json"), parseCorpus], + [await load("results/smoke.json"), (value) => parseBenchmarkResult(value, awaitCorpus)], + ]; + for (const [record, parse] of records) { + const changed = structuredClone(record) as RecordValue; + changed.digest = `sha256:${"0".repeat(64)}`; + expect(errorCode(() => parse(changed))).toBe("EVENT_LOOP_DIGEST"); + } + const changedBehavior = structuredClone(actor) as RecordValue; + object(changedBehavior.behavior, "behavior").sourceDigest = `sha256:${"0".repeat(64)}`; + expect(errorCode(() => parseEventLoop(changedBehavior))).toBe("EVENT_LOOP_DIGEST"); + }); + + test("event payload schema and complete event byte limit are checked against the behavior", async () => { + const actor = structuredClone(await load("actors/echo.json")) as RecordValue; + const events = structuredClone(await load("actors/echo.events.json")) as RecordValue; + const first = object((events.events as unknown[])[0], "event"); + const payload = object(first.payload, "payload"); + payload.extra = true; + expect(errorCode(() => parseEvents(events, actor))).toBe("EVENT_LOOP_UNKNOWN_KEY"); + delete payload.extra; + payload.amount = "two"; + expect(errorCode(() => parseEvents(events, actor))).toBe("EVENT_LOOP_SCHEMA"); + payload.amount = 2; + delete payload.amount; + expect(errorCode(() => parseEvents(events, actor))).toBe("EVENT_LOOP_PARSE"); + payload.amount = 2; + const size = Math.max(...(events.events as unknown[]).map((event) => canonicalBytes(event, "event"))); + const limits = object(actor.limits, "limits"); + limits.maxEventBytes = size; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEvents(events, actor))).toBe("ACCEPTED"); + limits.maxEventBytes = size - 1; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEvents(events, actor))).toBe("EVENT_LOOP_BOUNDS"); + limits.maxEventBytes = 65_536; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEvents(events, actor))).toBe("ACCEPTED"); + limits.maxEventBytes = 65_537; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEvents(events, actor))).toBe("EVENT_LOOP_BOUNDS"); + }); + + test("complete initial state is limited and v1 writes address one field", async () => { + const actor = structuredClone(await load("actors/echo.json")) as RecordValue; + const limits = object(actor.limits, "limits"); + const actors = actor.actors as unknown[]; + const firstActor = object(actors[0], "actor"); + const initialBytes = canonicalBytes({ count: 0 }, "initial state"); + limits.maxStateBytes = initialBytes; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("ACCEPTED"); + limits.maxStateBytes = initialBytes - 1; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_BOUNDS"); + limits.maxStateBytes = 262_144; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("ACCEPTED"); + limits.maxStateBytes = 262_145; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_BOUNDS"); + limits.maxStateBytes = 16_384; + const transition = object((firstActor.transitions as unknown[])[0], "transition"); + const write = object((transition.writes as unknown[])[0], "write"); + write.path = ["count", "nested"]; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_BOUNDS"); + write.path = []; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_BOUNDS"); + }); + + test("quiet forbids outputs, and sources cannot collide in event identity", async () => { + const actor = structuredClone(await load("actors/echo.json")) as RecordValue; + const artifactDigest = actor.digest; + const firstActor = object((actor.actors as unknown[])[0], "actor"); + const transition = object((firstActor.transitions as unknown[])[0], "transition"); + transition.result = { kind: "quiet" }; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_SEMANTIC"); + const events = object(await load("actors/echo.events.json"), "events"); + const firstEvent = object((events.events as unknown[])[0], "event"); + const otherSource = { ...firstEvent, source: "another-source" }; + expect(eventIdentity(artifactDigest, firstActor.id, firstEvent)).not.toBe(eventIdentity(artifactDigest, firstActor.id, otherSource)); + expect(eventIdentity(artifactDigest, firstActor.id, firstEvent)).toBe(eventIdentity(artifactDigest, firstActor.id, { ...firstEvent })); + }); + + test("nested JSON limits cover state initials and event payloads", async () => { + const actor = structuredClone(await load("actors/echo.json")) as RecordValue; + // State and event schema fields are edited only in memory; no fixture digest is rewritten. + const fields = (object(object((actor.actors as unknown[])[0], "actor").state, "state").fields as unknown[]); + const field = object(fields[0], "state field"); + field.type = "json"; + field.initial = { nested: [1] }; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("ACCEPTED"); + field.initial = [Array(EXPR_BOUNDS.maxListLen + 1).fill(0)]; + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_BOUNDS"); + + field.initial = null; + const declarations = actor.events as unknown[]; + const increment = object(declarations[0], "increment event"); + const payloadSchema = object(increment.payload, "payload schema"); + object((payloadSchema.fields as unknown[])[0], "amount schema").type = "json"; + actor.digest = canonicalDigest(withoutDigest(actor)); + const events = structuredClone(await load("actors/echo.events.json")) as RecordValue; + const first = object((events.events as unknown[])[0], "event"); + const payload = object(first.payload, "payload"); + payload.amount = { nested: [2] }; + events.digest = canonicalDigest(withoutDigest(events)); + expect(errorCode(() => parseEvents(events, actor))).toBe("ACCEPTED"); + let tooDeep: JsonValue = null; + for (let i = 0; i < EXPR_BOUNDS.maxValueDepth + 1; i++) tooDeep = [tooDeep]; + payload.amount = tooDeep; + expect(errorCode(() => parseEvents(events, actor))).toBe("EVENT_LOOP_BOUNDS"); + }); + + test("JSON values enforce depth, list, keys, UTF-8 strings, and canonical bytes", () => { + const check = (value: unknown): string => errorCode(() => json(value, "value")); + let deep: JsonValue = null; + for (let i = 0; i < EXPR_BOUNDS.maxValueDepth; i++) deep = [deep]; + expect(check(deep)).toBe("ACCEPTED"); + expect(check([deep])).toBe("EVENT_LOOP_BOUNDS"); + expect(check(Array(EXPR_BOUNDS.maxListLen).fill(0))).toBe("ACCEPTED"); + expect(check(Array(EXPR_BOUNDS.maxListLen + 1).fill(0))).toBe("EVENT_LOOP_BOUNDS"); + const keys = Object.fromEntries(Array.from({ length: EXPR_BOUNDS.maxObjectKeys }, (_, i) => [String(i), 0])); + expect(check(keys)).toBe("ACCEPTED"); + expect(check({ ...keys, extra: 0 })).toBe("EVENT_LOOP_BOUNDS"); + expect(check("x".repeat(EXPR_BOUNDS.maxStringBytes))).toBe("ACCEPTED"); + expect(check("x".repeat(EXPR_BOUNDS.maxStringBytes + 1))).toBe("EVENT_LOOP_BOUNDS"); + expect(check({ ["x".repeat(EXPR_BOUNDS.maxStringBytes + 1)]: 0 })).toBe("EVENT_LOOP_BOUNDS"); + const atLimit = Array(EXPR_BOUNDS.maxListLen).fill("x".repeat(253)); + atLimit[0] = "x".repeat(252); + expect(canonicalBytes(atLimit, "value")).toBe(EXPR_BOUNDS.maxValueBytes); + expect(check(atLimit)).toBe("ACCEPTED"); + atLimit[0] = "x".repeat(253); + expect(check(atLimit)).toBe("EVENT_LOOP_BOUNDS"); + const cyclic: unknown[] = []; + cyclic.push(cyclic); + expect(check(cyclic)).toBe("EVENT_LOOP_PARSE"); + expect(check(Array(1))).toBe("EVENT_LOOP_PARSE"); + }); + + test("state initial values obey their declared schema types", async () => { + const actor = structuredClone(await load("actors/echo.json")) as RecordValue; + const actors = actor.actors; + if (!Array.isArray(actors)) throw new Error("actor list did not parse"); + const firstActor = object(actors[0], "actor"); + const state = object(firstActor.state, "state"); + const fields = state.fields; + if (!Array.isArray(fields)) throw new Error("state fields did not parse"); + const firstField = object(fields[0], "state field"); + firstField.initial = "zero"; + expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_SCHEMA"); + }); + + test("result joins all pinned fixture identities, protocol, toolchain, and machine", async () => { + const corpus = await load("corpus.json"); + const base = await load("results/smoke.json"); + const result = object(base, "result"); + const fixtures = object(object(corpus, "corpus").fixtures, "fixtures"); + const ids = new Set([...fixtures.actors as unknown[], ...fixtures.projects as unknown[], ...fixtures.invalid as unknown[]] + .map((value) => object(value, "fixture").id)); + const cases = result.cases as unknown[]; + expect(cases.length).toBe(12); + expect(new Set(cases.map((value) => object(value, "case").id))).toEqual(ids); + expect((result.aggregate as unknown[]).length).toBe(24); + expect(cases.reduce((count, value) => count + (object(value, "case").samples as unknown[]).length, 0)).toBe(360); + + const reject = (change: (value: RecordValue) => void, code = "EVENT_LOOP_REFERENCE"): void => { + const changed = structuredClone(base) as RecordValue; + change(changed); + changed.digest = canonicalDigest(withoutDigest(changed)); + expect(errorCode(() => parseBenchmarkResult(changed, corpus))).toBe(code); + }; + reject((value) => { value.corpusDigest = `sha256:${"0".repeat(64)}`; }); + reject((value) => { object(value.protocol, "protocol").warmupSamples = 4; }); + reject((value) => { object(value.toolchain, "toolchain").compiler = "other@proposal"; }); + reject((value) => { object(value.toolchain, "toolchain").runtime = "other@1"; }); + reject((value) => { object(value.toolchain, "toolchain").native = "bun@1"; }); + reject((value) => { object(value.environment, "environment").os = "darwin"; }); + reject((value) => { object(object((value.cases as unknown[])[0], "case").observed, "observed").artifactDigest = `sha256:${"0".repeat(64)}`; }); + reject((value) => { object(object((value.cases as unknown[]).find((raw) => object(raw, "case").kind === "project"), "case").observed, "observed").closureDigest = `sha256:${"0".repeat(64)}`; }); + reject((value) => { (value.cases as unknown[]).pop(); }); + reject((value) => { (value.cases as unknown[]).reverse(); }); + reject((value) => { (value.cases as unknown[]).push({ ...object((value.cases as unknown[])[0], "case"), id: "extra" }); }); + reject((value) => { object((value.cases as unknown[])[1], "case").id = object((value.cases as unknown[])[0], "case").id; }, "EVENT_LOOP_ID"); + reject((value) => { object((value.cases as unknown[])[0], "case").id = "unknown-case"; }); + reject((value) => { + const first = object((value.cases as unknown[])[0], "case"); + first.kind = "project"; + first.observed = object((value.cases as unknown[]).find((raw) => object(raw, "case").id === "counter-project"), "project case").observed; + }); + reject((value) => { object((value.cases as unknown[])[0], "case").sourceDigest = `sha256:${"0".repeat(64)}`; }); + reject((value) => { + const first = object((value.cases as unknown[])[0], "case"); + first.kind = "invalid"; + first.expected = "rejected"; + first.code = "EVENT_LOOP_PARSE"; + first.span = { start: 0, end: 1 }; + delete first.observed; + for (const sample of first.samples as unknown[]) object(sample, "sample").outcome = "rejected"; + }); + const invalidIndex = cases.findIndex((value) => object(value, "case").kind === "invalid"); + reject((value) => { object((value.cases as unknown[])[invalidIndex], "case").code = "EVENT_LOOP_PARSE"; }); + reject((value) => { object(object((value.cases as unknown[])[invalidIndex], "case").span, "span").end = 2491; }); + }); + + test("new machine profile is closed, limited in UTF-8 bytes, and joined", async () => { + const originalCorpus = await load("corpus.json"); + const originalResult = await load("results/smoke.json"); + const corpus = structuredClone(originalCorpus) as RecordValue; + const identities = object(corpus.identities, "identities"); + const machine = object(identities.machine, "machine"); + machine.extra = true; + expect(errorCode(() => parseCorpus(corpus))).toBe("EVENT_LOOP_UNKNOWN_KEY"); + delete machine.extra; + const result = structuredClone(originalResult) as RecordValue; + for (const key of ["os", "arch", "cpu"] as const) { + machine[key] = "a".repeat(128); + corpus.digest = canonicalDigest(withoutDigest(corpus)); + object(result.environment, "environment")[key] = machine[key]; + result.corpusDigest = corpus.digest; + result.digest = canonicalDigest(withoutDigest(result)); + expect(errorCode(() => parseBenchmarkResult(result, corpus)), key).toBe("ACCEPTED"); + machine[key] = "a".repeat(129); + expect(errorCode(() => parseCorpus(corpus)), key).toBe("EVENT_LOOP_PARSE"); + machine[key] = ""; + expect(errorCode(() => parseCorpus(corpus)), key).toBe("EVENT_LOOP_PARSE"); + machine[key] = "a".repeat(128); + } + expect(errorCode(() => text("é".repeat(64), "profile"))).toBe("ACCEPTED"); + expect(errorCode(() => text("é".repeat(65), "profile"))).toBe("EVENT_LOOP_PARSE"); + }); + + test("count, byte, and list limits reject their first excess", async () => { + const actor = object(await load("actors/echo.json"), "actor"); + const events = object(await load("actors/echo.events.json"), "events"); + const corpus = object(await load("corpus.json"), "corpus"); + const result = object(await load("results/smoke.json"), "result"); + const project = object(await load("projects/echo.project.json"), "project"); + for (const [key, min, max] of [ + ["maxEventBytes", 1, 65_536], ["maxStateBytes", 1, 262_144], + ["maxOutputEvents", 0, 64], ["maxTransitionFuel", 1, 100_000], + ["maxMailboxDepth", 1, 1_024], ["revision", 1, 2_147_483_647], + ] as const) { + expect(errorCode(() => integer(min, key, min, max)), key).toBe("ACCEPTED"); + expect(errorCode(() => integer(max, key, min, max)), key).toBe("ACCEPTED"); + expect(errorCode(() => integer(min - 1, key, min, max)), key).toBe("EVENT_LOOP_BOUNDS"); + expect(errorCode(() => integer(max + 1, key, min, max)), key).toBe("EVENT_LOOP_BOUNDS"); + } + expect(errorCode(() => identifier("a".repeat(64), "id"))).toBe("ACCEPTED"); + expect(errorCode(() => identifier("a".repeat(65), "id"))).toBe("EVENT_LOOP_PARSE"); + expect(errorCode(() => path("a".repeat(512), "path"))).toBe("ACCEPTED"); + expect(errorCode(() => path("a".repeat(513), "path"))).toBe("EVENT_LOOP_PARSE"); + const expectMutation = (base: RecordValue, parse: (input: unknown) => unknown, change: (input: RecordValue) => void, code: string): void => { + const copy = structuredClone(base) as RecordValue; + change(copy); + expect(errorCode(() => parse(copy))).toBe(code); + }; + for (const [field, length] of [["events", 129], ["actors", 65]] as const) { + expectMutation(actor, parseEventLoop, (copy) => { copy[field] = Array(length).fill((actor[field] as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); + } + expectMutation(actor, parseEventLoop, (copy) => { + const transition = (object((actor.actors as unknown[])[0], "actor").transitions as unknown[])[0]; + object((copy.actors as unknown[])[0], "actor").transitions = Array(257).fill(transition); + }, "EVENT_LOOP_BOUNDS"); + expectMutation(actor, parseEventLoop, (copy) => { object(object((copy.events as unknown[])[0], "event").payload, "payload").fields = Array(65).fill({ name: "x", type: "number" }); }, "EVENT_LOOP_SCHEMA"); + expectMutation(actor, parseEventLoop, (copy) => { object((object((copy.actors as unknown[])[0], "actor").transitions as unknown[])[0], "transition").writes = Array(65).fill({}); }, "EVENT_LOOP_BOUNDS"); + expectMutation(actor, parseEventLoop, (copy) => { object((object((copy.actors as unknown[])[0], "actor").transitions as unknown[])[0], "transition").outputs = Array(65).fill({}); }, "EVENT_LOOP_BOUNDS"); + expectMutation(events, (value) => parseEvents(value, actor), (copy) => { copy.events = Array(257).fill((events.events as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); + expectMutation(project, parseProject, (copy) => { copy.files = Array(65).fill((project.files as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); + expectMutation(corpus, parseCorpus, (copy) => { object(copy.fixtures, "fixtures").invalid = Array(129).fill((object(corpus.fixtures, "fixtures").invalid as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); + expectMutation(result, (value) => parseBenchmarkResult(value, corpus), (copy) => { object(copy.protocol, "protocol").warmupSamples = 101; }, "EVENT_LOOP_BOUNDS"); + const protocol = object(result.protocol, "protocol"); + expect(errorCode(() => parseBenchmarkProtocol({ ...protocol, warmupSamples: 100 }, "protocol"))).toBe("ACCEPTED"); + expect(errorCode(() => parseBenchmarkProtocol({ ...protocol, measuredSamples: 0 }, "protocol"))).toBe("EVENT_LOOP_BOUNDS"); + expect(errorCode(() => parseBenchmarkProtocol({ ...protocol, measuredSamples: 1_000 }, "protocol"))).toBe("ACCEPTED"); + expect(errorCode(() => parseBenchmarkProtocol({ ...protocol, measuredSamples: 1_001 }, "protocol"))).toBe("EVENT_LOOP_BOUNDS"); + const sample = { phase: "cold", index: 999, durationNs: 86_400_000_000_000, outcome: "accepted" }; + expect(errorCode(() => parseBenchmarkSample(sample, 0, "sample"))).toBe("ACCEPTED"); + expect(errorCode(() => parseBenchmarkSample({ ...sample, index: 1_000 }, 0, "sample"))).toBe("EVENT_LOOP_BOUNDS"); + expect(errorCode(() => parseBenchmarkSample({ ...sample, durationNs: 86_400_000_000_001 }, 0, "sample"))).toBe("EVENT_LOOP_BOUNDS"); + }); +}); diff --git a/benchmarks/language/corpus.json b/benchmarks/language/corpus.json new file mode 100644 index 00000000..ec2b7e99 --- /dev/null +++ b/benchmarks/language/corpus.json @@ -0,0 +1,209 @@ +{ + "contract": "algal.language-benchmark.corpus.v1", + "kind": "corpus", + "id": "language-smoke", + "revision": 1, + "identities": { + "encoding": "utf-8", + "digest": "sha256-canonical-json", + "compiler": { + "id": "algal-event-loop-contract", + "version": "proposal" + }, + "runtimes": [ + "bun", + "rust" + ], + "machine": { + "os": "fixture", + "arch": "fixture", + "cpu": "synthetic-contract-fixture" + } + }, + "protocol": { + "warmupSamples": 3, + "measuredSamples": 15, + "phases": [ + "cold", + "warm" + ], + "order": "path", + "quantile": "nearest-rank", + "outliers": "none", + "regressionPercent": 20 + }, + "fixtures": { + "actors": [ + { + "id": "counter-actor", + "path": "actors/echo.json", + "kind": "actor", + "sha256": "sha256:c5aae485dcf8608cb94b771b94d1bf2986827977b2781efa27afd798a9f5e5c6", + "expected": { + "check": "accepted", + "artifactDigest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010", + "interfaceDigest": "sha256:caff4dce99e5795cd5e5cb2acf043496c1858dbef26ecaf77ff276b75e03f357", + "eventPath": "actors/echo.events.json", + "eventSha256": "sha256:15a12f10f3b23b888dc2a7401ee58aa197a6973f3561112280a6442f44c47cfa", + "eventDigest": "sha256:1869ff89ddc5785172505954b7b19278ee716b96c2e86c538b2cd6756e9dbce9" + } + } + ], + "projects": [ + { + "id": "counter-project", + "path": "projects/echo.project.json", + "kind": "project", + "sha256": "sha256:8cd75e3d252ce54b82e4f706cd2fe9c976053f0c1676b1f156788b033fd20818", + "expected": { + "check": "accepted", + "projectDigest": "sha256:fbf02f7559fe86742d9e8e36b1cb01f80686dad35022c46613dd6dcd368b41dc", + "closureDigest": "sha256:7f79a6f4e1bd22c9006b8c0c691d9e2b670eb2a6417dfc3f22940ad9834791a9" + } + } + ], + "invalid": [ + { + "id": "unknown-root", + "path": "invalid/unknown-root.json", + "kind": "invalid", + "sha256": "sha256:1421205da5f1487a6c6ddd30c21910d102c9c8570c7d9cdda051582e14b227d0", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_UNKNOWN_KEY", + "span": { + "start": 3053, + "end": 3063 + } + } + }, + { + "id": "unknown-behavior-key", + "path": "invalid/unknown-behavior-key.json", + "kind": "invalid", + "sha256": "sha256:e6b4f99e4d64bc84e8bff8f561002aa3fd704695cba08e04083fd13a435c3198", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_UNKNOWN_KEY", + "span": { + "start": 284, + "end": 289 + } + } + }, + { + "id": "wrong-contract", + "path": "invalid/wrong-contract.json", + "kind": "invalid", + "sha256": "sha256:bde6c2b5a3af5402a5b6b17b8f4c607b38c5107d409a8c62689d52edcea7ab9c", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_CONTRACT", + "span": { + "start": 17, + "end": 36 + } + } + }, + { + "id": "event-bytes-limit", + "path": "invalid/event-bytes-limit.json", + "kind": "invalid", + "sha256": "sha256:f9f61e42244d5e122cc8374225bca7268398a8c055555470db0facded4a8536e", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_BOUNDS", + "span": { + "start": 318, + "end": 323 + } + } + }, + { + "id": "duplicate-event", + "path": "invalid/duplicate-event.json", + "kind": "invalid", + "sha256": "sha256:5c543062f5179a716777cc50387b65f6f8494851da6aa3772b765e1e564cc4cd", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_ID", + "span": { + "start": 785, + "end": 794 + } + } + }, + { + "id": "malformed-digest", + "path": "invalid/malformed-digest.json", + "kind": "invalid", + "sha256": "sha256:c4ff5f7f90a78132a129819084acb10c321cbac034b8bedeae475d298da60704", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_DIGEST", + "span": { + "start": 116, + "end": 126 + } + } + }, + { + "id": "unknown-expression-key", + "path": "invalid/unknown-expression-key.json", + "kind": "invalid", + "sha256": "sha256:34ded797f7d5ba17b0a5182a59c71129c8f3a1889d5037c6f31b073db50497b3", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_UNKNOWN_KEY", + "span": { + "start": 1557, + "end": 1562 + } + } + }, + { + "id": "defer-with-writes", + "path": "invalid/defer-with-writes.json", + "kind": "invalid", + "sha256": "sha256:c127171d7bc7101b878db5c84cdd166a539206bafa7be0891312ae936a9bec1d", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_SEMANTIC", + "span": { + "start": 2487, + "end": 2492 + } + } + }, + { + "id": "unknown-event-reference", + "path": "invalid/unknown-event-reference.json", + "kind": "invalid", + "sha256": "sha256:099e1e35b4cc00b77ff2dad849f32d1f337874676d26bb6fcb92ce4a54b7ae0f", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_REFERENCE", + "span": { + "start": 1288, + "end": 1301 + } + } + }, + { + "id": "output-count-limit", + "path": "invalid/output-count-limit.json", + "kind": "invalid", + "sha256": "sha256:1f39633efbc0648ac3e16b28667c33368e204afb8ce8aef500e366040e76eff7", + "expected": { + "check": "rejected", + "code": "EVENT_LOOP_BOUNDS", + "span": { + "start": 375, + "end": 376 + } + } + } + ] + }, + "digest": "sha256:955bfd3eae801f566d464d0225487afad85c5665b1c2f953bf8b70b9cb4035cf" +} diff --git a/benchmarks/language/invalid/defer-with-writes.json b/benchmarks/language/invalid/defer-with-writes.json new file mode 100644 index 00000000..c2e83dd2 --- /dev/null +++ b/benchmarks/language/invalid/defer-with-writes.json @@ -0,0 +1,150 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "defer", + "key": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/duplicate-event.json b/benchmarks/language/invalid/duplicate-event.json new file mode 100644 index 00000000..7c9424b8 --- /dev/null +++ b/benchmarks/language/invalid/duplicate-event.json @@ -0,0 +1,143 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/event-bytes-limit.json b/benchmarks/language/invalid/event-bytes-limit.json new file mode 100644 index 00000000..7785e83e --- /dev/null +++ b/benchmarks/language/invalid/event-bytes-limit.json @@ -0,0 +1,143 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 65537, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/malformed-digest.json b/benchmarks/language/invalid/malformed-digest.json new file mode 100644 index 00000000..f999672b --- /dev/null +++ b/benchmarks/language/invalid/malformed-digest.json @@ -0,0 +1,143 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:bad", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/output-count-limit.json b/benchmarks/language/invalid/output-count-limit.json new file mode 100644 index 00000000..1d66ca9e --- /dev/null +++ b/benchmarks/language/invalid/output-count-limit.json @@ -0,0 +1,143 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 0, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/unknown-behavior-key.json b/benchmarks/language/invalid/unknown-behavior-key.json new file mode 100644 index 00000000..38b94f1a --- /dev/null +++ b/benchmarks/language/invalid/unknown-behavior-key.json @@ -0,0 +1,144 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4", + "clock": "2026-10-08T00:00:00Z" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/unknown-event-reference.json b/benchmarks/language/invalid/unknown-event-reference.json new file mode 100644 index 00000000..e51091a2 --- /dev/null +++ b/benchmarks/language/invalid/unknown-event-reference.json @@ -0,0 +1,143 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "missing-event", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/unknown-expression-key.json b/benchmarks/language/invalid/unknown-expression-key.json new file mode 100644 index 00000000..8c86fdd9 --- /dev/null +++ b/benchmarks/language/invalid/unknown-expression-key.json @@ -0,0 +1,144 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ], + "extra": true + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/invalid/unknown-root.json b/benchmarks/language/invalid/unknown-root.json new file mode 100644 index 00000000..fab92bd0 --- /dev/null +++ b/benchmarks/language/invalid/unknown-root.json @@ -0,0 +1,144 @@ +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010", + "unexpected": true +} diff --git a/benchmarks/language/invalid/wrong-contract.json b/benchmarks/language/invalid/wrong-contract.json new file mode 100644 index 00000000..7aefe53d --- /dev/null +++ b/benchmarks/language/invalid/wrong-contract.json @@ -0,0 +1,143 @@ +{ + "contract": "algal.event-loop.v0", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:59b2a1b5309fd3b7702cb0e646d5be007d6bfdf1423c60f57583886b38e37528", + "digest": "sha256:553554d743b55b141b46346cc7f560545dccb8d9c98fde15fea8c0f8692a7ce4" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + }, + { + "id": "reset", + "payload": { + "kind": "record", + "fields": [] + } + }, + { + "id": "incremented", + "payload": { + "kind": "record", + "fields": [ + { + "name": "amount", + "type": "number" + } + ] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [ + { + "name": "count", + "type": "number", + "initial": 0 + } + ] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": { + "contract": "algal.expr.v1", + "program": [ + "gte", + [ + "get", + "event", + "amount" + ], + 1 + ] + }, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": [ + "add", + [ + "get", + "state", + "count" + ], + [ + "get", + "event", + "amount" + ] + ] + } + } + ], + "outputs": [ + { + "kind": "notify", + "target": "self", + "event": "incremented", + "payload": { + "contract": "algal.expr.v1", + "program": [ + "get", + "event" + ] + } + } + ], + "result": { + "kind": "continue" + } + }, + { + "id": "reset", + "on": "reset", + "when": null, + "writes": [ + { + "path": [ + "count" + ], + "value": { + "contract": "algal.expr.v1", + "program": 0 + } + } + ], + "outputs": [], + "result": { + "kind": "quiet" + } + } + ] + } + ], + "digest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010" +} diff --git a/benchmarks/language/projects/echo.project.json b/benchmarks/language/projects/echo.project.json new file mode 100644 index 00000000..3d16fb9c --- /dev/null +++ b/benchmarks/language/projects/echo.project.json @@ -0,0 +1,16 @@ +{ + "contract": "algal.language-project.v1", + "id": "counter-project", + "entry": "actors/echo.json", + "files": [ + { + "path": "actors/echo.json", + "digest": "sha256:c5aae485dcf8608cb94b771b94d1bf2986827977b2781efa27afd798a9f5e5c6" + }, + { + "path": "actors/echo.events.json", + "digest": "sha256:15a12f10f3b23b888dc2a7401ee58aa197a6973f3561112280a6442f44c47cfa" + } + ], + "digest": "sha256:fbf02f7559fe86742d9e8e36b1cb01f80686dad35022c46613dd6dcd368b41dc" +} diff --git a/benchmarks/language/results/smoke.json b/benchmarks/language/results/smoke.json new file mode 100644 index 00000000..6f9ab284 --- /dev/null +++ b/benchmarks/language/results/smoke.json @@ -0,0 +1,2658 @@ +{ + "contract": "algal.language-benchmark.v1", + "kind": "result", + "corpusDigest": "sha256:955bfd3eae801f566d464d0225487afad85c5665b1c2f953bf8b70b9cb4035cf", + "protocol": { + "warmupSamples": 3, + "measuredSamples": 15, + "phases": [ + "cold", + "warm" + ], + "order": "path", + "quantile": "nearest-rank", + "outliers": "none" + }, + "toolchain": { + "compiler": "algal-event-loop-contract@proposal", + "runtime": "bun@1.3.14", + "native": "rust@proposal" + }, + "environment": { + "os": "fixture", + "arch": "fixture", + "cpu": "synthetic-contract-fixture" + }, + "cases": [ + { + "id": "counter-actor", + "kind": "actor", + "sourceDigest": "sha256:c5aae485dcf8608cb94b771b94d1bf2986827977b2781efa27afd798a9f5e5c6", + "expected": "accepted", + "observed": { + "artifactDigest": "sha256:8c5076158ed2e8a36fe7f86d25f37cce49ac78d50e8fa32abfe40ce111b5f010", + "interfaceDigest": "sha256:caff4dce99e5795cd5e5cb2acf043496c1858dbef26ecaf77ff276b75e03f357", + "eventDigest": "sha256:1869ff89ddc5785172505954b7b19278ee716b96c2e86c538b2cd6756e9dbce9" + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1000, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1001, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1002, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1003, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1004, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1005, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1006, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1007, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1008, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1009, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1010, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1011, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1012, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1013, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1014, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 400, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 401, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 402, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 403, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 404, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 405, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 406, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 407, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 408, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 409, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 410, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 411, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 412, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 413, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 414, + "outcome": "accepted" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1007, + "p95Ns": 1014 + }, + "warm": { + "count": 15, + "p50Ns": 407, + "p95Ns": 414 + } + } + }, + { + "id": "defer-with-writes", + "kind": "invalid", + "sourceDigest": "sha256:c127171d7bc7101b878db5c84cdd166a539206bafa7be0891312ae936a9bec1d", + "expected": "rejected", + "code": "EVENT_LOOP_SEMANTIC", + "span": { + "start": 2487, + "end": 2492 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1100, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1101, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1102, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1103, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1104, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1105, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1106, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1107, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1108, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1109, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1110, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1111, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1112, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1113, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1114, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 500, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 501, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 502, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 503, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 504, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 505, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 506, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 507, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 508, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 509, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 510, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 511, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 512, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 513, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 514, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1107, + "p95Ns": 1114 + }, + "warm": { + "count": 15, + "p50Ns": 507, + "p95Ns": 514 + } + } + }, + { + "id": "duplicate-event", + "kind": "invalid", + "sourceDigest": "sha256:5c543062f5179a716777cc50387b65f6f8494851da6aa3772b765e1e564cc4cd", + "expected": "rejected", + "code": "EVENT_LOOP_ID", + "span": { + "start": 785, + "end": 794 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1200, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1201, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1202, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1203, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1204, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1205, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1206, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1207, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1208, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1209, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1210, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1211, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1212, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1213, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1214, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 600, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 601, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 602, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 603, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 604, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 605, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 606, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 607, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 608, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 609, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 610, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 611, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 612, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 613, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 614, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1207, + "p95Ns": 1214 + }, + "warm": { + "count": 15, + "p50Ns": 607, + "p95Ns": 614 + } + } + }, + { + "id": "event-bytes-limit", + "kind": "invalid", + "sourceDigest": "sha256:f9f61e42244d5e122cc8374225bca7268398a8c055555470db0facded4a8536e", + "expected": "rejected", + "code": "EVENT_LOOP_BOUNDS", + "span": { + "start": 318, + "end": 323 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1300, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1301, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1302, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1303, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1304, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1305, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1306, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1307, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1308, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1309, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1310, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1311, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1312, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1313, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1314, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 700, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 701, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 702, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 703, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 704, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 705, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 706, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 707, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 708, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 709, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 710, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 711, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 712, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 713, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 714, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1307, + "p95Ns": 1314 + }, + "warm": { + "count": 15, + "p50Ns": 707, + "p95Ns": 714 + } + } + }, + { + "id": "malformed-digest", + "kind": "invalid", + "sourceDigest": "sha256:c4ff5f7f90a78132a129819084acb10c321cbac034b8bedeae475d298da60704", + "expected": "rejected", + "code": "EVENT_LOOP_DIGEST", + "span": { + "start": 116, + "end": 126 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1400, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1401, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1402, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1403, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1404, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1405, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1406, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1407, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1408, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1409, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1410, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1411, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1412, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1413, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1414, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 800, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 801, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 802, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 803, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 804, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 805, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 806, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 807, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 808, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 809, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 810, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 811, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 812, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 813, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 814, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1407, + "p95Ns": 1414 + }, + "warm": { + "count": 15, + "p50Ns": 807, + "p95Ns": 814 + } + } + }, + { + "id": "output-count-limit", + "kind": "invalid", + "sourceDigest": "sha256:1f39633efbc0648ac3e16b28667c33368e204afb8ce8aef500e366040e76eff7", + "expected": "rejected", + "code": "EVENT_LOOP_BOUNDS", + "span": { + "start": 375, + "end": 376 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1500, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1501, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1502, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1503, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1504, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1505, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1506, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1507, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1508, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1509, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1510, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1511, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1512, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1513, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1514, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 900, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 901, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 902, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 903, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 904, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 905, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 906, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 907, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 908, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 909, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 910, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 911, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 912, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 913, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 914, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1507, + "p95Ns": 1514 + }, + "warm": { + "count": 15, + "p50Ns": 907, + "p95Ns": 914 + } + } + }, + { + "id": "unknown-behavior-key", + "kind": "invalid", + "sourceDigest": "sha256:e6b4f99e4d64bc84e8bff8f561002aa3fd704695cba08e04083fd13a435c3198", + "expected": "rejected", + "code": "EVENT_LOOP_UNKNOWN_KEY", + "span": { + "start": 284, + "end": 289 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1600, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1601, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1602, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1603, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1604, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1605, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1606, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1607, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1608, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1609, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1610, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1611, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1612, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1613, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1614, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 1000, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 1001, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 1002, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 1003, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 1004, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 1005, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 1006, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 1007, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 1008, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 1009, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 1010, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 1011, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 1012, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 1013, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 1014, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1607, + "p95Ns": 1614 + }, + "warm": { + "count": 15, + "p50Ns": 1007, + "p95Ns": 1014 + } + } + }, + { + "id": "unknown-event-reference", + "kind": "invalid", + "sourceDigest": "sha256:099e1e35b4cc00b77ff2dad849f32d1f337874676d26bb6fcb92ce4a54b7ae0f", + "expected": "rejected", + "code": "EVENT_LOOP_REFERENCE", + "span": { + "start": 1288, + "end": 1301 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1700, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1701, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1702, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1703, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1704, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1705, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1706, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1707, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1708, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1709, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1710, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1711, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1712, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1713, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1714, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 1100, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 1101, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 1102, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 1103, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 1104, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 1105, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 1106, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 1107, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 1108, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 1109, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 1110, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 1111, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 1112, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 1113, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 1114, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1707, + "p95Ns": 1714 + }, + "warm": { + "count": 15, + "p50Ns": 1107, + "p95Ns": 1114 + } + } + }, + { + "id": "unknown-expression-key", + "kind": "invalid", + "sourceDigest": "sha256:34ded797f7d5ba17b0a5182a59c71129c8f3a1889d5037c6f31b073db50497b3", + "expected": "rejected", + "code": "EVENT_LOOP_UNKNOWN_KEY", + "span": { + "start": 1557, + "end": 1562 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1800, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1801, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1802, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1803, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1804, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1805, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1806, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1807, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1808, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1809, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1810, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1811, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1812, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1813, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1814, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 1200, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 1201, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 1202, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 1203, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 1204, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 1205, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 1206, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 1207, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 1208, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 1209, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 1210, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 1211, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 1212, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 1213, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 1214, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1807, + "p95Ns": 1814 + }, + "warm": { + "count": 15, + "p50Ns": 1207, + "p95Ns": 1214 + } + } + }, + { + "id": "unknown-root", + "kind": "invalid", + "sourceDigest": "sha256:1421205da5f1487a6c6ddd30c21910d102c9c8570c7d9cdda051582e14b227d0", + "expected": "rejected", + "code": "EVENT_LOOP_UNKNOWN_KEY", + "span": { + "start": 3053, + "end": 3063 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 1900, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 1901, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 1902, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 1903, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 1904, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 1905, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 1906, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 1907, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 1908, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 1909, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 1910, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 1911, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 1912, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 1913, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 1914, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 1300, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 1301, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 1302, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 1303, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 1304, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 1305, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 1306, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 1307, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 1308, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 1309, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 1310, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 1311, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 1312, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 1313, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 1314, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 1907, + "p95Ns": 1914 + }, + "warm": { + "count": 15, + "p50Ns": 1307, + "p95Ns": 1314 + } + } + }, + { + "id": "wrong-contract", + "kind": "invalid", + "sourceDigest": "sha256:bde6c2b5a3af5402a5b6b17b8f4c607b38c5107d409a8c62689d52edcea7ab9c", + "expected": "rejected", + "code": "EVENT_LOOP_CONTRACT", + "span": { + "start": 17, + "end": 36 + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 2000, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 2001, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 2002, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 2003, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 2004, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 2005, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 2006, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 2007, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 2008, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 2009, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 2010, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 2011, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 2012, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 2013, + "outcome": "rejected" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 2014, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 1400, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 1401, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 1402, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 1403, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 1404, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 1405, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 1406, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 1407, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 1408, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 1409, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 1410, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 1411, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 1412, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 1413, + "outcome": "rejected" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 1414, + "outcome": "rejected" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 2007, + "p95Ns": 2014 + }, + "warm": { + "count": 15, + "p50Ns": 1407, + "p95Ns": 1414 + } + } + }, + { + "id": "counter-project", + "kind": "project", + "sourceDigest": "sha256:8cd75e3d252ce54b82e4f706cd2fe9c976053f0c1676b1f156788b033fd20818", + "expected": "accepted", + "observed": { + "projectDigest": "sha256:fbf02f7559fe86742d9e8e36b1cb01f80686dad35022c46613dd6dcd368b41dc", + "closureDigest": "sha256:7f79a6f4e1bd22c9006b8c0c691d9e2b670eb2a6417dfc3f22940ad9834791a9" + }, + "samples": [ + { + "phase": "cold", + "index": 0, + "durationNs": 2100, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 1, + "durationNs": 2101, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 2, + "durationNs": 2102, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 3, + "durationNs": 2103, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 4, + "durationNs": 2104, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 5, + "durationNs": 2105, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 6, + "durationNs": 2106, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 7, + "durationNs": 2107, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 8, + "durationNs": 2108, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 9, + "durationNs": 2109, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 10, + "durationNs": 2110, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 11, + "durationNs": 2111, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 12, + "durationNs": 2112, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 13, + "durationNs": 2113, + "outcome": "accepted" + }, + { + "phase": "cold", + "index": 14, + "durationNs": 2114, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 0, + "durationNs": 1500, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 1, + "durationNs": 1501, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 2, + "durationNs": 1502, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 3, + "durationNs": 1503, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 4, + "durationNs": 1504, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 5, + "durationNs": 1505, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 6, + "durationNs": 1506, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 7, + "durationNs": 1507, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 8, + "durationNs": 1508, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 9, + "durationNs": 1509, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 10, + "durationNs": 1510, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 11, + "durationNs": 1511, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 12, + "durationNs": 1512, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 13, + "durationNs": 1513, + "outcome": "accepted" + }, + { + "phase": "warm", + "index": 14, + "durationNs": 1514, + "outcome": "accepted" + } + ], + "summary": { + "cold": { + "count": 15, + "p50Ns": 2107, + "p95Ns": 2114 + }, + "warm": { + "count": 15, + "p50Ns": 1507, + "p95Ns": 1514 + } + } + } + ], + "aggregate": [ + { + "id": "counter-actor", + "phase": "cold", + "count": 15, + "p50Ns": 1007, + "p95Ns": 1014 + }, + { + "id": "counter-actor", + "phase": "warm", + "count": 15, + "p50Ns": 407, + "p95Ns": 414 + }, + { + "id": "defer-with-writes", + "phase": "cold", + "count": 15, + "p50Ns": 1107, + "p95Ns": 1114 + }, + { + "id": "defer-with-writes", + "phase": "warm", + "count": 15, + "p50Ns": 507, + "p95Ns": 514 + }, + { + "id": "duplicate-event", + "phase": "cold", + "count": 15, + "p50Ns": 1207, + "p95Ns": 1214 + }, + { + "id": "duplicate-event", + "phase": "warm", + "count": 15, + "p50Ns": 607, + "p95Ns": 614 + }, + { + "id": "event-bytes-limit", + "phase": "cold", + "count": 15, + "p50Ns": 1307, + "p95Ns": 1314 + }, + { + "id": "event-bytes-limit", + "phase": "warm", + "count": 15, + "p50Ns": 707, + "p95Ns": 714 + }, + { + "id": "malformed-digest", + "phase": "cold", + "count": 15, + "p50Ns": 1407, + "p95Ns": 1414 + }, + { + "id": "malformed-digest", + "phase": "warm", + "count": 15, + "p50Ns": 807, + "p95Ns": 814 + }, + { + "id": "output-count-limit", + "phase": "cold", + "count": 15, + "p50Ns": 1507, + "p95Ns": 1514 + }, + { + "id": "output-count-limit", + "phase": "warm", + "count": 15, + "p50Ns": 907, + "p95Ns": 914 + }, + { + "id": "unknown-behavior-key", + "phase": "cold", + "count": 15, + "p50Ns": 1607, + "p95Ns": 1614 + }, + { + "id": "unknown-behavior-key", + "phase": "warm", + "count": 15, + "p50Ns": 1007, + "p95Ns": 1014 + }, + { + "id": "unknown-event-reference", + "phase": "cold", + "count": 15, + "p50Ns": 1707, + "p95Ns": 1714 + }, + { + "id": "unknown-event-reference", + "phase": "warm", + "count": 15, + "p50Ns": 1107, + "p95Ns": 1114 + }, + { + "id": "unknown-expression-key", + "phase": "cold", + "count": 15, + "p50Ns": 1807, + "p95Ns": 1814 + }, + { + "id": "unknown-expression-key", + "phase": "warm", + "count": 15, + "p50Ns": 1207, + "p95Ns": 1214 + }, + { + "id": "unknown-root", + "phase": "cold", + "count": 15, + "p50Ns": 1907, + "p95Ns": 1914 + }, + { + "id": "unknown-root", + "phase": "warm", + "count": 15, + "p50Ns": 1307, + "p95Ns": 1314 + }, + { + "id": "wrong-contract", + "phase": "cold", + "count": 15, + "p50Ns": 2007, + "p95Ns": 2014 + }, + { + "id": "wrong-contract", + "phase": "warm", + "count": 15, + "p50Ns": 1407, + "p95Ns": 1414 + }, + { + "id": "counter-project", + "phase": "cold", + "count": 15, + "p50Ns": 2107, + "p95Ns": 2114 + }, + { + "id": "counter-project", + "phase": "warm", + "count": 15, + "p50Ns": 1507, + "p95Ns": 1514 + } + ], + "digest": "sha256:eaa3e9d80962b8efbf9bcb9cb67aebe377241597f1661eb8fd7449f6be7a7b5d" +} diff --git a/benchmarks/language/span.test.ts b/benchmarks/language/span.test.ts new file mode 100644 index 00000000..6eb9a255 --- /dev/null +++ b/benchmarks/language/span.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { locateJsonSpan } from "./span"; + +const bytes = (source: string): Uint8Array => new TextEncoder().encode(source); + +test("empty JSON keys and values have non-empty byte spans", () => { + const source = bytes('{"": "", "nested": {"": ""}}'); + expect(locateJsonSpan(source, [""], true)).toEqual({ start: 1, end: 3 }); + expect(locateJsonSpan(source, [""])).toEqual({ start: 5, end: 7 }); + expect(locateJsonSpan(source, ["nested", ""], true)).toEqual({ start: 20, end: 22 }); + expect(locateJsonSpan(source, ["nested", ""])).toEqual({ start: 24, end: 26 }); + expect(locateJsonSpan(bytes('""'), [])).toEqual({ start: 0, end: 2 }); +}); + +test("non-empty string spans remain inside their quotes in UTF-8 bytes", () => { + expect(locateJsonSpan(bytes('{"é":"x"}'), ["é"], true)).toEqual({ start: 2, end: 4 }); + expect(locateJsonSpan(bytes('{"é":"x"}'), ["é"])).toEqual({ start: 7, end: 8 }); +}); diff --git a/benchmarks/language/span.ts b/benchmarks/language/span.ts new file mode 100644 index 00000000..4e28b262 --- /dev/null +++ b/benchmarks/language/span.ts @@ -0,0 +1,82 @@ +// Source-byte locations for the P0 JSON diagnostic fixtures. This does not +// implement the event-loop checker or infer a source-language location. +export type ByteSpan = { start: number; end: number }; + +export function locateJsonSpan(bytes: Uint8Array, at: readonly (string | number)[], key = false): ByteSpan { + if (bytes.byteLength < 1 || bytes.byteLength > 16_384 || at.length > 64) { + throw new Error("diagnostic source or path exceeds its limit"); + } + const decoder = new TextDecoder("utf-8", { fatal: true }); + JSON.parse(decoder.decode(bytes)); // The cursor below only accepts valid JSON. + let cursor = 0; + let nodes = 0; + let found: ByteSpan | undefined; + const space = (): void => { + while (bytes[cursor] === 32 || bytes[cursor] === 10 || bytes[cursor] === 13 || bytes[cursor] === 9) cursor++; + }; + const same = (path: readonly (string | number)[]): boolean => + path.length === at.length && path.every((part, index) => part === at[index]); + const string = (): { name: string; span: ByteSpan } => { + if (bytes[cursor++] !== 34) throw new Error("expected JSON string"); + const start = cursor; + while (cursor < bytes.byteLength && bytes[cursor] !== 34) { + cursor += bytes[cursor] === 92 ? 2 : 1; + } + const end = cursor++; + return { + name: JSON.parse(decoder.decode(bytes.subarray(start - 1, cursor))) as string, + // Empty strings have no bytes between the quotes. Include both quote + // bytes so a located diagnostic still has a non-empty source span. + span: start === end ? { start: start - 1, end: end + 1 } : { start, end }, + }; + }; + const value = (path: readonly (string | number)[], depth: number): ByteSpan => { + if (depth > 64 || ++nodes > 16_384) throw new Error("diagnostic source exceeds depth or node limit"); + space(); + const start = cursor; + const first = bytes[cursor]; + if (first === 34) return string().span; + if (first === 123) { + cursor++; + space(); + while (bytes[cursor] !== 125) { + const property = string(); + const child = [...path, property.name]; + if (key && same(child)) found = property.span; + space(); + if (bytes[cursor++] !== 58) throw new Error("expected JSON colon"); + const span = value(child, depth + 1); + if (!key && same(child)) found = span; + space(); + if (bytes[cursor] !== 44) break; + cursor++; + space(); + } + if (bytes[cursor++] !== 125) throw new Error("expected JSON object end"); + } else if (first === 91) { + cursor++; + space(); + let index = 0; + while (bytes[cursor] !== 93) { + const child = [...path, index++]; + const span = value(child, depth + 1); + if (!key && same(child)) found = span; + space(); + if (bytes[cursor] !== 44) break; + cursor++; + space(); + } + if (bytes[cursor++] !== 93) throw new Error("expected JSON array end"); + } else { + while (cursor < bytes.byteLength && ![32, 10, 13, 9, 44, 93, 125].includes(bytes[cursor]!)) cursor++; + } + return { start, end: cursor }; + }; + const root = value([], 0); + if (!key && same([])) found = root; + space(); + if (cursor !== bytes.byteLength || !found || found.end <= found.start) { + throw new Error("diagnostic source path not found"); + } + return found; +} diff --git a/spec/v1/README.md b/spec/v1/README.md new file mode 100644 index 00000000..a618201c --- /dev/null +++ b/spec/v1/README.md @@ -0,0 +1,59 @@ +# ALGAL v1 contract index + +This page is the shared index for the versioned wire contracts in `spec/v1/`. +A contract name is an exact `algal.*.v1` identifier. The P0 fixture parser +checks foreign values as `unknown`, rejects unknown contract keys, and checks +the documented limits exercised by its tests. It is not a runtime parser or a +replay implementation. + +## North-star P0 contracts + +| Contract | Record and review surface | Status | Next owner | +| --- | --- | --- | --- | +| `algal.event-loop.v1` | [event-loop behavior](./event-loop.md) and [valid and invalid fixtures](../../benchmarks/language/) | P0 proposal | P1 evaluator | +| `algal.event-loop.events.v1` | [event vector fixture](../../benchmarks/language/actors/echo.events.json) | P0 proposal | P1 evaluator | +| `algal.language-benchmark.v1` | [benchmark result contract](../../benchmarks/language/README.md) and result fixture | P0 proposal | P2 harness | +| `algal.language-benchmark.corpus.v1` | [baseline corpus manifest](../../benchmarks/language/corpus.json) | P0 proposal | P2 harness | +| `algal.language-project.v1` | [project closure fixture](../../benchmarks/language/projects/echo.project.json) | P0 proposal | P2 harness | + +The fixture parser and its deterministic checks live beside the fixtures in +[`benchmarks/language/contract.test.ts`](../../benchmarks/language/contract.test.ts). +They prove the contract shape and fixture identities only. They do not claim a +compiler, runtime performance, language quality, or cumulative-skill result. + +## Shared identity vocabulary + +| Identity | Scope | Must not be substituted for | +| --- | --- | --- | +| behavior revision tuple | `(behavior.id, behavior.revision, root.digest)`; the root covers the complete evaluator input | source or runtime identity | +| behavior metadata digest | id, revision, and source digest only | full behavior artifact digest | +| source digest | declared SHA-256 of exact UTF-8 source bytes | behavior semantics or compiler identity | +| artifact digest | canonical behavior record consumed by an evaluator | raw source bytes | +| runtime identity | declared Bun or Rust/WASM execution input in benchmark evidence | behavior artifact digest | +| result digest | canonical benchmark result including samples and environment | execution receipt | +| replay identity | message, transition, state, and output identities in a trace | wall-clock timing | + +The P0 smoke actor has no pinned source file. Its `sourceDigest` is checked for +digest syntax and covered by the artifact digest, but its claimed source bytes +cannot be verified from this corpus. Source-to-artifact provenance requires a +future compiler fixture with retained source bytes and a checked mapping. + +Digests use `sha256:` followed by 64 lowercase hexadecimal characters. Receipts +and replay records contain no wall-clock fields. Benchmark durations belong only +to host-side evidence and are bounded integer nanoseconds. + +## Dependency and scope decision + +P0 proposes records and evidence boundaries. P1 may implement the portable +transition evaluator only after the event-loop contract is reviewed. P2 may +measure the existing check and replay path only after the corpus and protocol +are accepted. P1 and P2 are separate work lanes and remain blocked by this +proposal until the P0 gate is integrated. + +The P0 decision rejects a separate event-loop runtime. TypeScript is the +intended reference boundary and Rust/WASM is the parity target. Neither +implementation consumes this proposed event-loop record yet. Future +implementations must consume the same `algal.event-loop.v1` data and canonical +identity rules; a future host, supervisor, or habitat adapter must not fork +semantics or introduce a second wire contract. This is an architecture decision, +not evidence that either runtime has been qualified. diff --git a/spec/v1/event-loop.md b/spec/v1/event-loop.md new file mode 100644 index 00000000..4275ab49 --- /dev/null +++ b/spec/v1/event-loop.md @@ -0,0 +1,340 @@ +# algal.event-loop.v1 + +This document is the proposed portable data contract for a deterministic actor +and event loop. It is the P0 contract gate. It defines values, identities, +limits, transition outcomes, and replay evidence. It does not define a scheduler, +a provider adapter, a filesystem, or a live service. The P1 evaluator must accept +this contract before a source-language or node implementation starts. + +The contract reuses `algal.expr.v1` for pure expressions. An expression is data +and is evaluated by the existing contract-owned evaluator. Event-loop values are +canonical JSON. No field in this contract is a wall-clock time, and execution +receipts remain separate from benchmark timing records. + +## Vocabulary + +| Term | Meaning | +| --- | --- | +| behavior | A named revision of an actor transition set. The root digest identifies the complete bytes that an evaluator uses. | +| behavior revision | A positive integer scoped to a behavior id. A revision is immutable after publication. | +| actor | A logical mailbox and state schema selected by an actor id in a behavior. It is not an operating-system process. | +| event | A typed input value delivered to an actor. An event has a type id, payload, and sequence supplied by its source. | +| message | One delivery of an event to one actor. Its id is derived from the behavior, actor, event, and delivery identity. | +| transition | A deterministic rule selected by an event type and an optional pure guard. | +| state write | An atomic replacement of one declared state field with the result of a pure expression. | +| notification | A new typed event emitted for a target actor. It is data in the trace, not an immediate host call. | +| defer | A pending result keyed by a pure expression. It asks the host to retry or resume without claiming that work completed. | +| escalation | A typed request for the durable path. It is recorded as pending and does not grant a model, tool, network, or filesystem capability. | +| backpressure | The declared response when accepting another mailbox entry would exceed `maxMailboxDepth`. | +| supervisor | A host-owned durable owner of actor instances. It admits behavior revisions, applies mailbox policy, and records pending outcomes; it is not an actor transition or a new evaluator. | +| habitat | A named host or deployment boundary that supplies supervisor storage, capacity, and admission policy. A habitat does not change behavior semantics or grant a behavior any capabilities. | +| source identity | A claimed digest for source bytes. The P0 smoke fixture does not retain source bytes, so it cannot prove origin. | +| runtime identity | The declared implementation and version used to evaluate or measure a record. It belongs to evidence and is not part of behavior semantics. | +| replay | Deterministic re-evaluation of a trace against the pinned behavior revision, with no clock, provider, or ambient host input. | +| trace | An ordered, replayable record of accepted deliveries, transition decisions, state writes, notifications, and pending outcomes. | + +## Envelope + +The root is a closed object with exactly these keys: + +```json +{ + "contract": "algal.event-loop.v1", + "behavior": { + "id": "counter", + "revision": 1, + "sourceDigest": "sha256:<64 lowercase hex>", + "digest": "sha256:<64 lowercase hex>" + }, + "limits": { + "maxEventBytes": 1024, + "maxStateBytes": 16384, + "maxOutputEvents": 8, + "maxTransitionFuel": 1000, + "maxMailboxDepth": 64 + }, + "events": [ + { + "id": "increment", + "payload": { + "kind": "record", + "fields": [{ "name": "amount", "type": "number" }] + } + } + ], + "actors": [ + { + "id": "counter", + "state": { + "kind": "record", + "fields": [{ "name": "count", "type": "number", "initial": 0 }] + }, + "transitions": [ + { + "id": "increment", + "on": "increment", + "when": null, + "writes": [], + "outputs": [], + "result": { "kind": "quiet" } + } + ] + } + ], + "digest": "sha256:<64 lowercase hex>" +} +``` + +`contract` is the exact string shown above. `behavior` is a closed object. +`sourceDigest` declares a SHA-256 for purported UTF-8 source bytes; +it is not a path, proof that those bytes exist, or permission to read them. `limits`, `events`, and +`actors` are closed objects and arrays described below. The root `digest` covers +the canonical root object with its own `digest` key omitted. The behavior +`digest` covers the canonical behavior object with its own `digest` key omitted. +A verifier recomputes both digests before using a value. + +Every contract-owned object rejects unknown keys. A payload is not an escape +from this rule: a record payload accepts only fields declared by its event +schema. The `json` field type permits a canonical JSON value inside that field, +subject to the expression value limits and the event and state byte limits, +but it does not permit undeclared record fields. A parser takes the incoming value as `unknown`, checks its shape, +then returns the contract type. It never treats a foreign object as an already +validated record. + +## Identity and ordering + +All digests use `sha256:` followed by 64 lowercase hexadecimal characters over +canonical UTF-8 JSON. Canonical JSON uses the repository's canonical value +encoder, not host object insertion order. Numbers must be finite and represent +values supported by that encoder. + +The following identities are stable and contain no clock value: + +1. `behavior.digest` identifies the behavior metadata (`id`, `revision`, and + `sourceDigest`) without its `digest` member. The root `digest` identifies the + complete behavior artifact, including limits, event schemas, and transitions. + The tuple `(behavior.id, behavior.revision, root.digest)` identifies one + immutable revision. A new source digest or transition changes the root digest, + even when the revision number is accidentally reused. +2. An event source supplies a non-negative `sequence` within its own source + stream. An event identity is the digest of `{ "behavior": root.digest, + "actor": actor.id, "source": source id, "sequence": sequence, "type": + event type, "payload": payload }`. The source id is inside the digest, so + two sources can use the same sequence without an identity collision. +3. A message identity is the digest of `{ "event": event identity, "target": + actor.id, "delivery": delivery identity }`. The same message id delivered + again is a duplicate. A duplicate records a no-op decision and does not run a + transition or emit a notification. +4. A notification receives a new event identity and a deterministic ordinal in + its parent transition. The ordinal starts at zero for each transition. A + notification cannot reuse its parent's message id. +5. State is identified by `(root.digest, actor.id, accepted message prefix)`. + A state write replaces the declared field atomically. A trace order is the + accepted message sequence, then transition and output ordinals. Concurrent + hosts must present the same logical order or produce separate traces; this + contract does not claim that an arbitrary distributed scheduler is ordered. + +A verifier compares identities before values. It rejects a repeated identity with +changed content, a sequence outside the declared integer range, a notification +that is not listed by its parent transition, or a trace that skips a required +parent decision. External effects are at-most-once claims only when their host +adapter supplies a matching idempotency record. This contract does not claim +exactly-once behavior for arbitrary external systems. + +## Supervisor and habitat boundaries + +An actor is a logical mailbox and state machine. A supervisor owns the durable +lifecycle around one or more actors: it admits an immutable behavior revision, +assigns delivery identities, enforces mailbox and state limits, and records +rejection, deferral, quarantine, and escalation outcomes. Supervisor state is +host evidence and is not silently added to an actor's declared state. + +A habitat is the named host boundary in which a supervisor runs. It may provide +storage, capacity, a scheduling policy, and an admission decision. Habitat policy +is not a transition, does not alter event ordering, and cannot turn an escalation +into a model, tool, network, or filesystem permission. A habitat link or provider +adapter belongs to a separate contract. The event-loop evaluator receives only +its declared data and the bounded logical order selected by the host. + +## Source and runtime identity + +`behavior.sourceDigest` declares a SHA-256 for the exact UTF-8 source bytes +that an authoring tool would supply. The P0 smoke fixture retains no such source +bytes and has no checked source-to-artifact mapping. Its source digest is checked +for syntax and covered by the artifact digest, not verified as provenance. A +source digest is not a compiler digest, runtime digest, quality score, or +permission to read a path. A source map, compiler +version, and source-language diagnostic are separate evidence records keyed by +the behavior revision. + +A runtime identity names the implementation and version used for a check, replay, +or benchmark sample. It is recorded in host-side evidence, never inserted into +behavior identity, transition results, or receipts. TypeScript and Rust/WASM +must use the same canonical data contract when they are qualified for parity. +A runtime identity cannot make an otherwise invalid behavior valid. + +## Limits object + +`limits` is closed and has the following bounds. These are admission bounds, not +performance promises. + +| Field | Bound | +| --- | --- | +| `maxEventBytes` | 1 to 65,536 canonical UTF-8 bytes for one complete event record | +| `maxStateBytes` | 1 to 262,144 canonical UTF-8 bytes | +| `maxOutputEvents` | 0 to 64 notifications per transition | +| `maxTransitionFuel` | 1 to 100,000 `algal.expr.v1` fuel units | +| `maxMailboxDepth` | 1 to 1,024 pending messages | +| behavior id, actor id, event id, transition id, field name | 1 to 64 UTF-8 bytes, lower kebab case (`[a-z0-9]+(?:-[a-z0-9]+)*`) | +| behavior revision | 1 to 2,147,483,647 | +| actors | 1 to 64 entries | +| event declarations | 1 to 128 entries | +| transitions per actor | 1 to 256 entries | +| state or event fields | 0 to 64 entries | +| transition writes | 0 to 64 entries | +| transition outputs | 0 to `limits.maxOutputEvents` entries | +| state-write path | exactly one declared field name in v1 | +| expression canonical bytes | at most 16,384, using `algal.expr.v1` bounds | +| nested `json` state or event field | at most 32 value levels (scalar at depth 0), 1,024 items per list, 256 keys per object, 65,536 UTF-8 bytes per string or key, and 262,144 canonical UTF-8 bytes per value | + +A `json` field is checked against those `algal.expr.v1` value bounds before +canonicalization, even when its enclosing event or state has a stricter byte +limit. The same check applies to initial state values and delivered payloads. The event and state byte limits are +checked before accepting a message or committing a state. A host must not raise a +limit after parsing a behavior. Every newly added count, list, string, and byte +field needs a focused test at its lower edge, upper edge, and first rejected +value. + +## Events and schemas + +An event declaration is `{ "id", "payload" }`. `payload` is a record schema with +zero or more fields. Each field is `{ "name", "type" }`; names are unique and +the type is one of `null`, `boolean`, `number`, `string`, or `json`. All declared +fields are required. Record payloads reject missing fields and unknown fields. +The `json` type is the explicit, bounded escape for a value whose internal shape +is owned by a caller. It does not change the event identity rule. + +A state schema is the same record shape, except each field also has `initial`. +Initial values are checked against their declared type and the complete canonical +initial state record must fit `maxStateBytes`. State v1 has no implicit fields. +A state field cannot be added, removed, or renamed by a transition. + +An `algal.event-loop.events.v1` vector is a closed object with `contract`, +`actor`, `events`, and `digest`. The actor must exist in the checked behavior. +It contains 1 to 256 events. Each event is a closed object with `source`, +`sequence`, `type`, and `payload`. The source uses the 1 to 64 byte id syntax; +its sequence is an integer from 0 to 2,147,483,647. The type must name a +declared event. The payload must have exactly its required, typed schema fields. +The canonical UTF-8 bytes of the *complete event object*, including the +payload, must fit `maxEventBytes`. The vector digest covers the canonical root +without its `digest` key. A checker verifies the behavior first, then the +vector and its digest; it does not accept an event vector alone. + +The event and actor id sets are global within one behavior. An output may name +only a declared event and an existing actor id or the literal target `self`. +`self` resolves to the actor handling the transition. An event declaration can +be present without an output; this permits externally delivered inputs and +future revisions without changing the schema vocabulary. + +## Transitions and outcomes + +A transition is a closed object with `id`, `on`, `when`, `writes`, `outputs`, and +`result`. + +- `id` is unique within its actor. `on` names a declared event. +- `when` is `null` or an `algal.expr.v1` envelope. It sees `{ "state", "event" }` + and must return a boolean. A literal `get` name outside that pair fails + admission. The evaluator never calls a host function from a guard. +- Each write is `{ "path", "value" }`. `path` names one declared state field in + v1, represented as a one-element list. A nested path is rejected in v1; a + future revision must explicitly define any nested-write semantics. `value` is an + `algal.expr.v1` envelope over `{ "state", "event" }`. + Writes are evaluated against the pre-transition state and are committed as one + record after all expressions pass. +- Each output is `{ "kind": "notify", "target", "event", "payload" }`. + `payload` is evaluated against `{ "state", "event" }` and checked against the + destination event schema. Outputs are ordered by their array position and are + limited by `maxOutputEvents`. +- `result.kind` is one of `continue`, `quiet`, `defer`, or `escalate`. + `continue` commits writes and outputs. `quiet` commits writes and emits no + outputs. `defer` has one additional `key` expression and requires empty writes + and outputs. `escalate` has one additional `request` expression and also + requires empty writes and outputs. Both pending outcomes are records for the + durable host path, not host effects performed by the evaluator. + +For one event, guards are evaluated in transition id order. The first true guard +wins. A behavior cannot rely on object insertion order. If no guard matches, the +host records `no-transition` and leaves state unchanged. A malformed payload, +unknown event, duplicate message, fuel exhaustion, or bound failure also leaves +state unchanged. The evaluator never partially commits a state write or output. + +Backpressure is part of the host protocol, not a hidden transition. A host +accepts at most `maxMailboxDepth` pending messages. It must return one of +`reject`, `defer`, or `quarantine`, with a reason and the message identity. The +choice is recorded in the trace and is not interpreted as a successful event. + +## Replay trace + +A trace entry is host evidence for the portable evaluator. It contains only +canonical values, logical sequence numbers, digests, transition ids, outcomes, +and bounded state/output summaries. It may contain a receipt digest for a +recorded effect. It must not contain `createdAt`, `updatedAt`, a timestamp, +random host identifiers, a process address, or a provider credential. + +A replay verifier loads the behavior, then processes entries in order. It checks +message identity, payload schema, guard and expression results, fuel, state +bytes, output identities, and the recorded outcome. A replay can prove that the +recorded transition is internally consistent. It cannot prove that a provider +returned a truthful answer or that a future external effect will have the same +result. + +Trace storage and an eventual event-loop trace wire contract are intentionally +separate from this behavior envelope. P1 may add that record only by reusing +these identity and outcome rules. + +## Decision record: no separate runtime + +P0 chooses one semantic contract and two possible implementations of that +contract. The existing TypeScript runtime is the reference implementation +boundary. Rust/WASM is a parity implementation boundary. Neither gets a private +actor language, a second scheduler semantic, or a compatibility alias. This keeps +behavior, identity, and replay reviewable before an evaluator or host adapter is +built. + +This decision does not qualify either implementation, promise performance, or +permit a live provider. It only rejects a separate event-loop runtime as a P0 +deliverable. Future implementations must consume these records and pass the +same fixture and replay checks before they can be considered for integration. + +## Errors and versioning + +The portable parser reports one stable code for the first failure: + +`EVENT_LOOP_PARSE` for a non-object or malformed JSON value, +`EVENT_LOOP_CONTRACT` for the wrong contract string, +`EVENT_LOOP_UNKNOWN_KEY` for an unknown object key, +`EVENT_LOOP_ID` for an invalid or duplicate identity, +`EVENT_LOOP_DIGEST` for a malformed or mismatched digest, +`EVENT_LOOP_BOUNDS` for a count, byte, depth, or fuel limit, +`EVENT_LOOP_SCHEMA` for an invalid field type or initial value, +`EVENT_LOOP_REFERENCE` for an unknown event, actor, state field, or expression +name, and `EVENT_LOOP_SEMANTIC` for an invalid transition outcome. +The P0 byte-fixture checker maps malformed JSON and invalid UTF-8 to +`EVENT_LOOP_PARSE`. Since these and a non-object JSON root have no contract +field path, their diagnostic spans cover the complete source (1 to 16,384 +bytes). An empty or larger source fails the byte bound before a span can be +reported. + +The contract has no compatibility alias. A future incompatible shape receives a +new `algal.event-loop.v2` identifier. A v1 parser rejects an unknown root key +rather than ignoring it, and a v2 parser must not reinterpret a v1 value without +an explicit migration record. + +## Scope of this proposal + +This P0 contract freezes the portable vocabulary and the input bounds. It does +not claim a runtime, a compiler, actor supervision, cloud delivery, cumulative +skill, provider quality, or performance improvement. The deterministic corpus in +`benchmarks/language/` is the acceptance fixture for this proposal. P1 can add a +Bun evaluator and a Rust/WASM evaluator only after both consume these exact +records and produce matching canonical digests. From 086d03673f4fd640a34f1af233dc9ced253d7b06 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:25:48 -0400 Subject: [PATCH 2/7] test: enforce P0 project closure and located diagnostics --- benchmarks/language/P0-REPAIR-EVIDENCE.md | 17 + benchmarks/language/README.md | 16 +- benchmarks/language/contract.test.ts | 392 +++++++++++++------- benchmarks/language/project-closure.test.ts | 131 +++++++ benchmarks/language/project-closure.ts | 105 ++++++ 5 files changed, 515 insertions(+), 146 deletions(-) create mode 100644 benchmarks/language/P0-REPAIR-EVIDENCE.md create mode 100644 benchmarks/language/project-closure.test.ts create mode 100644 benchmarks/language/project-closure.ts diff --git a/benchmarks/language/P0-REPAIR-EVIDENCE.md b/benchmarks/language/P0-REPAIR-EVIDENCE.md new file mode 100644 index 00000000..9d3fd440 --- /dev/null +++ b/benchmarks/language/P0-REPAIR-EVIDENCE.md @@ -0,0 +1,17 @@ +# P0 repair evidence, 2026-10-11 + +Status at preparation: **P0_CONTRACT_GATE_FAILED**. This repair belongs to draft PR #276 on branch `codex/p0-contract-freeze-20261009`, whose parent is `c5792e0a57e52d2e4ee534f882cf6065cbb67070`. The parent's green `Required` does **not** cover the repair. The independent review of the local repair recommended **do not merge** because the full gates and exact-head CI had not passed; a positive independent review is still needed. P1 and P2 remain blocked. The no-separate-runtime contract, the full-source span for malformed JSON/UTF-8, and attribution of output/state overages to the configured limit are agent decisions the owner may revisit. + +## Repair + +The project fixture-reading boundary calls `verifyProjectClosure` after record validation, using the raw bytes of each referenced file. It checks raw digests and sums bytes before accepting the closure identity. A 262,144-byte in-memory project passes through that boundary; a 262,145-byte project fails at its excess file path with a located UTF-8 span. The checked project's expected and closure digests now come from this boundary rather than an independent manual loop. The helper tests file count, per-file bytes, paths, IDs, missing files, changed bytes, unknown keys, and wrong-version contract diagnostics. + +The event-loop byte checker carries structured paths through contract-owned nested objects, schema fields, transitions, expressions, JSON values, and limits and revision numbers. It returns exact UTF-8 spans for unknown keys and out-of-range numeric limits. Tests cover unknown keys at 16 record positions, both invalid edges and noninteger values of each numeric limit, valid upper edges, zero outputs, and revision edges. The ten pinned invalid fixtures still return their original codes and spans. The 64-byte ID correction is included. These fixtures are synthetic contract checks, not compiler/source provenance, measured performance, an event-loop runtime, runtime parity for the proposed contract, or cumulative-skill evidence. + +## Checks and blockers + +Fresh on the repaired local bytes: `bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts benchmarks/language/project-closure.test.ts` passed (30 tests, 358 assertions); `bun run typecheck`, `bun run lint`, `bun run docs:check`, `bun run build:site`, `cargo fmt --all -- --check`, and `git diff HEAD --check` passed. `bun run check:copy` is not defined in this repository; it is not a passed check. + +Earlier local reviews, before the latest assertions/evidence, reported: `bun run check` timed out after 600 seconds during tests, after a loopback `EADDRINUSE`; default locked Cargo test/clippy/build hit the pinned `apple-foundation` libgit2 SSL `-9808` fetch error. Using `CARGO_NET_GIT_FETCH_WITH_CLI=true`, Cargo workspace tests built but failed in `tests/http.rs`; focused HTTP recompilation timed out. `bun scripts/application-parity.ts` timed out after 600 seconds. An earlier native parity pass was against an existing binary, not a freshly built one. None of these is a passing full gate on these exact bytes. Loopback and dependency-fetch-capable CI can replay these checks on a published head. + +Keep PR #276 draft without auto-merge. Before integration, require exact-head green `Required`, a positive independent review, and a recommendation to merge for this selected lane. Do not infer a gate pass from the parent's earlier green check. diff --git a/benchmarks/language/README.md b/benchmarks/language/README.md index eb80033a..547b8882 100644 --- a/benchmarks/language/README.md +++ b/benchmarks/language/README.md @@ -18,7 +18,7 @@ The fixture test parses foreign JSON as `unknown`, rejects unknown keys, checks all fixture digests, and exercises the listed failure codes: ```sh -bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts +bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts benchmarks/language/project-closure.test.ts ``` ## Baseline layout @@ -28,6 +28,8 @@ benchmarks/language/ README.md corpus.json contract.test.ts + project-closure.ts + project-closure.test.ts span.ts span.test.ts actors/ @@ -43,7 +45,10 @@ benchmarks/language/ `actors/echo.json` is the one valid `algal.event-loop.v1` behavior fixture. `actors/echo.events.json` is its deterministic input vector. The project fixture -pins both files as one closure. The ten invalid behavior fixtures each record a +pins both files as one closure. The fixture-reading test checks the raw bytes of +every referenced file, their digests, and the summed 262,144-byte limit. A +262,145-byte in-memory closure fails at its excess file path. The ten invalid +behavior fixtures each record a specific parser failure. Colocated tests also mutate copies of the valid fixtures to test digest, payload, byte-limit, and transition rejections. The fixture count is deliberately small for the P0 contract gate. It is not the 100-program corpus target in the north-star plan. @@ -153,7 +158,9 @@ with the span covering the complete source file. A valid JSON root that is not an object also receives a complete-source span. An empty file or a file above 16,384 bytes fails the input bound before a span can be reported. The ten pinned invalid fixtures are valid JSON; the malformed-byte cases are tested in memory. -Changing a pinned fixture diagnostic requires a corpus revision. +Unknown behavior fields and out-of-range numeric limits are also tested for +exact UTF-8 key or value spans. Changing a pinned fixture diagnostic requires a +corpus revision. ## Identity rules @@ -317,7 +324,8 @@ value limits before their enclosing state and event byte budgets are checked. | Value | Bound | | --- | --- | -| corpus id, case id, operation, toolchain and environment string | 1 to 128 UTF-8 bytes | +| corpus id and case id | 1 to 64 UTF-8 bytes, lower kebab case | +| operation, toolchain and environment string | 1 to 128 UTF-8 bytes | | corpus fixtures | 1 to 256 total entries; at most 128 invalid entries | | source file | 1 to 16,384 bytes | | project closure | 1 to 262,144 bytes and at most 64 files | diff --git a/benchmarks/language/contract.test.ts b/benchmarks/language/contract.test.ts index 17056715..0c01f76f 100644 --- a/benchmarks/language/contract.test.ts +++ b/benchmarks/language/contract.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { checkProgram, EXPR_BOUNDS } from "../../src/expr"; import { canonicalize, isJsonValue, type JsonValue } from "../../src/values"; import { locateJsonSpan, type ByteSpan } from "./span"; +import { ProjectClosureError, verifyProjectClosure } from "./project-closure"; const ROOT = import.meta.dir; const DIGEST = /^sha256:[0-9a-f]{64}$/; @@ -46,9 +47,9 @@ function fail(code: ErrorCode, message: string, at?: readonly (string | number)[ throw new FixtureError(code, message, at); } -function object(value: unknown, what: string): RecordValue { +function object(value: unknown, what: string, at?: readonly (string | number)[]): RecordValue { if (value === null || typeof value !== "object" || Array.isArray(value)) { - fail("EVENT_LOOP_PARSE", `${what} must be an object`); + fail("EVENT_LOOP_PARSE", `${what} must be an object`, at); } return value as RecordValue; } @@ -59,20 +60,20 @@ function exact(value: RecordValue, keys: readonly string[], what: string, at?: r if (!allowed.has(key)) fail("EVENT_LOOP_UNKNOWN_KEY", `${what}.${key}`, at && [...at, key]); } for (const key of keys) { - if (!Object.hasOwn(value, key)) fail("EVENT_LOOP_PARSE", `${what}.${key} is missing`); + if (!Object.hasOwn(value, key)) fail("EVENT_LOOP_PARSE", `${what}.${key} is missing`, at); } } -function text(value: unknown, what: string, maxBytes = 128): string { +function text(value: unknown, what: string, maxBytes = 128, at?: readonly (string | number)[]): string { if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maxBytes) { - fail("EVENT_LOOP_PARSE", `${what} must be a non-empty bounded string`); + fail("EVENT_LOOP_PARSE", `${what} must be a non-empty bounded string`, at); } return value; } -function identifier(value: unknown, what: string): string { - const id = text(value, what, 64); - if (!ID.test(id)) fail("EVENT_LOOP_ID", `${what} is not lower kebab case`); +function identifier(value: unknown, what: string, at?: readonly (string | number)[]): string { + const id = text(value, what, 64, at); + if (!ID.test(id)) fail("EVENT_LOOP_ID", `${what} is not lower kebab case`, at); return id; } @@ -90,102 +91,102 @@ function digest(value: unknown, what: string, at?: readonly (string | number)[]) // Apply the expression value limits to caller-owned JSON too, before // canonicalization or a recursive unbounded isJsonValue walk. -function json(value: unknown, what: string): JsonValue { +function json(value: unknown, what: string, at?: readonly (string | number)[]): JsonValue { const encoder = new TextEncoder(); const ancestors = new Set(); const maxBytes = EXPR_BOUNDS.maxValueBytes; - function size(node: unknown, depth: number): number { + function size(node: unknown, depth: number, location?: readonly (string | number)[]): number { if (depth > EXPR_BOUNDS.maxValueDepth || (node !== null && typeof node === "object" && depth === EXPR_BOUNDS.maxValueDepth)) { - fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value depth`); + fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value depth`, location); } if (typeof node === "string" && encoder.encode(node).byteLength > EXPR_BOUNDS.maxStringBytes) { - fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON string bytes`); + fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON string bytes`, location); } if (Array.isArray(node)) { - if (node.length > EXPR_BOUNDS.maxListLen) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON list length`); - if (ancestors.has(node)) fail("EVENT_LOOP_PARSE", `${what} is cyclic`); + if (node.length > EXPR_BOUNDS.maxListLen) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON list length`, location); + if (ancestors.has(node)) fail("EVENT_LOOP_PARSE", `${what} is cyclic`, location); ancestors.add(node); let bytes = 2; for (let i = 0; i < node.length; i++) { - if (!Object.hasOwn(node, i)) fail("EVENT_LOOP_PARSE", `${what} has a sparse list`); - bytes += Number(i > 0) + size(node[i], depth + 1); - if (bytes > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`); + if (!Object.hasOwn(node, i)) fail("EVENT_LOOP_PARSE", `${what} has a sparse list`, location); + bytes += Number(i > 0) + size(node[i], depth + 1, location && [...location, i]); + if (bytes > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`, location); } ancestors.delete(node); return bytes; } if (node !== null && typeof node === "object") { const proto = Object.getPrototypeOf(node); - if (proto !== Object.prototype && proto !== null) fail("EVENT_LOOP_PARSE", `${what} is not a JSON object`); - if (ancestors.has(node)) fail("EVENT_LOOP_PARSE", `${what} is cyclic`); + if (proto !== Object.prototype && proto !== null) fail("EVENT_LOOP_PARSE", `${what} is not a JSON object`, location); + if (ancestors.has(node)) fail("EVENT_LOOP_PARSE", `${what} is cyclic`, location); const keys = Object.keys(node); - if (keys.length > EXPR_BOUNDS.maxObjectKeys) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON object keys`); + if (keys.length > EXPR_BOUNDS.maxObjectKeys) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON object keys`, location); ancestors.add(node); let bytes = 2; for (const [index, key] of keys.entries()) { if (encoder.encode(key).byteLength > EXPR_BOUNDS.maxStringBytes) { - fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON key bytes`); + fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON key bytes`, location); } bytes += Number(index > 0) + encoder.encode(JSON.stringify(key)).byteLength + 1 + - size((node as Record)[key], depth + 1); - if (bytes > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`); + size((node as Record)[key], depth + 1, location && [...location, key]); + if (bytes > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`, location); } ancestors.delete(node); return bytes; } if (node !== null && typeof node !== "boolean" && !(typeof node === "number" && Number.isFinite(node)) && typeof node !== "string") { - fail("EVENT_LOOP_PARSE", `${what} is not canonical JSON`); + fail("EVENT_LOOP_PARSE", `${what} is not canonical JSON`, location); } const scalar = JSON.stringify(node); - if (typeof scalar !== "string") fail("EVENT_LOOP_PARSE", `${what} is not canonical JSON`); + if (typeof scalar !== "string") fail("EVENT_LOOP_PARSE", `${what} is not canonical JSON`, location); return encoder.encode(scalar).byteLength; } - if (size(value, 0) > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`); + if (size(value, 0, at) > maxBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds JSON value bytes`, at); return value as JsonValue; } -function canonicalBytes(value: unknown, what: string): number { - return new TextEncoder().encode(canonicalize(json(value, what))).byteLength; +function canonicalBytes(value: unknown, what: string, at?: readonly (string | number)[]): number { + return new TextEncoder().encode(canonicalize(json(value, what, at))).byteLength; } -function jsonBytes(value: unknown, what: string): number { - const size = canonicalBytes(value, what); - if (size > EXPR_BOUNDS.maxProgramBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds expression bytes`); +function jsonBytes(value: unknown, what: string, at?: readonly (string | number)[]): number { + const size = canonicalBytes(value, what, at); + if (size > EXPR_BOUNDS.maxProgramBytes) fail("EVENT_LOOP_BOUNDS", `${what} exceeds expression bytes`, at); return size; } // Include quoted data in the depth check: the evaluator's static walk skips // quote payloads, but they still contribute to the program's JSON depth. -function expressionShape(value: unknown, what: string): void { +function expressionShape(value: unknown, what: string, at?: readonly (string | number)[]): void { let nodes = 0; - function visit(node: unknown, depth: number): void { + function visit(node: unknown, depth: number, location?: readonly (string | number)[]): void { nodes++; if (nodes > EXPR_BOUNDS.maxProgramNodes || depth > EXPR_BOUNDS.maxProgramDepth) { - fail("EVENT_LOOP_BOUNDS", `${what} exceeds expression nodes or depth`); + fail("EVENT_LOOP_BOUNDS", `${what} exceeds expression nodes or depth`, location); } if (Array.isArray(node)) { - for (const child of node) visit(child, depth + 1); + for (const [index, child] of node.entries()) visit(child, depth + 1, location && [...location, index]); } else if (node !== null && typeof node === "object") { - for (const child of Object.values(node)) visit(child, depth + 1); + for (const [key, child] of Object.entries(node)) visit(child, depth + 1, location && [...location, key]); } } - visit(value, 1); + visit(value, 1, at); } function expression(value: unknown, what: string, at?: readonly (string | number)[]): void { - const record = object(value, what); + const record = object(value, what, at); exact(record, ["contract", "program"], what, at); - if (record.contract !== "algal.expr.v1") fail("EVENT_LOOP_CONTRACT", `${what}.contract`); - expressionShape(record.program, `${what}.program`); - const program = json(record.program, `${what}.program`); - jsonBytes(program, `${what}.program`); + if (record.contract !== "algal.expr.v1") fail("EVENT_LOOP_CONTRACT", `${what}.contract`, at && [...at, "contract"]); + expressionShape(record.program, `${what}.program`, at && [...at, "program"]); + const program = json(record.program, `${what}.program`, at && [...at, "program"]); + jsonBytes(program, `${what}.program`, at && [...at, "program"]); const checked = checkProgram(program, ["state", "event"]); if (!checked.ok) { - if (checked.err.code === "EXPR_BOUNDS") fail("EVENT_LOOP_BOUNDS", `${what}.program exceeds expression bounds`); - if (checked.err.code === "EXPR_PATH") fail("EVENT_LOOP_REFERENCE", `${what}.program names an undeclared value`); - fail("EVENT_LOOP_PARSE", `${what}.program is not a valid expression`); + if (checked.err.code === "EXPR_BOUNDS") fail("EVENT_LOOP_BOUNDS", `${what}.program exceeds expression bounds`, at && [...at, "program"]); + if (checked.err.code === "EXPR_PATH") fail("EVENT_LOOP_REFERENCE", `${what}.program names an undeclared value`, at && [...at, "program"]); + fail("EVENT_LOOP_PARSE", `${what}.program is not a valid expression`, at && [...at, "program"]); } } @@ -206,28 +207,30 @@ function matchesType(value: unknown, type: string): boolean { } } -function parseSchema(value: unknown, withInitial: boolean, what: string): Map { - const record = object(value, what); - exact(record, ["kind", "fields"], what); - if (record.kind !== "record" || !Array.isArray(record.fields) || record.fields.length > 64) { - fail("EVENT_LOOP_SCHEMA", `${what} must be a bounded record`); +function parseSchema(value: unknown, withInitial: boolean, what: string, at?: readonly (string | number)[]): Map { + const record = object(value, what, at); + exact(record, ["kind", "fields"], what, at); + if (record.kind !== "record") fail("EVENT_LOOP_SCHEMA", `${what}.kind`, at && [...at, "kind"]); + if (!Array.isArray(record.fields) || record.fields.length > 64) { + fail("EVENT_LOOP_SCHEMA", `${what}.fields must be a bounded list`, at && [...at, "fields"]); } const fields = new Map(); for (const [index, raw] of record.fields.entries()) { - const field = object(raw, `${what}.fields[${index}]`); + const fieldAt = at && [...at, "fields", index]; + const field = object(raw, `${what}.fields[${index}]`, fieldAt); const keys = withInitial ? ["name", "type", "initial"] : ["name", "type"]; - exact(field, keys, `${what}.fields[${index}]`); - const name = identifier(field.name, `${what}.fields[${index}].name`); - if (fields.has(name)) fail("EVENT_LOOP_ID", `${what} duplicates ${name}`); + exact(field, keys, `${what}.fields[${index}]`, fieldAt); + const name = identifier(field.name, `${what}.fields[${index}].name`, fieldAt && [...fieldAt, "name"]); + if (fields.has(name)) fail("EVENT_LOOP_ID", `${what} duplicates ${name}`, fieldAt && [...fieldAt, "name"]); const type = field.type; if (typeof type !== "string" || !TYPES.has(type)) { - fail("EVENT_LOOP_SCHEMA", `${what}.fields[${index}].type`); + fail("EVENT_LOOP_SCHEMA", `${what}.fields[${index}].type`, fieldAt && [...fieldAt, "type"]); } fields.set(name, type); if (withInitial) { - json(field.initial, `${what}.fields[${index}].initial`); + json(field.initial, `${what}.fields[${index}].initial`, fieldAt && [...fieldAt, "initial"]); if (!matchesType(field.initial, type)) { - fail("EVENT_LOOP_SCHEMA", `${what}.fields[${index}].initial does not match ${type}`); + fail("EVENT_LOOP_SCHEMA", `${what}.fields[${index}].initial does not match ${type}`, fieldAt && [...fieldAt, "initial"]); } } } @@ -235,31 +238,32 @@ function parseSchema(value: unknown, withInitial: boolean, what: string): Map = {}; - for (const raw of state.fields as unknown[]) { - const field = object(raw, "state field"); - initial[identifier(field.name, "state field name")] = json(field.initial, "initial state field"); + for (const [fieldIndex, raw] of (state.fields as unknown[]).entries()) { + const fieldAt = [...at, "state", "fields", fieldIndex]; + const field = object(raw, "state field", fieldAt); + initial[identifier(field.name, "state field name", [...fieldAt, "name"])] = + json(field.initial, "initial state field", [...fieldAt, "initial"]); } - if (canonicalBytes(initial, `actors[${index}].initial`) > maxStateBytes) { - fail("EVENT_LOOP_BOUNDS", `actors[${index}].initial exceeds maxStateBytes`); + if (canonicalBytes(initial, `actors[${index}].initial`, [...at, "state"]) > maxStateBytes) { + fail("EVENT_LOOP_BOUNDS", `actors[${index}].initial exceeds maxStateBytes`, ["limits", "maxStateBytes"]); } if (!Array.isArray(record.transitions) || record.transitions.length < 1 || record.transitions.length > 256) { - fail("EVENT_LOOP_BOUNDS", `actors[${index}].transitions`); + fail("EVENT_LOOP_BOUNDS", `actors[${index}].transitions`, [...at, "transitions"]); } const transitionIds = new Set(); for (const [transitionIndex, raw] of record.transitions.entries()) { - const transition = object(raw, `actors[${index}].transitions[${transitionIndex}]`); - exact(transition, ["id", "on", "when", "writes", "outputs", "result"], `actors[${index}].transitions[${transitionIndex}]`); - const transitionId = identifier(transition.id, "transition.id"); - if (transitionIds.has(transitionId)) fail("EVENT_LOOP_ID", `duplicate transition ${transitionId}`); + const transitionAt = [...at, "transitions", transitionIndex]; + const transition = object(raw, `actors[${index}].transitions[${transitionIndex}]`, transitionAt); + exact(transition, ["id", "on", "when", "writes", "outputs", "result"], `actors[${index}].transitions[${transitionIndex}]`, transitionAt); + const transitionId = identifier(transition.id, "transition.id", [...transitionAt, "id"]); + if (transitionIds.has(transitionId)) fail("EVENT_LOOP_ID", `duplicate transition ${transitionId}`, [...transitionAt, "id"]); transitionIds.add(transitionId); if (typeof transition.on !== "string" || !eventIds.has(transition.on)) { - fail("EVENT_LOOP_REFERENCE", `transition ${transitionId} event`, ["actors", index, "transitions", transitionIndex, "on"]); + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId} event`, [...transitionAt, "on"]); } - if (transition.when !== null) expression(transition.when, `transition ${transitionId}.when`, ["actors", index, "transitions", transitionIndex, "when"]); - if (!Array.isArray(transition.writes) || transition.writes.length > 64) fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.writes`); + if (transition.when !== null) expression(transition.when, `transition ${transitionId}.when`, [...transitionAt, "when"]); + if (!Array.isArray(transition.writes) || transition.writes.length > 64) fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.writes`, [...transitionAt, "writes"]); for (const [writeIndex, rawWrite] of transition.writes.entries()) { - const write = object(rawWrite, `transition ${transitionId}.writes[${writeIndex}]`); - exact(write, ["path", "value"], `transition ${transitionId}.writes[${writeIndex}]`); + const writeAt = [...transitionAt, "writes", writeIndex]; + const write = object(rawWrite, `transition ${transitionId}.writes[${writeIndex}]`, writeAt); + exact(write, ["path", "value"], `transition ${transitionId}.writes[${writeIndex}]`, writeAt); if (!Array.isArray(write.path) || write.path.length !== 1) { - fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.path`); + fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.path`, [...writeAt, "path"]); } if (typeof write.path[0] !== "string" || !stateFields.has(write.path[0])) { - fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.state field`); + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.state field`, [...writeAt, "path", 0]); } - for (const [pathIndex, segment] of write.path.entries()) identifier(segment, `transition ${transitionId}.path[${pathIndex}]`); - expression(write.value, `transition ${transitionId}.value`); + for (const [pathIndex, segment] of write.path.entries()) identifier(segment, `transition ${transitionId}.path[${pathIndex}]`, [...writeAt, "path", pathIndex]); + expression(write.value, `transition ${transitionId}.value`, [...writeAt, "value"]); } - if (!Array.isArray(transition.outputs) || transition.outputs.length > maxOutputs) { - fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.outputs`, ["limits", "maxOutputEvents"]); + if (!Array.isArray(transition.outputs) || transition.outputs.length > 64) { + fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.outputs`, [...transitionAt, "outputs"]); + } + if (transition.outputs.length > maxOutputs) { + fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.outputs exceeds maxOutputEvents`, ["limits", "maxOutputEvents"]); } for (const [outputIndex, rawOutput] of transition.outputs.entries()) { - const output = object(rawOutput, `transition ${transitionId}.outputs[${outputIndex}]`); - exact(output, ["kind", "target", "event", "payload"], `transition ${transitionId}.outputs[${outputIndex}]`); - if (output.kind !== "notify") fail("EVENT_LOOP_SEMANTIC", `transition ${transitionId}.output kind`); + const outputAt = [...transitionAt, "outputs", outputIndex]; + const output = object(rawOutput, `transition ${transitionId}.outputs[${outputIndex}]`, outputAt); + exact(output, ["kind", "target", "event", "payload"], `transition ${transitionId}.outputs[${outputIndex}]`, outputAt); + if (output.kind !== "notify") fail("EVENT_LOOP_SEMANTIC", `transition ${transitionId}.output kind`, [...outputAt, "kind"]); if (output.target !== "self" && (typeof output.target !== "string" || !actorIds.has(output.target))) { - fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.output target`); + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.output target`, [...outputAt, "target"]); } if (typeof output.event !== "string" || !eventIds.has(output.event)) { - fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.output event`); + fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.output event`, [...outputAt, "event"]); } - expression(output.payload, `transition ${transitionId}.output payload`); + expression(output.payload, `transition ${transitionId}.output payload`, [...outputAt, "payload"]); } - parseResult(transition.result, transition.writes.length, transition.outputs.length, `transition ${transitionId}.result`, ["actors", index, "transitions", transitionIndex, "result"]); + parseResult(transition.result, transition.writes.length, transition.outputs.length, `transition ${transitionId}.result`, [...transitionAt, "result"]); } return actorId; } function parseEventLoop(value: unknown): RecordValue { - const root = object(value, "event-loop"); + const root = object(value, "event-loop", []); exact(root, ["contract", "behavior", "limits", "events", "actors", "digest"], "event-loop", []); if (root.contract !== "algal.event-loop.v1") fail("EVENT_LOOP_CONTRACT", "event-loop.contract", ["contract"]); - const behavior = object(root.behavior, "behavior"); + const behavior = object(root.behavior, "behavior", ["behavior"]); exact(behavior, ["id", "revision", "sourceDigest", "digest"], "behavior", ["behavior"]); - identifier(behavior.id, "behavior.id"); - integer(behavior.revision, "behavior.revision", 1, 2_147_483_647); + identifier(behavior.id, "behavior.id", ["behavior", "id"]); + integer(behavior.revision, "behavior.revision", 1, 2_147_483_647, ["behavior", "revision"]); digest(behavior.sourceDigest, "behavior.sourceDigest", ["behavior", "sourceDigest"]); - verifyDigest(behavior, "behavior"); - const limits = object(root.limits, "limits"); - exact(limits, ["maxEventBytes", "maxStateBytes", "maxOutputEvents", "maxTransitionFuel", "maxMailboxDepth"], "limits"); + verifyDigest(behavior, "behavior", ["behavior"]); + const limits = object(root.limits, "limits", ["limits"]); + exact(limits, ["maxEventBytes", "maxStateBytes", "maxOutputEvents", "maxTransitionFuel", "maxMailboxDepth"], "limits", ["limits"]); integer(limits.maxEventBytes, "limits.maxEventBytes", 1, 65_536, ["limits", "maxEventBytes"]); - integer(limits.maxStateBytes, "limits.maxStateBytes", 1, 262_144); - integer(limits.maxOutputEvents, "limits.maxOutputEvents", 0, 64); - integer(limits.maxTransitionFuel, "limits.maxTransitionFuel", 1, 100_000); - integer(limits.maxMailboxDepth, "limits.maxMailboxDepth", 1, 1_024); - if (!Array.isArray(root.events) || root.events.length < 1 || root.events.length > 128) fail("EVENT_LOOP_BOUNDS", "events"); + integer(limits.maxStateBytes, "limits.maxStateBytes", 1, 262_144, ["limits", "maxStateBytes"]); + integer(limits.maxOutputEvents, "limits.maxOutputEvents", 0, 64, ["limits", "maxOutputEvents"]); + integer(limits.maxTransitionFuel, "limits.maxTransitionFuel", 1, 100_000, ["limits", "maxTransitionFuel"]); + integer(limits.maxMailboxDepth, "limits.maxMailboxDepth", 1, 1_024, ["limits", "maxMailboxDepth"]); + if (!Array.isArray(root.events) || root.events.length < 1 || root.events.length > 128) fail("EVENT_LOOP_BOUNDS", "events", ["events"]); const eventIds = new Set(); for (const [index, raw] of root.events.entries()) { const id = parseEvent(raw, index); if (eventIds.has(id)) fail("EVENT_LOOP_ID", `duplicate event ${id}`, ["events", index, "id"]); eventIds.add(id); } - if (!Array.isArray(root.actors) || root.actors.length < 1 || root.actors.length > 64) fail("EVENT_LOOP_BOUNDS", "actors"); + if (!Array.isArray(root.actors) || root.actors.length < 1 || root.actors.length > 64) fail("EVENT_LOOP_BOUNDS", "actors", ["actors"]); const actorIds = new Set(); for (const [index, raw] of root.actors.entries()) { - const actor = object(raw, `actors[${index}]`); - exact(actor, ["id", "state", "transitions"], `actors[${index}]`); - const id = identifier(actor.id, `actors[${index}].id`); - if (actorIds.has(id)) fail("EVENT_LOOP_ID", `duplicate actor ${id}`); + const actorAt = ["actors", index]; + const actor = object(raw, `actors[${index}]`, actorAt); + exact(actor, ["id", "state", "transitions"], `actors[${index}]`, actorAt); + const id = identifier(actor.id, `actors[${index}].id`, [...actorAt, "id"]); + if (actorIds.has(id)) fail("EVENT_LOOP_ID", `duplicate actor ${id}`, [...actorAt, "id"]); actorIds.add(id); } for (const [index, raw] of root.actors.entries()) parseActor(raw, index, eventIds, actorIds, limits.maxOutputEvents as number, limits.maxStateBytes as number); - verifyDigest(root, "event-loop"); + verifyDigest(root, "event-loop", []); return root; } @@ -413,7 +427,7 @@ function path(value: unknown, what: string): string { return result; } -function parseProject(value: unknown): RecordValue { +function parseProjectRecord(value: unknown): RecordValue { const root = object(value, "project"); exact(root, ["contract", "id", "entry", "files", "digest"], "project"); if (root.contract !== "algal.language-project.v1") fail("EVENT_LOOP_CONTRACT", "project.contract"); @@ -434,6 +448,16 @@ function parseProject(value: unknown): RecordValue { return root; } +// The fixture-reading boundary checks the bytes of every referenced file. +// Record-only validation cannot establish the project closure's byte limit. +async function parseProject( + value: unknown, + readBytes: (path: string) => Promise = (filePath) => readFile(join(ROOT, filePath)), +): Promise<{ byteLength: number; projectDigest: string; closureDigest: string }> { + parseProjectRecord(value); + return verifyProjectClosure(value, readBytes); +} + function parseFixtureEntry(value: unknown, expectedKind: "actor" | "project" | "invalid"): void { const entry = object(value, "fixture"); exact(entry, ["id", "path", "kind", "sha256", "expected"], "fixture"); @@ -536,9 +560,9 @@ function withoutDigest(value: unknown): JsonValue { return json(copy, "record without digest"); } -function verifyDigest(record: RecordValue, what: string): void { - if (digest(record.digest, `${what}.digest`) !== canonicalDigest(withoutDigest(record))) { - fail("EVENT_LOOP_DIGEST", `${what}.digest does not match canonical content`); +function verifyDigest(record: RecordValue, what: string, at?: readonly (string | number)[]): void { + if (digest(record.digest, `${what}.digest`, at && [...at, "digest"]) !== canonicalDigest(withoutDigest(record))) { + fail("EVENT_LOOP_DIGEST", `${what}.digest does not match canonical content`, at && [...at, "digest"]); } } @@ -821,7 +845,7 @@ describe("language benchmark contract fixtures", () => { const corpus = await load("corpus.json"); parseEventLoop(actor); parseEvents(events, actor); - parseProject(project); + await parseProject(project); parseCorpus(corpus); const actorRecord = object(actor, "actor"); const behavior = object(actorRecord.behavior, "behavior"); @@ -900,18 +924,11 @@ describe("language benchmark contract fixtures", () => { const projectEntry = object((fixtures.projects as unknown[])[0], "project fixture"); const projectExpected = object(projectEntry.expected, "project expected"); const project = object(await load(path(projectEntry.path, "project fixture path")), "project"); - parseProject(project); - expect(projectExpected.projectDigest).toBe(project.digest); - expect(projectExpected.closureDigest).toBe(canonicalDigest(project.files)); - let closureBytes = 0; - for (const raw of project.files as unknown[]) { - const file = object(raw, "project file"); - const bytes = await readFile(join(ROOT, path(file.path, "project file path"))); - closureBytes += bytes.byteLength; - expect(rawDigest(bytes)).toBe(digest(file.digest, "project file digest")); - } - expect(closureBytes).toBeGreaterThan(0); - expect(closureBytes).toBeLessThanOrEqual(262_144); + const closure = await parseProject(project); + expect(projectExpected.projectDigest).toBe(closure.projectDigest); + expect(projectExpected.closureDigest).toBe(closure.closureDigest); + expect(closure.byteLength).toBeGreaterThan(0); + expect(closure.byteLength).toBeLessThanOrEqual(262_144); const result = object(await load("results/smoke.json"), "result"); const cases = result.cases as unknown[]; const actorObserved = object(object(cases.find((raw) => object(raw, "case").id === actorEntry.id), "actor case").observed, "actor observed"); @@ -919,8 +936,8 @@ describe("language benchmark contract fixtures", () => { expect(actorObserved.interfaceDigest).toBe(canonicalDigest({ events: actor.events, actors: actorSchemas })); expect(actorObserved.eventDigest).toBe(eventVector.digest); const projectObserved = object(object(cases.find((raw) => object(raw, "case").id === projectEntry.id), "project case").observed, "project observed"); - expect(projectObserved.projectDigest).toBe(project.digest); - expect(projectObserved.closureDigest).toBe(canonicalDigest(project.files)); + expect(projectObserved.projectDigest).toBe(closure.projectDigest); + expect(projectObserved.closureDigest).toBe(closure.closureDigest); }); test("every invalid fixture fails with its recorded code and bounded span", async () => { @@ -1054,6 +1071,97 @@ describe("language benchmark contract fixtures", () => { expect(errorCode(() => expression(envelope(["quote", [quoted]]), "expression"))).toBe("EVENT_LOOP_BOUNDS"); }); + test("contract-owned unknown keys have exact UTF-8 key spans at each depth", async () => { + const base = await load("actors/echo.json"); + const paths: readonly (readonly (string | number)[])[] = [ + [], ["behavior"], ["limits"], ["events", 0], ["events", 0, "payload"], + ["events", 0, "payload", "fields", 0], ["actors", 0], ["actors", 0, "state"], + ["actors", 0, "state", "fields", 0], ["actors", 0, "transitions", 0], + ["actors", 0, "transitions", 0, "when"], + ["actors", 0, "transitions", 0, "writes", 0], + ["actors", 0, "transitions", 0, "writes", 0, "value"], + ["actors", 0, "transitions", 0, "outputs", 0], + ["actors", 0, "transitions", 0, "outputs", 0, "payload"], + ["actors", 0, "transitions", 0, "result"], + ]; + for (const at of paths) { + const actor = structuredClone(base) as RecordValue; + let field: unknown = actor; + for (const part of at) field = (field as Record)[part]; + const key = "éxtra"; + (field as RecordValue)[key] = true; + const bytes = new TextEncoder().encode(JSON.stringify(actor)); + const span = locateJsonSpan(bytes, [...at, key], true); + expect(checkEventLoopBytes(bytes), at.join(".")).toEqual({ code: "EVENT_LOOP_UNKNOWN_KEY", span }); + expect(new TextDecoder().decode(bytes.subarray(span.start, span.end))).toBe(key); + } + }); + + test("all behavior numeric bounds return exact UTF-8 value spans", async () => { + const base = await load("actors/echo.json"); + for (const [key, min, max] of [ + ["maxEventBytes", 1, 65_536], ["maxStateBytes", 1, 262_144], + ["maxOutputEvents", 0, 64], ["maxTransitionFuel", 1, 100_000], + ["maxMailboxDepth", 1, 1_024], + ] as const) { + for (const value of [min - 1, max + 1, 1.5, "2"]) { + const actor = structuredClone(base) as RecordValue; + object(actor.limits, "limits")[key] = value; + const bytes = new TextEncoder().encode(JSON.stringify(actor)); + const span = locateJsonSpan(bytes, ["limits", key]); + expect(checkEventLoopBytes(bytes), `${key}=${value}`).toEqual({ code: "EVENT_LOOP_BOUNDS", span }); + expect(new TextDecoder().decode(bytes.subarray(span.start, span.end))).toBe(String(value)); + } + const actor = structuredClone(base) as RecordValue; + object(actor.limits, "limits")[key] = max; + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(checkEventLoopBytes(new TextEncoder().encode(JSON.stringify(actor))), `${key} max`).toEqual({ code: "ACCEPTED" }); + } + const atZero = structuredClone(base) as RecordValue; + object(atZero.limits, "limits").maxOutputEvents = 0; + const transition = object((object((atZero.actors as unknown[])[0], "actor").transitions as unknown[])[0], "transition"); + transition.outputs = []; + atZero.digest = canonicalDigest(withoutDigest(atZero)); + expect(checkEventLoopBytes(new TextEncoder().encode(JSON.stringify(atZero)))).toEqual({ code: "ACCEPTED" }); + for (const revision of [0, 2_147_483_648, 1.5]) { + const actor = structuredClone(base) as RecordValue; + object(actor.behavior, "behavior").revision = revision; + const bytes = new TextEncoder().encode(JSON.stringify(actor)); + expect(checkEventLoopBytes(bytes)).toEqual({ + code: "EVENT_LOOP_BOUNDS", span: locateJsonSpan(bytes, ["behavior", "revision"]), + }); + } + const highRevision = structuredClone(base) as RecordValue; + const behavior = object(highRevision.behavior, "behavior"); + behavior.revision = 2_147_483_647; + behavior.digest = canonicalDigest(withoutDigest(behavior)); + highRevision.digest = canonicalDigest(withoutDigest(highRevision)); + expect(checkEventLoopBytes(new TextEncoder().encode(JSON.stringify(highRevision)))).toEqual({ code: "ACCEPTED" }); + }); + + test("project fixture boundary rejects a closure one byte over the limit", async () => { + const contents = new Map(Array.from({ length: 17 }, (_, i) => + [`file-${i}`, new Uint8Array(i === 16 ? 1 : 16_384).fill(97)] as const)); + const files = [...contents].map(([filePath, bytes]) => ({ path: filePath, digest: rawDigest(bytes) })); + const body = { contract: "algal.language-project.v1", id: "fixture", entry: "file-0", files }; + const project = { ...body, digest: canonicalDigest(body) }; + const readBytes = async (filePath: string): Promise => contents.get(filePath)!; + const full = { ...project, files: files.slice(0, 16) }; + full.digest = canonicalDigest(withoutDigest(full)); + expect((await parseProject(full, readBytes)).byteLength).toBe(262_144); + try { + await parseProject(project, readBytes); + throw new Error("closure above limit was accepted"); + } catch (error) { + if (!(error instanceof ProjectClosureError)) throw error; + expect(error.code).toBe("EVENT_LOOP_BOUNDS"); + expect(error.at).toEqual(["files", 16, "path"]); + const bytes = new TextEncoder().encode(JSON.stringify(project)); + const span = locateJsonSpan(bytes, error.at); + expect(new TextDecoder().decode(bytes.subarray(span.start, span.end))).toBe("file-16"); + } + }); + test("unknown keys and first out-of-range limits are rejected", async () => { const actor = object(await load("actors/echo.json"), "actor"); const unknown = structuredClone(actor) as RecordValue; @@ -1072,7 +1180,7 @@ describe("language benchmark contract fixtures", () => { const records: [unknown, (value: unknown) => unknown][] = [ [actor, parseEventLoop], [events, (value) => parseEvents(value, actor)], - [await load("projects/echo.project.json"), parseProject], + [await load("projects/echo.project.json"), parseProjectRecord], [await load("corpus.json"), parseCorpus], [await load("results/smoke.json"), (value) => parseBenchmarkResult(value, awaitCorpus)], ]; @@ -1344,7 +1452,7 @@ describe("language benchmark contract fixtures", () => { expectMutation(actor, parseEventLoop, (copy) => { object((object((copy.actors as unknown[])[0], "actor").transitions as unknown[])[0], "transition").writes = Array(65).fill({}); }, "EVENT_LOOP_BOUNDS"); expectMutation(actor, parseEventLoop, (copy) => { object((object((copy.actors as unknown[])[0], "actor").transitions as unknown[])[0], "transition").outputs = Array(65).fill({}); }, "EVENT_LOOP_BOUNDS"); expectMutation(events, (value) => parseEvents(value, actor), (copy) => { copy.events = Array(257).fill((events.events as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); - expectMutation(project, parseProject, (copy) => { copy.files = Array(65).fill((project.files as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); + expectMutation(project, parseProjectRecord, (copy) => { copy.files = Array(65).fill((project.files as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); expectMutation(corpus, parseCorpus, (copy) => { object(copy.fixtures, "fixtures").invalid = Array(129).fill((object(corpus.fixtures, "fixtures").invalid as unknown[])[0]); }, "EVENT_LOOP_BOUNDS"); expectMutation(result, (value) => parseBenchmarkResult(value, corpus), (copy) => { object(copy.protocol, "protocol").warmupSamples = 101; }, "EVENT_LOOP_BOUNDS"); const protocol = object(result.protocol, "protocol"); diff --git a/benchmarks/language/project-closure.test.ts b/benchmarks/language/project-closure.test.ts new file mode 100644 index 00000000..8496fc76 --- /dev/null +++ b/benchmarks/language/project-closure.test.ts @@ -0,0 +1,131 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { canonicalize } from "../../src/values"; +import { locateJsonSpan } from "./span"; +import { ProjectClosureError, verifyProjectClosure } from "./project-closure"; + +const hash = (value: Uint8Array | string): string => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const source = (length: number): Uint8Array => new Uint8Array(length).fill(97); +const root = import.meta.dir; + +type Project = { + contract: "algal.language-project.v1"; + id: string; + entry: string; + files: { path: string; digest: string }[]; + digest: string; +}; + +function fixture(chunks: readonly Uint8Array[]): { project: Project; read: (path: string) => Promise } { + const contents = new Map(chunks.map((bytes, index) => [`file-${index}`, bytes])); + const files = [...contents].map(([path, bytes]) => ({ path, digest: hash(bytes) })); + const body = { contract: "algal.language-project.v1" as const, id: "fixture", entry: "file-0", files }; + return { + project: { ...body, digest: hash(canonicalize(body)) }, + read: async (path) => { + const bytes = contents.get(path); + if (!bytes) throw new Error("missing file"); + return bytes; + }, + }; +} + +function rehash(project: Project): void { + const { digest: _digest, ...body } = project; + project.digest = hash(canonicalize(body)); +} + +async function rejected(value: unknown, read: (path: string) => Promise): Promise { + try { + await verifyProjectClosure(value, read); + } catch (error) { + if (error instanceof ProjectClosureError) return error; + throw error; + } + throw new Error("expected a rejected project closure"); +} + +test("checked project fixture closes over its actual pinned file bytes", async () => { + const project = JSON.parse(await readFile(join(root, "projects/echo.project.json"), "utf8")) as unknown; + const result = await verifyProjectClosure(project, (path) => readFile(join(root, path))); + expect(result).toEqual({ + byteLength: (await readFile(join(root, "actors/echo.json"))).byteLength + + (await readFile(join(root, "actors/echo.events.json"))).byteLength, + projectDigest: "sha256:fbf02f7559fe86742d9e8e36b1cb01f80686dad35022c46613dd6dcd368b41dc", + closureDigest: "sha256:7f79a6f4e1bd22c9006b8c0c691d9e2b670eb2a6417dfc3f22940ad9834791a9", + }); +}); + +test("closure accepts 262144 bytes and rejects 262145 with a UTF-8 file-path span", async () => { + const full = fixture(Array.from({ length: 16 }, () => source(16_384))); + expect((await verifyProjectClosure(full.project, full.read)).byteLength).toBe(262_144); + const excess = fixture([...Array.from({ length: 16 }, () => source(16_384)), source(1)]); + const error = await rejected(excess.project, excess.read); + expect(error.code).toBe("EVENT_LOOP_BOUNDS"); + expect(error.at).toEqual(["files", 16, "path"]); + const encoded = new TextEncoder().encode(JSON.stringify(excess.project)); + const span = locateJsonSpan(encoded, error.at); + expect(new TextDecoder().decode(encoded.subarray(span.start, span.end))).toBe("file-16"); +}); + +test("file size, file count, id and path limits reject the first excess", async () => { + const one = fixture([source(1)]); + expect((await verifyProjectClosure(one.project, one.read)).byteLength).toBe(1); + const largestFile = fixture([source(16_384)]); + expect((await verifyProjectClosure(largestFile.project, largestFile.read)).byteLength).toBe(16_384); + for (const length of [0, 16_385]) { + const data = fixture([source(length)]); + expect((await rejected(data.project, data.read)).code).toBe("EVENT_LOOP_BOUNDS"); + } + const sixtyFour = fixture(Array.from({ length: 64 }, () => source(1))); + expect((await verifyProjectClosure(sixtyFour.project, sixtyFour.read)).byteLength).toBe(64); + for (const count of [0, 65]) { + const data = fixture(Array.from({ length: count }, () => source(1))); + expect((await rejected(data.project, data.read)).code).toBe("EVENT_LOOP_BOUNDS"); + } + const data = fixture([source(1)]); + for (const length of [1, 64]) { + data.project.id = "a".repeat(length); + rehash(data.project); + expect((await verifyProjectClosure(data.project, data.read)).byteLength).toBe(1); + } + for (const id of ["", "a".repeat(65), "é"]) { + data.project.id = id; + expect((await rejected(data.project, data.read)).code).toBe("EVENT_LOOP_ID"); + } + data.project.id = "fixture"; + for (const path of ["a", "a".repeat(512), "é".repeat(256)]) { + data.project.entry = path; + data.project.files[0]!.path = path; + rehash(data.project); + expect((await verifyProjectClosure(data.project, async () => source(1))).byteLength).toBe(1); + } + for (const path of ["", "a".repeat(513), "é".repeat(257), "../escape", "a\\b"]) { + data.project.entry = path; + expect((await rejected(data.project, data.read)).code).toBe("EVENT_LOOP_PARSE"); + } +}); + +test("closed project records reject unknown fields, changed bytes and missing files", async () => { + const data = fixture([source(1)]); + const wrongContract = { ...data.project, contract: "algal.language-project.v2" }; + const error = await rejected(wrongContract, data.read); + expect(error.code).toBe("EVENT_LOOP_CONTRACT"); + expect(error.at).toEqual(["contract"]); + expect((await rejected({ ...data.project, extra: 1 }, data.read)).code).toBe("EVENT_LOOP_UNKNOWN_KEY"); + const extra = structuredClone(data.project); + Object.assign(extra.files[0]!, { extra: 1 }); + const unknown = await rejected(extra, data.read); + expect(unknown.code).toBe("EVENT_LOOP_UNKNOWN_KEY"); + expect(unknown.at).toEqual(["files", 0, "extra"]); + const encoded = new TextEncoder().encode(JSON.stringify(extra)); + const span = locateJsonSpan(encoded, unknown.at, true); + expect(new TextDecoder().decode(encoded.subarray(span.start, span.end))).toBe("extra"); + expect((await rejected(data.project, async () => source(2))).code).toBe("EVENT_LOOP_DIGEST"); + expect((await rejected(data.project, async () => { throw new Error("missing"); })).code).toBe("EVENT_LOOP_REFERENCE"); + const wrongDigest = structuredClone(data.project); + wrongDigest.digest = `sha256:${"0".repeat(64)}`; + expect((await rejected(wrongDigest, data.read)).code).toBe("EVENT_LOOP_DIGEST"); +}); diff --git a/benchmarks/language/project-closure.ts b/benchmarks/language/project-closure.ts new file mode 100644 index 00000000..aaba59d1 --- /dev/null +++ b/benchmarks/language/project-closure.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { canonicalize } from "../../src/values"; + +// This checks a project and the raw files it names. A project digest alone +// cannot establish the size or contents of the referenced closure. +const MAX_FILES = 64; +const MAX_ID_BYTES = 64; +const MAX_PATH_BYTES = 512; +const MAX_FILE_BYTES = 16_384; +const MAX_CLOSURE_BYTES = 262_144; +const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const DIGEST = /^sha256:[0-9a-f]{64}$/; +type At = readonly (string | number)[]; +type File = { path: string; digest: string }; + +export class ProjectClosureError extends Error { + constructor(readonly code: "EVENT_LOOP_PARSE" | "EVENT_LOOP_CONTRACT" | "EVENT_LOOP_UNKNOWN_KEY" | "EVENT_LOOP_ID" | + "EVENT_LOOP_BOUNDS" | "EVENT_LOOP_REFERENCE" | "EVENT_LOOP_DIGEST", readonly at: At) { + super(`${code} at ${at.join(".")}`); + } +} + +function fail(code: ProjectClosureError["code"], at: At): never { + throw new ProjectClosureError(code, at); +} + +function record(value: unknown, keys: readonly string[], at: At): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) fail("EVENT_LOOP_PARSE", at); + const result = value as Record; + for (const key of Object.keys(result)) { + if (!keys.includes(key)) fail("EVENT_LOOP_UNKNOWN_KEY", [...at, key]); + } + for (const key of keys) { + if (!Object.hasOwn(result, key)) fail("EVENT_LOOP_PARSE", [...at, key]); + } + return result; +} + +function id(value: unknown, at: At): string { + if (typeof value !== "string" || value.length === 0 || + Buffer.byteLength(value, "utf8") > MAX_ID_BYTES || !ID.test(value)) fail("EVENT_LOOP_ID", at); + return value; +} + +function path(value: unknown, at: At): string { + if (typeof value !== "string" || value.length === 0 || + Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES || value.startsWith("/") || + value.includes("\\") || value.split("/").some((part) => !part || part === "." || part === "..")) { + fail("EVENT_LOOP_PARSE", at); + } + return value; +} + +function sha(value: unknown, at: At): string { + if (typeof value !== "string" || !DIGEST.test(value)) fail("EVENT_LOOP_DIGEST", at); + return value; +} + +function hash(bytes: Uint8Array | string): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +export async function verifyProjectClosure( + value: unknown, + readBytes: (path: string) => Promise, +): Promise<{ byteLength: number; projectDigest: string; closureDigest: string }> { + const root = record(value, ["contract", "id", "entry", "files", "digest"], []); + if (root.contract !== "algal.language-project.v1") fail("EVENT_LOOP_CONTRACT", ["contract"]); + const projectId = id(root.id, ["id"]); + const entry = path(root.entry, ["entry"]); + if (!Array.isArray(root.files) || root.files.length < 1 || root.files.length > MAX_FILES) { + fail("EVENT_LOOP_BOUNDS", ["files"]); + } + const files: File[] = []; + const paths = new Set(); + for (const [index, raw] of root.files.entries()) { + const at: At = ["files", index]; + const file = record(raw, ["path", "digest"], at); + const filePath = path(file.path, [...at, "path"]); + if (paths.has(filePath)) fail("EVENT_LOOP_ID", [...at, "path"]); + paths.add(filePath); + files.push({ path: filePath, digest: sha(file.digest, [...at, "digest"]) }); + } + if (!paths.has(entry)) fail("EVENT_LOOP_REFERENCE", ["entry"]); + const projectDigest = hash(canonicalize({ contract: root.contract, id: projectId, entry, files })); + if (sha(root.digest, ["digest"]) !== projectDigest) fail("EVENT_LOOP_DIGEST", ["digest"]); + + let byteLength = 0; + for (const [index, file] of files.entries()) { + let bytes: Uint8Array; + try { + bytes = await readBytes(file.path); + } catch { + fail("EVENT_LOOP_REFERENCE", ["files", index, "path"]); + } + if (!(bytes instanceof Uint8Array)) fail("EVENT_LOOP_PARSE", ["files", index, "path"]); + if (bytes.byteLength < 1 || bytes.byteLength > MAX_FILE_BYTES) { + fail("EVENT_LOOP_BOUNDS", ["files", index, "path"]); + } + byteLength += bytes.byteLength; + if (byteLength > MAX_CLOSURE_BYTES) fail("EVENT_LOOP_BOUNDS", ["files", index, "path"]); + if (hash(bytes) !== file.digest) fail("EVENT_LOOP_DIGEST", ["files", index, "digest"]); + } + return { byteLength, projectDigest, closureDigest: hash(canonicalize(files)) }; +} From 6d5e8849a12aac6e088ca63025b58891d9e20c31 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:32:11 -0400 Subject: [PATCH 3/7] fix: reject unsafe P0 project paths before reading bytes --- benchmarks/language/project-closure.test.ts | 10 +++++++++- benchmarks/language/project-closure.ts | 14 +++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/benchmarks/language/project-closure.test.ts b/benchmarks/language/project-closure.test.ts index 8496fc76..cf41b0d8 100644 --- a/benchmarks/language/project-closure.test.ts +++ b/benchmarks/language/project-closure.test.ts @@ -102,10 +102,18 @@ test("file size, file count, id and path limits reject the first excess", async rehash(data.project); expect((await verifyProjectClosure(data.project, async () => source(1))).byteLength).toBe(1); } - for (const path of ["", "a".repeat(513), "é".repeat(257), "../escape", "a\\b"]) { + for (const path of ["", "a".repeat(513), "é".repeat(257), "../escape", "a\\b", "C:/escape", "C:escape", "a\0b", "a\nb"]) { data.project.entry = path; expect((await rejected(data.project, data.read)).code).toBe("EVENT_LOOP_PARSE"); } + for (const path of ["C:/escape", "a\0b"]) { + data.project.entry = "file-0"; + data.project.files[0]!.path = path; + rehash(data.project); + const error = await rejected(data.project, async () => { throw new Error("must not read"); }); + expect(error.code).toBe("EVENT_LOOP_PARSE"); + expect(error.at).toEqual(["files", 0, "path"]); + } }); test("closed project records reject unknown fields, changed bytes and missing files", async () => { diff --git a/benchmarks/language/project-closure.ts b/benchmarks/language/project-closure.ts index aaba59d1..2642e614 100644 --- a/benchmarks/language/project-closure.ts +++ b/benchmarks/language/project-closure.ts @@ -42,12 +42,16 @@ function id(value: unknown, at: At): string { return value; } +function isProjectPath(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && + Buffer.byteLength(value, "utf8") <= MAX_PATH_BYTES && !value.startsWith("/") && + !value.includes("\\") && !/^[a-zA-Z]:/.test(value) && + !Array.from(value).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) && + !value.split("/").some((part) => !part || part === "." || part === ".."); +} + function path(value: unknown, at: At): string { - if (typeof value !== "string" || value.length === 0 || - Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES || value.startsWith("/") || - value.includes("\\") || value.split("/").some((part) => !part || part === "." || part === "..")) { - fail("EVENT_LOOP_PARSE", at); - } + if (!isProjectPath(value)) fail("EVENT_LOOP_PARSE", at); return value; } From e0942fb69cba1553fd30ff72edaafac4aca53e52 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:16:48 -0400 Subject: [PATCH 4/7] fix: validate P0 corpus fixture paths before reads --- benchmarks/language/README.md | 7 +- benchmarks/language/contract.test.ts | 129 ++++++++++++++++---- benchmarks/language/project-closure.test.ts | 9 +- benchmarks/language/project-closure.ts | 7 +- 4 files changed, 124 insertions(+), 28 deletions(-) diff --git a/benchmarks/language/README.md b/benchmarks/language/README.md index 547b8882..802a84c5 100644 --- a/benchmarks/language/README.md +++ b/benchmarks/language/README.md @@ -133,8 +133,11 @@ Each actor fixture entry is: A project entry uses the same `id`, `path`, `kind`, and `sha256` fields. Its `expected` object contains `check: "accepted"`, `projectDigest`, and `closureDigest`. `projectDigest` covers the project record without `digest`. -`closureDigest` covers the canonical ordered `files` array. Every project path -is relative, uses `/`, and is at most 512 UTF-8 bytes. +`closureDigest` covers the canonical ordered `files` array. Project file paths +and corpus fixture paths, including `eventPath`, use `/` and contain 1 to 512 +UTF-8 bytes. They cannot start with a drive prefix or `/`, contain a control +character or backslash, or include empty, `.` or `..` components. The corpus +checks all fixture paths before reading any fixture file. An invalid entry has `kind: "invalid"` and this expected object: diff --git a/benchmarks/language/contract.test.ts b/benchmarks/language/contract.test.ts index 0c01f76f..e43d79e9 100644 --- a/benchmarks/language/contract.test.ts +++ b/benchmarks/language/contract.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { checkProgram, EXPR_BOUNDS } from "../../src/expr"; import { canonicalize, isJsonValue, type JsonValue } from "../../src/values"; import { locateJsonSpan, type ByteSpan } from "./span"; -import { ProjectClosureError, verifyProjectClosure } from "./project-closure"; +import { isProjectPath, ProjectClosureError, verifyProjectClosure } from "./project-closure"; const ROOT = import.meta.dir; const DIGEST = /^sha256:[0-9a-f]{64}$/; @@ -419,11 +419,9 @@ function parseEvents(value: unknown, behaviorValue: unknown): RecordValue { return root; } -function path(value: unknown, what: string): string { - const result = text(value, what, 512); - if (result.startsWith("/") || result.includes("\\") || result.split("/").some((part) => part.length === 0 || part === "." || part === "..")) { - fail("EVENT_LOOP_PARSE", `${what} is not a relative POSIX path`); - } +function path(value: unknown, what: string, at?: readonly (string | number)[]): string { + const result = text(value, what, 512, at); + if (!isProjectPath(result)) fail("EVENT_LOOP_PARSE", `${what} is not a relative POSIX path`, at); return result; } @@ -458,11 +456,15 @@ async function parseProject( return verifyProjectClosure(value, readBytes); } -function parseFixtureEntry(value: unknown, expectedKind: "actor" | "project" | "invalid"): void { - const entry = object(value, "fixture"); - exact(entry, ["id", "path", "kind", "sha256", "expected"], "fixture"); - identifier(entry.id, "fixture.id"); - path(entry.path, "fixture.path"); +function parseFixtureEntry( + value: unknown, + expectedKind: "actor" | "project" | "invalid", + at: readonly (string | number)[], +): void { + const entry = object(value, "fixture", at); + exact(entry, ["id", "path", "kind", "sha256", "expected"], "fixture", at); + identifier(entry.id, "fixture.id", [...at, "id"]); + path(entry.path, "fixture.path", [...at, "path"]); if (entry.kind !== expectedKind) fail("EVENT_LOOP_PARSE", "fixture.kind"); digest(entry.sha256, "fixture.sha256"); const expected = object(entry.expected, "fixture.expected"); @@ -471,7 +473,7 @@ function parseFixtureEntry(value: unknown, expectedKind: "actor" | "project" | " if (expected.check !== "accepted") fail("EVENT_LOOP_PARSE", "fixture.expected.check"); digest(expected.artifactDigest, "fixture.expected.artifactDigest"); digest(expected.interfaceDigest, "fixture.expected.interfaceDigest"); - path(expected.eventPath, "fixture.expected.eventPath"); + path(expected.eventPath, "fixture.expected.eventPath", [...at, "expected", "eventPath"]); digest(expected.eventSha256, "fixture.expected.eventSha256"); digest(expected.eventDigest, "fixture.expected.eventDigest"); } else if (expectedKind === "project") { @@ -528,11 +530,13 @@ function parseCorpus(value: unknown): RecordValue { const fixtureIds = new Set(); const fixturePaths = new Set(); const parseFixtureGroup = (entries: unknown[], kind: "actor" | "project" | "invalid"): void => { - for (const entry of entries) { - parseFixtureEntry(entry, kind); - const record = object(entry, "fixture"); - const id = identifier(record.id, "fixture.id"); - const fixturePath = path(record.path, "fixture.path"); + const group = kind === "actor" ? "actors" : kind === "project" ? "projects" : "invalid"; + for (const [index, entry] of entries.entries()) { + const at = ["fixtures", group, index]; + parseFixtureEntry(entry, kind, at); + const record = object(entry, "fixture", at); + const id = identifier(record.id, "fixture.id", [...at, "id"]); + const fixturePath = path(record.path, "fixture.path", [...at, "path"]); if (fixtureIds.has(id)) fail("EVENT_LOOP_ID", `duplicate fixture ${id}`); if (fixturePaths.has(fixturePath)) fail("EVENT_LOOP_ID", `duplicate fixture path ${fixturePath}`); fixtureIds.add(id); @@ -546,6 +550,29 @@ function parseCorpus(value: unknown): RecordValue { return root; } +// Check every path in the corpus before reading any referenced fixture bytes. +async function readCorpusFixtures( + value: unknown, + readBytes: (filePath: string) => Promise = (filePath) => readFile(join(ROOT, filePath)), +): Promise> { + const corpus = parseCorpus(value); + const fixtures = object(corpus.fixtures, "corpus.fixtures"); + const contents = new Map(); + for (const group of ["actors", "projects", "invalid"] as const) { + for (const [index, raw] of (fixtures[group] as unknown[]).entries()) { + const entry = object(raw, "fixture"); + const at = ["fixtures", group, index, "path"]; + const fixturePath = path(entry.path, "fixture.path", at); + const bytes = await readBytes(fixturePath); + if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > 16_384) { + fail("EVENT_LOOP_BOUNDS", "fixture source bytes", at); + } + contents.set(fixturePath, bytes); + } + } + return contents; +} + function canonicalDigest(value: unknown): string { return `sha256:${createHash("sha256").update(canonicalize(json(value, "digest value"))).digest("hex")}`; } @@ -896,13 +923,13 @@ describe("language benchmark contract fixtures", () => { test("corpus fixture entries pin raw bytes and expected identities", async () => { const corpus = object(await load("corpus.json"), "corpus"); const fixtures = object(corpus.fixtures, "corpus.fixtures"); + const contents = await readCorpusFixtures(corpus); for (const group of [fixtures.actors, fixtures.projects, fixtures.invalid]) { if (!Array.isArray(group)) throw new Error("corpus fixture group did not parse"); for (const raw of group) { const entry = object(raw, "fixture"); - const bytes = await readFile(join(ROOT, text(entry.path, "fixture.path", 512))); - expect(bytes.byteLength).toBeGreaterThan(0); - expect(bytes.byteLength).toBeLessThanOrEqual(16_384); + const bytes = contents.get(path(entry.path, "fixture.path")); + if (!bytes) throw new Error("checked fixture bytes missing"); expect(rawDigest(bytes)).toBe(digest(entry.sha256, "fixture.sha256")); } } @@ -940,14 +967,74 @@ describe("language benchmark contract fixtures", () => { expect(projectObserved.closureDigest).toBe(closure.closureDigest); }); + test("corpus fixture paths reject drive prefixes and controls before any reads with located spans", async () => { + const base = await load("corpus.json"); + const cases = [ + { group: "actors", field: "path", value: "C:/escape" }, + { group: "projects", field: "path", value: "C:escape" }, + { group: "invalid", field: "path", value: "a\nb" }, + { group: "actors", field: "path", value: "a\0b" }, + { group: "actors", field: "path", value: "a\u007fb" }, + { group: "actors", field: "eventPath", value: "D:/events" }, + { group: "actors", field: "eventPath", value: "a\rb" }, + ] as const; + for (const { group, field, value } of cases) { + const corpus = structuredClone(base) as RecordValue; + const fixtures = object(corpus.fixtures, "fixtures"); + const entry = object((fixtures[group] as unknown[])[0], "fixture"); + const at: (string | number)[] = ["fixtures", group, 0]; + if (field === "eventPath") { + object(entry.expected, "fixture.expected").eventPath = value; + at.push("expected", "eventPath"); + } else { + entry.path = value; + at.push("path"); + } + let reads = 0; + try { + await readCorpusFixtures(corpus, async () => { + reads++; + throw new Error("unsafe fixture path reached file read"); + }); + throw new Error("unsafe fixture path was accepted"); + } catch (error) { + if (!(error instanceof FixtureError)) throw error; + expect(error.code, value).toBe("EVENT_LOOP_PARSE"); + expect(error.at, value).toEqual(at); + const bytes = new TextEncoder().encode(JSON.stringify(corpus)); + const span = locateJsonSpan(bytes, at); + expect(new TextDecoder().decode(bytes.subarray(span.start, span.end))).toBe(JSON.stringify(value).slice(1, -1)); + } + expect(reads, value).toBe(0); + } + }); + + test("corpus fixture reads keep only 1 to 16384 bytes", async () => { + const corpus = await load("corpus.json"); + const maximum = new Uint8Array(16_384); + expect((await readCorpusFixtures(corpus, async () => maximum)).size).toBe(12); + for (const bytes of [new Uint8Array(0), new Uint8Array(16_385)]) { + try { + await readCorpusFixtures(corpus, async () => bytes); + throw new Error("invalid fixture byte length was accepted"); + } catch (error) { + if (!(error instanceof FixtureError)) throw error; + expect(error.code).toBe("EVENT_LOOP_BOUNDS"); + expect(error.at).toEqual(["fixtures", "actors", 0, "path"]); + } + } + }); + test("every invalid fixture fails with its recorded code and bounded span", async () => { const corpus = object(await load("corpus.json"), "corpus"); const fixtures = object(corpus.fixtures, "corpus.fixtures"); if (!Array.isArray(fixtures.invalid)) throw new Error("invalid fixture group did not parse"); + const contents = await readCorpusFixtures(corpus); for (const raw of fixtures.invalid) { const entry = object(raw, "fixture"); const expected = object(entry.expected, "fixture.expected"); - const bytes = await readFile(join(ROOT, text(entry.path, "fixture.path", 512))); + const bytes = contents.get(path(entry.path, "fixture.path")); + if (!bytes) throw new Error("checked fixture bytes missing"); expect(checkEventLoopBytes(bytes), text(entry.id, "fixture.id")).toEqual({ code: expected.code, span: expected.span, }); diff --git a/benchmarks/language/project-closure.test.ts b/benchmarks/language/project-closure.test.ts index cf41b0d8..8ef32731 100644 --- a/benchmarks/language/project-closure.test.ts +++ b/benchmarks/language/project-closure.test.ts @@ -96,23 +96,26 @@ test("file size, file count, id and path limits reject the first excess", async expect((await rejected(data.project, data.read)).code).toBe("EVENT_LOOP_ID"); } data.project.id = "fixture"; - for (const path of ["a", "a".repeat(512), "é".repeat(256)]) { + for (const path of ["a", "a".repeat(512), "é".repeat(256), "a\u00a0b"]) { data.project.entry = path; data.project.files[0]!.path = path; rehash(data.project); expect((await verifyProjectClosure(data.project, async () => source(1))).byteLength).toBe(1); } - for (const path of ["", "a".repeat(513), "é".repeat(257), "../escape", "a\\b", "C:/escape", "C:escape", "a\0b", "a\nb"]) { + for (const path of ["", "a".repeat(513), "é".repeat(257), "../escape", "a\\b", "C:/escape", "C:escape", "a\0b", "a\nb", "a\u0080b", "a\u009fb"]) { data.project.entry = path; expect((await rejected(data.project, data.read)).code).toBe("EVENT_LOOP_PARSE"); } - for (const path of ["C:/escape", "a\0b"]) { + for (const path of ["C:/escape", "a\0b", "a\u0085b", "a\u009fb"]) { data.project.entry = "file-0"; data.project.files[0]!.path = path; rehash(data.project); const error = await rejected(data.project, async () => { throw new Error("must not read"); }); expect(error.code).toBe("EVENT_LOOP_PARSE"); expect(error.at).toEqual(["files", 0, "path"]); + const encoded = new TextEncoder().encode(JSON.stringify(data.project)); + const span = locateJsonSpan(encoded, error.at); + expect(new TextDecoder().decode(encoded.subarray(span.start, span.end))).toBe(JSON.stringify(path).slice(1, -1)); } }); diff --git a/benchmarks/language/project-closure.ts b/benchmarks/language/project-closure.ts index 2642e614..88b6dcf5 100644 --- a/benchmarks/language/project-closure.ts +++ b/benchmarks/language/project-closure.ts @@ -42,11 +42,14 @@ function id(value: unknown, at: At): string { return value; } -function isProjectPath(value: unknown): value is string { +export function isProjectPath(value: unknown): value is string { return typeof value === "string" && value.length > 0 && Buffer.byteLength(value, "utf8") <= MAX_PATH_BYTES && !value.startsWith("/") && !value.includes("\\") && !/^[a-zA-Z]:/.test(value) && - !Array.from(value).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) && + !Array.from(value).some((char) => { + const code = char.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) && !value.split("/").some((part) => !part || part === "." || part === ".."); } From 023797a4c122b809b3a9ef817b8fde617cba4c99 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:45:44 -0400 Subject: [PATCH 5/7] Reject surrogate path aliases in P0 language fixtures --- benchmarks/language/P0-REPAIR-EVIDENCE.md | 18 +++++++++++- benchmarks/language/contract.test.ts | 23 +++++++++++++++ benchmarks/language/project-closure.test.ts | 32 +++++++++++++++++++++ benchmarks/language/project-closure.ts | 7 +++-- 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/benchmarks/language/P0-REPAIR-EVIDENCE.md b/benchmarks/language/P0-REPAIR-EVIDENCE.md index 9d3fd440..aaba2c6a 100644 --- a/benchmarks/language/P0-REPAIR-EVIDENCE.md +++ b/benchmarks/language/P0-REPAIR-EVIDENCE.md @@ -8,10 +8,26 @@ The project fixture-reading boundary calls `verifyProjectClosure` after record v The event-loop byte checker carries structured paths through contract-owned nested objects, schema fields, transitions, expressions, JSON values, and limits and revision numbers. It returns exact UTF-8 spans for unknown keys and out-of-range numeric limits. Tests cover unknown keys at 16 record positions, both invalid edges and noninteger values of each numeric limit, valid upper edges, zero outputs, and revision edges. The ten pinned invalid fixtures still return their original codes and spans. The 64-byte ID correction is included. These fixtures are synthetic contract checks, not compiler/source provenance, measured performance, an event-loop runtime, runtime parity for the proposed contract, or cumulative-skill evidence. +On the uncommitted `codex/p0-surrogate-guard-20261011` branch, corpus, event, and project fixture paths reject unpaired UTF-16 surrogates before reads. Valid paired Unicode remains accepted; located regressions include the 512-byte boundary. At the earlier checkpoint this main-based branch lacked the PR's `spec/v1/README.md` and `spec/v1/event-loop.md`, so its local checks could not qualify a combined P0 head. On 2026-10-11, those two documents were restored byte-for-byte from PR commit `e0942fb69cba1553fd30ff72edaafac4aca53e52` into the local worktree; their Git blobs match `a618201c4ff2075f8971df43455fd15c0751e5bc` and `4275ab4909d6c996bf62ea39c93da0d96df8d80c`. This reconciliation is agent-decided, owner may revisit. The original PR head's green `Required` does not cover this local tree. No event-loop runtime or shared Rust contract changed. + ## Checks and blockers -Fresh on the repaired local bytes: `bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts benchmarks/language/project-closure.test.ts` passed (30 tests, 358 assertions); `bun run typecheck`, `bun run lint`, `bun run docs:check`, `bun run build:site`, `cargo fmt --all -- --check`, and `git diff HEAD --check` passed. `bun run check:copy` is not defined in this repository; it is not a passed check. +Fresh on the earlier repaired local bytes: `bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts benchmarks/language/project-closure.test.ts` passed (30 tests, 358 assertions); `bun run typecheck`, `bun run lint`, `bun run docs:check`, `bun run build:site`, `cargo fmt --all -- --check`, and `git diff HEAD --check` passed. `bun run check:copy` is not defined in this repository; it is not a passed check. + +On the combined local tree based on `bc6ddae6e426`, `bun install --frozen-lockfile --offline` passed. The focused test passed (33 tests, 461 assertions); `bun run typecheck`, `bun run lint`, `bun run docs:check`, `bun run build:site`, and `git diff HEAD --check` passed. Lint first failed because `eslint .` traversed the task-owned Bun install cache; it passed after that cache was removed. `bun run check` timed out after 600 seconds during tests, with loopback `EADDRINUSE` in `src/vendor-cli.test.ts`. This is not a passing aggregate gate. The PR comparison, before this evidence update, was exactly four benchmark files with a SHA-256 patch hash of `2d9f931535c442904818f13a18c164b39a264101d92a0e2aadcc18e354bcd3c9`. No commit, push, or PR-head CI was performed in this reconciliation. Earlier local reviews, before the latest assertions/evidence, reported: `bun run check` timed out after 600 seconds during tests, after a loopback `EADDRINUSE`; default locked Cargo test/clippy/build hit the pinned `apple-foundation` libgit2 SSL `-9808` fetch error. Using `CARGO_NET_GIT_FETCH_WITH_CLI=true`, Cargo workspace tests built but failed in `tests/http.rs`; focused HTTP recompilation timed out. `bun scripts/application-parity.ts` timed out after 600 seconds. An earlier native parity pass was against an existing binary, not a freshly built one. None of these is a passing full gate on these exact bytes. Loopback and dependency-fetch-capable CI can replay these checks on a published head. Keep PR #276 draft without auto-merge. Before integration, require exact-head green `Required`, a positive independent review, and a recommendation to merge for this selected lane. Do not infer a gate pass from the parent's earlier green check. + +## Separate review of the reconciled tree, 2026-10-11 + +The 25 added files remain in the P0 integration-owner paths (`benchmarks/language/**`, `spec/v1/{README,event-loop}.md`). Against the recorded PR head `e0942fb6`, only four benchmark files differed before this review's evidence-only edit. The current remote PR was independently observed open and draft at that old head; its successful `Required` is not an exact-head check of this local repair. The source revision, synthetic smoke durations, and existing native parity script do not establish event-loop runtime parity, source provenance, real performance, or held-out improvement. + +On this local source tree, `bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts benchmarks/language/project-closure.test.ts` passed (33 tests, 461 assertions), `bun run docs:check`, `bun run build:site`, `cargo fmt --all -- --check`, and `git diff HEAD --check` passed. `bun run check` timed out after 600 seconds in tests following `src/vendor-cli.test.ts` loopback `EADDRINUSE`; typecheck and lint ran before that timeout, but the aggregate did not pass. Default `cargo test --workspace --locked` failed to fetch `apple-foundation` through libgit2 (SSL `-9808`). `CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --workspace --locked` built and failed in `tests/http.rs`; direct execution of the built HTTP test confirmed both cases fail binding a loopback listener (`Operation not permitted`). With the same Git-fetch override, `cargo clippy --workspace --all-targets --locked -- -D warnings` and `cargo build --locked` passed. `bun scripts/native-parity.ts` then passed (86 incumbent examples and 12 budget cases) against that built binary; it exercises no new event-loop semantics. None of these observations replaces a passing full gate on the repaired PR head. + +The `corpus.protocol.regressionPercent` test gap identified by the independent review was repaired locally: a new test checks valid 0 and 100 values with corrected digests, the first rejected values -1 and 101, noninteger, string, null, Boolean, and missing-field input. The three focused language tests now pass (34 tests, 470 assertions). This is synthetic parser coverage, not measured performance or new event-loop runtime parity. Exact-head CI and a new independent positive review are still pending; keep PR #276 draft without auto-merge and P1/P2 blocked. + +## Transfer checkpoint, 2026-10-11 + +The repaired local tree differs from the existing PR head in four benchmark files; the two restored spec files match that PR byte-for-byte. On this tree the focused test passed (34 tests, 470 assertions), as did typecheck, lint, docs check, site build, Cargo format, Clippy and build using CLI Git fetching, incumbent native parity (86 examples and 12 budget cases), and `git diff HEAD --check`. The full `bun run check` **timed out at 600 seconds** in the test phase after `src/vendor-cli.test.ts` reported loopback `EADDRINUSE`, so it did not pass. Default Cargo tests failed at a libgit2 `apple-foundation` SSL `-9808` fetch; CLI Git fetching built and ran tests but failed at `tests/http.rs`, where loopback binding was prohibited. These local limitations require a fresh passing exact-head `Required` in CI. An independent positive review of the repaired head remains outstanding; no merge or P1/P2 work is qualified by this checkpoint. diff --git a/benchmarks/language/contract.test.ts b/benchmarks/language/contract.test.ts index e43d79e9..a3ea1737 100644 --- a/benchmarks/language/contract.test.ts +++ b/benchmarks/language/contract.test.ts @@ -976,6 +976,11 @@ describe("language benchmark contract fixtures", () => { { group: "actors", field: "path", value: "a\0b" }, { group: "actors", field: "path", value: "a\u007fb" }, { group: "actors", field: "eventPath", value: "D:/events" }, + { group: "actors", field: "path", value: "actors/x\ud800.json" }, + { group: "projects", field: "path", value: "projects/x\udc00.json" }, + { group: "invalid", field: "path", value: "invalid/x\ud800.json" }, + { group: "actors", field: "eventPath", value: "actors/x\ud800.events.json" }, + { group: "actors", field: "eventPath", value: "actors/x\udc00.events.json" }, { group: "actors", field: "eventPath", value: "a\rb" }, ] as const; for (const { group, field, value } of cases) { @@ -1503,6 +1508,24 @@ describe("language benchmark contract fixtures", () => { expect(errorCode(() => text("é".repeat(65), "profile"))).toBe("EVENT_LOOP_PARSE"); }); + test("corpus regressionPercent accepts 0 and 100, rejects adjacent bounds and malformed values", async () => { + const base = await load("corpus.json"); + for (const value of [0, 100]) { + const corpus = structuredClone(base) as RecordValue; + object(corpus.protocol, "protocol").regressionPercent = value; + corpus.digest = canonicalDigest(withoutDigest(corpus)); + expect(errorCode(() => parseCorpus(corpus)), `regressionPercent=${value}`).toBe("ACCEPTED"); + } + for (const value of [-1, 101, 0.5, "20", null, true]) { + const corpus = structuredClone(base) as RecordValue; + object(corpus.protocol, "protocol").regressionPercent = value; + expect(errorCode(() => parseCorpus(corpus)), `regressionPercent=${String(value)}`).toBe("EVENT_LOOP_BOUNDS"); + } + const missing = structuredClone(base) as RecordValue; + delete object(missing.protocol, "protocol").regressionPercent; + expect(errorCode(() => parseCorpus(missing))).toBe("EVENT_LOOP_PARSE"); + }); + test("count, byte, and list limits reject their first excess", async () => { const actor = object(await load("actors/echo.json"), "actor"); const events = object(await load("actors/echo.events.json"), "events"); diff --git a/benchmarks/language/project-closure.test.ts b/benchmarks/language/project-closure.test.ts index 8ef32731..35e89fe2 100644 --- a/benchmarks/language/project-closure.test.ts +++ b/benchmarks/language/project-closure.test.ts @@ -119,6 +119,38 @@ test("file size, file count, id and path limits reject the first excess", async } }); +test("lone surrogates cannot alias project paths, but paired Unicode and 512 UTF-8 bytes work", async () => { + const data = fixture([source(1)]); + for (const valid of ["actor-\u{1f680}.json", "a".repeat(508) + "\u{1f680}"]) { + expect(Buffer.byteLength(valid, "utf8")).toBeLessThanOrEqual(512); + data.project.entry = valid; + data.project.files[0]!.path = valid; + rehash(data.project); + expect((await verifyProjectClosure(data.project, async (path) => { + expect(path).toBe(valid); + return source(1); + })).byteLength).toBe(1); + } + expect(Buffer.byteLength("a".repeat(508) + "\u{1f680}", "utf8")).toBe(512); + + for (const invalid of ["actor-\ud800.json", "actor-\udc00.json", "\ud800\ud800", "\udc00\ud800", "a".repeat(509) + "\ud800", "a".repeat(509) + "\u{1f680}"]) { + for (const location of ["entry", "file"] as const) { + const project = structuredClone(data.project); + const at = location === "entry" ? ["entry"] : ["files", 0, "path"]; + if (location === "entry") project.entry = invalid; + else project.files[0]!.path = invalid; + let reads = 0; + const error = await rejected(project, async () => { reads++; return source(1); }); + expect(error.code).toBe("EVENT_LOOP_PARSE"); + expect(error.at).toEqual(at); + expect(reads).toBe(0); + const encoded = new TextEncoder().encode(JSON.stringify(project)); + const span = locateJsonSpan(encoded, at); + expect(new TextDecoder().decode(encoded.subarray(span.start, span.end))).toBe(JSON.stringify(invalid).slice(1, -1)); + } + } +}); + test("closed project records reject unknown fields, changed bytes and missing files", async () => { const data = fixture([source(1)]); const wrongContract = { ...data.project, contract: "algal.language-project.v2" }; diff --git a/benchmarks/language/project-closure.ts b/benchmarks/language/project-closure.ts index 88b6dcf5..1d8a4c30 100644 --- a/benchmarks/language/project-closure.ts +++ b/benchmarks/language/project-closure.ts @@ -47,8 +47,11 @@ export function isProjectPath(value: unknown): value is string { Buffer.byteLength(value, "utf8") <= MAX_PATH_BYTES && !value.startsWith("/") && !value.includes("\\") && !/^[a-zA-Z]:/.test(value) && !Array.from(value).some((char) => { - const code = char.charCodeAt(0); - return code < 32 || (code >= 127 && code <= 159); + // Iterate code points so valid pairs survive while lone UTF-16 + // surrogates cannot alias replacement-character paths at the file read. + const code = char.codePointAt(0)!; + return code < 32 || (code >= 127 && code <= 159) || + (code >= 0xd800 && code <= 0xdfff); }) && !value.split("/").some((part) => !part || part === "." || part === ".."); } From db3f787fac813322d4f1ecd6da2a246c45c01bd6 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:51:55 -0400 Subject: [PATCH 6/7] P0: bind event source sequences after fixture validation --- .../language/P0-EVENT-IDENTITY-DECISION.md | 32 +++++++++ benchmarks/language/README.md | 16 ++++- benchmarks/language/contract.test.ts | 48 +++++++++++++- benchmarks/language/event-sequence.test.ts | 65 +++++++++++++++++++ benchmarks/language/event-sequence.ts | 46 +++++++++++++ spec/v1/event-loop.md | 48 +++++++++----- 6 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 benchmarks/language/P0-EVENT-IDENTITY-DECISION.md create mode 100644 benchmarks/language/event-sequence.test.ts create mode 100644 benchmarks/language/event-sequence.ts diff --git a/benchmarks/language/P0-EVENT-IDENTITY-DECISION.md b/benchmarks/language/P0-EVENT-IDENTITY-DECISION.md new file mode 100644 index 00000000..9c9d22be --- /dev/null +++ b/benchmarks/language/P0-EVENT-IDENTITY-DECISION.md @@ -0,0 +1,32 @@ +# P0 event identity decision, 2026-10-12 + +**Agent-decided, owner may revisit.** For one behavior revision and actor, a +source's sequence is a logical-event key. Its first admitted canonical type and +payload bind that key. Identical later input is a duplicate, including when a +host assigns another delivery identity; changed type or payload is an +`EVENT_LOOP_ID` conflict and must not trigger a second transition. Different +sources may reuse a sequence. A distinct actor or behavior revision has a +distinct key scope. + +Rationale: including type and payload in the full event digest alone lets a +source silently reuse its sequence with different content and mint a new event. +Likewise, deduplicating only message ids lets a changed delivery id settle the +same logical event again. Both would undermine the north star's fail-closed +identity and duplicate-delivery rules. The additive key binding preserves the +existing full-content digest for content addressing without making a digest a +permission to overwrite a prior delivery. + +`spec/v1/event-loop.md` specifies the conflict, duplicate, and retained +host/replay behavior. `event-sequence.ts` provides a bounded post-schema vector +check. The fixture-admission path in `contract.test.ts` calls it after schema, +byte-limit, and digest checks; the colocated tests exercise re-digested changed +type and payload conflicts, exact redelivery, distinct keys, canonical payload +ordering, and the 1..256 vector bound. These are contract-fixture tests, not a +runtime, durable host, or cross-runtime replay. + +**Remaining delivery gates:** independently review the combined tree and obtain +a new exact-head `Required` result. The older green CI run for `023797a4` does +not cover this decision or these tests. Local application-parity timeout and +socket-restricted aggregate checks remain unresolved local results; clean CI on +the earlier head is not a local pass. Keep PR #276 draft until a fresh +integration recommendation explicitly reconciles the environment and gates. diff --git a/benchmarks/language/README.md b/benchmarks/language/README.md index 802a84c5..8f3f1e2d 100644 --- a/benchmarks/language/README.md +++ b/benchmarks/language/README.md @@ -14,11 +14,13 @@ case identity and expectation to that corpus, and checks raw samples against summaries. The event-loop shape is specified in [`spec/v1/event-loop.md`](../../spec/v1/event-loop.md). -The fixture test parses foreign JSON as `unknown`, rejects unknown keys, checks -all fixture digests, and exercises the listed failure codes: +The structural fixture test parses foreign JSON as `unknown`, rejects unknown +keys, checks fixture digests, and exercises the listed failure codes. The +fixture-admission path composes the post-schema event-sequence identity check +after the event schema, byte, and digest checks: ```sh -bun test benchmarks/language/contract.test.ts benchmarks/language/span.test.ts benchmarks/language/project-closure.test.ts +bun test benchmarks/language ``` ## Baseline layout @@ -28,6 +30,8 @@ benchmarks/language/ README.md corpus.json contract.test.ts + event-sequence.ts + event-sequence.test.ts project-closure.ts project-closure.test.ts span.ts @@ -53,6 +57,12 @@ specific parser failure. Colocated tests also mutate copies of the valid fixtures to test digest, payload, byte-limit, and transition rejections. The fixture count is deliberately small for the P0 contract gate. It is not the 100-program corpus target in the north-star plan. +`event-sequence.ts` checks one parsed event vector for conflicting reuse of a +source/sequence key and marks identical redeliveries as duplicates. `parseEvents` +in `contract.test.ts` runs it after structural, schema, byte, and digest checks. +The [P0 decision and remaining delivery gates](P0-EVENT-IDENTITY-DECISION.md) +record the choice and the limits of this fixture-only evidence. + ## Architecture vocabulary The event-loop contract defines an actor as a logical mailbox and state machine. diff --git a/benchmarks/language/contract.test.ts b/benchmarks/language/contract.test.ts index a3ea1737..4ae058c6 100644 --- a/benchmarks/language/contract.test.ts +++ b/benchmarks/language/contract.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { checkProgram, EXPR_BOUNDS } from "../../src/expr"; import { canonicalize, isJsonValue, type JsonValue } from "../../src/values"; import { locateJsonSpan, type ByteSpan } from "./span"; +import { checkEventSequence, EventSequenceError } from "./event-sequence"; import { isProjectPath, ProjectClosureError, verifyProjectClosure } from "./project-closure"; const ROOT = import.meta.dir; @@ -382,7 +383,7 @@ function parseEventLoop(value: unknown): RecordValue { return root; } -function parseEvents(value: unknown, behaviorValue: unknown): RecordValue { +function parseEvents(value: unknown, behaviorValue: unknown): { value: RecordValue; duplicates: number[] } { const behavior = parseEventLoop(behaviorValue); const limits = object(behavior.limits, "behavior.limits"); const declarations = new Map>(); @@ -416,7 +417,17 @@ function parseEvents(value: unknown, behaviorValue: unknown): RecordValue { } } verifyDigest(root, "events"); - return root; + // Schema, byte limits, and the whole-vector digest are checked above before + // binding a source/sequence to its first admitted type and payload. + try { + const { duplicates } = checkEventSequence( + root.events as { source: string; sequence: number; type: string; payload: JsonValue }[], + ); + return { value: root, duplicates }; + } catch (error) { + if (error instanceof EventSequenceError) fail(error.code, error.message, error.at); + throw error; + } } function path(value: unknown, what: string, at?: readonly (string | number)[]): string { @@ -1316,6 +1327,39 @@ describe("language benchmark contract fixtures", () => { expect(errorCode(() => parseEvents(events, actor))).toBe("EVENT_LOOP_BOUNDS"); }); + test("event-vector admission rejects re-digested key conflicts but accepts identical redelivery", async () => { + const actor = await load("actors/echo.json"); + const base = object(await load("actors/echo.events.json"), "events"); + const first = object((base.events as unknown[])[0], "first event"); + const admit = (last: RecordValue): RecordValue => { + const vector = structuredClone(base) as RecordValue; + vector.events = [...vector.events as unknown[], last]; + vector.digest = canonicalDigest(withoutDigest(vector)); + expect(vector.digest).not.toBe(base.digest); + return vector; + }; + for (const changed of [ + { ...first, payload: { amount: 4 } }, + { ...first, type: "reset", payload: {} }, + ]) { + const vector = admit(changed); + try { + parseEvents(vector, actor); + throw new Error("re-digested event-key conflict was accepted"); + } catch (error) { + if (!(error instanceof FixtureError)) throw error; + expect(error.code).toBe("EVENT_LOOP_ID"); + expect(error.at).toEqual(["events", 3, "sequence"]); + } + } + const duplicate = admit({ ...first, payload: { amount: 2 } }); + expect(parseEvents(duplicate, actor).duplicates).toEqual([3]); + expect(parseEvents(base, actor).duplicates).toEqual([]); + // An invalid schema cannot be hidden behind a conflicting logical key. + const invalid = admit({ ...first, type: "reset", payload: { amount: 4 } }); + expect(errorCode(() => parseEvents(invalid, actor))).toBe("EVENT_LOOP_UNKNOWN_KEY"); + }); + test("complete initial state is limited and v1 writes address one field", async () => { const actor = structuredClone(await load("actors/echo.json")) as RecordValue; const limits = object(actor.limits, "limits"); diff --git a/benchmarks/language/event-sequence.test.ts b/benchmarks/language/event-sequence.test.ts new file mode 100644 index 00000000..b754ad4f --- /dev/null +++ b/benchmarks/language/event-sequence.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { canonicalize, type JsonValue } from "../../src/values"; +import { checkEventSequence, EventSequenceError } from "./event-sequence"; + +type CheckedEvent = { source: string; sequence: number; type: string; payload: JsonValue }; + +const fixture = JSON.parse(await readFile(`${import.meta.dir}/actors/echo.events.json`, "utf8")) as { + events: CheckedEvent[]; + digest: string; +}; +const first = fixture.events[0]!; + +function conflict(events: CheckedEvent[], index: number): void { + try { + checkEventSequence(events); + throw new Error("conflicting sequence was accepted"); + } catch (error) { + if (!(error instanceof EventSequenceError)) throw error; + expect(error.code).toBe("EVENT_LOOP_ID"); + expect(error.at).toEqual(["events", index, "sequence"]); + } +} + +test("one actor's source/sequence is bound to type and payload, even after rehashing", () => { + expect(checkEventSequence(fixture.events)).toEqual({ duplicates: [] }); + const changedPayload = [...fixture.events, { ...first, payload: { amount: 4 } }]; + const changedType = [...fixture.events, { ...first, type: "reset", payload: {} }]; + for (const events of [changedPayload, changedType]) { + // A whole-vector digest can be recomputed for either changed event. It + // cannot authorize reusing an already bound logical-event key. + const digest = `sha256:${createHash("sha256").update(canonicalize({ + contract: "algal.event-loop.events.v1", actor: "counter", events, + })).digest("hex")}`; + expect(digest).not.toBe(fixture.digest); + conflict(events, fixture.events.length); + } +}); + +test("identical redelivery is a duplicate, while another source or sequence is distinct", () => { + expect(checkEventSequence([...fixture.events, { ...first, payload: { amount: 2 } }])).toEqual({ duplicates: [3] }); + expect(checkEventSequence([...fixture.events, { ...first, source: "other-source" }])).toEqual({ duplicates: [] }); + expect(checkEventSequence([...fixture.events, { ...first, sequence: 3 }])).toEqual({ duplicates: [] }); + // Each vector is scoped to one actor and behavior revision; another actor's + // independent vector cannot inherit this vector's seen-event set. + expect(checkEventSequence([{ ...first, payload: { amount: 4 } }])).toEqual({ duplicates: [] }); + const a = { source: "fixture", sequence: 0, type: "json", payload: { a: 1, b: 2 } }; + expect(checkEventSequence([a, { ...a, payload: { b: 2, a: 1 } }])).toEqual({ duplicates: [1] }); +}); + +test("identity check retains the event-vector count bound", () => { + for (const events of [[], Array.from({ length: 257 }, (_, sequence) => ({ ...first, sequence }))]) { + try { + checkEventSequence(events); + throw new Error("out-of-range vector was accepted"); + } catch (error) { + if (!(error instanceof EventSequenceError)) throw error; + expect(error.code).toBe("EVENT_LOOP_BOUNDS"); + expect(error.at).toEqual(["events"]); + } + } + expect(checkEventSequence([first])).toEqual({ duplicates: [] }); + expect(checkEventSequence(Array.from({ length: 256 }, (_, sequence) => ({ ...first, sequence }))).duplicates).toEqual([]); +}); diff --git a/benchmarks/language/event-sequence.ts b/benchmarks/language/event-sequence.ts new file mode 100644 index 00000000..ee6d73df --- /dev/null +++ b/benchmarks/language/event-sequence.ts @@ -0,0 +1,46 @@ +import { canonicalize, type JsonValue } from "../../src/values"; + +// P0 event-vector identity check. Call only after the closed event/schema and +// byte-limit checks: this handles the cross-row condition they cannot detect. +// One vector targets one actor under one checked behavior revision. +type CheckedEvent = { + source: string; + sequence: number; + type: string; + payload: JsonValue; +}; + +export class EventSequenceError extends Error { + readonly code: "EVENT_LOOP_BOUNDS" | "EVENT_LOOP_ID"; + readonly at: readonly (string | number)[]; + + constructor(code: "EVENT_LOOP_BOUNDS" | "EVENT_LOOP_ID", at: readonly (string | number)[]) { + super(`${code} at ${at.join(".")}`); + this.code = code; + this.at = at; + } +} + +export function checkEventSequence(events: readonly CheckedEvent[]): { duplicates: number[] } { + if (events.length < 1 || events.length > 256) { + throw new EventSequenceError("EVENT_LOOP_BOUNDS", ["events"]); + } + const seen = new Map(); + const duplicates: number[] = []; + for (const [index, event] of events.entries()) { + // Source ids are lower kebab case and sequence numbers are bounded by the + // caller's schema check. The separator therefore cannot alias either one. + const key = `${event.source}\0${event.sequence}`; + const content = canonicalize({ type: event.type, payload: event.payload }); + const previous = seen.get(key); + if (previous !== undefined) { + if (previous !== content) { + throw new EventSequenceError("EVENT_LOOP_ID", ["events", index, "sequence"]); + } + duplicates.push(index); + } else { + seen.set(key, content); + } + } + return { duplicates }; +} diff --git a/spec/v1/event-loop.md b/spec/v1/event-loop.md index 4275ab49..689022bf 100644 --- a/spec/v1/event-loop.md +++ b/spec/v1/event-loop.md @@ -121,10 +121,17 @@ The following identities are stable and contain no clock value: "actor": actor.id, "source": source id, "sequence": sequence, "type": event type, "payload": payload }`. The source id is inside the digest, so two sources can use the same sequence without an identity collision. + Within one behavior revision and actor, `(source, sequence)` is a stable + logical-event key: its first admitted canonical `type` and `payload` bind + that key. Reuse with changed type or payload is an `EVENT_LOOP_ID` conflict, + not a newly admitted event, even though its full-content digest differs. 3. A message identity is the digest of `{ "event": event identity, "target": - actor.id, "delivery": delivery identity }`. The same message id delivered - again is a duplicate. A duplicate records a no-op decision and does not run a - transition or emit a notification. + actor.id, "delivery": delivery identity }`. It identifies a delivery + attempt, not a second opportunity to settle the same logical event. An exact + repeat of a bound event is a duplicate even if the host assigns a different + delivery identity. A duplicate records a no-op decision and does not run a + transition or emit a notification. The host must retain the event-key binding + and the settled decision across delivery attempts and restarts. 4. A notification receives a new event identity and a deterministic ordinal in its parent transition. The ordinal starts at zero for each transition. A notification cannot reuse its parent's message id. @@ -134,10 +141,13 @@ The following identities are stable and contain no clock value: hosts must present the same logical order or produce separate traces; this contract does not claim that an arbitrary distributed scheduler is ordered. -A verifier compares identities before values. It rejects a repeated identity with -changed content, a sequence outside the declared integer range, a notification -that is not listed by its parent transition, or a trace that skips a required -parent decision. External effects are at-most-once claims only when their host +A verifier compares identities before values. It rejects reuse of a bound +logical-event key with changed content, a sequence outside the declared integer +range, a notification that is not listed by its parent transition, or a trace +that skips a required parent decision. A conflict is not a duplicate and cannot +commit a transition. Event-vector checks enforce this rule within their bounded +vector; a host and trace verifier must enforce it against retained earlier +entries as well. External effects are at-most-once claims only when their host adapter supplies a matching idempotency record. This contract does not claim exactly-once behavior for arbitrary external systems. @@ -226,9 +236,12 @@ It contains 1 to 256 events. Each event is a closed object with `source`, its sequence is an integer from 0 to 2,147,483,647. The type must name a declared event. The payload must have exactly its required, typed schema fields. The canonical UTF-8 bytes of the *complete event object*, including the -payload, must fit `maxEventBytes`. The vector digest covers the canonical root -without its `digest` key. A checker verifies the behavior first, then the -vector and its digest; it does not accept an event vector alone. +payload, must fit `maxEventBytes`. A repeated `(source, sequence)` within this +actor's vector must have the same canonical type and payload; an identical +repeat is a duplicate, and changed content is `EVENT_LOOP_ID` at the repeated +sequence. The vector digest covers the canonical root without its `digest` key. +A checker verifies the behavior first, then the vector and its digest; it does +not accept an event vector alone. The event and actor id sets are global within one behavior. An output may name only a declared event and an existing actor id or the literal target `self`. @@ -265,8 +278,9 @@ A transition is a closed object with `id`, `on`, `when`, `writes`, `outputs`, an For one event, guards are evaluated in transition id order. The first true guard wins. A behavior cannot rely on object insertion order. If no guard matches, the host records `no-transition` and leaves state unchanged. A malformed payload, -unknown event, duplicate message, fuel exhaustion, or bound failure also leaves -state unchanged. The evaluator never partially commits a state write or output. +unknown event, duplicate event (including a redelivery under a new delivery id), +conflicting event key, fuel exhaustion, or bound failure leaves state unchanged. +The evaluator never partially commits a state write or output. Backpressure is part of the host protocol, not a hidden transition. A host accepts at most `maxMailboxDepth` pending messages. It must return one of @@ -282,11 +296,11 @@ recorded effect. It must not contain `createdAt`, `updatedAt`, a timestamp, random host identifiers, a process address, or a provider credential. A replay verifier loads the behavior, then processes entries in order. It checks -message identity, payload schema, guard and expression results, fuel, state -bytes, output identities, and the recorded outcome. A replay can prove that the -recorded transition is internally consistent. It cannot prove that a provider -returned a truthful answer or that a future external effect will have the same -result. +message identity, the retained event-key binding, payload schema, guard and +expression results, fuel, state bytes, output identities, and the recorded +outcome. A replay can prove that the recorded transition is internally +consistent. It cannot prove that a provider returned a truthful answer or that +a future external effect will have the same result. Trace storage and an eventual event-loop trace wire contract are intentionally separate from this behavior envelope. P1 may add that record only by reusing From 926c5795c11e0d91145c9385931534d9ce24e8b2 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:09:41 -0400 Subject: [PATCH 7/7] P0: reject repeated state writes in one transition --- benchmarks/language/contract.test.ts | 29 ++++++++++++++++++++++++++++ spec/v1/event-loop.md | 6 +++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/benchmarks/language/contract.test.ts b/benchmarks/language/contract.test.ts index 4ae058c6..2953d850 100644 --- a/benchmarks/language/contract.test.ts +++ b/benchmarks/language/contract.test.ts @@ -307,6 +307,7 @@ function parseActor( } if (transition.when !== null) expression(transition.when, `transition ${transitionId}.when`, [...transitionAt, "when"]); if (!Array.isArray(transition.writes) || transition.writes.length > 64) fail("EVENT_LOOP_BOUNDS", `transition ${transitionId}.writes`, [...transitionAt, "writes"]); + const writtenFields = new Set(); for (const [writeIndex, rawWrite] of transition.writes.entries()) { const writeAt = [...transitionAt, "writes", writeIndex]; const write = object(rawWrite, `transition ${transitionId}.writes[${writeIndex}]`, writeAt); @@ -318,6 +319,9 @@ function parseActor( fail("EVENT_LOOP_REFERENCE", `transition ${transitionId}.state field`, [...writeAt, "path", 0]); } for (const [pathIndex, segment] of write.path.entries()) identifier(segment, `transition ${transitionId}.path[${pathIndex}]`, [...writeAt, "path", pathIndex]); + const field = write.path[0] as string; + if (writtenFields.has(field)) fail("EVENT_LOOP_SEMANTIC", `transition ${transitionId} repeats state field ${field}`, [...writeAt, "path"]); + writtenFields.add(field); expression(write.value, `transition ${transitionId}.value`, [...writeAt, "value"]); } if (!Array.isArray(transition.outputs) || transition.outputs.length > 64) { @@ -1389,6 +1393,31 @@ describe("language benchmark contract fixtures", () => { expect(errorCode(() => parseEventLoop(actor))).toBe("EVENT_LOOP_BOUNDS"); }); + test("writes to distinct fields are valid, but a repeated field is rejected", async () => { + const actor = structuredClone(await load("actors/echo.json")) as RecordValue; + const firstActor = object((actor.actors as unknown[])[0], "actor"); + const state = object(firstActor.state, "state"); + (state.fields as unknown[]).push({ name: "total", type: "number", initial: 0 }); + const transition = object((firstActor.transitions as unknown[])[0], "transition"); + const writes = transition.writes as unknown[]; + const secondWrite = structuredClone(writes[0]) as RecordValue; + secondWrite.path = ["total"]; + writes.push(secondWrite); + actor.digest = canonicalDigest(withoutDigest(actor)); + expect(errorCode(() => parseEventLoop(actor))).toBe("ACCEPTED"); + + secondWrite.path = ["count"]; + actor.digest = canonicalDigest(withoutDigest(actor)); + try { + parseEventLoop(actor); + throw new Error("a repeated state field was accepted"); + } catch (error) { + if (!(error instanceof FixtureError)) throw error; + expect(error.code).toBe("EVENT_LOOP_SEMANTIC"); + expect(error.at).toEqual(["actors", 0, "transitions", 0, "writes", 1, "path"]); + } + }); + test("quiet forbids outputs, and sources cannot collide in event identity", async () => { const actor = structuredClone(await load("actors/echo.json")) as RecordValue; const artifactDigest = actor.digest; diff --git a/spec/v1/event-loop.md b/spec/v1/event-loop.md index 689022bf..f0e00d00 100644 --- a/spec/v1/event-loop.md +++ b/spec/v1/event-loop.md @@ -262,6 +262,9 @@ A transition is a closed object with `id`, `on`, `when`, `writes`, `outputs`, an v1, represented as a one-element list. A nested path is rejected in v1; a future revision must explicitly define any nested-write semantics. `value` is an `algal.expr.v1` envelope over `{ "state", "event" }`. + A transition may write each declared field at most once: a repeated path is + rejected at admission with `EVENT_LOOP_SEMANTIC` at the second `path`. Distinct + fields may be written together; separate transitions may write the same field. Writes are evaluated against the pre-transition state and are committed as one record after all expressions pass. - Each output is `{ "kind": "notify", "target", "event", "payload" }`. @@ -332,7 +335,8 @@ The portable parser reports one stable code for the first failure: `EVENT_LOOP_BOUNDS` for a count, byte, depth, or fuel limit, `EVENT_LOOP_SCHEMA` for an invalid field type or initial value, `EVENT_LOOP_REFERENCE` for an unknown event, actor, state field, or expression -name, and `EVENT_LOOP_SEMANTIC` for an invalid transition outcome. +name, and `EVENT_LOOP_SEMANTIC` for an invalid transition outcome or repeated +write path within one transition. The P0 byte-fixture checker maps malformed JSON and invalid UTF-8 to `EVENT_LOOP_PARSE`. Since these and a non-object JSON root have no contract field path, their diagnostic spans cover the complete source (1 to 16,384