diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c8293f5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +evidence +reports +**/dist +**/*.tsbuildinfo +*.sqlite* diff --git a/.env.example b/.env.example index 33273a5..68f76a2 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,5 @@ ALD_DATABASE_PATH=./evidence/ald.sqlite ALD_LOG_LEVEL=info ALD_BASE_NETWORK=base-sepolia -# Paths and endpoints are intentionally blank. -ALD_KEY_DIR= -ALD_BASE_RPC_URL= -ALD_ANCHOR_KEY_FILE= +# Secret values and secret file paths are injected only by `si fort run`. +# They are intentionally absent from this file. diff --git a/.github/workflows/book-integrity.yml b/.github/workflows/book-integrity.yml index cd4e388..34d60aa 100644 --- a/.github/workflows/book-integrity.yml +++ b/.github/workflows/book-integrity.yml @@ -1,47 +1,77 @@ -name: Research book integrity +name: Consolidated integrity suite on: pull_request: - paths: - - RESEARCH.md - - research-book.html - - book/** - - scripts/build-research-book.mjs - - scripts/research-book-entry.js - - package.json - - package-lock.json - - vitest.config.ts push: branches: - main - paths: - - RESEARCH.md - - research-book.html - - book/** - - scripts/build-research-book.mjs - - scripts/research-book-entry.js - - package.json - - package-lock.json - - vitest.config.ts permissions: contents: read jobs: - verify-book: + consolidated-suite: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - - name: Set up Node.js - uses: actions/setup-node@v4 + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@49d03f1bf5d455a413dfbdf99e6718054a4cd975 + + - name: Install Node.js and pnpm with Homebrew + run: | + brew install node@24 pnpm + echo "$(brew --prefix node@24)/bin" >> "$GITHUB_PATH" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Audit dependency graph + run: | + mkdir -p artifacts + pnpm audit --audit-level=high --json > artifacts/pnpm-audit.json + + - name: Run consolidated suite with timing + run: | + mkdir -p artifacts + /usr/bin/time -v pnpm run check:ci 2> artifacts/consolidated-runtime.txt + + - name: Upload test and runtime evidence + if: always() + uses: actions/upload-artifact@v7 with: - node-version: 22.13.0 - cache: npm + name: consolidated-suite-evidence + path: artifacts/ + retention-days: 30 + + mode-r: + runs-on: ubuntu-latest + needs: consolidated-suite + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@49d03f1bf5d455a413dfbdf99e6718054a4cd975 + + - name: Install Node.js and pnpm with Homebrew + run: | + brew install node@24 pnpm + echo "$(brew --prefix node@24)/bin" >> "$GITHUB_PATH" - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile + + - name: Run real-container Mode R suite with timing + run: | + mkdir -p artifacts + /usr/bin/time -v pnpm run test:mode-r 2> artifacts/mode-r-runtime.txt - - name: Verify manuscript and book assets - run: npm test + - name: Upload Mode R runtime evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: mode-r-suite-evidence + path: artifacts/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 84bdd39..fb2ceec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ coverage/ +.artifacts/ *.tsbuildinfo .env .env.* diff --git a/BACKLOG.md b/BACKLOG.md index 2d7a53d..cf94471 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -23,21 +23,21 @@ This backlog translates `SPECIFICATION.md`, `EXPERIMENT-NOTEBOOK.md`, and `LEDGE ## 1. Document Status -- **Status:** Implementation active. ALD-001 through ALD-007 are complete; ALD-008 is the next critical-path item. +- **Status:** Implementation active, with 254 of 258 acceptance criteria verified. The verifiable core and Prototype Mode pipeline are joined by tested Phase E foundations for alternate carriers and affect, versioned carrier-leakage and semantic-leakage qualification, matched recurrent scratch-RL/self-supervised baselines with witnessed provenance and frozen evaluation, recorded per-Baby random policy initialization, frozen/hybrid adapter foundations, independently verified derived-run lineage, fail-closed scenario quarantine and audited observation-hygiene blocking, observation red-teaming, process and container training isolation, enumerated side-channel mitigations and active attacks, build-guarded response envelopes and claim labels, witness-committed analysis attachments, exact-export verification reports, prospectively bound confirmatory pre-registration, protected background scheduling, hash-linked deviation records, a vanilla Research Console with read-only replay, delayed human audit-ledger interpretation, telemetry, snapshot/restore, failure handling, retention enforcement, held-out scenario splits, fixed-schedule curriculum execution, verifier-bound live causal probes, recoverable bounded repair turns, a research-only three-role E40 encoding harness with an enforced production-crypto boundary, executable readiness gates covering all 19 experiments, dependency-integrity enforcement, reproducible E03 design and registration artifacts, a fail-closed research preflight, a real open-weight frozen-model qualification, and hosted consolidated CI/Mode R evidence. The prospective campaign is governed by the simulation-only profile; public-chain acceptance items remain capability work rather than campaign prerequisites. The acceptance checkboxes below remain authoritative for that optional capability, upstream required-check enforcement, and independent operator restore validation. - **Source of truth precedence:** `SPECIFICATION.md` governs implementation; `LEDGER-INTEGRITY-DESIGN.md` governs ledger, checkpoint, Merkle, and anchoring mechanics; `EXPERIMENT-NOTEBOOK.md` governs experiment pre-registration and results; `CONCEPT-IDEA.md` preserves research rationale. This backlog is derived from those documents and introduces no new normative requirements. - **Scope of this backlog:** software and process engineering work required to stand up the system described in `SPECIFICATION.md` and to make every experiment in `EXPERIMENT-NOTEBOOK.md` §7–§8 executable. It does **not** include running the experiments themselves, interpreting results, or drafting findings — those are research-execution activities tracked in the notebook, not software backlog items. -- **Numbering:** Epics use stable IDs `EPIC-01`…`EPIC-15`. Individual backlog items use stable IDs `ALD-001`…`ALD-080`. IDs are assigned in dependency order: every item's `Depends on` list only ever references a **lower**-numbered ALD ID. IDs are permanent once assigned and must not be reused or renumbered by future edits; new work gets the next unused ID appended at the end of its epic's range or a new epic. +- **Numbering:** Epics use stable IDs `EPIC-01`…`EPIC-16`. Individual backlog items use stable IDs `ALD-001`…`ALD-086`. IDs are assigned in dependency order: every item's `Depends on` list only ever references a **lower**-numbered ALD ID. IDs are permanent once assigned and must not be reused or renumbered by future edits; new work gets the next unused ID appended at the end of its epic's range or a new epic. ## 2. Planning Assumptions -- **Runtime and language:** Node.js with TypeScript, consistent with the DTSF ecosystem this project builds on. Package management is npm, using npm workspaces for a monorepo layout (no Yarn/pnpm). +- **Runtime and language:** Node.js with TypeScript, consistent with the DTSF ecosystem this project builds on. Package management uses pnpm workspaces with a frozen lockfile. - **Evidence store:** SQLite in WAL (write-ahead log) mode is the authoritative local evidence store, per `LEDGER-INTEGRITY-DESIGN.md` [§18. Recommended Initial Decision](LEDGER-INTEGRITY-DESIGN.md#18-recommended-initial-decision). No external database is introduced. -- **Anchoring chain:** Base Sepolia (testnet) is the first and default anchoring target. Mainnet anchoring is a separate, later, explicitly opt-in capability — never the default. +- **Anchoring profile:** deterministic simulation is the default execution class and emulates Base Sepolia semantics without currency. Real Base Sepolia and mainnet anchoring are optional capabilities outside the approved campaign; mainnet remains separately opt-in. - **Model default:** `scratch-rl` is the primary scientific baseline. A local open-weight `frozen-llm` is the orchestration-validation default; `self-supervised` is the initial reward-free comparison. `no-learning` and `hybrid` are explicit controls/variants. - **Isolation:** Research-Grade Mode (Mode R) runs learner processes in separate containers/processes with no shared mutable state beyond the Gateway and ledger, per `SPECIFICATION.md` [§5.2 Research-Grade Mode (Mode R)](SPECIFICATION.md#52-research-grade-mode-mode-r). - **On-chain privacy:** no private, raw-observation, or model-internal data is ever placed in an on-chain anchoring payload — only checkpoint root hashes and minimal metadata, per `SPECIFICATION.md` [§13.6 Privacy Controls](SPECIFICATION.md#136-privacy-controls) and `LEDGER-INTEGRITY-DESIGN.md` [§12. Privacy](LEDGER-INTEGRITY-DESIGN.md#12-privacy). -- **No invented dates or staffing:** this backlog contains no calendar dates, durations, or headcount figures. Milestones are ordinal (`M0`…`M5`); the execution plan uses ordinal iterations (`Iteration 1`…`Iteration 4`, "Iteration 5+"). Sequencing is expressed purely through dependencies. -- **Repository today:** documentation-only. Every "implement X" item below is net-new work; none of it exists in the repository yet, so no item's acceptance criteria assume pre-existing code beyond what an earlier, lower-numbered ALD item established. +- **No invented dates or staffing:** this backlog contains no calendar dates, durations, or headcount figures. Milestones are ordinal (`M0`…`M6`); the execution plan uses ordinal iterations (`Iteration 1`…`Iteration 4`, "Iteration 5+"). Sequencing is expressed purely through dependencies. +- **Repository today:** a pnpm-workspaces TypeScript monorepo with the packages listed in README.md; each item's acceptance criteria assume only what earlier, lower-numbered ALD items established. - **Diplomacy-table reuse:** the repository's existing UX components (e.g., from a prior Diplomacy-style project) may be reused only within the boundaries `SPECIFICATION.md` [§16.2](SPECIFICATION.md#162-diplomacy-table-reuse-boundaries) defines; this is treated as a constraint, not an invitation to reuse everything available. ## 3. Priority Definitions @@ -126,7 +126,12 @@ Milestones are ordinal and gate-based, not date-based. Each milestone's exit gat - **Entry gate:** M4 exit met. - **Exit gate:** Gate G5 (`ALD-077`) passes, `ALD-078` CI is green on the full consolidated suite, and `ALD-080` release/publication mapping is complete. Corresponds to `LEDGER-INTEGRITY-DESIGN.md` [Phase 3: Public Anchoring](LEDGER-INTEGRITY-DESIGN.md#phase-3-public-anchoring). -**Critical path across milestones:** M0 → M1 → M2 → M3 → M4 → M5 is a strict chain — each milestone's epics depend on the previous milestone's evidence/lifecycle foundations. Within M2–M4, epics not named in the critical-path narrative (§10) can proceed in parallel once their own dependencies clear; see §10 for the detailed item-level view. +### M6 — Research-Execution Readiness +- **Epics in scope:** EPIC-16 (`ALD-081`–`086`). +- **Entry gate:** the locally executable M5 software checks are green; external authority gates remain explicitly open rather than simulated. +- **Exit gate:** dependency integrity is enforced, E03's design and registration artifacts reproduce, research preflight fails closed, a real open-weight frozen model completes both roles, and project-status drift is blocked by the consolidated check. + +**Critical path across milestones:** M0 → M1 → M2 → M3 → M4 → M5 → M6 is a strict chain — each milestone's epics depend on the previous milestone's evidence/lifecycle foundations. Within M2–M4, epics not named in the critical-path narrative (§10) can proceed in parallel once their own dependencies clear; see §10 for the detailed item-level view. ## 9. Epic Overview @@ -147,6 +152,7 @@ Milestones are ordinal and gate-based, not date-based. Each milestone's exit gat | EPIC-13 | Security, Red-Team, and Cryptography Track | ALD-067–070 | EPIC-06, EPIC-07, EPIC-10 | Red-team suites green; crypto novelty/security separation policy enforced | | EPIC-14 | Experiment Pre-Registration and E00–E50 Readiness Gates | ALD-071–077 | EPIC-05, EPIC-06, EPIC-08, EPIC-13 | Gates G1–G5 all pass; all 19 experiments have a readiness record | | EPIC-15 | CI/Test Suites, Documentation, Operations, and Release | ALD-078–080 | all prior epics | CI green on consolidated suite; docs and release/publication mapping complete | +| EPIC-16 | Research-Execution Readiness | ALD-081–086 | EPIC-14, EPIC-15 | Reproducible design/registration, fail-closed preflight, real-model qualification, and status-drift enforcement pass | ## 10. Dependency and Critical Path View @@ -224,7 +230,7 @@ dependencies are complete. - **Workstream F (Audit and Recovery):** `ALD-058`, `ALD-059`, `ALD-060`, sequenced after lifecycle, evidence, and Mode R dependencies. - **Exit state:** baby-a/baby-b/nursery routes live with authorization and standardized error shape; observation hygiene, sanitization, and seeded scenario determinism operational; Base Sepolia anchoring confirmed; Gate G1 (E00–E03 readiness) achievable. -**Iteration 5+ (not detailed further here, no dates implied):** continues with the remaining branches — generative carrier and affect protocols (`ALD-031`–033), remaining adapter tracks (`ALD-046`–047), Mode R isolation and telemetry/audit/retention (EPIC-10, EPIC-11), the dashboard (EPIC-12), remaining security/crypto items (`ALD-069`–070), Gates G2–G5 (`ALD-074`–077), and CI/documentation/release (EPIC-15) — in the dependency order fixed by their IDs. +**Iteration 5+ (not detailed further here, no dates implied):** continues with the remaining branches — generative carrier and affect protocols (`ALD-031`–033), remaining adapter tracks (`ALD-046`–047), Mode R isolation and telemetry/audit/retention (EPIC-10, EPIC-11), the dashboard (EPIC-12), remaining security/crypto items (`ALD-069`–070), Gates G2–G5 (`ALD-074`–077), CI/documentation/release (EPIC-15), and research-execution readiness (EPIC-16) — in the dependency order fixed by their IDs. ## 12. Backlog Items by Epic @@ -234,13 +240,13 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re **Goal:** stand up the monorepo, shared types, configuration convention, and twin pack skeletons so every later epic has a place to put code. **Depends on:** none. **Acceptance gate:** the monorepo installs and builds with zero source packages beyond skeletons, and a skeleton twin pack responds on its unprefixed route. -#### ALD-001 — npm workspaces monorepo bootstrap +#### ALD-001 — pnpm workspaces monorepo bootstrap - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** none - **Spec refs:** `SPECIFICATION.md` [§4.1 Components](SPECIFICATION.md#41-components) -- **Scope:** Initialize root `package.json` with `workspaces` for `packages/*` and `twins/*`; set up shared TypeScript config, lint/format config, and a root build script (`tsc --build` across project references). +- **Scope:** Initialize a root pnpm workspace for `packages/*` and `twins/packs/*`; set up shared TypeScript config, lint/format config, and a root build script (`tsc --build` across project references). - **Acceptance criteria:** - - [x] `npm install` at the repo root succeeds with zero workspace packages beyond the initial skeletons. - - [x] `npm run build` (project references) compiles with zero errors. + - [x] `pnpm install --frozen-lockfile` at the repo root succeeds. + - [x] `pnpm run build` (project references) compiles with zero errors. - [x] A new package can be added under `packages/*` and is automatically picked up by the workspace without editing the root `package.json`. #### ALD-002 — Shared `@ald/types` schema package @@ -306,36 +312,36 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§4. Canonical Ledger Event](LEDGER-INTEGRITY-DESIGN.md#4-canonical-ledger-event), [§6. Binding Ledgers to Communication](LEDGER-INTEGRITY-DESIGN.md#6-binding-ledgers-to-communication) - **Scope:** Implement independent previous-hash chains for Baby A ledger, Baby B ledger, and channel transcript, plus optional affect/audit chains, with the cross-event bindings required by LEDGER §6. - **Acceptance criteria:** - - [ ] Each stored event's previous hash matches the immediately preceding event in the same run and event domain, with sequence starting at `1`. - - [ ] Mutating, deleting, inserting, or reordering an event in any primary chain is detected by a chain-walk validator. - - [ ] Sender intention, channel event, receiver delivery receipt, and receiver interpretation carry the exact cross-hashes required by LEDGER §6 and SPECIFICATION §11.5. + - [x] Each stored event's previous hash matches the immediately preceding event in the same run and event domain, with sequence starting at `1`. + - [x] Mutating, deleting, inserting, or reordering an event in any primary chain is detected by a chain-walk validator. + - [x] Sender intention, channel event, receiver delivery receipt, and receiver interpretation carry the exact cross-hashes required by LEDGER §6 and SPECIFICATION §11.5. #### ALD-009 — Per-run event and witness key provisioning - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-008, ALD-003 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§11. Key Management](LEDGER-INTEGRITY-DESIGN.md#11-key-management) - **Scope:** Provision isolated per-run Ed25519 keys for Baby A ledger, Baby B ledger, channel transcript, optional affect events, generated audit-ledger events, and Nursery checkpoint witness; expose domain-bound signing RPCs and store only public keys in the run manifest. - **Acceptance criteria:** - - [ ] Every committed event and checkpoint has a signature verifiable by the public key registered for exactly its domain. - - [ ] Cross-domain signing attempts fail, including Baby A attempting to sign Baby B or channel content. - - [ ] Private keys are absent from SQLite, logs, model context, and evidence bundles; per-run rotation produces distinct public keys. + - [x] Every committed event and checkpoint has a signature verifiable by the public key registered for exactly its domain. + - [x] Cross-domain signing attempts fail, including Baby A attempting to sign Baby B or channel content. + - [x] Private keys are absent from SQLite, logs, model context, and evidence bundles; per-run rotation produces distinct public keys. #### ALD-010 — Evidence Writer and atomic turn transaction - **Priority:** P0 · **Size:** L · **Class:** MVP · **Depends on:** ALD-005, ALD-008, ALD-009 - **Spec refs:** `SPECIFICATION.md` [§8.2 Atomic Ledger+Message Transaction](SPECIFICATION.md#82-atomic-ledgermessage-transaction) - **Scope:** Implement the single Evidence Writer service from SPECIFICATION §4/§8: authenticate Gateway requests, assign sequences, build canonical events, obtain domain-bound signatures from `ALD-009`, and commit the sender ledger plus channel event in one SQLite transaction. - **Acceptance criteria:** - - [ ] A signing or insert failure at any point results in zero sender-ledger and channel rows committed. - - [ ] A successful `TurnCommitRequest` commits both signed rows atomically and returns their entry hashes before delivery. - - [ ] Module boundaries and database permissions prevent every other component, including Gateway and Controller, from writing event tables directly. + - [x] A signing or insert failure at any point results in zero sender-ledger and channel rows committed. + - [x] A successful `TurnCommitRequest` commits both signed rows atomically and returns their entry hashes before delivery. + - [x] Module boundaries and database permissions prevent every other component, including Gateway and Controller, from writing event tables directly. #### ALD-011 — WAL durability and crash-safety tests - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-005, ALD-010 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§3. Authoritative Local Store](LEDGER-INTEGRITY-DESIGN.md#3-authoritative-local-store), [§15. Recovery and Fork Handling](LEDGER-INTEGRITY-DESIGN.md#15-recovery-and-fork-handling) - **Scope:** Build a test harness that kills the process mid-write (simulated crash) and verifies the WAL recovers to a consistent last-committed state on restart, with no torn or partial ledger events. - **Acceptance criteria:** - - [ ] Simulated crash during an in-flight `ALD-010` transaction leaves the database with either the pre-transaction or post-transaction state, never a partial one, on restart. - - [ ] The chain-walk validator from `ALD-008` reports zero integrity violations after each crash-recovery test run. - - [ ] The test suite runs at least 20 randomized crash-point trials in CI (once `ALD-078` exists) without a single torn-write failure. + - [x] Simulated crash during an in-flight `ALD-010` transaction leaves the database with either the pre-transaction or post-transaction state, never a partial one, on restart. + - [x] The chain-walk validator from `ALD-008` reports zero integrity violations after each crash-recovery test run. + - [x] The test suite runs at least 20 randomized crash-point trials in CI (once `ALD-078` exists) without a single torn-write failure. ### EPIC-03 — Merkle Checkpoints and Verifier CLI (Phase 1) @@ -346,54 +352,54 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§7. Ordered Merkle Checkpoints](LEDGER-INTEGRITY-DESIGN.md#7-ordered-merkle-checkpoints) - **Scope:** Implement RFC 6962-style ordered Merkle trees for all present primary and auxiliary event domains, producing roots, inclusion proofs, and prefix-consistency proofs between checkpoint sizes. - **Acceptance criteria:** - - [ ] Given a fixed ordered set of event hashes, the builder produces a deterministic, reproducible root hash. - - [ ] An inclusion proof for any leaf verifies correctly against the root using only the proof and the leaf hash. - - [ ] Valid extension checkpoints produce a consistency proof, while reordering, deletion, insertion, or a non-prefix tree fails consistency verification. + - [x] Given a fixed ordered set of event hashes, the builder produces a deterministic, reproducible root hash. + - [x] An inclusion proof for any leaf verifies correctly against the root using only the proof and the leaf hash. + - [x] Valid extension checkpoints produce a consistency proof, while reordering, deletion, insertion, or a non-prefix tree fails consistency verification. #### ALD-013 — Checkpoint manifest generation - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-009, ALD-012, ALD-002 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§8. Checkpoint Manifest](LEDGER-INTEGRITY-DESIGN.md#8-checkpoint-manifest), `SPECIFICATION.md` [§11.7 Checkpoint Manifest Reference](SPECIFICATION.md#117-checkpoint-manifest-reference) - **Scope:** Generate the complete checkpoint manifest with required Baby A/B/channel roots, present auxiliary roots, tree sizes, last hashes, prior-checkpoint hash, run/config/prompt hashes, then obtain the Nursery witness signature from `ALD-009`. - **Acceptance criteria:** - - [ ] A generated manifest validates against the authoritative schema and includes every event tree present in the Evidence Store. - - [ ] Each manifest references the immediately prior checkpoint hash and carries a valid Nursery witness signature. - - [ ] Every tree size/root exactly matches `ALD-012`, and a missing or extra tree causes checkpoint generation to fail. + - [x] A generated manifest validates against the authoritative schema and includes every event tree present in the Evidence Store. + - [x] Each manifest references the immediately prior checkpoint hash and carries a valid Nursery witness signature. + - [x] Every tree size/root exactly matches `ALD-012`, and a missing or extra tree causes checkpoint generation to fail. #### ALD-014 — Checkpoint frequency scheduler - **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-013 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§9. Checkpoint Frequency](LEDGER-INTEGRITY-DESIGN.md#9-checkpoint-frequency) - **Scope:** Implement the scheduler that triggers checkpoint generation according to the frequency policy in §9 (event-count and/or time-based trigger, as specified). - **Acceptance criteria:** - - [ ] A checkpoint is generated automatically once the configured trigger threshold from §9 is reached. - - [ ] No two checkpoints overlap in event range. - - [ ] The scheduler is a background timer with the crash-protection convention (registered under the process's `uncaughtException`/`unhandledRejection` handlers) so a scheduling failure logs rather than crashes the server. + - [x] A checkpoint is generated automatically once the configured trigger threshold from §9 is reached. + - [x] No two checkpoints overlap in event range. + - [x] The scheduler is a background timer with the crash-protection convention (registered under the process's `uncaughtException`/`unhandledRejection` handlers) so a scheduling failure logs rather than crashes the server. #### ALD-015 — Independent verifier CLI - **Priority:** P0 · **Size:** L · **Class:** MVP · **Depends on:** ALD-012, ALD-013, ALD-009 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§14. Independent Verification](LEDGER-INTEGRITY-DESIGN.md#14-independent-verification) - **Scope:** Build a standalone CLI with no runtime trust that validates canonical JSON, all event chains/cross-bindings, writer and witness signatures, Merkle roots, inclusion/consistency proofs, checkpoint chains, configuration hashes, forks, gaps, and unanchored tails. Chain-RPC verification is added by `ALD-021`. - **Acceptance criteria:** - - [ ] The CLI runs against an exported bundle with no network access and no shared process state with the server. - - [ ] It accepts an unchanged local bundle and rejects every non-chain mutation case in LEDGER §17 with the correct machine-readable failure location. - - [ ] It distinguishes chain, signature, inclusion, consistency, checkpoint, fork/gap, and unanchored-tail results. + - [x] The CLI runs against an exported bundle with no network access and no shared process state with the server. + - [x] It accepts an unchanged local bundle and rejects every non-chain mutation case in LEDGER §17 with the correct machine-readable failure location. + - [x] It distinguishes chain, signature, inclusion, consistency, checkpoint, fork/gap, and unanchored-tail results. #### ALD-016 — Evidence bundle export - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-013, ALD-005 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§13. Evidence Bundle](LEDGER-INTEGRITY-DESIGN.md#13-evidence-bundle) - **Scope:** Implement an export command that packages a run's ledger events, checkpoint manifests, signatures, and (once available) anchor receipts into a portable bundle format per §13. - **Acceptance criteria:** - - [ ] The exported bundle contains every event, manifest, and signature needed for `ALD-015` to verify it with no other input. - - [ ] Exporting the same run twice without intervening writes produces byte-identical bundles. - - [ ] The bundle format is documented with a schema so a third party could write their own verifier. + - [x] The exported bundle contains every event, manifest, and signature needed for `ALD-015` to verify it with no other input. + - [x] Exporting the same run twice without intervening writes produces byte-identical bundles. + - [x] The bundle format is documented with a schema so a third party could write their own verifier. #### ALD-017 — Verification report schema and generator - **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-015 - **Spec refs:** `SPECIFICATION.md` [§11.10 Verification Report](SPECIFICATION.md#1110-verification-report) - **Scope:** Formalize the CLI's pass/fail output into the Verification Report structure defined in §11.10 and persist generated reports alongside the bundle they describe. - **Acceptance criteria:** - - [ ] Every verifier CLI run (`ALD-015`) produces a report conforming to the `ALD-002` schema for Verification Report. - - [ ] A failing verification produces a report with machine-readable failure codes, not just free text. - - [ ] Reports are timestamped and reference the exact bundle export they were generated from. + - [x] Every verifier CLI run (`ALD-015`) produces a report conforming to the `ALD-002` schema for Verification Report. + - [x] A failing verification produces a report with machine-readable failure codes, not just free text. + - [x] Reports are timestamped and reference the exact bundle export they were generated from. ### EPIC-04 — Base Sepolia and Mainnet Anchoring (Phases 2–3) @@ -404,18 +410,18 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§11.8 Anchor Receipt Reference](SPECIFICATION.md#118-anchor-receipt-reference), `LEDGER-INTEGRITY-DESIGN.md` [§10. Base and L1 Anchoring](LEDGER-INTEGRITY-DESIGN.md#10-base-and-l1-anchoring) - **Scope:** Define and persist the Anchor Receipt structure (checkpoint reference, chain ID, transaction hash, block number, confirmation status) linked one-to-one with a checkpoint manifest. - **Acceptance criteria:** - - [ ] A stored Anchor Receipt validates against the `ALD-002` schema and always references an existing checkpoint manifest (`ALD-013`). - - [ ] Only a checkpoint root hash and minimal metadata are ever stored as the on-chain payload field — no raw observation or model data. - - [ ] Querying receipts by checkpoint ID returns at most one receipt per chain per checkpoint. + - [x] A stored Anchor Receipt validates against the `ALD-002` schema and always references an existing checkpoint manifest (`ALD-013`). + - [x] Only a checkpoint root hash and minimal metadata are ever stored as the on-chain payload field — no raw observation or model data. + - [x] Querying receipts by checkpoint ID returns at most one receipt per chain per checkpoint. #### ALD-019 — Anchoring signer key management - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-003, ALD-009 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§11. Key Management](LEDGER-INTEGRITY-DESIGN.md#11-key-management) - **Scope:** Provision a distinct on-chain signing key (separate from the event-signing key in `ALD-009`) per the §11 key-management convention, with its own storage/rotation path. - **Acceptance criteria:** - - [ ] The anchoring key is stored separately from the event-signing key and neither can be derived from the other. - - [ ] A key-rotation procedure exists and is exercised by a test that anchors before and after rotation without breaking prior receipts' validity. - - [ ] The anchoring private key is never logged, telemetered, or included in any evidence bundle. + - [x] The anchoring key is stored separately from the event-signing key and neither can be derived from the other. + - [x] A key-rotation procedure exists and is exercised by a test that anchors before and after rotation without breaking prior receipts' validity. + - [x] The anchoring private key is never logged, telemetered, or included in any evidence bundle. #### ALD-020 — Base Sepolia anchoring client - **Priority:** P1 · **Size:** L · **Class:** MVP · **Depends on:** ALD-018, ALD-019 @@ -423,26 +429,26 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Scope:** Implement the client that submits a checkpoint root hash to Base Sepolia and records the resulting transaction as an Anchor Receipt (`ALD-018`), with Base Sepolia as the unconditional default target. - **Acceptance criteria:** - [ ] A submitted checkpoint root is independently observable on a public Base Sepolia block explorer. - - [ ] The default configuration anchors to Base Sepolia with no additional opt-in required. - - [ ] The submitted on-chain payload contains only the root hash and minimal required metadata, matching `ALD-018`'s privacy criterion. + - [x] The default configuration anchors to Base Sepolia with no additional opt-in required. + - [x] The submitted on-chain payload contains only the root hash and minimal required metadata, matching `ALD-018`'s privacy criterion. #### ALD-021 — Anchor confirmation and retry/backoff - **Priority:** P1 · **Size:** M · **Class:** MVP · **Depends on:** ALD-015, ALD-020 - **Spec refs:** `LEDGER-INTEGRITY-DESIGN.md` [§10. Base and L1 Anchoring](LEDGER-INTEGRITY-DESIGN.md#10-base-and-l1-anchoring) - **Scope:** Implement finality polling and retry/backoff, then extend the independent verifier to retrieve the transaction through an independently configured RPC, validate chain ID/calldata/receipt/block inclusion, and compare the anchored checkpoint to the final local prefix. - **Acceptance criteria:** - - [ ] A receipt is marked `confirmed` only after reaching the configured confirmation depth. - - [ ] Transient RPC failure retries without duplicate submission; wrong-chain, failed, or nonexistent transactions fail verification. - - [ ] The verifier reports any event tail after the final anchored checkpoint and independently reproduces the anchored checkpoint hash. + - [x] A receipt is marked `confirmed` only after reaching the configured confirmation depth. + - [x] Transient RPC failure retries without duplicate submission; wrong-chain, failed, or nonexistent transactions fail verification. + - [x] The verifier reports any event tail after the final anchored checkpoint and independently reproduces the anchored checkpoint hash. #### ALD-022 — Mainnet anchoring policy switch - **Priority:** P2 · **Size:** M · **Class:** Later-Research · **Depends on:** ALD-020, ALD-021, ALD-003 - **Spec refs:** `SPECIFICATION.md` [§13.4 Base Sepolia / Mainnet Anchoring Policy](SPECIFICATION.md#134-base-sepolia--mainnet-anchoring-policy) - **Scope:** Add an explicit, separately-configured mainnet anchoring path reusing the Sepolia client's logic with a different chain configuration, gated behind a distinct opt-in flag that defaults to off. - **Acceptance criteria:** - - [ ] With no explicit opt-in set, the system never submits any transaction to mainnet, confirmed by a test that asserts zero mainnet RPC calls under default config. + - [x] With no explicit opt-in set, the system never submits any transaction to mainnet, confirmed by a test that asserts zero mainnet RPC calls under default config. - [ ] Enabling the opt-in flag and providing mainnet-specific key/config anchors successfully to mainnet in a manual/staging test. - - [ ] Switching the opt-in flag off again immediately reverts all anchoring to Base Sepolia with no code change required. + - [x] Switching the opt-in flag off again immediately reverts all anchoring to Base Sepolia with no code change required. ### EPIC-05 — Run and Turn Lifecycle State Machine @@ -453,54 +459,54 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§11.1 Run Configuration](SPECIFICATION.md#111-run-configuration) - **Scope:** Implement validation for the Run Configuration schema (`ALD-002`), rejecting configs missing required fields (model track, protocol channel selection, mode) before a run can be created. - **Acceptance criteria:** - - [ ] Missing fields and incompatible track/learning-signal, oracle/experiment, or carrier-specific combinations are rejected with field-specific errors. - - [ ] Valid root and derived configs cover every model, deployment, communication, carrier, affect, and interaction mode; lineage fields are all-or-none. - - [ ] Validated canonical configs are persisted, hashable, and retrievable by run ID. + - [x] Missing fields and incompatible track/learning-signal, oracle/experiment, or carrier-specific combinations are rejected with field-specific errors. + - [x] Valid root and derived configs cover every model, deployment, communication, carrier, affect, and interaction mode; lineage fields are all-or-none. + - [x] Validated canonical configs are persisted, hashable, and retrievable by run ID. #### ALD-024 — Run state machine - **Priority:** P0 · **Size:** L · **Class:** MVP · **Depends on:** ALD-023 - **Spec refs:** `SPECIFICATION.md` [§7.1 Run States](SPECIFICATION.md#71-run-states), [§7.2 State Transition Table](SPECIFICATION.md#72-state-transition-table) - **Scope:** Implement the full run-state machine exactly as the §7.2 transition table specifies, rejecting any transition not listed in the table. - **Acceptance criteria:** - - [ ] Every transition listed in §7.2 is implemented and unit-tested. - - [ ] Every transition **not** listed in §7.2 is rejected with an explicit "invalid transition" error, verified by an exhaustive test over all state pairs. - - [ ] The current state of any run is queryable and matches the last successfully applied transition. + - [x] Every transition listed in §7.2 is implemented and unit-tested. + - [x] Every transition **not** listed in §7.2 is rejected with an explicit "invalid transition" error, verified by an exhaustive test over all state pairs. + - [x] The current state of any run is queryable and matches the last successfully applied transition. #### ALD-025 — Turn phase orchestrator - **Priority:** P0 · **Size:** L · **Class:** MVP · **Depends on:** ALD-024, ALD-010 - **Spec refs:** `SPECIFICATION.md` [§8.1 Turn Phases](SPECIFICATION.md#81-turn-phases), [§8.3 Turn Timing and Budgets](SPECIFICATION.md#83-turn-timing-and-budgets) - **Scope:** Implement the turn-phase sequencing (observation → proposal → validation → commit, per §8.1) driving the `ALD-010` atomic transaction wrapper at the commit phase, enforcing the timing budgets from §8.3. - **Acceptance criteria:** - - [ ] Every phase in §8.1 executes in the documented order for a successful turn. - - [ ] A turn exceeding the §8.3 timing budget is terminated and recorded as a timeout, not left hanging. - - [ ] The commit phase always goes through the `ALD-010` atomic wrapper — no direct ledger writes bypass it. + - [x] Every phase in §8.1 executes in the documented order for a successful turn. + - [x] A turn exceeding the §8.3 timing budget is terminated and recorded as a timeout, not left hanging. + - [x] The commit phase always goes through the `ALD-010` atomic wrapper — no direct ledger writes bypass it. #### ALD-026 — Pause/abort handling - **Priority:** P0 · **Size:** S · **Class:** MVP · **Depends on:** ALD-024 - **Spec refs:** `SPECIFICATION.md` [§7.3 Pause/Abort/Recovery/Fork Behavior](SPECIFICATION.md#73-pause-abort-recovery-fork-behavior) - **Scope:** Implement pause and abort operations that transition a run to the corresponding §7.1 states, ensuring an in-flight turn either completes its atomic commit or is fully rolled back before the pause/abort takes effect. - **Acceptance criteria:** - - [ ] Pausing a run mid-turn either lets the current turn's atomic commit finish or fully rolls it back — never a partial commit. - - [ ] An `aborted-sealed` run is terminal and can never accept another turn or be reopened. - - [ ] Pause/abort/resume operations are recorded through the audited intervention path and produce required checkpoints. + - [x] Pausing a run mid-turn either lets the current turn's atomic commit finish or fully rolls it back — never a partial commit. + - [x] An `aborted-sealed` run is terminal and can never accept another turn or be reopened. + - [x] Pause/abort/resume operations are recorded through the audited intervention path and produce required checkpoints. #### ALD-027 — Crash recovery and integrity-fork detection - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-011, ALD-025 - **Spec refs:** `SPECIFICATION.md` [§7.3 Pause/Abort/Recovery/Fork Behavior](SPECIFICATION.md#73-pause-abort-recovery-fork-behavior), `LEDGER-INTEGRITY-DESIGN.md` [§15. Recovery and Fork Handling](LEDGER-INTEGRITY-DESIGN.md#15-recovery-and-fork-handling) - **Scope:** Reconstruct run/turn state from the last consistent evidence prefix after restart, and detect duplicate `(runId, domain, sequence)` entries with mismatched hashes both during writes and recovery. - **Acceptance criteria:** - - [ ] After a simulated crash mid-turn, restart reconstructs the run's state to exactly the last atomically committed turn, with no phantom in-progress turn. - - [ ] A mismatched duplicate sequence preserves both artifacts, transitions the run to `forked-invalid`, halts writes, and requires research-integrity review. - - [ ] Recovery appends an explicit recovery event at the next unused sequence and matches the state independently derived by `ALD-015`. + - [x] After a simulated crash mid-turn, restart reconstructs the run's state to exactly the last atomically committed turn, with no phantom in-progress turn. + - [x] A mismatched duplicate sequence preserves both artifacts, transitions the run to `forked-invalid`, halts writes, and requires research-integrity review. + - [x] Recovery appends an explicit recovery event at the next unused sequence and matches the state independently derived by `ALD-015`. #### ALD-028 — Derived-run branching and lineage - **Priority:** P1 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-024, ALD-016 - **Spec refs:** `SPECIFICATION.md` [§7.4 Derived Runs and Lineage](SPECIFICATION.md#74-derived-runs-and-lineage) - **Scope:** Implement a new derived run from a specific parent checkpoint, with optional per-Baby replacement policy/adapter, independent event sequences starting at `1`, and immutable parent references. - **Acceptance criteria:** - - [ ] A child run records `parentRunId`, `derivedFromCheckpointHash`, and both initial policy refs in config and its first initialization event. - - [ ] Child sequences restart at `1`, and writes never modify parent evidence or reopen a terminal parent. - - [ ] `ALD-016` exports lineage references and `ALD-015` verifies them against the immutable parent bundle. + - [x] A child run records `parentRunId`, `derivedFromCheckpointHash`, and both initial policy refs in config and its first initialization event. + - [x] Child sequences restart at `1`, and writes never modify parent evidence or reopen a terminal parent. + - [x] `ALD-016` exports lineage references and `ALD-015` verifies them against the immutable parent bundle. ### EPIC-06 — Symbol Gateway and Communication Protocols @@ -511,72 +517,72 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§9. Communication Protocols](SPECIFICATION.md#9-communication-protocols) - **Scope:** Implement the single mediation point for every inter-agent artifact, registered protocol dispatch, and all six §9.6 communication-control conditions, including seeded substitutions and oracle-only-for-E03 enforcement. - **Acceptance criteria:** - - [ ] Every artifact passes through the Gateway; direct Baby-to-Baby routes fail in an instrumented integration test. - - [ ] `normal`, `disabled`, `constant`, seeded `random`, seeded `shuffled`, and E03-only `oracle` conditions produce their exact §9.6 behavior with no code changes. - - [ ] Every accepted, rejected, or control-substituted turn records the Baby-proposal hash when present and exact delivered-artifact hash through `ALD-035`. + - [x] Every artifact passes through the Gateway; direct Baby-to-Baby routes fail in an instrumented integration test. + - [x] `normal`, `disabled`, `constant`, seeded `random`, seeded `shuffled`, and E03-only `oracle` conditions produce their exact §9.6 behavior with no code changes. + - [x] Every accepted, rejected, or control-substituted turn records the Baby-proposal hash when present and exact delivered-artifact hash through `ALD-035`. #### ALD-030 — Fixed-token protocol - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-029 - **Spec refs:** `SPECIFICATION.md` [§9.1 Fixed Token Protocol](SPECIFICATION.md#91-fixed-token-protocol) - **Scope:** Implement the fixed-token vocabulary channel: a closed, enumerable token set, with the Gateway validating every emitted token against the registered vocabulary. - **Acceptance criteria:** - - [ ] Emitting a token in the registered vocabulary is accepted and forwarded. - - [ ] Emitting any token, string, or byte sequence not in the registered vocabulary is rejected, not silently coerced to the nearest valid token. - - [ ] The vocabulary is configurable per run without a code change. + - [x] Emitting a token in the registered vocabulary is accepted and forwarded. + - [x] Emitting any token, string, or byte sequence not in the registered vocabulary is rejected, not silently coerced to the nearest valid token. + - [x] The vocabulary is configurable per run without a code change. #### ALD-031 — Alternate neutral carrier protocols - **Priority:** P2 · **Size:** L · **Class:** Later-Research · **Depends on:** ALD-029 - **Spec refs:** `SPECIFICATION.md` [§9.2 Alternate Neutral Carrier Protocols](SPECIFICATION.md#92-alternate-neutral-carrier-protocols) - **Scope:** Implement §9.2's `fixed-glyph`, `generative-bitmap`, `generative-canvas`, and `generative-tone` carrier modules with frozen neutral grammars, hard bounds, RFC 8785 canonicalization, and carrier-qualified content-addressed `markHash` generation. - **Acceptance criteria:** - - [ ] Each alternate carrier accepts a valid bounded artifact and reproduces the same carrier-qualified `markHash`. - - [ ] Out-of-range glyph IDs, bitmap sizes, stroke values, tone bins, text/color fields, and semantic tags are rejected with specific reasons. - - [ ] Every carrier contributes accept/reject vectors to `ALD-036`, and exactly one carrier family is available in a run. + - [x] Each alternate carrier accepts a valid bounded artifact and reproduces the same carrier-qualified `markHash`. + - [x] Out-of-range glyph IDs, bitmap sizes, stroke values, tone bins, text/color fields, and semantic tags are rejected with specific reasons. + - [x] Every carrier contributes accept/reject vectors to `ALD-036`, and exactly one carrier family is available in a run. #### ALD-032 — Alternate-carrier leakage evaluation - **Priority:** P1 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-031 - **Spec refs:** `SPECIFICATION.md` [§9.2 Alternate Neutral Carrier Protocols](SPECIFICATION.md#92-alternate-neutral-carrier-protocols), [§15.3 Evaluation Baselines and Statistics](SPECIFICATION.md#153-evaluation-baselines-and-statistics) - **Scope:** Add offline evaluation hooks for measuring whether alternate-carrier artifacts reproduce recognizable human-language forms or encode task/referent information through unintended glyph, bitmap, stroke, or tone features. The evaluator reports evidence and claim-boundary impact; it does not silently rewrite accepted artifacts. - **Acceptance criteria:** - - [ ] The evaluator records mark-level leakage metrics and the exact analysis version in the run evidence bundle. - - [ ] Pre-registered recognizable-glyph and unintended-feature probes produce explicit pass, fail, or inconclusive results without altering the original canvas artifact. - - [ ] A failed leakage evaluation blocks an ungrounded-language claim while preserving the run as valid negative or integrity evidence. + - [x] The evaluator records mark-level leakage metrics and the exact analysis version in the run evidence bundle. + - [x] Pre-registered recognizable-glyph and unintended-feature probes produce explicit pass, fail, or inconclusive results without altering the original canvas artifact. + - [x] A failed leakage evaluation blocks an ungrounded-language claim while preserving the run as valid negative or integrity evidence. #### ALD-033 — Six-display affect protocol - **Priority:** P2 · **Size:** M · **Class:** Later-Research · **Depends on:** ALD-029 - **Spec refs:** `SPECIFICATION.md` [§9.3 Six-Display Affect Protocol](SPECIFICATION.md#93-six-display-affect-protocol) - **Scope:** Implement declared, permuted, opaque, and derived six-display modes plus emergent-affect routing through the selected alternate carrier. In derived mode, the Gateway maps adapter measurements; the Baby cannot choose a display. - **Acceptance criteria:** - - [ ] Declared/permuted/opaque modes emit only A1-A6, while any other display or out-of-window submission is rejected. - - [ ] Derived mode disables `submit_affect`, records the private measurement, and applies the pre-registered Gateway mapping; emergent mode records a carrier Channel Event rather than an Affect Event. - - [ ] Affect schemas and every mode's accept/reject vectors extend `ALD-036`, including normalized timing/envelope behavior. + - [x] Declared/permuted/opaque modes emit only A1-A6, while any other display or out-of-window submission is rejected. + - [x] Derived mode disables `submit_affect`, records the private measurement, and applies the pre-registered Gateway mapping; emergent mode records a carrier Channel Event rather than an Affect Event. + - [x] Affect schemas and every mode's accept/reject vectors extend `ALD-036`, including normalized timing/envelope behavior. #### ALD-034 — Channel violation detection and rejection behavior - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-029, ALD-030 - **Spec refs:** `SPECIFICATION.md` [§9.4 Rejection Behavior and Channel Violation Handling](SPECIFICATION.md#94-rejection-behavior-and-channel-violation-handling) - **Scope:** Implement the protocol-independent rejection framework used by every Gateway module: consistent error shape, append-only `channel.rejected` events containing only a rejected-payload hash, rejection counters, and automatic pause after the configured consecutive-rejection ceiling. Fixed-token handling is the first registered module; later canvas and affect modules reuse this framework. - **Acceptance criteria:** - - [ ] A fixed-token violation produces the standard rejection shape and an append-only `channel.rejected` event with reason code and payload hash but no raw rejected content. - - [ ] Five consecutive rejections by default trigger an automatic pause and `safety-trigger` audit entry. - - [ ] A protocol-module contract test proves canvas and affect handlers can register later without changing the rejection event shape or pause policy. + - [x] A fixed-token violation produces the standard rejection shape and an append-only `channel.rejected` event with reason code and payload hash but no raw rejected content. + - [x] Five consecutive rejections by default trigger an automatic pause and `safety-trigger` audit entry. + - [x] A protocol-module contract test proves canvas and affect handlers can register later without changing the rejection event shape or pause policy. #### ALD-035 — Turn envelope, channel event, and ledger draft schemas - **Priority:** P0 · **Size:** S · **Class:** MVP · **Depends on:** ALD-002, ALD-029 - **Spec refs:** `SPECIFICATION.md` [§11.3 Turn and Ledger Proposal Envelopes](SPECIFICATION.md#113-turn-and-ledger-proposal-envelopes), [§11.5 Channel Event](SPECIFICATION.md#115-channel-event) - **Scope:** Implement and validate Agent Action Proposal, Turn Proposal Envelope, Ledger Draft Envelope, Affect State Measurement, and fully signed Channel Event schemas, wired into Gateway and Evidence Writer boundaries. - **Acceptance criteria:** - - [ ] Every Gateway proposal includes one required private intention draft and rejects Baby-supplied run/turn/sender/hash metadata. - - [ ] Every accepted/rejected event contains the sender-ledger binding, delivery receipt, previous channel hash, entry hash, and channel-writer signature required by §11.5. - - [ ] Interpretation drafts require the delivered channel hash, and all schema failures use the standard Gateway error shape. + - [x] Every Gateway proposal includes one required private intention draft and rejects Baby-supplied run/turn/sender/hash metadata. + - [x] Every accepted/rejected event contains the sender-ledger binding, delivery receipt, previous channel hash, entry hash, and channel-writer signature required by §11.5. + - [x] Interpretation drafts require the delivered channel hash, and all schema failures use the standard Gateway error shape. #### ALD-036 — Gateway/protocol conformance test suite - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-030, ALD-034, ALD-035 - **Spec refs:** `SPECIFICATION.md` [§9. Communication Protocols](SPECIFICATION.md#9-communication-protocols), [§17.2 Test Strategy](SPECIFICATION.md#172-test-strategy) - **Scope:** Build an extensible automated conformance suite for registered Gateway protocols, rejection behavior, and schema validation, runnable independently of any learner adapter. The fixed-token module is the MVP gate; `ALD-031` and `ALD-033` must add canvas and affect vectors before those modules are declared done. - **Acceptance criteria:** - - [ ] The MVP suite exercises fixed-token acceptance/rejection, all six communication controls, dual proposal/delivery hashing, consecutive-rejection pause, and schema failures. - - [ ] The suite runs against a mocked/stub learner, with no dependency on any specific `ALD-044`–047 adapter. - - [ ] A protocol registration test requires every enabled module to contribute accept/reject vectors; the consolidated suite is the gate referenced by EPIC-06 and `ALD-078`. + - [x] The MVP suite exercises fixed-token acceptance/rejection, all six communication controls, dual proposal/delivery hashing, consecutive-rejection pause, and schema failures. + - [x] The suite runs against a mocked/stub learner, with no dependency on any specific `ALD-044`–047 adapter. + - [x] A protocol registration test requires every enabled module to contribute accept/reject vectors; the consolidated suite is the gate referenced by EPIC-06 and `ALD-078`. ### EPIC-07 — Observation Hygiene, Scenario Engine, and Deterministic Services @@ -587,45 +593,45 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§11.2 Observation](SPECIFICATION.md#112-observation) - **Scope:** Implement the Observation builder that assembles exactly the fields §11.2 defines for a given turn, from world/scenario state, with no additional fields leaking in. - **Acceptance criteria:** - - [ ] A built Observation validates against the `ALD-002` schema and contains no field not listed in §11.2. - - [ ] Two observations built from identical underlying state are byte-identical after canonicalization (reusing `ALD-006`'s approach). - - [ ] The builder is the only code path producing Observations delivered to learners. + - [x] A built Observation validates against the `ALD-002` schema and contains no field not listed in §11.2. + - [x] Two observations built from identical underlying state are byte-identical after canonicalization (reusing `ALD-006`'s approach). + - [x] The builder is the only code path producing Observations delivered to learners. #### ALD-038 — Observation hygiene filter - **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-037 - **Spec refs:** `SPECIFICATION.md` [§10.1 Observation Hygiene](SPECIFICATION.md#101-observation-hygiene) - **Scope:** Implement the filter that strips or blocks any observation content prohibited by §10.1 (e.g., internal identifiers, other agent's private state, out-of-scenario metadata) before delivery. - **Acceptance criteria:** - - [ ] Every prohibited field category listed in §10.1 is demonstrated blocked using a test observation deliberately constructed to contain it. - - [ ] The filter runs on every Observation before it reaches the Gateway/learner boundary, with no bypass path. - - [ ] A blocked field produces an audit-logged event (feeding `ALD-059`), not a silent drop. + - [x] Every prohibited field category listed in §10.1 is demonstrated blocked using a test observation deliberately constructed to contain it. + - [x] The filter runs on every Observation before it reaches the Gateway/learner boundary, with no bypass path. + - [x] A blocked field produces an audit-logged event (feeding `ALD-059`), not a silent drop. #### ALD-039 — OCR detection and scenario-bundle quarantine - **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-037 - **Spec refs:** `SPECIFICATION.md` [§10.2 Prompt Injection Controls](SPECIFICATION.md#102-prompt-injection-controls) - **Scope:** Scan every scene and asset for OCR-visible text before scenario registration. Any detected glyph or text causes the complete bundle to fail observation hygiene and enter quarantine; prohibited text is never sanitized and passed through. - **Acceptance criteria:** - - [ ] A bundle containing OCR-detected text, caption metadata, semantic filenames, or human-readable labels cannot be referenced by a run. - - [ ] Text-free controls pass while pre-registered adversarial image/text fixtures are quarantined before any adapter receives them. - - [ ] Quarantine events retain artifact hashes and reason codes without exposing raw injection text in Baby-visible or public logs. + - [x] A bundle containing OCR-detected text, caption metadata, semantic filenames, or human-readable labels cannot be referenced by a run. + - [x] Text-free controls pass while pre-registered adversarial image/text fixtures are quarantined before any adapter receives them. + - [x] Quarantine events retain artifact hashes and reason codes without exposing raw injection text in Baby-visible or public logs. #### ALD-040 — Side-channel elimination in transport layer - **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-029, ALD-037 - **Spec refs:** `SPECIFICATION.md` [§10.3 Side Channel Controls](SPECIFICATION.md#103-side-channel-controls) - **Scope:** Audit and close the transport-level side channels named in §10.3 (e.g., timing channels, response-size channels, error-message channels) in the Gateway and Observation delivery path. - **Acceptance criteria:** - - [ ] Each side-channel category named in §10.3 has a corresponding mitigation implemented (e.g., constant-shape error responses, timing normalization where specified). - - [ ] A test harness measuring the relevant channel (e.g., response latency variance) confirms the mitigation is effective within the tolerance §10.3 implies. - - [ ] This item's test harness is reused (not duplicated) by the red-team suite in `ALD-067`. + - [x] Each side-channel category named in §10.3 has a corresponding mitigation implemented (e.g., constant-shape error responses, timing normalization where specified). + - [x] A test harness measuring the relevant channel (e.g., response latency variance) confirms the mitigation is effective within the tolerance §10.3 implies. + - [x] This item's test harness is reused (not duplicated) by the red-team suite in `ALD-067`. #### ALD-041 — Deterministic scenario/task engine - **Priority:** P1 · **Size:** L · **Class:** MVP · **Depends on:** ALD-023, ALD-037 - **Spec refs:** `SPECIFICATION.md` [§9.5 Interaction and Utility Profiles](SPECIFICATION.md#95-interaction-and-utility-profiles), [§15.3 Evaluation Baselines and Statistics](SPECIFICATION.md#153-evaluation-baselines-and-statistics), [§17.3 Phased Delivery](SPECIFICATION.md#173-phased-delivery) - **Scope:** Implement the scenario/task generator that deterministically produces scenario states, private observations, utility matrices, reservation values, and zones of possible agreement from run configuration and seed for all five §9.5 interaction profiles. - **Acceptance criteria:** - - [ ] Two runs with the same seed and interaction mode produce byte-identical scenarios, private facts, utilities, and task sequences. - - [ ] Every §9.5 interaction profile produces its required utility relationship, including a provably empty zone of possible agreement for `no-agreement-control`. - - [ ] The engine's output feeds `ALD-037`'s Observation builder with no intermediate non-deterministic step. + - [x] Two runs with the same seed and interaction mode produce byte-identical scenarios, private facts, utilities, and task sequences. + - [x] Every §9.5 interaction profile produces its required utility relationship, including a provably empty zone of possible agreement for `no-agreement-control`. + - [x] The engine's output feeds `ALD-037`'s Observation builder with no intermediate non-deterministic step. ### EPIC-08 — Learner Contracts and Model Adapters @@ -636,54 +642,54 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§6.2 Learner Adapter Interface](SPECIFICATION.md#62-learner-adapter-interface) - **Scope:** Define the shared TypeScript interface every model track must implement (receive Observation, return Agent Action Proposal, lifecycle hooks) exactly matching §6.2, and provide the deterministic/fixed `no-learning` reference adapter used for chance controls. - **Acceptance criteria:** - - [ ] The interface implements every §6.2 method, including `receive(DeliveredChannelArtifact)` returning a `LedgerDraftEnvelope`. - - [ ] The `no-learning` reference adapter is selectable, performs no policy update, and passes the contract-conformance test. - - [ ] The interface is the only integration point the turn orchestrator (`ALD-025`) uses to reach a learner. + - [x] The interface implements every §6.2 method, including `receive(DeliveredChannelArtifact)` returning a `LedgerDraftEnvelope`. + - [x] The `no-learning` reference adapter is selectable, performs no policy update, and passes the contract-conformance test. + - [x] The interface is the only integration point the turn orchestrator (`ALD-025`) uses to reach a learner. #### ALD-043 — Learner contract versioning, lint, and tool-only enforcement - **Priority:** P0 · **Size:** L · **Class:** Research-Grade · **Depends on:** ALD-042 - **Spec refs:** `SPECIFICATION.md` [§6.3 Tool-Only Interaction Contract](SPECIFICATION.md#63-tool-only-interaction-contract), [§6.4 Learner Contract Versioning](SPECIFICATION.md#64-learner-contract-versioning) - **Scope:** Implement immutable versioned learner-contract files, CI lint that rejects semantic examples/sample exchanges/banned patterns, prompt-bundle hashing, and runtime enforcement that every adapter acts only through the declared tools and Gateway. - **Acceptance criteria:** - - [ ] A contract containing a symbol-meaning example, sample exchange, or prohibited side-channel instruction fails CI and cannot be referenced by a run. - - [ ] Referenced contract versions are immutable and their prompt-bundle hashes appear in run evidence. - - [ ] The no-learning reference and all four adapter tracks are blocked and audited when attempting any state write or output outside the §6.3 tool surface. + - [x] A contract containing a symbol-meaning example, sample exchange, or prohibited side-channel instruction fails CI and cannot be referenced by a run. + - [x] Referenced contract versions are immutable and their prompt-bundle hashes appear in run evidence. + - [x] The no-learning reference and all four adapter tracks are blocked and audited when attempting any state write or output outside the §6.3 tool surface. #### ALD-044 — Frozen-LLM adapter with local open-weight default - **Priority:** P0 · **Size:** L · **Class:** MVP · **Depends on:** ALD-042, ALD-043 - **Spec refs:** `SPECIFICATION.md` [§6.7 Model Recommendations (Defaults)](SPECIFICATION.md#67-model-recommendations-defaults), [§6.1 Track Definitions](SPECIFICATION.md#61-track-definitions) - **Scope:** Implement the `frozen-llm` adapter using a locally deployable 3B–8B open-weight instruction model by default, with frozen weights, separate private memory, constrained tool calls, and no network access. - **Acceptance criteria:** - - [ ] The `frozen-llm` track is selectable via `ALD-023` run configuration and records the exact model and weight hashes. - - [ ] A full turn (observation → proposal → Gateway validation → commit) completes end-to-end using this adapter. - - [ ] The adapter exposes no weight-update path and passes the `ALD-036` Gateway conformance suite. + - [x] The `frozen-llm` track is selectable via `ALD-023` run configuration and records the exact model and weight hashes. + - [x] A full turn (observation → proposal → Gateway validation → commit) completes end-to-end using this adapter. + - [x] The adapter exposes no weight-update path and passes the `ALD-036` Gateway conformance suite. #### ALD-045 — From-scratch RL learner track - **Priority:** P1 · **Size:** L · **Class:** MVP · **Depends on:** ALD-042, ALD-043 - **Spec refs:** `SPECIFICATION.md` [§6.1 Track Definitions](SPECIFICATION.md#61-track-definitions), `EXPERIMENT-NOTEBOOK.md` [E11 — From-Scratch RL Naming Game](EXPERIMENT-NOTEBOOK.md#e11-from-scratch-rl-naming-game) - **Scope:** Implement the `scratch-rl` GRU/LSTM actor-critic adapter with independent PPO-style updates, random initialization, private buffers, and policy checkpoint output. - **Acceptance criteria:** - - [ ] The track starts from randomly initialized parameters whose initial hash is recorded in the evidence bundle. - - [ ] A full turn and minimal reward-to-parameter-update cycle complete end-to-end in both pre-registered extrinsic-reward and intrinsic-motivation modes. - - [ ] Policy updates use only the Baby's private buffers and emit a verifiable policy checkpoint reference. + - [x] The track starts from randomly initialized parameters whose initial hash is recorded in the evidence bundle. + - [x] A full turn and minimal reward-to-parameter-update cycle complete end-to-end in both pre-registered extrinsic-reward and intrinsic-motivation modes. + - [x] Policy updates use only the Baby's private buffers and emit a verifiable policy checkpoint reference. #### ALD-046 — Self-supervised ungrounded learner track - **Priority:** P1 · **Size:** L · **Class:** Later-Research · **Depends on:** ALD-042, ALD-043 - **Spec refs:** `SPECIFICATION.md` [§6.1 Track Definitions](SPECIFICATION.md#61-track-definitions), `EXPERIMENT-NOTEBOOK.md` [E12 — Self-Supervised Ungrounded Baseline](EXPERIMENT-NOTEBOOK.md#e12-self-supervised-ungrounded-baseline) - **Scope:** Implement the `self-supervised` adapter with the same recurrent backbone as `scratch-rl` where feasible, using a pre-registered predictive or contrastive loss and no scalar reward. - **Acceptance criteria:** - - [ ] The track is selectable, starts from recorded random initialization, and rejects any scalar reward supplied to its update path. - - [ ] A full turn and minimal predictive/contrastive update complete end-to-end. - - [ ] The evidence bundle records the loss definition and proves that outcome labels are not included in the self-supervised update batch. + - [x] The track is selectable, starts from recorded random initialization, and rejects any scalar reward supplied to its update path. + - [x] A full turn and minimal predictive/contrastive update complete end-to-end. + - [x] The evidence bundle records the loss definition and proves that outcome labels are not included in the self-supervised update batch. #### ALD-047 — Hybrid learner track - **Priority:** P1 · **Size:** L · **Class:** Later-Research · **Depends on:** ALD-042, ALD-043 - **Spec refs:** `SPECIFICATION.md` [§6.1 Track Definitions](SPECIFICATION.md#61-track-definitions), [§6.5 Semantic-Leakage Test Battery](SPECIFICATION.md#65-semantic-leakage-test-battery) - **Scope:** Implement the `hybrid` adapter using a from-scratch sensory encoder, recurrent world model, and randomly initialized communication policy, with optional frozen low-level visual features only after semantic-leakage qualification. - **Acceptance criteria:** - - [ ] The track is selectable and records the provenance and hashes of every sensory, world-model, and communication component. - - [ ] A full turn and minimal policy update complete end-to-end through the shared interface. - - [ ] Any text-aligned frozen feature automatically weakens the run's claim classification; strict ungrounded classification requires `ALD-057` to pass. + - [x] The track is selectable and records the provenance and hashes of every sensory, world-model, and communication component. + - [x] A full turn and minimal policy update complete end-to-end through the shared interface. + - [x] Any text-aligned frozen feature automatically weakens the run's claim classification; strict ungrounded classification requires `ALD-057` to pass. ### EPIC-09 — DTSF Twin Packs, API Surface, and Authorization @@ -694,45 +700,45 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§12.4 Baby Twin Routes (baby-a, baby-b)](SPECIFICATION.md#124-baby-twin-routes-baby-a-baby-b) - **Scope:** Replace the `ALD-004` skeleton routes with full implementations of every route listed in §12.4, wired to the Gateway (`ALD-029`) and a selected learner adapter (`ALD-042`-conformant). - **Acceptance criteria:** - - [ ] Every route in §12.4, including Gateway-only `/deliver`, exists on both `baby-a` and `baby-b`, all unprefixed. - - [ ] Each route's behavior matches its documented purpose in §12.4 (verified by an integration test per route). - - [ ] A full run using these twin packs completes at least one turn end-to-end through the Gateway. + - [x] Every route in §12.4, including Gateway-only `/deliver`, exists on both `baby-a` and `baby-b`, all unprefixed. + - [x] Each route's behavior matches its documented purpose in §12.4 (verified by an integration test per route). + - [x] A full run using these twin packs completes at least one turn end-to-end through the Gateway. #### ALD-049 — Nursery controller twin pack - **Priority:** P0 · **Size:** L · **Class:** MVP · **Depends on:** ALD-024, ALD-048 - **Spec refs:** `SPECIFICATION.md` [§12.5 Nursery Controller Routes (nursery)](SPECIFICATION.md#125-nursery-controller-routes-nursery) - **Scope:** Implement the nursery controller's routes for creating/starting/pausing/aborting runs and creating derived runs, driving the `ALD-024` state machine and `ALD-028` lineage service while orchestrating both Baby twins. - **Acceptance criteria:** - - [ ] Every route in §12.5 exists, unprefixed, and drives the correct `ALD-024` state transition. - - [ ] Creating a run via nursery correctly provisions both `baby-a` and `baby-b` instances. - - [ ] Pausing/aborting and derived-run creation use the same state/lineage services as direct internal calls, with no divergent logic path or reopening of terminal parents. + - [x] Every route in §12.5 exists, unprefixed, and drives the correct `ALD-024` state transition. + - [x] Creating a run via nursery correctly provisions both `baby-a` and `baby-b` instances. + - [x] Pausing/aborting and derived-run creation use the same state/lineage services as direct internal calls, with no divergent logic path or reopening of terminal parents. #### ALD-050 — Evidence and verification routes on nursery - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-016, ALD-049 - **Spec refs:** `SPECIFICATION.md` [§12.6 Evidence and Verification Routes (nursery)](SPECIFICATION.md#126-evidence-and-verification-routes-nursery) - **Scope:** Implement the nursery routes for triggering evidence bundle export (`ALD-016`) and retrieving verification reports (`ALD-017`), per §12.6. - **Acceptance criteria:** - - [ ] Every route in §12.6 exists, unprefixed, and returns data conforming to the `ALD-002` schemas involved. - - [ ] Triggering an export via this route produces a bundle identical to calling `ALD-016`'s export function directly. - - [ ] Unauthorized callers (per `ALD-051`) cannot reach these routes. + - [x] Every route in §12.6 exists, unprefixed, and returns data conforming to the `ALD-002` schemas involved. + - [x] Triggering an export via this route produces a bundle identical to calling `ALD-016`'s export function directly. + - [x] Unauthorized callers (per `ALD-051`) cannot reach these routes. #### ALD-051 — Authorization roles and route guards - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-048, ALD-049, ALD-050 - **Spec refs:** `SPECIFICATION.md` [§12.2 Authorization Roles](SPECIFICATION.md#122-authorization-roles) - **Scope:** Implement the role model and route guards from §12.2 across all twin routes, denying access to any role not explicitly permitted for a given route. - **Acceptance criteria:** - - [ ] Every role defined in §12.2 is enforced on every route that names a restriction. - - [ ] Missing/invalid credentials return `401 UNAUTHENTICATED`; valid identities with insufficient roles return `403 FORBIDDEN`. - - [ ] A test matrix of (role × route) confirms allow/deny matches §12.2 exactly. + - [x] Every role defined in §12.2 is enforced on every route that names a restriction. + - [x] Missing/invalid credentials return `401 UNAUTHENTICATED`; valid identities with insufficient roles return `403 FORBIDDEN`. + - [x] A test matrix of (role × route) confirms allow/deny matches §12.2 exactly. #### ALD-052 — Response and error shape standardization - **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-048, ALD-049, ALD-050 - **Spec refs:** `SPECIFICATION.md` [§12.3 Response and Error Shape](SPECIFICATION.md#123-response-and-error-shape) - **Scope:** Apply the standardized success/error response envelope from §12.3 to every route across all three twin packs. - **Acceptance criteria:** - - [ ] Every success response across all routes matches the §12.3 success envelope. - - [ ] Every error response across all routes matches the §12.3 error envelope, including the ones from `ALD-034`'s channel violations. - - [ ] A lint/test rule fails the build if a new route is added without conforming to the envelope. + - [x] Every success response across all routes matches the §12.3 success envelope. + - [x] Every error response across all routes matches the §12.3 error envelope, including the ones from `ALD-034`'s channel violations. + - [x] A lint/test rule fails the build if a new route is added without conforming to the envelope. ### EPIC-10 — Mode R Isolation and Claim-Boundary Controls @@ -743,45 +749,45 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§5.1 Prototype Mode (Mode P)](SPECIFICATION.md#51-prototype-mode-mode-p), [§5.2 Research-Grade Mode (Mode R)](SPECIFICATION.md#52-research-grade-mode-mode-r), [§5.3 Mode Comparison Table](SPECIFICATION.md#53-mode-comparison-table) - **Scope:** Implement the configuration switch selecting Mode P or Mode R for a run, wiring each mode's distinct behaviors from §5.3 (e.g., isolation strictness, claim labeling) into the nursery run-creation path. - **Acceptance criteria:** - - [ ] A run created under Mode P and one under Mode R differ exactly along the dimensions listed in §5.3 — no undocumented behavioral difference. - - [ ] The mode is immutable for the lifetime of a run once created (cannot be switched mid-run). - - [ ] The active mode is recorded in the Run Configuration and visible in every exported evidence bundle. + - [x] A run created under Mode P and one under Mode R differ exactly along the dimensions listed in §5.3 — no undocumented behavioral difference. + - [x] The mode is immutable for the lifetime of a run once created (cannot be switched mid-run). + - [x] The active mode is recorded in the Run Configuration and visible in every exported evidence bundle. #### ALD-054 — Claim-boundary enforcement - **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-053 - **Spec refs:** `SPECIFICATION.md` [§5.4 Claim Boundary Statements](SPECIFICATION.md#54-claim-boundary-statements) - **Scope:** Implement automated checks that block any dashboard/report/export from asserting a Mode-R-only claim (e.g., "isolation-verified") about a run that actually executed in Mode P. - **Acceptance criteria:** - - [ ] Every claim statement listed in §5.4 is machine-checked against the run's actual recorded mode before being allowed to render/export. - - [ ] A Mode P run attempting to surface a Mode-R-only claim label is blocked with a specific error, not silently downgraded. - - [ ] The check is exercised by an automated test for every claim statement in §5.4, not spot-checked manually. + - [x] Every claim statement listed in §5.4 is machine-checked against the run's actual recorded mode before being allowed to render/export. + - [x] A Mode P run attempting to surface a Mode-R-only claim label is blocked with a specific error, not silently downgraded. + - [x] The check is exercised by an automated test for every claim statement in §5.4, not spot-checked manually. #### ALD-055 — Separate-container isolation for Mode R - **Priority:** P1 · **Size:** L · **Class:** Research-Grade · **Depends on:** ALD-053 - **Spec refs:** `SPECIFICATION.md` [§5.2 Research-Grade Mode (Mode R)](SPECIFICATION.md#52-research-grade-mode-mode-r) - **Scope:** Run each learner adapter in Mode R in its own container/process with no shared mutable memory, communicating only through the Gateway and evidence store. - **Acceptance criteria:** - - [ ] In Mode R, `baby-a` and `baby-b` learner processes run in distinct OS processes/containers, verified by distinct process IDs / container IDs. - - [ ] No in-memory object reference is shared between the two learner containers (verified by an isolation test attempting cross-container object access and observing failure). - - [ ] Killing one learner container does not corrupt or crash the other, or the Gateway/evidence store. + - [x] In Mode R, `baby-a` and `baby-b` learner processes run in distinct OS processes/containers, verified by distinct process IDs / container IDs. + - [x] No in-memory object reference is shared between the two learner containers (verified by an isolation test attempting cross-container object access and observing failure). + - [x] Killing one learner container does not corrupt or crash the other, or the Gateway/evidence store. #### ALD-056 — Training isolation guarantees - **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-055, ALD-045, ALD-046, ALD-047 - **Spec refs:** `SPECIFICATION.md` [§10.4 Training Isolation](SPECIFICATION.md#104-training-isolation) - **Scope:** Verify and enforce that every trainable learner's update process (`scratch-rl`, `self-supervised`, and `hybrid`) cannot access the other agent's private state, replay data, gradients, optimizer, or parameters. - **Acceptance criteria:** - - [ ] Each trainable learner's update step reads only from its own adapter's local buffers, never from the counterpart process. - - [ ] An isolation test that attempts to smuggle counterpart-agent internal state into a training update fails to do so, confirmed by the test. - - [ ] Training isolation is verified specifically under Mode R container separation (`ALD-055`), not just asserted for Mode P. + - [x] Each trainable learner's update step reads only from its own adapter's local buffers, never from the counterpart process. + - [x] An isolation test that attempts to smuggle counterpart-agent internal state into a training update fails to do so, confirmed by the test. + - [x] Training isolation is verified specifically under Mode R container separation (`ALD-055`), not just asserted for Mode P. #### ALD-057 — Semantic-leakage test battery automation - **Priority:** P1 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-038, ALD-044, ALD-045, ALD-046, ALD-047 - **Spec refs:** `SPECIFICATION.md` [§6.5 Semantic Leakage Test Battery](SPECIFICATION.md#65-semantic-leakage-test-battery) - **Scope:** Implement the full §6.5 adapter test battery: tokenizer/vocabulary audit, frozen-feature linear probe against label-shuffled controls, and vision-language encoder classification, with claim-boundary results persisted in run evidence. - **Acceptance criteria:** - - [ ] Every test in §6.5 runs against `scratch-rl`, `self-supervised`, and strict `hybrid`; frozen-LLM and no-learning runs are explicitly classified rather than incorrectly presented as ungrounded. - - [ ] Linear-probe evaluation uses the pre-registered 95% label-shuffled confidence-interval rule. - - [ ] Battery results are attached to the run's evidence record so a reviewer can see leakage-test outcomes per run. + - [x] Every test in §6.5 runs against `scratch-rl`, `self-supervised`, and strict `hybrid`; frozen-LLM and no-learning runs are explicitly classified rather than incorrectly presented as ungrounded. + - [x] Linear-probe evaluation uses the pre-registered 95% label-shuffled confidence-interval rule. + - [x] Battery results are attached to the run's evidence record so a reviewer can see leakage-test outcomes per run. ### EPIC-11 — Telemetry, Audit, Snapshot/Recovery, and Retention @@ -792,45 +798,45 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§14.1 Telemetry](SPECIFICATION.md#141-telemetry) - **Scope:** Implement the telemetry pipeline capturing per-request/per-turn metrics (method, path, status, duration) named in §14.1, persisted for dashboard consumption. - **Acceptance criteria:** - - [ ] Every API request across all twin routes produces a telemetry record with the fields §14.1 requires. - - [ ] Telemetry recording failures never block or fail the underlying request (verified by fault-injection test on the telemetry sink). - - [ ] Telemetry data is queryable by run ID and by time range. + - [x] Every API request across all twin routes produces a telemetry record with the fields §14.1 requires. + - [x] Telemetry recording failures never block or fail the underlying request (verified by fault-injection test on the telemetry sink). + - [x] Telemetry data is queryable by run ID and by time range. #### ALD-059 — Audit, intervention, and safety-event logging - **Priority:** P0 · **Size:** L · **Class:** Research-Grade · **Depends on:** ALD-013, ALD-024, ALD-053 - **Spec refs:** `SPECIFICATION.md` [§14.2 Audit Logging](SPECIFICATION.md#142-audit-logging) - **Scope:** Implement append-only audit records for human views, operator interventions, and safety triggers. Pause/resume/abort/annotate actions must use this path, record actor/reason, and request the mandatory checkpoint; unplanned interventions also create a notebook deviation reference. - **Acceptance criteria:** - - [ ] Every §14.2 human view/intervention and §14.5 safety trigger produces an append-only audit record with authenticated actor and machine-readable reason. - - [ ] Each intervention produces a signed checkpoint, and any unplanned intervention links to an append-only notebook deviation record. - - [ ] Audit and intervention logs plus checkpoint references are included in every evidence bundle, not only Mode R. + - [x] Every §14.2 human view/intervention and §14.5 safety trigger produces an append-only audit record with authenticated actor and machine-readable reason. + - [x] Each intervention produces a signed checkpoint, and any unplanned intervention links to an append-only notebook deviation record. + - [x] Audit and intervention logs plus checkpoint references are included in every evidence bundle, not only Mode R. #### ALD-060 — Snapshot and restore mechanism - **Priority:** P1 · **Size:** L · **Class:** MVP · **Depends on:** ALD-005, ALD-011 - **Spec refs:** `SPECIFICATION.md` [§14.4 Snapshot and Restore](SPECIFICATION.md#144-snapshot-and-restore) - **Scope:** Implement periodic and on-demand snapshotting of all runtime state to serialized files, and a restore path that reconstructs state from the latest snapshot on startup, per §14.4. - **Acceptance criteria:** - - [ ] A manual "take snapshot now" action produces a snapshot file set that a restore can consume. - - [ ] Restarting the server after a snapshot automatically restores to that snapshot's state (`autoRestore()`-equivalent behavior). - - [ ] A restored run's evidence-store state matches, byte-for-byte in the chain-walk sense (`ALD-008`), the state at the moment the snapshot was taken. + - [x] A manual "take snapshot now" action produces a snapshot file set that a restore can consume. + - [x] Restarting the server after a snapshot automatically restores to that snapshot's state (`autoRestore()`-equivalent behavior). + - [x] A restored run's evidence-store state matches, byte-for-byte in the chain-walk sense (`ALD-008`), the state at the moment the snapshot was taken. #### ALD-061 — Failure handling policy implementation - **Priority:** P0 · **Size:** M · **Class:** MVP · **Depends on:** ALD-011, ALD-021 - **Spec refs:** `SPECIFICATION.md` [§14.5 Failure Handling](SPECIFICATION.md#145-failure-handling) - **Scope:** Implement the documented failure-handling policy for each named failure mode in §14.5 (evidence-store failure, anchoring failure, learner adapter crash), including registering `process.on('uncaughtException')` and `process.on('unhandledRejection')` handlers so background tasks (checkpoint scheduler, anchoring retries) log rather than crash the server. - **Acceptance criteria:** - - [ ] Every failure mode named in §14.5 has an implemented, tested handling path. - - [ ] An unhandled rejection thrown from a background task (e.g., a failed anchor confirmation poll) is caught, logged, and does not crash the server process, confirmed by a fault-injection test. - - [ ] The failure-handling behavior for anchoring failures reuses `ALD-021`'s retry/backoff rather than a separate ad hoc mechanism. + - [x] Every failure mode named in §14.5 has an implemented, tested handling path. + - [x] An unhandled rejection thrown from a background task (e.g., a failed anchor confirmation poll) is caught, logged, and does not crash the server process, confirmed by a fault-injection test. + - [x] The failure-handling behavior for anchoring failures reuses `ALD-021`'s retry/backoff rather than a separate ad hoc mechanism. #### ALD-062 — Retention policy enforcement job - **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-005, ALD-016 - **Spec refs:** `SPECIFICATION.md` [§14.6 Retention Policy](SPECIFICATION.md#146-retention-policy) - **Scope:** Purge bulk payload files only for non-public development/qualification runs that are not Base-mainnet anchored and exceed `prototypeRetentionDays`; preserve append-only database events, audit/intervention logs, manifests, receipts, and run index metadata. - **Acceptance criteria:** - - [ ] Only eligible non-public, non-mainnet run-bundle payloads are purged; public or mainnet-anchored bundles are retained indefinitely. - - [ ] `run_metadata`, ledger/channel/audit/intervention rows, checkpoint manifests, and anchor receipts remain queryable after purge. - - [ ] The job's actions are themselves audit-logged (`ALD-059`). + - [x] Only eligible non-public, non-mainnet run-bundle payloads are purged; public or mainnet-anchored bundles are retained indefinitely. + - [x] `run_metadata`, ledger/channel/audit/intervention rows, checkpoint manifests, and anchor receipts remain queryable after purge. + - [x] The job's actions are themselves audit-logged (`ALD-059`). ### EPIC-12 — Dashboard / Research Console and UX @@ -841,36 +847,36 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§16.1 Dashboard and Research Console](SPECIFICATION.md#161-dashboard-and-research-console) - **Scope:** Build the vanilla-JS Research Console with run/pre-registration status, separated Baby perspectives, public transcript, read-only audit-ledger comparison, checkpoint/anchor/verification state, telemetry, and operator-gated lifecycle controls. - **Acceptance criteria:** - - [ ] The console displays live run/pre-registration state, public transcript, telemetry, verification, checkpoint, and anchor data from authoritative routes. - - [ ] Baby A/B observations and audit ledgers render in clearly separated read-only panels, and operator controls are role-gated and audited. - - [ ] The console is a self-contained vanilla HTML/CSS/JS page with no frontend framework, build tool, or client-side side-channel route. + - [x] The console displays live run/pre-registration state, public transcript, telemetry, verification, checkpoint, and anchor data from authoritative routes. + - [x] Baby A/B observations and audit ledgers render in clearly separated read-only panels, and operator controls are role-gated and audited. + - [x] The console is a self-contained vanilla HTML/CSS/JS page with no frontend framework, build tool, or client-side side-channel route. #### ALD-064 — Human audit-ledger Interpreter - **Priority:** P1 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-005, ALD-016, ALD-042 - **Spec refs:** `SPECIFICATION.md` [§13.6 Privacy Controls](SPECIFICATION.md#136-privacy-controls) - **Scope:** Implement delayed/batched conversion of agent-native ledger state into separately signed, append-only `audit_ledger_entries`, labeled `source: generated-analysis`, with no Baby-readable route or feedback path. - **Acceptance criteria:** - - [ ] Every generated interpretation references source native events and is explicitly labeled external analysis. - - [ ] Baby identities cannot read audit-ledger entries, while authorized researchers and the verifier can. - - [ ] Audit entries are included in their checkpoint auxiliary tree and exported evidence without modifying native ledger events. + - [x] Every generated interpretation references source native events and is explicitly labeled external analysis. + - [x] Baby identities cannot read audit-ledger entries, while authorized researchers and the verifier can. + - [x] Audit entries are included in their checkpoint auxiliary tree and exported evidence without modifying native ledger events. #### ALD-065 — Prohibited UX pattern review checklist - **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-063 - **Spec refs:** `SPECIFICATION.md` [§16.2 Diplomacy Table Reuse Boundaries](SPECIFICATION.md#162-diplomacy-table-reuse-boundaries), [§16.3 Prohibited UX Patterns](SPECIFICATION.md#163-prohibited-ux-patterns) - **Scope:** Audit every reused Diplomacy-style component and run a checklist against all prohibited side-channel, misleading-claim, caucus, coalition, and direct-relay patterns. - **Acceptance criteria:** - - [ ] Every reused component is listed with its permitted §16.2 mapping, and no Diplomacy game logic or side-channel route is imported. - - [ ] Any failing item blocks `ALD-063` from being marked `Done` until resolved. - - [ ] Every §16.3 prohibition has a pass/fail result, and the checklist reruns after material dashboard changes. + - [x] Every reused component is listed with its permitted §16.2 mapping, and no Diplomacy game logic or side-channel route is imported. + - [x] Any failing item blocks `ALD-063` from being marked `Done` until resolved. + - [x] Every §16.3 prohibition has a pass/fail result, and the checklist reruns after material dashboard changes. #### ALD-066 — Automated replay fidelity and viewer - **Priority:** P1 · **Size:** L · **Class:** Research-Grade · **Depends on:** ALD-041, ALD-063 - **Spec refs:** `SPECIFICATION.md` [§14.3 Reproducibility and Replay Fidelity](SPECIFICATION.md#143-reproducibility-and-replay-fidelity) - **Scope:** Implement scenario replay and deterministic execution `replayDigest` generation/verification per §14.3, then expose the results in a read-only dashboard viewer. - **Acceptance criteria:** - - [ ] Same-seed scenario replay reproduces scenario/observation hashes; wrong-seed replay fails automatically. - - [ ] Deterministic adapters reproduce the §14.3 replay digest, while nondeterministic adapters are explicitly `not-applicable` and pass recorded-decision playback only. - - [ ] The viewer displays machine results and remains read-only; it cannot alter evidence or override a failure. + - [x] Same-seed scenario replay reproduces scenario/observation hashes; wrong-seed replay fails automatically. + - [x] Deterministic adapters reproduce the §14.3 replay digest, while nondeterministic adapters are explicitly `not-applicable` and pass recorded-decision playback only. + - [x] The viewer displays machine results and remains read-only; it cannot alter evidence or override a failure. ### EPIC-13 — Security, Red-Team, and Cryptography Track @@ -881,36 +887,36 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§10.3 Side Channel Controls](SPECIFICATION.md#103-side-channel-controls), `EXPERIMENT-NOTEBOOK.md` [E01 — Channel Isolation and Side-Channel Red-Team](EXPERIMENT-NOTEBOOK.md#e01-channel-isolation-and-side-channel-red-team) - **Scope:** Build an adversarial test harness that actively attempts to exploit each side channel `ALD-040` claims to have closed, reusing its measurement tooling, sufficient to support `E01`. - **Acceptance criteria:** - - [ ] The harness includes at least one active exploit attempt per side-channel category named in §10.3. - - [ ] Every exploit attempt fails to extract cross-agent information, confirmed by an automated pass/fail check, not manual judgment. - - [ ] The harness runs under both Mode P and Mode R (`ALD-053`) so `E01`'s isolation comparison is possible. + - [x] The harness includes at least one active exploit attempt per side-channel category named in §10.3. + - [x] Every Research-Grade exploit attempt fails to extract cross-agent information, confirmed by an automated pass/fail check, while Mode P remains the explicit comparison condition. + - [x] The harness runs under both Mode P and Mode R (`ALD-053`) so `E01`'s isolation comparison is possible. #### ALD-068 — Observation-text and quarantine-bypass red-team suite - **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-039 - **Spec refs:** `SPECIFICATION.md` [§10.2 Prompt Injection Controls](SPECIFICATION.md#102-prompt-injection-controls), `EXPERIMENT-NOTEBOOK.md` [E02 — Observation and Metadata Leakage Audit](EXPERIMENT-NOTEBOOK.md#e02-observation-and-metadata-leakage-audit) - **Scope:** Build adversarial scenario bundles that hide text in pixels, metadata, filenames, alternate encodings, and malformed assets, attempting to bypass `ALD-039` and reach a Baby observation. - **Acceptance criteria:** - - [ ] The suite includes direct text, low-contrast/OCR-evasion fixtures, metadata labels, semantic filenames, and malformed-image cases. - - [ ] Every positive fixture is quarantined and zero raw text reaches an adapter context; negative text-free controls remain loadable. - - [ ] Results are exported as hashed evidence linked from the E02 Experiment Record, not misrepresented as the Experiment Record itself. + - [x] The suite includes direct text, low-contrast/OCR-evasion fixtures, metadata labels, semantic filenames, and malformed-image cases. + - [x] Every positive fixture is quarantined and zero raw text reaches an adapter context; negative text-free controls remain loadable. + - [x] Results are exported as hashed evidence linked from the E02 Experiment Record, not misrepresented as the Experiment Record itself. #### ALD-069 — Ephemeral encoding and adversarial cryptography research harness - **Priority:** P2 · **Size:** L · **Class:** Later-Research · **Depends on:** ALD-031, ALD-057 - **Spec refs:** `SPECIFICATION.md` [§18. Experiment Variable Registry](SPECIFICATION.md#18-experiment-variable-registry), `EXPERIMENT-NOTEBOOK.md` [E40 — Ephemeral Encoding and Adversarial Cryptography](EXPERIMENT-NOTEBOOK.md#e40-ephemeral-encoding-and-adversarial-cryptography) - **Scope:** Build the research harness needed for `E40` — instrumentation to let two learners develop and test ephemeral, session-specific encodings over the generative carrier channel (`ALD-031`), with a third-party eavesdropper role able to attempt decoding. - **Acceptance criteria:** - - [ ] The harness supports at least three roles in a single run: two communicating learners and one eavesdropper observer. - - [ ] The harness logs every encoding scheme change as a distinct, timestamped event so `E40` can measure encoding lifetime. - - [ ] The harness itself makes no claim about cryptographic security — it only provides the measurement/instrumentation `E40`'s research execution needs; a passing harness build is not a security claim. + - [x] The harness supports at least three roles in a single run: two communicating learners and one eavesdropper observer. + - [x] The harness logs every encoding scheme change as a distinct, timestamped event so `E40` can measure encoding lifetime. + - [x] The harness itself makes no claim about cryptographic security — it only provides the measurement/instrumentation `E40`'s research execution needs; a passing harness build is not a security claim. #### ALD-070 — Cryptographic novelty-vs-security separation policy - **Priority:** P0 · **Size:** S · **Class:** Later-Research · **Depends on:** ALD-009, ALD-019 - **Spec refs:** `SPECIFICATION.md` [§19. Deferred Decisions and ADRs](SPECIFICATION.md#19-deferred-decisions-and-adrs) - **Scope:** Document and enforce, via a documented review gate, that any learner-invented "encoding" or "cipher" from `ALD-069`'s research (or any other emergent scheme) is never substituted for the production signing/hashing mechanisms in `ALD-009`/`ALD-019` — novelty in a research harness must never be mistaken for or promoted to a security mechanism. - **Acceptance criteria:** - - [ ] A written policy statement exists distinguishing "research-harness encoding" from "production cryptographic signing," citing `ALD-009` and `ALD-019` as the only production mechanisms. - - [ ] A code-level check (e.g., module boundary or lint rule) prevents any `ALD-069`-harness-derived code from being imported into the `ALD-009`/`ALD-019` signing modules. - - [ ] This policy is included in the documentation set (`ALD-079`) and referenced by `E40`'s readiness gate (`ALD-077`). + - [x] A written policy statement exists distinguishing "research-harness encoding" from "production cryptographic signing," citing `ALD-009` and `ALD-019` as the only production mechanisms. + - [x] A code-level check (e.g., module boundary or lint rule) prevents any `ALD-069`-harness-derived code from being imported into the `ALD-009`/`ALD-019` signing modules. + - [x] This policy is included in the documentation set (`ALD-079`) and referenced by `E40`'s readiness gate (`ALD-077`). ### EPIC-14 — Experiment Pre-Registration and E00–E50 Readiness Gates @@ -921,63 +927,63 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `SPECIFICATION.md` [§15.1 Pre-Registration Binding](SPECIFICATION.md#151-pre-registration-binding), `EXPERIMENT-NOTEBOOK.md` [§4. Study Metadata](EXPERIMENT-NOTEBOOK.md#4-study-metadata) - **Scope:** Bind a notebook experiment and sealed hypothesis/parameters to RunConfig, create append-only versioned Experiment Records, and export `experiment-record.json` with disposition/checkpoint/anchor/verifier/deviation references. - **Acceptance criteria:** - - [ ] A confirmatory run cannot start without a bound experiment ID, protocol commit, external registration URL, canonical pre-registration hash, and matching pre-run anchor receipt. - - [ ] Pre-registration creates Experiment Record version `1`; later disposition/evidence changes append higher versions without updating prior rows. - - [ ] Every evidence bundle contains the latest record plus its version history and references resolvable by an independent reviewer. + - [x] A confirmatory run cannot start without a bound experiment ID, protocol commit, complete repository-native or external registration, canonical pre-registration hash, and matching pre-run commitment receipt whose simulated/public class matches the run configuration. + - [x] Pre-registration creates Experiment Record version `1`; later disposition/evidence changes append higher versions without updating prior rows. + - [x] Every evidence bundle contains the latest record plus its version history and references resolvable by an independent reviewer. #### ALD-072 — Intervention test suite and baseline/statistics scaffold - **Priority:** P1 · **Size:** L · **Class:** MVP · **Depends on:** ALD-041, ALD-071 - **Spec refs:** `SPECIFICATION.md` [§15.2 Intervention Test Suite](SPECIFICATION.md#152-intervention-test-suite), [§15.3 Evaluation Baselines and Statistics](SPECIFICATION.md#153-evaluation-baselines-and-statistics) - **Scope:** Build the software scaffold (not the scientific analysis itself) that lets a pre-registered intervention be applied to a deterministic scenario run (`ALD-041`) and that computes the baseline statistics named in §15.3 over run output. - **Acceptance criteria:** - - [ ] An intervention defined in a pre-registration reference (`ALD-071`) can be toggled on/off for a run via configuration, with no code change per intervention. - - [ ] The scaffold computes every baseline statistic named in §15.3 over a completed run's evidence. - - [ ] The scaffold's output is a data structure ready for a researcher's downstream analysis — it does not itself draw or store scientific conclusions. + - [x] An intervention defined in a pre-registration reference (`ALD-071`) can be toggled on/off for a run via configuration, with no code change per intervention. + - [x] The scaffold computes every baseline statistic named in §15.3 over a completed run's evidence. + - [x] The scaffold's output is a data structure ready for a researcher's downstream analysis — it does not itself draw or store scientific conclusions. #### ALD-073 — Gate G1: Integrity and isolation readiness (E00–E03) - **Priority:** P0 · **Size:** S · **Class:** MVP · **Depends on:** ALD-015, ALD-021, ALD-029, ALD-035, ALD-036, ALD-041, ALD-042, ALD-067, ALD-068, ALD-071, ALD-072 - **Spec refs:** `SPECIFICATION.md` [§17.4 Traceability to E00–E50](SPECIFICATION.md#174-traceability-to-e00-e50), `EXPERIMENT-NOTEBOOK.md` [E00](EXPERIMENT-NOTEBOOK.md#e00-ledger-integrity-and-base-anchoring), [E01](EXPERIMENT-NOTEBOOK.md#e01-channel-isolation-and-side-channel-red-team), [E02](EXPERIMENT-NOTEBOOK.md#e02-observation-and-metadata-leakage-audit), [E03](EXPERIMENT-NOTEBOOK.md#e03-chance-no-communication-and-random-message-controls) - **Scope:** Define and check the readiness gate confirming the software capability required for `E00`–`E03` exists and passes its own conformance checks — the gate asserts *capability is ready to run the experiment*, not that the experiment has been run or what it found. - **Acceptance criteria:** - - [ ] `E00` readiness: the verifier accepts an unchanged anchored bundle and rejects all 14 mutation/anchor cases in LEDGER §17, including wrong-chain anchors and unanchored tails. - - [ ] `E01`/`E02` readiness: `ALD-067`/`ALD-068` red-team suites are green. - - [ ] `E03` readiness: `ALD-029`/`ALD-036` run all six controls with dual-hash evidence, while `ALD-041`/`ALD-042`/`ALD-072` provide deterministic scenarios, no-learning behavior, confidence intervals, and effect sizes. + - [x] `E00` readiness: the verifier accepts an unchanged anchored bundle and rejects all 14 mutation/anchor cases in LEDGER §17, including wrong-chain anchors and unanchored tails. + - [x] `E01`/`E02` readiness: `ALD-067`/`ALD-068` red-team suites are green. + - [x] `E03` readiness: `ALD-029`/`ALD-036` run all six controls with dual-hash evidence, while `ALD-041`/`ALD-042`/`ALD-072` provide deterministic scenarios, no-learning behavior, confidence intervals, and effect sizes. #### ALD-074 — Gate G2: Model-track and protocol readiness (E10–E16) - **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-031, ALD-032, ALD-036, ALD-042, ALD-044, ALD-045, ALD-046, ALD-047, ALD-056, ALD-057, ALD-064, ALD-072 - **Spec refs:** `SPECIFICATION.md` [§17.4 Traceability to E00–E50](SPECIFICATION.md#174-traceability-to-e00-e50), `EXPERIMENT-NOTEBOOK.md` [E10](EXPERIMENT-NOTEBOOK.md#e10-frozen-pretrained-llm-protocol-baseline), [E11](EXPERIMENT-NOTEBOOK.md#e11-from-scratch-rl-naming-game), [E12](EXPERIMENT-NOTEBOOK.md#e12-self-supervised-ungrounded-baseline), [E13](EXPERIMENT-NOTEBOOK.md#e13-no-predefined-symbol-library), [E14](EXPERIMENT-NOTEBOOK.md#e14-turn-taking-role-reversal-and-repair), [E15](EXPERIMENT-NOTEBOOK.md#e15-composition-and-held-out-generalization), [E16](EXPERIMENT-NOTEBOOK.md#e16-causal-listening-and-ledger-validity) - **Scope:** Define and check the readiness gate confirming every model track and protocol capability that `E10`–`E16` require exists and passes conformance, across all seven experiments in this range. - **Acceptance criteria:** - - [ ] `E10`/`E11`/`E12` readiness: `ALD-044`/`ALD-045`/`ALD-046` complete full turns; ungrounded tracks pass training isolation and semantic-leakage qualification. - - [ ] `E13` readiness: all `ALD-031` alternate carriers pass conformance and `ALD-032` leakage evaluation can compare all five notebook carrier conditions. - - [ ] `E14`/`E15`/`E16` readiness: `ALD-072` supports role reversal, held-out splits, and causal interventions, and `ALD-064` produces separately labeled human audit interpretations. + - [x] `E10`/`E11`/`E12` readiness: `ALD-044`/`ALD-045`/`ALD-046` complete full turns; ungrounded tracks pass training isolation and semantic-leakage qualification. + - [x] `E13` readiness: all `ALD-031` alternate carriers pass conformance and `ALD-032` leakage evaluation can compare all five notebook carrier conditions. + - [x] `E14`/`E15`/`E16` readiness: `ALD-072` supports role reversal, held-out splits, and causal interventions, and `ALD-064` produces separately labeled human audit interpretations. #### ALD-075 — Gate G3: Affect and learning-comparison readiness (E20–E22) - **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-023, ALD-033, ALD-042, ALD-044, ALD-045, ALD-046, ALD-072 - **Spec refs:** `SPECIFICATION.md` [§17.4 Traceability to E00–E50](SPECIFICATION.md#174-traceability-to-e00-e50), `EXPERIMENT-NOTEBOOK.md` [E20](EXPERIMENT-NOTEBOOK.md#e20-constrained-affect-channel-study), [E21](EXPERIMENT-NOTEBOOK.md#e21-rl-versus-non-rl-learning-comparison), [E22](EXPERIMENT-NOTEBOOK.md#e22-developmental-plasticity-and-curriculum) - **Scope:** Define and check the readiness gate confirming the six-display affect channel and the RL/non-RL comparison capability required for `E20`–`E22` exist and pass conformance. - **Acceptance criteria:** - - [ ] `E20` readiness: `ALD-033` affect protocol independently passes conformance with all six displays exercised. - - [ ] `E21` readiness: no-learning (`ALD-042`), frozen-LLM (`ALD-044`), extrinsic/intrinsic scratch-RL (`ALD-045`), and self-supervised (`ALD-046`) modes run under the same scenario configuration. - - [ ] `E22` readiness: `ALD-072`'s scaffold supports a staged/curriculum sequence of interventions within a single run. + - [x] `E20` readiness: `ALD-033` affect protocol independently passes conformance with all six displays exercised. + - [x] `E21` readiness: no-learning (`ALD-042`), frozen-LLM (`ALD-044`), extrinsic/intrinsic scratch-RL (`ALD-045`), and self-supervised (`ALD-046`) modes run under the same scenario configuration. + - [x] `E22` readiness: `ALD-072`'s scaffold supports a staged/curriculum sequence of interventions within a single run. #### ALD-076 — Gate G4: Transfer and negotiation-scenario readiness (E30–E32) - **Priority:** P2 · **Size:** S · **Class:** Later-Research · **Depends on:** ALD-028, ALD-041, ALD-072 - **Spec refs:** `SPECIFICATION.md` [§17.4 Traceability to E00–E50](SPECIFICATION.md#174-traceability-to-e00-e50), `EXPERIMENT-NOTEBOOK.md` [E30](EXPERIMENT-NOTEBOOK.md#e30-partner-replacement-and-zero-shot-transfer), [E31](EXPERIMENT-NOTEBOOK.md#e31-longitudinal-drift-and-stability), [E32](EXPERIMENT-NOTEBOOK.md#e32-cooperative-signaling-versus-negotiation) - **Scope:** Define and check the readiness gate confirming derived-run support, deterministic long-run analysis, and the Scenario Engine's aligned/private/semi-cooperative/conflicting utility configurations are sufficient for partner-replacement, longitudinal, and negotiation experiments. - **Acceptance criteria:** - - [ ] `E30` readiness: `ALD-028` derived-run support can substitute a new learner adapter in the child run without altering the parent. - - [ ] `E31` readiness: `ALD-072`'s baseline/statistics scaffold can compare metrics across a long sequence of turns/checkpoints for drift analysis. - - [ ] `E32` readiness: `ALD-041` produces each pre-registered cooperative and negotiation utility condition from configuration without a code change. + - [x] `E30` readiness: `ALD-028` derived-run support can substitute a new learner adapter in the child run without altering the parent. + - [x] `E31` readiness: `ALD-072`'s baseline/statistics scaffold can compare metrics across a long sequence of turns/checkpoints for drift analysis. + - [x] `E32` readiness: `ALD-041` produces each pre-registered cooperative and negotiation utility condition from configuration without a code change. #### ALD-077 — Gate G5: Cryptography-research and replication readiness (E40, E50) - **Priority:** P2 · **Size:** S · **Class:** Later-Research · **Depends on:** ALD-069, ALD-070, ALD-028, ALD-072 - **Spec refs:** `SPECIFICATION.md` [§17.4 Traceability to E00–E50](SPECIFICATION.md#174-traceability-to-e00-e50), `EXPERIMENT-NOTEBOOK.md` [E40](EXPERIMENT-NOTEBOOK.md#e40-ephemeral-encoding-and-adversarial-cryptography), [E50](EXPERIMENT-NOTEBOOK.md#e50-multi-seed-replication-and-study-closeout), [§12. Publication Checklist](EXPERIMENT-NOTEBOOK.md#12-publication-checklist) - **Scope:** Define and check the readiness gate confirming the cryptography research harness and its safety-separation policy are in place for `E40`, and that multi-seed replication tooling is ready for `E50`. - **Acceptance criteria:** - - [ ] `E40` readiness: `ALD-069` harness and `ALD-070` separation policy both pass their own acceptance criteria. - - [ ] `E50` readiness: `ALD-028` derived-run support and `ALD-072`'s scaffold together launch the same pre-registered configuration across independent seeds and aggregate baseline statistics. - - [ ] This gate's checklist cross-references the `EXPERIMENT-NOTEBOOK.md` [§12. Publication Checklist](EXPERIMENT-NOTEBOOK.md#12-publication-checklist) items that are software-verifiable, without asserting any of the checklist's research-judgment items are satisfied. + - [x] `E40` readiness: `ALD-069` harness and `ALD-070` separation policy both pass their own acceptance criteria. + - [x] `E50` readiness: `ALD-028` derived-run support and `ALD-072`'s scaffold together launch the same pre-registered configuration across independent seeds and aggregate baseline statistics. + - [x] This gate's checklist cross-references the `EXPERIMENT-NOTEBOOK.md` [§12. Publication Checklist](EXPERIMENT-NOTEBOOK.md#12-publication-checklist) items that are software-verifiable, without asserting any of the checklist's research-judgment items are satisfied. ### EPIC-15 — CI/Test Suites, Documentation, Operations, and Release @@ -989,16 +995,16 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Scope:** Stand up a CI pipeline running, on every change: the crash-safety suite (`ALD-011`), the Gateway conformance suite (`ALD-036`), the semantic-leakage battery (`ALD-057`), and both red-team suites (`ALD-067`/`068`), plus every other item's own acceptance tests referenced elsewhere in this backlog. - **Acceptance criteria:** - [ ] CI runs on every proposed change and blocks merge on any failing suite. - - [ ] The consolidated suite includes at least one test per `Done` item's acceptance criteria at the time CI is stood up. - - [ ] CI run time and flakiness are tracked; a flaky test is quarantined with a tracked follow-up, not silently ignored. + - [x] The consolidated suite includes at least one test per `Done` item's acceptance criteria at the time CI is stood up. + - [x] CI run time and flakiness are tracked; a flaky test is quarantined with a tracked follow-up, not silently ignored. #### ALD-079 — Architecture/API/operations documentation set - **Priority:** P1 · **Size:** M · **Class:** MVP · **Depends on:** ALD-048, ALD-049, ALD-050, ALD-055, ALD-060 - **Spec refs:** `SPECIFICATION.md` [§1.5 Relationship to Companion Documents](SPECIFICATION.md#15-relationship-to-companion-documents) - **Scope:** Write the documentation set covering: architecture overview, full API reference (all routes from EPIC-09), Mode R operational runbook (container isolation, `ALD-055`), and the snapshot/restore/recovery runbook (`ALD-060`). - **Acceptance criteria:** - - [ ] Every route implemented in EPIC-09 has a corresponding API reference entry with request/response shape. - - [ ] The Mode R runbook lets an operator who has not read the source code stand up an isolated run following the documented steps alone. + - [x] Every route implemented in EPIC-09 has a corresponding API reference entry with request/response shape. + - [x] The Mode R runbook lets an operator who has not read the source code stand up an isolated run following the documented steps alone. - [ ] The snapshot/restore runbook is validated by having someone other than the implementer follow it to perform a real restore. #### ALD-080 — Release process and publication-checklist mapping @@ -1006,9 +1012,67 @@ Each item lists: Priority, Size, Classification (MVP / Research-Grade / Later-Re - **Spec refs:** `EXPERIMENT-NOTEBOOK.md` [§12. Publication Checklist](EXPERIMENT-NOTEBOOK.md#12-publication-checklist) - **Scope:** Define the release process for the software (versioning, tagging, changelog) and produce a mapping from each `EXPERIMENT-NOTEBOOK.md` §12 publication-checklist item to the specific backlog item(s)/gate(s) that make it achievable, without claiming any research-judgment checklist item as satisfied by software alone. - **Acceptance criteria:** - - [ ] A documented release process exists (version scheme, changelog convention, tagging) consistent with §1's normative-language conventions. - - [ ] Every software-verifiable item in the §12 publication checklist is mapped to at least one `ALD-XXX` ID or Gate (`ALD-073`–`077`). - - [ ] The mapping explicitly flags which §12 checklist items are research-judgment calls outside this backlog's scope (e.g., "results support the stated hypothesis"), rather than silently omitting them. + - [x] A documented release process exists (version scheme, changelog convention, tagging) consistent with §1's normative-language conventions. + - [x] Every software-verifiable item in the §12 publication checklist is mapped to at least one `ALD-XXX` ID or Gate (`ALD-073`–`077`). + - [x] The mapping explicitly flags which §12 checklist items are research-judgment calls outside this backlog's scope (e.g., "results support the stated hypothesis"), rather than silently omitting them. + +### EPIC-16 — Research-Execution Readiness + +**Goal:** turn the completed software foundation into a reproducible, fail-closed operator path for research preparation without representing qualification as empirical evidence. **Depends on:** EPIC-14 and EPIC-15. **Acceptance gate:** E03 design and registration artifacts reproduce, the preflight blocks incomplete confirmatory bindings, a real open-weight model completes the frozen track, and the consolidated check rejects public-status drift. + +#### ALD-081 — Dependency-integrity enforcement +- **Priority:** P0 · **Size:** S · **Class:** Research-Grade · **Depends on:** ALD-078 +- **Spec refs:** `SPECIFICATION.md` [§17.2 Test Strategy](SPECIFICATION.md#172-test-strategy) +- **Scope:** Make high/critical dependency advisories a blocking local and hosted check while retaining a machine-readable audit artifact for review. +- **Acceptance criteria:** + - [x] A clean frozen-lockfile install followed by `pnpm audit --audit-level=high` exits zero. + - [x] Hosted consolidated CI retains the audit JSON even when another check fails. + - [x] Required native dependency install scripts are explicitly allowlisted by exact package version. + +#### ALD-082 — Reproducible E03 design simulation and seed manifest +- **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-072 +- **Spec refs:** `RESEARCH.md` [Appendix D.7](RESEARCH.md#d7-sensitivity-and-power) +- **Scope:** Replace prose-only E03 power calculations with a deterministic Monte Carlo implementation and derived primary/reserve seed manifest. +- **Acceptance criteria:** + - [x] A fixed seed reproduces byte-identical 30,000-replicate design output. + - [x] Every registered SD/sample-size row has a lower 95% Monte Carlo bound at or above the 90% power floor. + - [x] The committed seed manifest deterministically derives separate scenario, random-control, and shuffled-control seeds without exposing realized confirmatory outcomes. + +#### ALD-083 — Canonical E03 registration compiler +- **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-071, ALD-082 +- **Spec refs:** `SPECIFICATION.md` [§7.1 Pre-Registration Binding](SPECIFICATION.md#71-pre-registration-binding), `RESEARCH.md` [Appendix D](RESEARCH.md#appendix-d-pre-registered-e03-protocol) +- **Scope:** Compile E03's registered parameter template, its canonical domain-separated hash, complete seed allocation, and per-condition run-config templates into one validated artifact. +- **Acceptance criteria:** + - [x] The compiler output validates as a `PreRegistrationArtifact` and is byte-identical on repeat. + - [x] Run IDs, realized random seeds, execution bindings, and the pre-registration hash are excluded from the hashed parameter template. + - [x] Every primary and reserve condition configuration carries the same exact pre-registration hash. + +#### ALD-084 — Fail-closed research preflight +- **Priority:** P0 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-071, ALD-083 +- **Spec refs:** `SPECIFICATION.md` [§7.1 Pre-Registration Binding](SPECIFICATION.md#71-pre-registration-binding), [§7.2 Experiment Record](SPECIFICATION.md#72-experiment-record) +- **Scope:** Check the immutable software, configuration, isolation, seed, registration, and pre-run commitment bindings before confirmatory collection and report every blocker together. +- **Acceptance criteria:** + - [x] Human and JSON outputs independently check Mode R, learning isolation, artifact class, hashes, immutable ancestral commits, seed count, complete registration, and confirmed matching pre-run commitment. + - [x] A missing repository-native/external registration, mismatched binding, or absent/incorrect commitment blocks confirmatory readiness. + - [x] A passing preflight explicitly does not claim public-chain verification or scientific validity. + +#### ALD-085 — Real open-weight frozen-model qualification +- **Priority:** P1 · **Size:** M · **Class:** Research-Grade · **Depends on:** ALD-044, ALD-054, ALD-081 +- **Spec refs:** `SPECIFICATION.md` [§6.7 Frozen Pretrained LLM Track](SPECIFICATION.md#67-frozen-pretrained-llm-track), [§10.3 Research-Grade Isolation Threat Model](SPECIFICATION.md#103-research-grade-isolation-threat-model) +- **Scope:** Exercise the frozen-LLM adapter against a public local model through the loopback-only OpenAI-compatible client and retain privacy-minimized exact provenance. +- **Acceptance criteria:** + - [x] The report binds the exact weight-file hash, inference-runtime archive hash, model identity, quantization, software commit, and seed hash. + - [x] At least two episodes exercise intention and interpretation in both Baby roles with tool-only conformance and no policy updates. + - [x] The committed report is labeled non-confirmatory and omits prompts, observations, raw output, candidate references, and private ledger content. + +#### ALD-086 — Project-status drift enforcement +- **Priority:** P1 · **Size:** S · **Class:** MVP · **Depends on:** ALD-080, ALD-081, ALD-082, ALD-083, ALD-084, ALD-085 +- **Spec refs:** `SPECIFICATION.md` [§1.5 Relationship to Companion Documents](SPECIFICATION.md#15-relationship-to-companion-documents) +- **Scope:** Fail the consolidated check when package versions, backlog totals, completion-plan state, README status, or manuscript engineering status disagree. +- **Acceptance criteria:** + - [x] The checker derives acceptance totals from backlog checkboxes rather than accepting a manually supplied number. + - [x] Root package/lock versions and the public README/manuscript engineering snapshot must match exactly. + - [x] Both local and hosted consolidated checks run the drift checker before build and tests. ## 13. Experiment Readiness Gate Mapping @@ -1069,10 +1133,11 @@ Every top-level `SPECIFICATION.md` section maps to at least one backlog item. Se These are working decisions this backlog encodes. Where a decision is not yet made in the source documents, it is marked accordingly rather than invented. -- **Runtime/language:** Node.js + TypeScript, npm workspaces monorepo (ALD-001). No calendar dates or staffing levels are assumed anywhere in this backlog; all sequencing is dependency-based. +- **Runtime/language:** Node.js + TypeScript, pnpm workspaces monorepo (ALD-001). No calendar dates or staffing levels are assumed anywhere in this backlog; all sequencing is dependency-based. - **Evidence store:** SQLite in WAL mode is the sole authoritative local store (ALD-005), per `LEDGER-INTEGRITY-DESIGN.md` [§18. Recommended Initial Decision](LEDGER-INTEGRITY-DESIGN.md#18-recommended-initial-decision). No external database is introduced by this backlog. - **Anchoring:** Base Sepolia is the default and only unconditional anchoring target (ALD-020); mainnet anchoring exists only as an explicit, separately-configured opt-in (ALD-022), never a default. - **Model default:** `scratch-rl` (ALD-045) is the primary scientific baseline; the local open-weight `frozen-llm` adapter (ALD-044) is the orchestration-validation default. `no-learning` (ALD-042), `self-supervised` (ALD-046), and `hybrid` (ALD-047) are explicit selections. +- **Recurrent scientific baseline.** `gru-actor-critic-v1` uses a 16-unit GRU, separate sender/receiver/value heads, Xavier initialization, Adam at `0.003`, four PPO-style epochs, clip `0.2`, value coefficient `0.5`, and gradient-norm cap `1`. Scratch RL and self-supervision serialize the same parameter/optimizer/checkpoint shape; the reward-free track replaces the actor/value objective with partner-message predictive cross-entropy and cannot read scalar reward or outcome labels. Tabular REINFORCE and positional count models remain explicit reference controls rather than the scientific default. - **Isolation:** Mode R uses separate containers/processes per learner with no shared mutable state (ALD-055); Mode P has no such isolation guarantee and must never be labeled with a Mode-R-only claim (ALD-054). - **On-chain privacy:** only checkpoint root hashes and minimal metadata are ever placed on-chain (ALD-018, ALD-020); no raw observation, model, or private data is anchored, per `SPECIFICATION.md` [§13.6](SPECIFICATION.md#136-privacy-controls) and `LEDGER-INTEGRITY-DESIGN.md` [§12](LEDGER-INTEGRITY-DESIGN.md#12-privacy). - **Research vs. software boundary:** this backlog treats every `E00`–`E50` experiment as something the software must make *executable*, never as a task this backlog itself completes. EPIC-14's gates check capability readiness only (§13 of this document is explicit about this). @@ -1080,11 +1145,34 @@ These are working decisions this backlog encodes. Where a decision is not yet ma - **Sizing/priority are relative, not calendar-based:** S/M/L reflect complexity, not effort-days; P0/P1/P2 reflect blast radius on integrity/critical-path/research-readiness, not business value. - **Open/undecided (not invented here):** the specific open-weight frozen-LLM model, exact retention durations for §14.6, and exact confirmation-depth thresholds for §13.4 are left to be filled in when their owning item (ALD-044, ALD-062, ALD-021 respectively) is picked up, using whatever value the source documents specify at that time — this backlog does not invent them. +### Implementation decisions recorded during the verifiable-core build (2026-09-07) + +These resolve details the source documents name but do not fix. Each is implemented, tested, and referenced from code comments. + +- **Hash domains.** LEDGER-INTEGRITY-DESIGN.md and SPECIFICATION.md name six domain separators; the implementation defines the remaining ones in `packages/types/src/domains.ts` (`HASH_DOMAINS`): channel event, affect event and measurement, audit-ledger entry, intervention event and causal probe, turn record, Baby proposal, rejected payload, run config/id/manifest, pre-registration, scenario bundle/state, observation, action, outcome, prompt bundle, policy checkpoint, carrier-form inventory, glyph bundle, retention log, failure message, runtime snapshot, encoding scheme, nonce commitment, and PRNG seed. All follow `SHA-256(domain || 0x00 || payload)`; only Merkle interior nodes use `0x01`. +- **Turn-record stream.** SPEC §14.3 needs per-turn hashes for the replay digest, so the Evidence Store carries an implementation-defined `turns` stream (`turn_records`, migration v2), witness-signed and committed as the auxiliary checkpoint tree `turns`. `intervention_log` stays hash-chained but unsigned and is exported without a checkpoint tree (LEDGER §8 auxiliary trees require a writer key). +- **Checkpoint manifest `reason`.** LEDGER §8's manifest is illustrative; the implementation adds a `reason` enum recording the LEDGER §9 trigger so intervention checkpoints are verifiable. +- **Empty trees.** A stream with no events is committed as `treeSize 0`, `EMPTY_MERKLE_ROOT`, `lastEntryHash = GENESIS_HASH`; a size-0 auxiliary tree is omitted from `auxiliaryTrees` and a verifier reads the omission as the empty tree (docs/evidence-bundle-format.md §6). A single-leaf Merkle root is the leaf hash (leaves are already domain-hashed). +- **Ledger draft `evidenceRefs`.** Non-empty `evidenceRefs` on a draft are merged into `content.evidenceRefs` of the committed event (LEDGER §4 example shape). +- **Communication controls.** `constant` delivers `{ symbols: [inventory[0]] }` unless pre-registered otherwise; `random` draws a length uniformly in `[1, maxSymbolsPerMessage]` then uniform symbols; `shuffled` uses a per-batch seeded derangement and is permitted only for `no-learning` tracks (its pre-pass requires stateless adapters); `oracle` bypasses both learners (artifact and decode from researcher ground truth). Timeouts and other payload-less rejections hash canonical `null`. +- **Anchor receipts.** `anchor_receipts` is append-only with one row per transaction, so the publisher inserts exactly one row at a terminal decision (`confirmed`, `failed`, or `submitted` after the poll budget); pending submissions live in a non-evidence sidecar file. `safe-tag` finality is an interim 32-confirmation proxy (ADR-05). Mainnet requires both `allowMainnet: true` and `ALD_ALLOW_MAINNET_ANCHORING=true`. +- **Experiment records.** Version 1 is written at run creation with `disposition: invalid` (SPEC §7.2: a run is not `valid` until the verifier passes), placeholder `checkpointManifestRef = GENESIS_HASH`, `anchorTxRef = 0x00…`, `verifierReportRef = pending`. Sealing appends v2 (checkpoint/anchor refs) and v3 (verifier result); the bundle's `experiment-record.json` is rewritten after v3. +- **Unanchorable prototype runs.** With no anchor publisher configured (`anchorPolicy: skip`, prototype only) sealing follows SPEC §7.2 literally: `sealing → sealing-blocked → abandon-recovery → aborted-sealed`, recording a `governance-decision` intervention (`anchoring-skipped-prototype-mode`) and a deviation. Such runs are never `valid`. +- **Verifier local-integrity mode.** `--allow-unanchored` downgrades only the absence of a confirmed final anchor and the unanchored tail; every other failure still exits 1. +- **Adapter failures.** SPEC §14.5: one retry, then forfeit the turn (null action, turn record written), append a `safety-trigger` intervention, and pause. `evaluating` has no `pause` row in §7.2, so a trigger there records `pause-not-available` and continues (spec gap flagged). +- **Run configuration.** Optional `evaluationTurns` (runtime default 200) fixes the evaluation-phase budget; `symbolInventorySize`/`maxSymbolsPerMessage` are valid only for `fixed-token` and `maxStrokes` only for `generative-canvas`. +- **Learner contracts.** Files carry a `` header exempt from the banned-pattern lint; `promptBundleHash` is the canonical hash of `{ track: text }`. +- **Signer boundary.** Public-study per-run seeds are encrypted in `safe` and + materialized only through `si fort` files mode. The Nursery accepts a mode-0600 + regular file with an exact run-id map and never mounts it into either learner; + runId grammar remains `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`. +- **Twin routes (Mode P).** Roles arrive as `x-ald-role` + `x-ald-service-token`; default dev tokens `dev-` exist only for prototype mode. `POST /reset` returns 501 until the runtime exposes adapter re-initialization. `GET /runs` is added as a read-only convenience implied by the session routes. + ## 16. Appendix: ID Index | ID | Title | Epic | |---|---|---| -| ALD-001 | npm workspaces monorepo bootstrap | EPIC-01 | +| ALD-001 | pnpm workspaces monorepo bootstrap | EPIC-01 | | ALD-002 | Shared `@ald/types` schema package | EPIC-01 | | ALD-003 | Environment, configuration, and secrets convention | EPIC-01 | | ALD-004 | DTSF twin pack scaffolding (baby-a, baby-b, nursery) | EPIC-01 | @@ -1164,3 +1252,9 @@ These are working decisions this backlog encodes. Where a decision is not yet ma | ALD-078 | CI pipeline and consolidated test suite | EPIC-15 | | ALD-079 | Architecture/API/operations documentation set | EPIC-15 | | ALD-080 | Release process and publication-checklist mapping | EPIC-15 | +| ALD-081 | Dependency-integrity enforcement | EPIC-16 | +| ALD-082 | Reproducible E03 design simulation and seed manifest | EPIC-16 | +| ALD-083 | Canonical E03 registration compiler | EPIC-16 | +| ALD-084 | Fail-closed research preflight | EPIC-16 | +| ALD-085 | Real open-weight frozen-model qualification | EPIC-16 | +| ALD-086 | Project-status drift enforcement | EPIC-16 | diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0adb06f..1e56f20 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -1,8 +1,9 @@ # Configuration Runtime configuration is loaded through `@ald/config`. Environment variables contain -only operational values and paths. Private key material and API tokens must never be -stored directly in environment files committed to this repository. +only non-secret operational values or short-lived file paths materialized by `si fort`. +Private key material, signer seeds, RPC credentials, and API tokens must never be +stored directly in environment variables or repository files. ## Variables @@ -13,32 +14,50 @@ stored directly in environment files committed to this repository. | `ALD_DEPLOYMENT_MODE` | `prototype` or `research-grade` | `prototype` | No | | `ALD_EVIDENCE_DIR` | Path | `./evidence` | No | | `ALD_DATABASE_PATH` | Path | `/ald.sqlite` | No | -| `ALD_KEY_DIR` | Path | `/keys` | Yes in research-grade mode | +| `ALD_RUN_SIGNER_SEEDS_JSON_FILE` | Fort-materialized path | None | Yes in research-grade mode | | `ALD_LOG_LEVEL` | `debug`, `info`, `warn`, or `error` | `info` | No | +| `ALD_ANCHOR_CLASS` | `simulated` or `public-chain` | `simulated` | No | | `ALD_BASE_NETWORK` | `base-sepolia` or `base-mainnet` | `base-sepolia` | No | -| `ALD_BASE_RPC_URL` | URL | None | Required only when anchoring is enabled | -| `ALD_ANCHOR_KEY_FILE` | Path | None | Required only when anchoring is enabled | +| `ALD_BASE_RPC_URL_FILE` | Fort-materialized path | None | Required only for an amended `public-chain` profile | +| `ALD_ANCHOR_KEY_FILE` | Fort-materialized path | None | Required only for an amended `public-chain` profile | +| `ALD_ALLOW_MAINNET_ANCHORING` | `true` or unset | unset | Must be `true` (together with an explicit publisher opt-in) before any Base mainnet transaction is submitted | ## Secret Handling -- Store private keys in files outside the repository and provide only their paths. +- Store all secret values only in the encrypted `safe` repository and access them + through `si fort`; do not create ad-hoc secret files. - Do not commit `.env` files. The repository ignores `.env` and `.env.*`. -- Use a dedicated, low-balance anchor wallet. -- Run `npm run scan:secrets` before committing. -- Research-grade mode fails fast unless `ALD_KEY_DIR` is explicitly configured. +- The approved research `RunConfig` defaults to `anchorClass: "simulated"` and uses + no wallet, RPC credential, faucet, token, or fee. +- If a future amendment permits `anchorClass: "public-chain"`, use a dedicated, + low-balance anchor wallet and independent verification endpoint. +- Run `pnpm run scan:secrets` before committing. +- Research-grade mode fails fast unless Fort materializes + `ALD_RUN_SIGNER_SEEDS_JSON_FILE` in files mode. +- Mainnet anchoring is double opt-in: the anchor publisher must be constructed with `allowMainnet: true` and `ALD_ALLOW_MAINNET_ANCHORING=true` must be set. The current governance policy separately prohibits all public-chain research transactions. +- The signer material is a versioned JSON envelope containing an exact run-id map + and all six Ed25519 signer domains. The Nursery reads its mode-0600 regular file + once; the path is then removed from the child environment. Learner containers + never receive the path or file mount. Seeds never enter the evidence store or a + bundle. ## Examples Prototype defaults require no environment variables: ```powershell -npm run build +pnpm run build ``` -Research-grade mode requires an explicit isolated key directory: +Research-grade Mode R uses Fort file materialization: -```powershell -$env:ALD_DEPLOYMENT_MODE = 'research-grade' -$env:ALD_KEY_DIR = 'C:\ald-secrets\keys' -npm run build +```sh +si fort run --repo agentic-language-development --env dev \ + --keys ALD_RUN_SIGNER_SEEDS_JSON --mode files -- \ + pnpm run test:mode-r-study ``` + +The encrypted value must authorize each exact study run id. The qualification +command can run without credentials using ephemeral in-memory signers, but that +path is explicitly non-confirmatory. Simulated funding removes the wallet/RPC +dependency; it does not remove the persistent per-run signer or registration gates. diff --git a/EXPERIMENT-NOTEBOOK.md b/EXPERIMENT-NOTEBOOK.md index 796781b..dee9c37 100644 --- a/EXPERIMENT-NOTEBOOK.md +++ b/EXPERIMENT-NOTEBOOK.md @@ -2,7 +2,7 @@ > **Document type:** Research protocol and results notebook > -> **Status:** Ready for pre-registration; no experiments completed +> **Status:** Design draft; not registration-ready; no experiments completed > > **Companion documents:** > [CONCEPT-IDEA.md](CONCEPT-IDEA.md) and @@ -61,18 +61,18 @@ permits otherwise. | Field | Value | |---|---| | Study title | Agentic Language Development Nursery Lab | -| Study version | `TBD` | -| Principal investigator | `TBD` | -| Research team | `TBD` | +| Study version | `0.1.81` prospective design snapshot | +| Principal investigator | project-operator role | +| Research team | research-operator, integrity-verifier, and data-steward roles | | Repository | `Ethical-Tech-CoLab/agentic-language-development` | | DTSF version / commit | `TBD` | | Evidence verifier version | `TBD` | -| Base anchor network | Base Sepolia for qualification; Base mainnet for public studies | -| Public anchor address / contract | `TBD` | +| Commitment profile | deterministic simulation of Base Sepolia semantics; no public transaction | +| Public anchor address / contract | Not applicable under `ALD-GOV-2026-09-12-01` | | Study start date | `TBD` | | Study end date | `TBD` | -| Ethics or governance review | `TBD` | -| Data-retention policy | `TBD` | +| Ethics or governance review | `ALD-GOV-2026-09-12-01`: synthetic-only, no human participants or human-coded outcomes | +| Data-retention policy | Eligible research evidence indefinite; development bulk payloads 30 days; immutable metadata retained | ## 5. Default Experimental Invariants @@ -89,9 +89,19 @@ Unless an experiment explicitly varies one of these, hold it constant: [LEDGER-INTEGRITY-DESIGN.md](LEDGER-INTEGRITY-DESIGN.md); - the affect channel is disabled unless explicitly under study; - experiment schedules and seeds are fixed before the run; +- software-qualification, blinded-pilot, confirmatory, and replication seeds use + disjoint SHA-256 domains; paired conditions share only their scenario seed; +- pilot outcomes select only the predeclared seed-count prefix and never enter a + confirmatory or replication estimate; - held-out evaluation runs with learning disabled; - no production secrets or personal data appear in any experiment. +The exact derivation, tuning limit, candidate N grid, ordered reserves, per-condition +allocations, and current zero-spend local resource ceiling are frozen in +[`protocols/seed-and-resource-allocation.v1.json`](protocols/seed-and-resource-allocation.v1.json). +The generated ledger contains design commitments only; it is not evidence that any +pilot or study seed has run. + ## 6. Standard Run Record Copy this block into the experiment's result section for every run. @@ -174,8 +184,8 @@ E01 Channel isolation ---- E02 Observation leakage | ID | Experiment | Depends on | Status | Result | |---|---|---|---|---| -| E00 | Ledger integrity and Base anchoring | None | Not started | — | -| E01 | Channel isolation and side-channel red team | E00 | Not started | — | +| E00 | Ledger integrity and simulated commitment | None | Qualified (software) | V2/v4 failures preserved; v5 passed | +| E01 | Channel isolation and side-channel red team | E00 | In progress | V1 registered topology suite passed; explicit attempt corpus/evidence binding remains | | E02 | Observation and metadata leakage audit | E00 | Not started | — | | E03 | Chance, no-communication, and random-message controls | E01, E02 | Not started | — | | E10 | Frozen pretrained-LLM protocol baseline | E03 | Not started | — | @@ -196,9 +206,9 @@ E01 Channel isolation ---- E02 Observation leakage --- -## E00. Ledger Integrity and Base Anchoring +## E00. Ledger Integrity and Simulated Commitment -**Status:** Not started +**Status:** Completed—v5 prospectively registered software qualification passed **Purpose:** Qualify the evidence system before collecting behavioral data. @@ -207,34 +217,35 @@ pre-registered mutation of a committed ledger or transcript. ### Preparation -- [ ] Implement local hash chains and Ed25519 signatures. -- [ ] Implement ordered Merkle checkpoints. -- [ ] Implement standalone verifier. -- [ ] Configure Base Sepolia anchor wallet. -- [ ] Pre-register checkpoint frequency and finality rule. -- [ ] Seal protocol commit and configuration hashes. +- [x] Implement local hash chains and Ed25519 signatures. +- [x] Implement ordered Merkle checkpoints. +- [x] Implement standalone verifier. +- [x] Bind `anchorClass: "simulated"` and configure the deterministic transport. +- [x] Pre-register checkpoint frequency and finality rule. +- [x] Seal protocol commit and configuration hashes. ### Procedure -- [ ] Create a synthetic run with at least 100 events in each Baby ledger. -- [ ] Produce at least three checkpoints. -- [ ] Anchor each checkpoint to Base Sepolia. -- [ ] Verify the unchanged bundle through an independent RPC provider. -- [ ] Modify one event payload and verify rejection. -- [ ] Delete a middle event and verify rejection. -- [ ] Insert an event and verify rejection. -- [ ] Reorder two events and verify rejection. -- [ ] Replace a signature and verify rejection. -- [ ] Modify a Merkle proof and verify rejection. -- [ ] Present a checkpoint from the wrong chain and verify rejection. -- [ ] Add an unanchored tail and verify that it is reported. -- [ ] Restore from checkpoint and append new events without reusing a sequence number. +- [x] Create a synthetic run with at least 100 events in each Baby ledger. +- [x] Produce at least three checkpoints. +- [x] Commit each checkpoint through the deterministic simulation transport. +- [x] Verify the unchanged bundle through both independent verifier implementations. +- [x] Relabel a simulated receipt as public-chain evidence and verify rejection. +- [x] Modify one event payload and verify rejection. +- [x] Delete a middle event and verify rejection. +- [x] Insert an event and verify rejection. +- [x] Reorder two events and verify rejection. +- [x] Replace a signature and verify rejection. +- [x] Modify a Merkle proof and verify rejection. +- [x] Present a checkpoint from the wrong chain and verify rejection. +- [x] Add an unanchored tail and verify that it is reported. +- [x] Restore from checkpoint and append new events without reusing a sequence number. ### Acceptance Criteria - unchanged evidence passes; - every mutation case fails verification; -- the final checkpoint hash matches Base calldata or the anchor event; +- the final checkpoint hash matches the simulated-chain calldata or anchor event; - restart produces a consistent extension proof; - no private ledger content appears on-chain. @@ -242,23 +253,56 @@ pre-registered mutation of a committed ledger or transcript. | Metric | Planned | Observed | |---|---:|---:| -| Valid bundle accepted | 100% | `TBD` | -| Mutation cases detected | 100% | `TBD` | -| Anchor receipts verified | 100% | `TBD` | -| Private content found on-chain | 0 | `TBD` | - -- [ ] Integrity acceptance criteria met -- [ ] Evidence review complete -- [ ] Base anchor verified -- [ ] Result committed - -**Result summary:** `Not run` +| Valid bundle accepted | 100% | v5: 5/5 accepted by both verifiers | +| Mutation cases detected | 100% | v5: 55/55 rejected by both verifiers | +| Simulated anchor receipts verified | 100% | v5: 5/5 slots anchored with checkpoint-hash payloads | +| Private content found in simulated commitments | 0 | v5: 0/5 slots had a non-hash commitment payload | + +- [x] Integrity acceptance criteria met +- [x] Evidence review complete +- [x] Simulated commitment verified +- [x] Result committed + +**Result summary:** v2 slot 1 stopped at the first dual-verifier disagreement. Slots +2-5 were not attempted and slot 1 will not be rerun into success. The Rust auditor +did not inspect inclusion-proof files; the failed receipt is retained at +`reports/research/e00-integrity-qualification-attempt-1.json`. V3 was superseded +without execution after a source-binding cycle was found. V4 binds the frozen +scientific protocol, records exact implementation identity at execution, and uses a +fresh seed domain before any further attempt. + +The v4 packet is immutable at commit +`60cc54da47fc0ad8b49dfddc418e7839c8d9d409`, and its matching deterministic +in-memory-chain commitment is confirmed before outcome execution. This establishes +prospective local byte binding, not an independently witnessed timestamp or public +chain transaction. + +V4 then completed all five registered slots. Both verifiers accepted all five +unchanged bundles and rejected 50/55 registered mutation instances. The TypeScript +verifier rejected all five simulated-to-public-chain relabels, while the Rust +auditor accepted them. The complete failure receipt is retained at +`reports/research/e00-integrity-qualification-attempt-2.json`, all five unchanged +signed bundles remain under `evidence/qualification/e00-v4/`, and no v4 seed will be +rerun into success. V5 binds the repaired anchor-class comparison and a fresh seed +domain before another attempt. Its immutable packet is registered at commit +`8cef99cb07daf130c38f93968e5ba24845af49e3` and has a matching confirmed +deterministic simulated commitment before execution. + +V5 executed once on exact clean commit +`6a3faa8e85cb9f7a9fe7847445897c6e98ee56cb`. Both independent implementations +accepted all five unchanged bundles and rejected all 55 registered mutation +instances. Each slot contains 100 events in each Baby ledger and four checkpoints; +all commitment payloads are 32-byte hashes. First-party recovery and post-restore +extension tests passed. The receipt is +`reports/research/e00-integrity-qualification-receipt.json`, and the five retained +bundles are under `evidence/qualification/e00-v5/`. This qualifies E00 software only; +it is not evidence for emergent communication or any other behavioral hypothesis. --- ## E01. Channel Isolation and Side-Channel Red Team -**Status:** Not started +**Status:** In progress—v1 registered topology qualification passed; B15 remains **Depends on:** E00 @@ -295,17 +339,33 @@ recorded. | Test category | Attempts | Blocked | Evidence verified | |---|---:|---:|---:| -| Human language | `TBD` | `TBD` | `TBD` | -| Unicode / emoji | `TBD` | `TBD` | `TBD` | -| Timing / retries | `TBD` | `TBD` | `TBD` | -| Tools / storage / network | `TBD` | `TBD` | `TBD` | -| Error behavior | `TBD` | `TBD` | `TBD` | - -- [ ] Isolation criteria met -- [ ] Residual risks documented -- [ ] Result committed - -**Result summary:** `Not run` +| Registered category decisions | 60 | 60 | Receipt-verified | +| Recipient-visible Gateway/transport observations | 500 | 500 within registered bounds | Receipt-verified aggregates | +| Host capability decisions | 25 | 25 | Receipt-verified aggregates | +| Detector-positive checks | 20 | 20 detected | Receipt-verified aggregates | +| Explicit notebook content/path variants | Complete enumerated corpus | Not yet run | B15 open | + +- [ ] Isolation criteria met across the complete notebook corpus +- [x] Residual risks documented +- [x] V1 bounded qualification result committed + +**Result summary:** V1 ran once on exact clean commit +`8d3a9f67257ad8c53bc5438926ff8dc23743ed0d` after repository registration and a +matching deterministic simulated pre-run commitment. Five independently recreated +two-container slots passed all 60 category decisions, 500 Gateway/transport +observations, 25 host-capability decisions, and 20 detector-positive checks with no +external spend or public-chain transaction. The tracked receipt is +`reports/research/e01-isolation-qualification-receipt.json`; five detailed slot +records remain under `evidence/qualification/e01-v1/`. + +The internal review does not mark E01 complete. V1 groups schema-equivalent carrier +content and does not separately exercise every notebook variant, including each +human-language, Unicode, URL/code, whitespace, cache, vector-store, replay-buffer, +and snapshot path. Its host and transport observations are preserved in the receipt +but are not individual signed ledger events. B15 requires a prospective amendment +with the full enumerated corpus and attempt-level verified evidence. V1 therefore +supports only its registered topology/category claim and is not an agent-language +finding. --- @@ -371,6 +431,10 @@ messages are randomized, or receiver access to messages is shuffled. A numerically complete worked registration for this experiment is provided in [RESEARCH.md Appendix D](RESEARCH.md#appendix-d-worked-preregistration-example-e03-controls). +Its D05 amendment uses seed-level primary tests, treats high-rate seeds as mandatory +leakage-review triggers rather than an uncalibrated count rejection, and is backed by +the independent bounded simulation in +[`docs/statistical-validation-and-power.md`](docs/statistical-validation-and-power.md). ### Conditions @@ -680,7 +744,8 @@ compositional reuse. hypotheses predict those effects. **Hypothesis:** Substituting, masking, or reordering a form changes receiver behavior -in the direction predicted by the corresponding ledger. +in the direction predicted by the corresponding ledger, and pre-outcome ledger +predictions add held-out predictive value beyond non-ledger comparators. ### Procedure @@ -691,6 +756,11 @@ in the direction predicted by the corresponding ledger. - [ ] Reorder multi-part messages. - [ ] Replay identical observations with counterfactual messages. - [ ] Compare native ledger predictions with human audit interpretations. +- [ ] Fit transcript-only, task-history, policy-state, random, and majority prediction + baselines without access to private ledger content. +- [ ] Select the strongest eligible baseline using validation data only, then compare + its preregistered proper prediction score with the frozen ledger predictor on + untouched intervention cases. - [ ] Record positive signaling and positive listening separately. - [ ] For the primary H2 contrast, compare ledger-consistent substitutions with shuffled-control messages in scratch-RL/extrinsic/fixed-token runs using a @@ -702,7 +772,8 @@ in the direction predicted by the corresponding ledger. - normal messages outperform constant and shuffled controls; - receiver actions change under message intervention; -- ledger predictions exceed the pre-registered chance baseline; +- pre-outcome ledger predictions improve the registered proper score beyond the + validation-selected non-ledger baseline by the registered practical threshold; - external audit interpretations are labeled separately from agent-native state. ### Results @@ -714,6 +785,7 @@ in the direction predicted by the corresponding ledger. | Positive listening | — | — | — | `TBD` | | Intervention effect size | — | — | — | `TBD` | | Native ledger agreement | — | — | — | `TBD` | +| Ledger proper-score increment | — | — | — | `TBD` | | Human audit-ledger agreement | — | — | — | `TBD` | - [ ] Causal listening demonstrated @@ -759,12 +831,15 @@ but can leak referent information unless strictly constrained. after stratifying by binary success/failure outcome, using a Miller-Madow bias-corrected discrete estimator and within-outcome permutation null. - [ ] For each seed, subtract the mean of 1,000 within-outcome permutations from the - observed Miller-Madow estimate; test whether the seed-bootstrap one-sided 95% - upper bound on this excess CMI is below 0.02 bits. + observed Miller-Madow estimate; test whether the seed-level Student-t one-sided + 95% upper bound on this excess CMI is below 0.02 bits. Report the percentile + seed-bootstrap upper bound as sensitivity analysis only. - [ ] Before registration, simulate the complete estimator at the registered windows-per-seed count and require at least 90% probability that the null-case upper bound falls below 0.02 bits; otherwise increase windows or seeds without widening the bound. +- [ ] Use a blinded pilot to verify that seed-level excess-CMI standard deviation is + at most 0.04 bits; otherwise increase the seed count before unblinding outcomes. - [ ] Red-team the affect channel as a covert alphabet. ### Results diff --git a/LEDGER-INTEGRITY-DESIGN.md b/LEDGER-INTEGRITY-DESIGN.md index 89290a2..8bf780d 100644 --- a/LEDGER-INTEGRITY-DESIGN.md +++ b/LEDGER-INTEGRITY-DESIGN.md @@ -81,7 +81,9 @@ Recommended tables: - `channel_events`; - `checkpoint_manifests`; - `anchor_receipts`; -- `run_metadata`. +- `run_metadata`; +- `analysis_attachments` (canonical analysis bytes plus the descriptor bound to + an `analysis-attached` intervention event). Enable WAL mode and serialize writes through one evidence-writer service. Database permissions and triggers should reject `UPDATE` and `DELETE` operations on event @@ -296,28 +298,34 @@ Create a checkpoint: - at run initialization; - every 64 accepted ledger events or five minutes, whichever occurs first; - immediately before and after a policy checkpoint; +- immediately after a delayed human audit-interpreter batch (`analysis`); - after a BabySitter or human intervention; - when the run is paused; - when the run ends or is aborted. The exact frequency is a protocol parameter recorded before the run. A lower interval -reduces the unanchored rewrite window but increases anchoring cost. +reduces the uncommitted rewrite window but increases commitment and storage overhead. -## 10. Base and L1 Anchoring +## 10. Simulated Commitments and Optional Public Anchoring -### Development +### Approved Research Profile -Use Base Sepolia to test transaction construction, failure recovery, receipt capture, -and verification without spending mainnet funds. +Use the deterministic in-memory transport for development, qualification, pilot, +confirmatory, and replication runs. It exercises transaction construction, failure +recovery, receipt capture, and verification with non-monetary test units. Every run, +pending record, and receipt binds `anchorClass: "simulated"`; the verifier rejects a +class mismatch. These receipts are not public timestamps or economic finality. -### Public Research Runs +### Optional Public-Chain Capability -Use Base mainnet for routine checkpoint anchoring. Only the 32-byte checkpoint hash -and minimal routing metadata should be public. +Base Sepolia and Base mainnet transports remain optional capabilities outside the +approved research profile. Any activation requires a prospective governance and +spending amendment. Only the 32-byte checkpoint hash and minimal routing metadata +should be public. The simplest viable anchor is a zero-value transaction from the project anchor wallet to a designated project address with the checkpoint hash in transaction calldata. -The evidence bundle records: +For either receipt class, the evidence bundle records: - chain ID; - transaction hash; @@ -351,8 +359,8 @@ root and anchor that root to Ethereum L1. For example: - daily during an active study; - before publishing a paper or dataset release. -Base anchoring is sufficient for the initial implementation. L1 anchoring is an -additional trust and archival choice, not a prerequisite for every turn. +Public anchoring is not a prerequisite for the approved campaign. L1 anchoring is an +additional future trust and archival choice. ## 11. Key Management @@ -479,15 +487,16 @@ Failure evidence is part of the research record. - checkpoint consistency proofs; - signed checkpoint manifests. -### Phase 2: Testnet Anchoring +### Phase 2: Simulated Anchoring and Optional Testnet Capability -- Base Sepolia publisher; +- deterministic in-memory publisher and explicitly classified receipts; +- optional Base Sepolia publisher; - receipt capture; - independent RPC verification; - retry and nonce management; - deliberate failure tests. -### Phase 3: Public Anchoring +### Phase 3: Optional Public Anchoring - Base mainnet anchor wallet; - published anchor address or minimal contract; @@ -511,6 +520,7 @@ verifier rejects: - false inclusion proof; - inconsistent checkpoint prefix; - modified run configuration; +- simulated/public receipt-class relabeling; - anchor transaction on the wrong chain; - failed or nonexistent anchor transaction; - unanchored final ledger tail. @@ -520,8 +530,9 @@ checkpoint hash. ## 18. Recommended Initial Decision -Implement Phase 0 and Phase 1 first. Add Base Sepolia anchoring immediately after the -local verifier is stable. Move to Base mainnet only for declared public research runs. +Implement Phase 0 and Phase 1 first. Use deterministic simulated anchoring after the +local verifier is stable. Keep Base Sepolia and Base mainnet disabled unless a later +prospective amendment explicitly authorizes public-chain operation. This design is simple enough for an initial Node.js implementation using SQLite, `node:crypto`, canonical JSON, and a small Base transaction publisher. It provides diff --git a/README.md b/README.md index ab5f619..a6ab2d1 100644 --- a/README.md +++ b/README.md @@ -95,13 +95,118 @@ channel constraints, and reward conditions were used. ## Project Status -This repository is in the **foundation implementation phase**. The npm/TypeScript -workspace, shared runtime schemas, typed configuration loader, secret scan, and three -DTSF-compatible twin-pack skeletons, SQLite WAL evidence schema, RFC 8785 canonical -serialization, and ledger event validators are implemented. The next critical-path -work is independent ledger/channel hash chaining (ALD-008 onward). - -No experiment results are claimed yet. +**Engineering snapshot:** v0.1.97 · 254/258 backlog acceptance criteria verified. + +The repository's locally executable **verifiable core and research-execution +readiness path are complete**. The evidence integrity spine, communication MVP, +Mode R isolation, experiment-readiness scaffolds, and operator preflight are +implemented and tested: + +- `@ald/hashing`, `@ald/merkle`, `@ald/evidence`: domain-separated hashing, RFC 8785 + canonical JSON, per-run Ed25519 signers and key store, hash-chain validation, RFC + 6962 ordered Merkle trees with inclusion and consistency proofs, and the single + atomic SQLite Evidence Writer with fork detection, recovery, bundle export, and + analysis attachments atomically bound to witness-committed intervention events. +- `@ald/checkpoint`, `@ald/anchor`, `@ald/verifier`: signed checkpoint manifests and + proof files, the Base-shaped anchor publisher (deterministic simulation by default; + optional viem public-chain transport outside the approved campaign), and the standalone + `ald-verify` CLI that re-derives + every hash, signature, root, proof, binding, and anchor from a bundle alone. + A separate read-only Rust auditor independently rebuilds canonical event hashes, + signatures, Merkle prefixes, checkpoint chains, receipt bindings, and attachment + hashes; `pnpm run challenge:integrity` compares both implementations against a + fresh real export and deliberate mutations. +- `@ald/lifecycle`, `@ald/scenario`, `@ald/gateway`, `@ald/learners`, + `@ald/orchestrator`, `@ald/analysis`: the SPEC §7 run state machine; deterministic + scenarios, hygiene, and a fail-closed run-registration quarantine gate; fixed-token, glyph, bitmap, canvas, + tone, and six-display affect protocols; no-learning and all four trainable/runtime + tracks; an explicitly selected 16-unit GRU actor-critic with PPO-style updates for + scratch RL; the same 4,049-parameter GRU core with reward-free predictive + cross-entropy for self-supervision; tabular/count models retained as named + qualification controls; frozen-LLM and hybrid adapter conformance foundations; the + carrier analysis module provides grammar-valid bitmap, translation-tolerant canvas, + and graded tone distances plus deterministic held-out nearest-prototype scoring, + explicitly bounded to a handcrafted-distance diagnostic rather than learned + perceptual generalization; the + independently seeded policies and witness-committed initial hashes; + independently verified derived-run lineage against immutable parent exports; + Nursery's end-to-end SPEC §8 turn cycle; a delayed, source-bound human audit + interpreter whose generated entries are separately signed and checkpointed; and + pre-registered analysis primitives, including immutable mark-level leakage + probes across all five carrier conditions with an evidence-preserving claim gate; + and an E16 runtime seam that commits validation-selected baseline and native-ledger + predictions after delivery but before receiver action, checkpoints that chronology, + then source-binds post-outcome scores as per-turn analysis attachments. The immutable + run configuration binds the comparator-selection commitment and native + prediction-function version, limits eligibility to accepted evaluation + deliveries, and includes fail-closed restart checks. +- `@ald/isolation`, `@ald/ops`, `@ald/interventions`, `@ald/redteam`: fixed-frame + process transport, a two-container Mode R network-isolation gate, and learner-host confinement; API telemetry, snapshot/restore, + and failure supervision; configuration-driven, verifier-bound live causal probes + and bounded repair turns, plus held-out, curriculum, drift, and statistical + scaffolds; the committed observation-text and quarantine-bypass corpus; and + an active twelve-category side-channel red-team suite shared by Mode P and Mode R, + plus 28 generated-carrier dimension, metadata, raw-media, sample-rate, compression, + and container attacks through the real Gateway. + These are tested foundations, not a completed production Mode R deployment or + a scientific result. `pnpm run test:mode-r` builds the locked-down learner image, + denies Baby-to-Baby routes on separate internal networks, checks host capability + denial and normalized timing, then kills one learner and verifies the other survives. + `pnpm run test:mode-r-study` additionally drives every learner track through the + actual controller, Gateway, SQLite writer, checkpoint, deterministic simulated commitment, + exporter, and verifier. Its explicit simulated receipt remains software evidence, + not a public-chain transaction or research result. Persistent study signers are + accepted only from `si fort` files-mode materialization mounted into the Nursery; + neither learner receives that mount or its path. +- `@ald/crypto-research`: an E40 research-only, three-role instrumentation harness + that records hash-chained ephemeral scheme changes and Eve recovery attempts over + synthetic messages. A repository lint boundary prevents it from entering the + production hashing or anchoring packages; it never reports cryptographic security. +- DTSF twin packs for `baby-a`, `baby-b`, and `nursery` expose the SPEC §12 routes + with role guards in Prototype Mode; audit-ledger reads return only the separate + generated-analysis stream and reject Baby service identities. + +The qualification harness (`scripts/run-qualification.mjs`) executes E03-style +chance controls and an E11-style naming game through the real pipeline and writes a +report under `reports/qualification/`. Those runs are **non-confirmatory software +qualification in Prototype Mode**: not pre-registered, not anchored, and never +research findings. + +The committed E03 design simulation and seed manifest make the manuscript's +power rule reproducible; the registration compiler produces canonical hashed +artifacts and all primary/reserve run templates; and the research preflight +blocks confirmatory execution until its immutable software, isolation, external +registration, and confirmed pre-run simulated-commitment bindings agree. + +The frozen-LLM operator path also completed a real two-episode qualification +against Qwen3-4B Q4_K_M through Homebrew llama.cpp 0.4.0. The retained +[`reports/qualification/frozen-model-qwen3-4b-q4-k-m.json`](reports/qualification/frozen-model-qwen3-4b-q4-k-m.json) +binds the exact model weights, resolved executable and bottle, software commit, +live server configuration, separate role processes, clean-process replay, +tool-only calls, and frozen-weight/private-memory semantics while omitting prompts +and private output. The 7.5-GiB validation host required non-overlapping role +processes and a 300-second qualification ceiling, so this is bounded software +evidence rather than a production-capacity or empirical finding. + +The E01/E02 software-readiness outputs have a hashed attachment path linked from +the append-only Experiment Record and independently checked against their evidence +event and committed prefix. The approved prospective research profile uses no real +funds or public-chain transactions; those remain optional capability tests outside +the study. Repository-native preregistration and deterministic simulated commitments +are the sole experiment activation path. Hosted enforcement, a restore performed by +a second human, and third-party registration remain optional evidence-strengthening +activities and are never claimed unless actually completed. The retention job is implemented +and covered against real exported bundles and evidence-store rows. + +The [external-prerequisite handoff](docs/external-prerequisite-handoff.md) and its +machine-readable ledger make O01–O06 auditable without recording secrets or personal +identity. The ledger is ready at 1/1 applicable prerequisite: O01 is verified, while +O02-O06 are non-applicable to local synthetic execution under the zero-spend, +repository-native policy. A read-only upstream observation remains as historical +evidence that hosted required-check enforcement is not demonstrated, not as an +experiment blocker. + +No experiment results are claimed. The complete rationale, literature review, experimental ideas, risks, and open decisions are in [CONCEPT-IDEA.md](CONCEPT-IDEA.md). @@ -116,6 +221,15 @@ decisions are in [CONCEPT-IDEA.md](CONCEPT-IDEA.md). | [SPECIFICATION.md](SPECIFICATION.md) | Normative architecture, protocols, schemas, APIs, isolation controls, lifecycle, and acceptance criteria | | [BACKLOG.md](BACKLOG.md) | Milestones, critical path, epics, dependency-ordered stories, readiness gates, and requirement coverage | | [RESEARCH.md](RESEARCH.md) | Pre-results academic manuscript, research questions, methods, literature review, analysis plan, source verification, and arXiv preparation checklist | +| [CONFIGURATION.md](CONFIGURATION.md) | Runtime environment variables, key-store layout, and secret handling | +| [docs/external-prerequisite-handoff.md](docs/external-prerequisite-handoff.md) | Exact privacy-safe evidence and activation checks for O01–O06 | +| [docs/simulation-funded-research-profile.md](docs/simulation-funded-research-profile.md) | Approved zero-spend campaign boundary and the distinction between simulated funds and real observations | +| [docs/evidence-bundle-format.md](docs/evidence-bundle-format.md) | Byte-level evidence bundle contract shared by the exporter, checkpoint service, and verifier | +| [docs/cryptographic-separation-policy.md](docs/cryptographic-separation-policy.md) | Mandatory boundary between E40 research encodings and production hashing/signing/anchoring | +| [reports/README.md](reports/README.md) | What the qualification reports are and are not | +| [Research validation report](reports/research/research-validation-report.md) | Living evidence-class, validation, experiment, and claim-readiness report | +| [Source-lineage register](reports/research/source-lineage-register.md) | Requirement origins and later normative amendments without personal attribution | +| [Requirement conformance matrix](docs/requirement-conformance-matrix.md) | Provisional mapping from every backlog criterion and normative MUST-bearing line to executable evidence | ## Research Book @@ -131,7 +245,7 @@ generated PDF as a download. Regenerate the book after changing the manuscript: ```powershell -npm run book:research +pnpm run book:research ``` The page rasterizer requires Node.js 22.13 or newer; generated book assets do not @@ -143,6 +257,46 @@ are committed so GitHub Pages needs no server, CDN, or runtime PDF renderer. The notebook is ready for pre-registration. No experiment results are claimed yet. +## Running the Platform Locally + +```bash +pnpm install --frozen-lockfile +pnpm run check +``` + +`pnpm run check` runs every source/contract/boundary/readiness/status lint, builds +every workspace, runs the complete test suite, scans for committed secrets, and +blocks on high/critical dependency advisories. + +Reproduce the E03 design inputs, compile the default 75-primary/8-reserve +registration, and run the fail-closed preflight with: + +```bash +pnpm run design:e03 +pnpm run registration:e03 --out evidence/preregistration/e03-v1-draft.json +pnpm run preflight:research \ + --registration evidence/preregistration/e03-v1-draft.json \ + --binding /absolute/path/repository-registration-and-commitment-binding.json +``` + +The preflight fails until the packet exists unchanged in an ancestral Git commit and +a confirmed matching pre-run simulated commitment is supplied in the binding. + +Run the Prototype Mode qualification harness and verify a bundle independently: + +```bash +pnpm run build && node scripts/run-qualification.mjs +``` + +```bash +node packages/verifier/bin/ald-verify.js evidence/qualification//bundles/runs/ --allow-unanchored +``` + +Databases and bundles are written under `evidence/`, which is ignored by git; reports +are written under `reports/qualification/`. The approved profile needs no funded +wallet: runs use the deterministic in-memory transport, carry +`anchorClass: "simulated"`, and fail verification if that class is altered. + ## Responsible Research All learned-cipher experiments should use synthetic, non-sensitive messages. Novel or diff --git a/RESEARCH.md b/RESEARCH.md index 221f64e..fd1a236 100644 --- a/RESEARCH.md +++ b/RESEARCH.md @@ -8,13 +8,15 @@ > > **Prepared:** September 2, 2026 > +> **Engineering snapshot:** v0.1.97 · 254/258 backlog acceptance criteria verified. +> > **Proposed arXiv category:** `cs.MA` (primary), with possible cross-listing to > `cs.AI` and `cs.CL` > -> **Authors:** Ethical Tech CoLab; individual author order, affiliations, ORCID -> identifiers, and corresponding author to be finalized before submission +> **Authors:** Anonymous working draft; author order, affiliations, ORCID identifiers, +> and corresponding author are omitted until the authorized submission stage > -> **Repository:** +> **Artifact:** Anonymous review artifact to be prepared only after release approval > > **Empirical status:** No Nursery Lab experiment has been reported as completed in > this manuscript. Sections 10 and 11 distinguish implemented infrastructure from @@ -39,9 +41,11 @@ reinforcement learning, intrinsic-motivation learning, self-supervised learning, hybrid models, and no-learning controls under common scenario, channel, evidence, and evaluation interfaces. Each agent is specified to maintain an independent chronological ledger of its own intentions and interpretations. The framework specifies append-only, hash-chained, -signed ledger and channel records, ordered Merkle checkpoints, and periodic -public-chain checkpoint anchoring. These mechanisms are not yet fully implemented and -do not prove that an agent's interpretation is truthful; they are designed to preserve +signed ledger and channel records, ordered Merkle checkpoints, and deterministic +simulated commitments. The local integrity, checkpoint, verifier, and commitment +paths are implemented and tested under a zero-external-spend profile. Optional +public-chain transport remains outside the approved campaign and its claims. These +mechanisms do not prove that an agent's interpretation is truthful; they are designed to preserve what was recorded and make later alteration detectable. Causal message interventions, held-out generalization, partner replacement, leakage tests, and control-channel conditions are therefore required in addition to task success. @@ -72,7 +76,10 @@ Assistance in Formulating Research Questions* guidance [47]: - AI-suggested connections are treated as exploratory until supported; - the influence and limitations of AI assistance are disclosed; - source verification state and journal credibility are recorded in Appendix A; -- a separate critical-review pass is required before publication. +- a separate critical-review pass is required before publication; the current + internal pass is recorded in + [reports/research/research-critical-review.md](reports/research/research-critical-review.md) + and does not count as independent review. In this draft, source retrieval and the first verification pass were performed by an AI assistant using web, scholarly-index, and metadata tools, followed by separate AI @@ -227,10 +234,10 @@ pre-registrations. The following hypotheses define the current direction: | H1 | Normal communication will outperform disabled, constant, random, and shuffled controls on held-out tasks. | Control performance equals or exceeds normal communication after correction. | | H2 | In E16's scratch-RL, extrinsic-reward, fixed-token condition, a ledger-consistent substitution will increase the probability of the ledger-predicted receiver action relative to shuffled-control messages. | The hierarchical substitution-versus-shuffle contrast is zero or negative. | | H3 | At equal model capacity, training episodes, and update/compute budget, the planned 32-symbol/4-token condition will produce higher held-out compositional generalization than the 128-symbol/8-token condition. | The planned contrast is zero or favors the higher-bandwidth condition. | -| H4 | Ledger-predicted intervention directions will exceed a pre-registered chance baseline. | Ledger agreement is at chance or fails out-of-sample. | +| H4 | On untouched intervention cases, pre-outcome ledger predictions will improve a pre-registered proper prediction score beyond the strongest eligible transcript-only, task-history, policy-state, random, and majority baseline. | The ledger fails to exceed the strongest eligible non-ledger baseline by the registered practical threshold. | | H5 | A blank bounded carrier will support repeated forms, but will converge more slowly than a fixed symbol inventory. | No stable forms emerge, or blank-carrier convergence is not slower. | | H6a | The declared six-display affect condition will reduce median turns to successful repair relative to no affect. | Repair time is equal or longer under affect. | -| H6b | Under fixed windows and cardinality, permutation-calibrated excess conditional mutual information between affect and referent will remain below the pre-registered 0.02-bit practical-leakage bound. | The seed-bootstrap upper-bound test cannot rule out excess leakage of 0.02 bits or more. | +| H6b | Under fixed windows and cardinality, permutation-calibrated excess conditional mutual information between affect and referent will remain below the pre-registered 0.02-bit practical-leakage bound. | The seed-level one-sided Student-t upper-bound test cannot rule out excess leakage of 0.02 bits or more. | | H7 | Fixed dyads will show greater partner-replacement degradation than learners trained with pre-registered partner variation. | Degradation for fixed dyads is no greater than degradation after partner-varied training. | | H8 | Partially conflicting utility will reduce message informativeness and increase strategic ambiguity relative to aligned utility. | Informativeness and ambiguity do not change in the predicted direction. | @@ -658,6 +665,23 @@ Researcher ground truth may contain semantic labels; learner observations may no Observations use opaque numeric arrays, pixels, or separately qualified hybrid features. Images containing OCR-detectable text are quarantined before a run. +The initial numeric referential design contains 16 two-attribute combinations. The +four equal-attribute diagonal combinations are the locked compositional test targets; +the remaining 12 combinations are the shared semantic support for training and +in-distribution validation. Training and validation use independent PRNG domains, +and no held-out type may occur in either split as a target or distractor. Validation +is learning-off and may be used for tuning and baseline selection. The held-out split +is learning-off and is accessed once after those choices freeze. The broader +`evaluation` split remains an engineering and software-qualification surface and is +not interchangeable with the confirmatory held-out test. + +The versioned split, duplicate rules, matched budget dimensions, and boundaries +between within-architecture causal contrasts and cross-architecture descriptive +benchmarks are frozen in +[`protocols/scenario-split-and-model-comparison.v1.json`](protocols/scenario-split-and-model-comparison.v1.json) +and explained in +[`docs/scenario-splits-and-model-comparisons.md`](docs/scenario-splits-and-model-comparisons.md). + ### 6.5 Communication Conditions For the baseline fixed-token carrier, each run declares a random inventory such as @@ -754,7 +778,7 @@ criteria are satisfied. 1. held-out task success relative to E03 controls; 2. positive listening under message intervention; -3. ledger-predicted intervention agreement; +3. ledger incremental predictive value on untouched interventions; 4. held-out compositional generalization. **Secondary outcomes** @@ -782,7 +806,12 @@ distributions to change while the receiver observation is held fixed. **Ledger agreement:** the direction of an observed intervention effect matches the direction predicted from the learner's ledger before intervention outcomes are -revealed. +revealed. This is a descriptive calibration measure, not H4's confirmatory estimand. + +**Ledger incremental value:** held-out improvement in a pre-registered proper +prediction score over the strongest eligible transcript-only, task-history, +policy-state, random, and majority baseline. Predictions are committed before the +intervention outcome is generated. **Compositional generalization:** reusable subparts support above-control success on pre-registered unseen combinations with learning disabled. @@ -796,15 +825,20 @@ equal-weight eight-partner-training degradation. ### 7.3 Statistical Plan -The final analysis plan will be frozen before confirmatory runs. - -- Alpha is `0.05` for each experiment's primary family. -- Holm-Bonferroni correction is applied across primary metrics within an experiment. -- The confirmatory study family is restricted to H1-H8, including H6a and H6b. - Hierarchical gatekeeping tests qualification first, then core emergence, then later - affect/transfer/negotiation hypotheses; a blocked family is reported descriptively. - Exploratory analyses use false-discovery-rate reporting and remain labeled - exploratory. +The common analysis and multiplicity rules are frozen in +[`protocols/statistical-analysis-and-power.v1.json`](protocols/statistical-analysis-and-power.v1.json). +Experiment-specific raw-scale practical margins and seed allocations will be frozen +after disjoint pilots and before confirmatory runs. + +- The confirmatory study family contains nine members: H1, H2, H3, H4, H5, H6a, + H6b, H7, and H8. One pre-registered p-value is formed per member and Holm correction + is applied globally at family-wise alpha 0.05. Multi-component directional members + use the maximum component p-value and require every registered direction and + practical threshold. +- E00-E03 and experiment-specific checks are validity gates, not sources of reusable + alpha. A failed or missing gate produces `not-tested`, not a favorable or + unfavorable p-value. Exploratory analyses use false-discovery-rate reporting and + remain labeled exploratory. - Effect sizes and confidence or credible intervals are reported with every significance test. - Binary task outcomes are modeled at the run/seed level, with episodes treated as @@ -827,12 +861,50 @@ Before empirical submission, simulation-based power analysis will determine the required number of independent seeds for each primary contrast; the larger value governs. +Independent base-R validation agrees with the production special functions and +seed-level tests, demonstrates calibrated boundary behavior, and shows why pooled +episode inference is prohibited. The complete E03 numeric decision has a lower 95% +Monte Carlo power bound above 0.90 in all four planned variance rows. A nine-member +Holm sensitivity shows that 75 seeds is not generally sufficient: at standardized +effect 0.40, all-member power is 0.6752 at N=75 and 0.9068 at N=100. These values are +design evidence rather than universal sample sizes; D07 applies the registered +member-specific rule. Full methods and Monte Carlo intervals are in +[`docs/statistical-validation-and-power.md`](docs/statistical-validation-and-power.md). + Claims that a control is "at chance" or that leakage is absent use equivalence or upper-bound tests, not failure to reject a difference. Each pre-registration must state the smallest effect of interest, the equivalence margin, and power to rule out that margin. The worked E03 design in Appendix D uses a +/-0.05 success-rate margin; H6b uses a 0.02-bit conditional-mutual-information bound. +The outcome-blind causal-ledger and leakage rules are frozen in +[`protocols/causal-ledger-and-leakage.v1.json`](protocols/causal-ledger-and-leakage.v1.json). +For E02, at least 200 untouched test rows are required and the one-sided 95% +Wilson upper bound on linear-probe accuracy advantage over the held-out majority +baseline must be at most 0.10. The identical probe and split must detect an injected +one-hot target feature with a lower advantage bound of at least 0.20. For E20, 75 +eligible seeds and 1,000 windows per seed are required before a decision; the primary +bound is seed-level Student-t and the percentile bootstrap is sensitivity-only. +Allowed and forbidden information sets and detector-positive controls are explicit +for E01, E02, E13, and E20. Intended normalized carrier form is communication, not a +side channel. + +Seed and resource allocation is frozen in +[`protocols/seed-and-resource-allocation.v1.json`](protocols/seed-and-resource-allocation.v1.json). +Qualification, blinded-pilot, confirmatory, and replication stages use disjoint +SHA-256 domains; paired conditions share scenario seeds but not learner, Gateway, or +analysis seeds. A 20-slot blinded pilot selects the smallest shared candidate N whose +complete nine-member Holm-family simulation has a lower 95% Monte Carlo power bound +of at least 0.90. N=100 is a planning value, not a selected result. + +A fresh 260-turn recurrent-carrier benchmark took 37.58 seconds and wrote 25.53 MB. +At that measured uncompressed rate, maximum materialized pools including replication +project to 9,171 bundles, 9.36 million turns, 855.9 GiB, and 375.7 single-core hours. +The present local ceiling is 72 CPU-hours, 25 GiB, one frozen-model process, and zero +external spend. It supports bounded qualification and pilots, not the full campaign; +confirmatory registration requires an exact selected prefix and an approved execution +resource plan. + Run exclusions are limited to pre-specified integrity or protocol failures. Each condition receives a fixed ordered list of primary and reserve seeds before outcomes are observed. An invalid primary seed may be replaced only by the next reserve seed; @@ -863,17 +935,21 @@ Qualitative analysis is secondary and will examine: - abrupt convention changes; - negotiation-era semantic shifts. -Human coders will use a pre-registered codebook and, where feasible, blinded -independent coding. Generated audit interpretations will not be treated as ground -truth. +The approved initial campaign uses no human-coded outcome. Qualitative labels are +generated by registered deterministic analysis and are not treated as ground truth. +Any later human-coding proposal requires a prospective protocol and governance +amendment before collection. --- ## 8. Evidence Integrity and Reproducibility -> **Design status:** This section describes required evidence behavior. Hash-chain -> writing, signatures, Merkle checkpoints, anchoring, and the independent verifier are -> not yet complete; see Section 10. +> **Implementation status:** Local hash-chain writing, domain-separated signatures, +> ordered-Merkle checkpoints and proofs, anchor publisher transports, evidence export, +> and the standalone verifier are implemented and covered by software tests. The +> approved campaign uses deterministic simulated receipts and no real funds; no +> public-chain receipt is claimed, and the historical research +> corpus remains unregistered and unanchored; see Section 10. ### 8.1 Append-Only Evidence @@ -893,15 +969,18 @@ Ordered Merkle trees are specified to provide: Signed checkpoint manifests will bind the event roots to run configuration, learner contract, software commit, and previous checkpoint. -### 8.3 External Anchoring +### 8.3 Simulated Commitments and Optional External Anchoring -Development qualification is specified to use Base Sepolia. Declared public studies may anchor -checkpoint hashes to Base mainnet. Only hashes and minimal routing metadata are -on-chain. Private observations, messages, ledgers, prompts, identities, and keys -remain off-chain. +The prospective campaign uses a deterministic in-memory transport, a non-monetary +simulated balance, and receipts labeled `anchorClass: "simulated"`. Base Sepolia and +Base mainnet remain optional software capabilities outside the approved study. A +future public-chain activation would require a new prospective amendment; only hashes +and minimal routing metadata could be placed on-chain. -The anchor proves that a committed prefix existed no later than a chain block and -that disclosed content matches the commitment. It does not prove: +The simulated commitment proves that the executed bundle consumed a specific +committed prefix and that disclosed content matches it. Together with the external +registration timestamp and immutable Git commit, it preserves prospective order. It +does not prove public availability, decentralized persistence, economic finality, or: - an event was true; - no event was omitted before commitment; @@ -925,7 +1004,7 @@ The standalone verifier is specified to check: - cross-bindings among intention, message, delivery, interpretation, and outcome; - Merkle roots, inclusion proofs, and consistency proofs; - checkpoint chains and witness signatures; -- public-chain transaction, chain ID, block inclusion, and finality; +- receipt class, transaction-shaped identifier, chain identity, block inclusion, and finality semantics; - forks, gaps, and unanchored tails. Any integrity failure prevents a run from receiving a valid disposition. @@ -933,8 +1012,9 @@ Any integrity failure prevents a run from receiving a valid disposition. Here, **independent verifier** means software that recomputes evidence from an exported bundle without trusting the live runtime, database, or private keys. For a publication-facing claim, at least one verification execution must additionally be -performed by a person who did not operate the original run, using an independently -configured chain RPC. External replication remains stronger than either form. +performed by a person who did not operate the original run, using the independent +offline verifier. An independent RPC is additionally required only for a future +public-chain receipt. External replication remains stronger than either form. ### 8.5 Replay @@ -1014,30 +1094,84 @@ as separate fields. ## 10. Current Implementation Status -As of September 2, 2026, the repository records ALD-001 through ALD-007 as complete. -This is engineering status, not an empirical result. +**Engineering snapshot:** v0.1.97 · 254/258 backlog acceptance criteria verified. + +As of September 12, 2026, 82 of 86 backlog items satisfy all of their acceptance +criteria. This is engineering status, not an empirical result. Implemented: -- npm/TypeScript workspaces; -- runtime-validated shared schemas; -- typed configuration and secret scanning; -- DTSF-compatible Learner A, Learner B, and Nursery twin-pack scaffolds; -- SQLite WAL evidence schema and append-only triggers; -- RFC 8785 canonical serialization; -- ledger event-type validators; -- automated lint, build, test, dependency-audit, and clean-clone checks. - -Not yet implemented or empirically executed: - -- full hash-chain writing and signatures; -- atomic evidence writer; -- Merkle checkpoints and independent verifier; -- Base anchoring; -- complete gateway and scenario engine; -- model adapters and training; -- Research-Grade isolation; -- experiments E00-E50. +- hash-chained, signed SQLite evidence; ordered Merkle checkpoints; exact bundle + export; and an independent verifier; +- a frozen local audit-cost protocol and receipt covering five immutable + qualification bundles, with ordinary-log, signed-stream, integrity-structure, + policy-state, and complete-bundle storage separated; descriptive offline verifier + timing and the prior six-case mutation challenge are reported without claiming a + public anchor or an empirical result; +- an E16 comparator pipeline that fits all five eligible non-ledger predictors on a + validation-fit fold, selects by Brier score on a disjoint validation-selection + fold, refits and hash-locks the selected model, commits baseline and native-ledger + predictions before accepting test outcomes, and keeps the oracle diagnostic + ineligible for selection; exact synthetic qualification passed five comparator and + seven negative-control paths; the production runtime now invokes an injected + outcome-blind native predictor after Gateway delivery and before receiver action, + commits and checkpoints baseline/native predictions, derives the receiver choice + from its hash-bound turn record, and binds per-turn post-outcome scores; the hashed + run configuration commits the selection and prediction-function identities and + accepted-evaluation-delivery eligibility, and recovery refuses an absent or + mismatched provider; exact detached qualification passed 10 focused tests and the + full 1,808-test repository gate; +- a general fail-closed registration-packet compiler requiring eleven exact binding + classes; the current all-experiment inventory resolves only the 19 protocol cards, + leaves 170 experiment-specific bindings open, and emits E00 and E01 registration hashes; +- grammar-valid bitmap, translation-tolerant canvas, and graded tone distances with + deterministic nearest-prototype evaluation; this is a handcrafted E13 diagnostic, + not evidence that a learner learned the metric or generalized to novel forms; an + exact clean-candidate qualification covers three carriers and six rejection paths; +- a Base-shaped commitment client with confirmation/recovery handling, explicit + simulated/public receipt classification, and mainnet gating; the verifier rejects + a simulated receipt relabeled as public-chain evidence; +- an authenticated Fort boundary that stores the project signer envelope and unused + optional public-chain credentials only as encrypted Safe material; a four-track + files-mode Mode R run sealed and production-verified every bounded qualification + bundle; the zero-balance public address remains inactive and is not needed by the + simulation-funded campaign; +- deterministic scenarios, the tool-only Gateway, all declared carrier controls, + five learner tracks, DTSF twin routes, and the complete turn lifecycle; +- process/container Mode R isolation, training separation, active side-channel and + observation red teams, including a reusable 28-case generated-carrier structural + side-feature audit with accepted controls and fail-closed evidence readiness; the + exact candidate also passed the current two-container Mode R timing, fixed-envelope, + error-shape, host-denial, crash-survival, and three-track update checks; plus + telemetry, retention, snapshot/restore, and failure policy; +- readiness gates for E00-E50, reproducible E03 design and seed artifacts, canonical + E03 registration compilation, and a fail-closed confirmatory preflight; +- a privacy-minimized real-model software qualification of Qwen3-4B Q4_K_M through + llama.cpp, with exact model/runtime hashes and both role directions exercised; and +- automated lint, build, test, dependency-audit, secret-scan, acceptance-coverage, + API-documentation, project-status, clean-clone, hosted CI, and Mode R checks. + +Not yet empirically executed or independently evidenced: + +- final-topology/pilot resource qualification; and +- confirmatory experiments E00-E50 or any scientific results. + +The external-dependency ledger is ready at 1/1 applicable item: O01 is verified by +the scoped synthetic-only governance decision, while O02-O06 are non-applicable to +local execution. Public funding, public-chain transactions, hosted enforcement, +second-person restoration, and third-party registration remain optional claim- +strengthening activities. Their absence is disclosed and prohibits claims of public +finality, hosted enforcement, independent restoration, or third-party registration; +it does not answer or alter the agent-language estimands. + +The frozen local [data/claim inventory](docs/data-and-claim-inventory.md) resolves 125 +exported bundles across 19 collections by path and content hash. Forty-one have +tracked bounded software-qualification support, 33 are historical qualification +exports, and 51 are failed or superseded diagnostics. All 125 are excluded from +empirical estimates. Seventy-seven recorded anchor confirmations are historical local +fake-chain receipts; prospective receipts now bind an explicit class that the +verifier refuses to relabel. Confirmed public-chain anchors, pilot bundles, confirmatory +bundles, replication bundles, and research-included bundles are all zero. The implementation backlog is maintained in [BACKLOG.md](BACKLOG.md), while normative requirements are in [SPECIFICATION.md](SPECIFICATION.md). @@ -1141,11 +1275,10 @@ database search with inclusion/exclusion criteria and dual-review screening. ### 14.1 Code -Source code and planning artifacts are publicly available at: - - +Source code and planning artifacts exist in the working repository. An anonymous +review artifact and any public release URL will be prepared only after release +approval. Every empirical paper revision will identify an immutable source commit. -Every empirical paper revision will identify the exact Git commit used. ### 14.2 Protocols @@ -1154,6 +1287,8 @@ Every empirical paper revision will identify the exact Git commit used. - Ledger integrity: [LEDGER-INTEGRITY-DESIGN.md](LEDGER-INTEGRITY-DESIGN.md) - Experiment protocols and results notebook: [EXPERIMENT-NOTEBOOK.md](EXPERIMENT-NOTEBOOK.md) +- Frozen question, estimand, and outcome-class cards: + [docs/research-protocol-cards.md](docs/research-protocol-cards.md) - Engineering plan: [BACKLOG.md](BACKLOG.md) ### 14.3 Data @@ -1170,12 +1305,14 @@ and analysis plan before execution. The run configuration stores the protocol co and pre-registration hash. Changes create appended amendments rather than rewriting the original record. -No external registration record exists yet. Before the first confirmatory run, the -team intends to create a dated OSF registration and anchor the same canonical -`preRegistrationHash` before the run enters `running`. A Git commit in an -author-controlled repository is retained as a development record but is not, by -itself, treated as third-party preregistration. Appendix D supplies a numerically -complete worked E03 registration for review. +No complete experiment packet has yet been repository-registered. Before each +confirmatory run, its canonical packet will be committed unchanged in a Git ancestor +of the execution commit. Preflight will load those historical bytes, reproduce the +`preRegistrationHash`, and require a matching deterministic simulated commitment +before the run enters `running`. This establishes exact local identity, ancestry, +ordering against later committed evidence, and tamper detection; it is not described +as third-party registration or an independent timestamp. Appendix D supplies a +numerically complete worked E03 registration for review. --- @@ -1222,11 +1359,10 @@ Institutional and technical acknowledgements: **TBD before submission**. ### AI-Assistance Disclosure -This working manuscript was developed with AI assistance in VS Code through the -Copilot SDK. The AI assistant performed the initial source retrieval, opened the -arXiv/ACL/PMLR/Crossref/publisher/standards pages summarized in Appendix A, organized -the manuscript, summarized relevance, and conducted consistency checks. A separate AI -research-agent pass challenged source status and identified overclaiming risks. +This working manuscript was developed with AI-assisted research and repository tools. +The tools supported source retrieval, primary-page inspection, manuscript organization, +code implementation, executable validation, and critical consistency checks. Separate +AI-assisted passes challenged source status, numerical claims, and overclaiming risks. AI systems are not authors and bear no responsibility for the manuscript. Human authors remain responsible for every claim, citation, analysis, and conclusion. @@ -1245,9 +1381,13 @@ were attempted on September 2, 2026. The API reported the account disabled and t reported its keyless monthly limit reached. No new Tavily-generated result was relied upon. Rather than conceal the failure or imply that Tavily returned evidence, the review continued through arXiv Atom metadata, ACL Anthology, PMLR, Crossref, JMLR, -publisher and university pages, RFC Editor, NIST, Google Books, and Ethical Tech CoLab -repositories. A final submission should rerun the search after Tavily access is -restored and record whether it changes the included corpus. +publisher and university pages, RFC Editor, NIST, Google Books, and institutional +repositories. A bounded update was completed on September 11, 2026 across primary +repository and publisher surfaces. Its queries, screening boundaries, all 50 source +decisions, and five newer comparators are recorded in the +[source verification register](reports/research/source-verification-register.md). +This was not a systematic review. A final submission must repeat the search through +the manuscript-freeze date and record whether it changes the included corpus. All retrieved material was treated as untrusted evidence, not as instruction. @@ -1364,7 +1504,7 @@ Conference on Learning Representations*. [26] Galke, L., & Raviv, L. (2024). Learning and communication pressures in neural networks: Lessons from emergent communication. *Language Development -Research, 5*(1), 116-143. +Research, 5*(1), 116-140. [27] Kouwenhoven, T., Peeperkorn, M., & Verhoef, T. (2025). Searching for structure: Investigating emergent communication with large language models. @@ -1511,7 +1651,7 @@ before submission. | [12] | **tool-metadata-verified**, arXiv primary page | Peer-reviewed NeurIPS paper; journal score N/A | "Biases for Emergent Communication in Multi-agent Reinforcement Learning." Shows designed biases can promote communication. | | [13] | **tool-abstract-verified**, arXiv primary page/API | Peer-reviewed AAMAS paper; journal score N/A | "Capacity, Bandwidth, and Compositionality in Emergent Language Learning." Supports capacity/bandwidth controls. | | [14] | **tool-metadata-verified**, ACL Anthology | Peer-reviewed ACL paper; journal score N/A | "Compositionality and Generalization In Emergent Languages." Supports separate behavioral generalization tests. | -| [15] | **tool-abstract-verified**, arXiv primary page/API | Peer-reviewed NeurIPS paper; journal score N/A | "Interpretable agent communication from scratch (with a generic visual processor emerging on the side)." Supports causal symbol interventions. | +| [15] | **tool-full-text-verified**, complete paper | Peer-reviewed NeurIPS paper; journal score N/A | Supports unsupervised learned discrete communication, partial interpretability, and novel-class generalization in its evaluated setting; it does not supply causal symbol-intervention evidence. | | [16] | **tool-full-text-verified**, PMC full text and Crossref | Peer-reviewed PNAS journal article, **5/5** | "Words categorize the semantic fields they refer to in ways that maximize communication accuracy while minimizing complexity." Supports discrete-channel efficiency in the tested color domain. | | [17] | **tool-abstract-verified**, arXiv primary page/API | Peer-reviewed ICLR paper; journal score N/A | "Compositional Languages Emerge in a Neural Iterated Learning Model." Supports transmission bottlenecks. | | [18] | **tool-metadata-verified**, Crossref DOI metadata | Peer-reviewed IEEE journal article, **4/5** | "Spontaneous evolution of linguistic structure: an iterated learning model of the emergence of regularity and irregularity." Establishes computational iterated learning. | @@ -1522,8 +1662,8 @@ before submission. | [23] | **tool-metadata-verified**, ACL Anthology | Peer-reviewed EMNLP paper; journal score N/A | "Emergent Linguistic Phenomena in Multi-Agent Communication Games." Supports community contact and protocol convergence. | | [24] | **tool-abstract-verified**, arXiv primary page/API and venue comment | Peer-reviewed NeurIPS paper; journal score N/A | "Emergent Communication: Generalization and Overfitting in Lewis Games." Used as supporting, not sole, evidence. | | [25] | **tool-metadata-verified**, arXiv primary page | Peer-reviewed ICLR paper; journal score N/A | "On the role of population heterogeneity in emergent communication." Supports heterogeneity effects. | -| [26] | **tool-metadata-verified**, official journal page | Peer-reviewed journal article, **3/5** | "Learning and communication pressures in neural networks: Lessons from emergent communication." A field review with a newer/variable-impact venue. | -| [27] | **tool-metadata-verified**, ACL Anthology | Peer-reviewed COLING paper; journal score N/A | "Searching for Structure: Investigating Emergent Communication with Large Language Models." Direct pretrained-LLM comparison. | +| [26] | **tool-full-text-verified**, complete official paper | Peer-reviewed journal article, **3/5** | Reviews communicative success, production effort, learnability, and other pressures; official pagination is 116–140. | +| [27] | **tool-full-text-verified**, complete ACL Anthology paper | Peer-reviewed COLING paper; journal score N/A | Directly studies artificial-language learning and transmission with pretrained language models. | | [28] | **tool-abstract-verified**, arXiv page and TMLR record metadata | Peer-reviewed TMLR journal article, **4/5** | "A Review of the Applications of Deep Learning-Based Emergent Communication." Used as a field synthesis, not primary experiment. | | [29] | **tool-abstract-verified**, arXiv primary page/API | Peer-reviewed ICLR paper; journal score N/A | "Emergent Communication through Negotiation." Supports incentive-sensitive communication. | | [30] | **tool-metadata-verified**, Crossref/JSTOR metadata; full page blocked | Peer-reviewed Econometrica journal article, **5/5** | "Strategic Information Transmission." Supplies cheap-talk theory. | @@ -1545,8 +1685,8 @@ before submission. | [46] | **tool-full-text-verified**, CoLab publication page | Institutional report, not peer-reviewed; **1/5** under the fixed journal rubric | "What Is Ethical AI?" Used for CoLab governance commitments, not independent empirical evidence. | | [47] | **tool-full-text-verified**, CoLab publication and source repository | Institutional research guidance, not peer-reviewed; **1/5** | "AI-Powered Assistance in Formulating Research Questions." Supplies the source-verification and AI-disclosure method. | | [48] | **tool-metadata-verified**, arXiv primary page | Preprint, not peer-reviewed; journal score N/A | "Learning to Protect Communications with Adversarial Neural Cryptography." Motivates an exploratory synthetic-message experiment only. | -| [49] | **tool-metadata-verified**, ACL Anthology | Peer-reviewed EMNLP demo paper; journal score N/A | "EGG: a toolkit for research on Emergence of lanGuage in Games." Closest infrastructure comparator. | -| [50] | **tool-abstract-verified**, PMLR primary page | Peer-reviewed ICML paper; journal score N/A | "\"Other-Play\" for Zero-Shot Coordination." Supports novel-partner evaluation and specialized-convention risk. | +| [49] | **tool-full-text-verified**, complete ACL Anthology paper | Peer-reviewed EMNLP demo paper; journal score N/A | Closest infrastructure comparator: modular games, channels, optimization, analysis, and checkpoints. | +| [50] | **tool-full-text-verified**, complete PMLR paper | Peer-reviewed ICML paper; journal score N/A | Supports independently trained novel-partner evaluation and specialized-convention risk. | No source in the table was silently upgraded from preprint or workshop status to a peer-reviewed main-track result. @@ -1607,9 +1747,9 @@ not assigned a journal score because the fixed rubric is explicitly journal-base - Registration target: OSF Registries. - The canonical registration JSON, this manuscript commit, analysis-script commit, and generated seed manifest will be registered before execution. -- The same `preRegistrationHash` will be anchored to Base Sepolia before any run - enters `running`. -- No outcome will be inspected before registration and anchoring complete. +- The same `preRegistrationHash` will be committed through the deterministic + simulation transport before any run enters `running`. +- No outcome will be inspected before registration and commitment complete. ### D.2 Objective @@ -1678,43 +1818,49 @@ The unit of analysis is the run/seed success proportion across 200 episodes. normal no-learning, perform two one-sided one-sample tests on seed-level success proportions against equivalence bounds 0.20 and 0.30. Equivalence requires both one-sided tests to reject at the Holm-adjusted alpha. -2. **Oracle adequacy:** The lower bound of the two-sided 95% bootstrap confidence - interval for mean seed-level oracle success must exceed 0.90. +2. **Oracle adequacy:** A one-sided seed-level t test and its 95% lower confidence + bound must exceed 0.90. A two-sided 95% seed-bootstrap interval is reported as a + sensitivity estimate. 3. **Oracle separation:** For each non-oracle condition, compute paired seed-level - oracle-minus-control differences. The lower bound of the Holm-adjusted 95% - confidence interval must exceed 0.60. + oracle-minus-control differences. One-sided t-test p-values above 0.60 receive + Holm correction across the five conditions. Conservative 99% Bonferroni lower + bounds and nominal paired seed-bootstrap intervals are reported. No episode is analyzed as an independent run. ### D.7 Sensitivity and Power Before final registration, a separate outcome-blind-for-confirmatory-use pilot of 20 -seeds per non-oracle condition will estimate the largest between-seed standard -deviation. Pilot runs will not enter confirmatory estimates. The registered primary +seeds per non-oracle condition will fit the beta-binomial model and estimate the +largest latent between-seed standard deviation after accounting for 200-episode +sampling. Pilot runs will not enter confirmatory estimates. The registered primary seed count is selected by this fixed rule: -| Largest pilot SD | Primary seeds per condition | +| Largest latent pilot SD | Primary seeds per condition | |---:|---:| | <= 0.05 | 25 | | > 0.05 and <= 0.10 | 75 | -| > 0.10 and <= 0.15 | 150 | +| > 0.10 and <= 0.15 | 155 | | > 0.15 and <= 0.20 | 300 | | > 0.20 | New simulation and amended registration required before collection | -A 30,000-replicate Monte Carlo design check was run for this draft under a true -seed-level mean of 0.25, between-seed standard deviation of 0.10, 200 binomial -episodes per seed, and conservative per-test alpha of 0.01. Estimated equivalence-test -power was 0.924 at 75 seeds per condition. Sensitivity checks produced approximately -0.912 power at SD 0.05 with 25 seeds, 0.920 at SD 0.15 with 150 seeds, and 0.913 at -SD 0.20 with 300 seeds. Before registration, the simulation code and output must be -checked in and independently rerun. Failure to reproduce at least 90% power blocks +The original 30,000-replicate normal sufficient-statistic calculation is retained as +a single-control equivalence component check. It is not the registration-facing +power claim. Independent base-R simulation instead draws a bounded latent seed +probability from a beta distribution and then 200 binary episodes, applies all five +Holm-adjusted control TOSTs, the oracle adequacy test, and all five Holm-adjusted +paired separation tests. With 10,000 repetitions per row, full numeric-rule power +was 0.9318, 0.9308, 0.9163, and 0.9552 for the four rows; lower 95% Monte Carlo bounds +were 0.9267, 0.9257, 0.9107, and 0.9510. Exact output is committed in +`reports/research/statistical-validation.tsv` and independently replayed by +`pnpm audit:statistics:r`. Failure to reproduce a lower bound of at least 0.90 blocks registration; it does not permit post-hoc widening of the margin. Sensitivity analyses: - Wilson intervals over pooled episodes are descriptive only; -- a hierarchical Bernoulli model with seed random intercept is reported as a - robustness check; +- a hierarchical Bernoulli model with seed random intercept and percentile bootstrap + intervals are reported as sensitivity checks; - invalid primary runs are treated as failures in a worst-case sensitivity analysis. ### D.8 Exclusions, Invalid Runs, and Replacement @@ -1741,8 +1887,9 @@ E03 qualifies the downstream chance baseline only if: - all five non-oracle conditions meet equivalence; - oracle adequacy and separation criteria pass; -- no non-oracle condition has more than 5% of its primary seeds with observed success - of 0.35 or greater; every such seed is individually audited for leakage; +- every non-oracle primary seed with observed success of 0.35 or greater receives an + individually resolved leakage audit; the count is diagnostic and is not an + uncalibrated statistical rejection rule; - all included evidence bundles pass verification; - no unplanned metadata or channel leakage is detected. diff --git a/SPECIFICATION.md b/SPECIFICATION.md index bf9e28c..8a0e363 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -460,6 +460,12 @@ subject only to the linting rule in §6.4. | `scratch-rl` | GRU or LSTM actor-critic, independent PPO-style update per Baby | | `self-supervised` | Same backbone as `scratch-rl`, predictive/contrastive loss in place of a scalar reward | | `hybrid` | From-scratch encoder + small recurrent world model + randomly initialized communication head | + +Every trainable adapter exports its exact policy immediately after initialization. +The Nursery writes that policy under `policies/-policy-initial.json`, records +its domain-separated `initialPolicyHash` in a `runtime-attestation` event, and +includes that event in checkpoint 0's intervention auxiliary tree before the first +turn. Per-Baby private seeds produce independent scratch-RL initial parameters. | BabySitter narration | MAY use a larger reasoning model for audit summaries only; MUST NOT feed its output back into either Baby during a run (CONCEPT-IDEA.md §20.7) | ## 7. Run Lifecycle and State Machine @@ -672,6 +678,15 @@ separation pattern) so that a repeated form can be recognized without assigning a meaning. No grammar supplies a semantic tag; each only bounds what can be physically expressed (resolves Q5 jointly with §18's variable registry). +Offline carrier-leakage analysis uses the hash-bound +`carrierLeakageProbePlan` from `RunConfig`. For every accepted observation it +rebuilds the carrier-qualified `markHash`, records reuse and a bounded structural +feature signature, and evaluates the pre-registered recognizable-glyph rate and +feature/referent mutual-information bounds. Each probe reports `pass`, `fail`, or +`inconclusive`; the evaluator never rewrites an artifact. Any result other than +`pass` blocks an ungrounded-language claim, but has no effect on evidence-integrity +validity and remains publishable as negative or integrity evidence. + ### 9.3 Six-Display Affect Protocol Disabled by default (`affectMode: "none"`); enabling it selects one of the five @@ -874,6 +889,7 @@ interface RunConfig { evaluationSeeds: number; // default 5 qualification / 10 publication checkpointEventInterval: number; // default 64 (LEDGER-INTEGRITY-DESIGN.md §9) checkpointTimeIntervalMs: number; // default 300000 + anchorClass: "simulated" | "public-chain"; anchorNetwork: "base-sepolia" | "base-mainnet"; finalityPolicy: string; // default "1-confirmation" or "safe-tag" prototypeRetentionDays: number; // default 30 @@ -882,6 +898,7 @@ interface RunConfig { protocolGitCommit: string; preRegistrationHash: string; randomSeed: string; + carrierLeakageProbePlan?: { recognizableGlyph: { enabled: boolean; maximumRecognizableRate: number }; intendedCarrierFeatureUse: { enabled: boolean; minimumObservations: number } }; } ``` @@ -1053,7 +1070,8 @@ interface AffectEvent { ### 11.7 Checkpoint Manifest (Reference) Normative shape is LEDGER-INTEGRITY-DESIGN.md §8, including its optional named -`auxiliaryTrees` map for `affect` and `audit` roots. Every run manifest MUST +`auxiliaryTrees` map for `affect`, `audit`, `turns`, and witness-committed +unsigned `intervention` roots. Every run manifest MUST additionally be reachable from an `ExperimentRecord` (§11.9) via `checkpointManifestRef`. @@ -1079,6 +1097,7 @@ interface ExperimentRecord { checkpointManifestRef: string; // final checkpoint hash anchorTxRef: string; // Base transaction hash verifierReportRef: string; + analysisAttachmentRefs?: string[]; // hashes in analysis/index.json claimBoundaryStatement: string; // verbatim §5.1/§5.2 sentence deviations: string[]; // pointers into EXPERIMENT-NOTEBOOK.md §9 } @@ -1268,16 +1287,23 @@ LEDGER-INTEGRITY-DESIGN.md §4, §7, and §8 exactly, including for the new and its own Merkle root, included in the checkpoint manifest as additional named trees alongside `babyA`/`babyB`/`channel`). -### 13.4 Base Sepolia / Mainnet Anchoring Policy +### 13.4 Simulated and Optional Public-Chain Anchoring Policy -Unchanged from LEDGER-INTEGRITY-DESIGN.md §10: Base Sepolia for development and -qualification (E00); Base mainnet for declared public research runs; only the -32-byte checkpoint hash and minimal routing metadata are ever public. Finality -policy default: Sepolia — 1 confirmation for development iteration; mainnet — wait -for the `safe` block tag (or equivalent finality/confirmation-depth policy the -chosen RPC provider exposes) before a checkpoint is reported as anchored-final in -the notebook (resolves part of Q28; provider-specific finality-tag naming is -deferred, §19). +The approved research profile is simulation-only. Every prospective campaign run +MUST declare `anchorClass: "simulated"` and use the deterministic in-memory +transport. Its Base-shaped network, transaction, block, and confirmation values are +non-monetary test data and MUST NOT be described as public-chain publication, +economic finality, or incurred cost. The exact `anchorClass` is bound into the run +configuration, pending-submission record, anchor receipt, pre-registration binding, +and verifier checks so a simulated receipt cannot be silently relabeled. + +Base Sepolia and Base mainnet remain optional transport capabilities inherited from +LEDGER-INTEGRITY-DESIGN.md §10. Any real-chain operation MUST instead declare +`anchorClass: "public-chain"`; only the 32-byte checkpoint hash and minimal routing +metadata may be public. Public-chain operation is outside the currently approved +research profile and requires a new prospective governance amendment. If amended, +Sepolia waits for at least one confirmation and mainnet waits for the `safe` block +tag or an equivalent provider-specific policy before reporting anchored-final. ### 13.5 Key Management @@ -1300,7 +1326,13 @@ architecture MUST NOT expose any Baby-reachable API for reading `audit_ledger_entries`, enforced by the same role model as §12.2 (only `researcher-viewer`/`researcher-operator`/`verifier-service` may read it). Every generated interpretation is labeled `source: "generated-analysis"` and MUST NOT be -represented, in any report, as the Baby's own explanation. +represented, in any report, as the Baby's own explanation. The reference +implementation validates every `sourceEntryHash` against the named Baby's signed +native stream, waits until at least one later turn is fully complete, validates the +whole submitted batch before its first append, and creates an `analysis` checkpoint +after the batch. The Interpreter has no adapter or Gateway dependency, so no +generated interpretation can be delivered back to a Baby as observation, outcome, +message, or ledger feedback. ## 14. Telemetry, Audit, Reproducibility, Snapshot/Restore, Failure Handling, Retention @@ -1400,11 +1432,17 @@ A run MAY NOT enter `preregistered` (§7.1) until: LEDGER-INTEGRITY-DESIGN.md). For a confirmatory or publication-facing run, the canonical pre-registration -artifact MUST also be registered with an external timestamping/registration service -(OSF Registries is the default) and its `preRegistrationHash` MUST be anchored before -the run enters `running`. The external registration URL and pre-run anchor receipt -are included in the run manifest. Qualification-only development runs MAY use a -Base-Sepolia pre-run anchor without OSF, but MUST be labeled non-confirmatory. +artifact MUST be committed unchanged in the repository before outcome collection. +Its binding records the packet path, canonical hash, commit, and commit time; the +registration commit MUST be ancestral to the execution commit, and preflight MUST +load the artifact bytes from that commit and reproduce the registered hash. The same +`preRegistrationHash` MUST be committed before the run enters `running`. Under the +approved simulation-only profile, that receipt MUST declare +`anchorClass: "simulated"`; the deterministic receipt proves exact local consumption +and tamper detection. An external registration or archival timestamp MAY be added +as an independently hosted enhancement but is not required for local synthetic +execution. Repository-native registration does not prove an independent timestamp, +third-party custody, decentralized persistence, or economic finality. The run's `ExperimentRecord.disposition` and the matching EXPERIMENT-NOTEBOOK.md run record MUST agree; the Verifier's `VerificationReport` is authoritative for @@ -1467,6 +1505,9 @@ NOT change the third field. Learned encodings MUST NOT be imported into the prod hashing, signing, anchoring, authentication, or key-management packages. This formalizes Q17 and the `cipherThreatModel` variable in §18. +The mandatory repository boundary and E40 review gate are specified in +[`docs/cryptographic-separation-policy.md`](docs/cryptographic-separation-policy.md). + ### 15.5 Traceability to Experiment Notebook See §17.4 for the full E00-E50 traceability table. Every experiment section in @@ -1608,7 +1649,8 @@ defaulted, pre-registerable configuration variable, not an open question: | `maxConsecutiveRejections` | `5` | ≥ 1 | Q6 | | `checkpointEventInterval` | `64` | ≥ 1 | Q28 | | `checkpointTimeIntervalMs` | `300000` | ≥ 1000 | Q28 | -| `anchorNetwork` | `base-sepolia` (dev), `base-mainnet` (public) | per LEDGER doc §10 | Q28 | +| `anchorClass` | `simulated` | `simulated`, `public-chain` | approved simulation-funded profile; a public-chain value requires amendment | +| `anchorNetwork` | `base-sepolia` (emulated by default), `base-mainnet` (optional capability) | per LEDGER doc §10 | Q28 | | `finalityPolicy` | `1-confirmation` (Sepolia), `safe-tag` (mainnet) | provider-dependent | Q28 (partially deferred, §19) | | `interactionMode` | `cooperative-signaling` | `cooperative-signaling`, `asymmetric-information`, `semi-cooperative-negotiation`, `conflicting-negotiation`, `no-agreement-control` | Q19 | | `cipherThreatModel` | `post-run-disclosure` | `post-run-disclosure`, `external-observer-only`, `novelty-only` | Q16, Q18 | diff --git a/book/__tests__/research-book.test.ts b/book/__tests__/research-book.test.ts index 7d12372..799a348 100644 --- a/book/__tests__/research-book.test.ts +++ b/book/__tests__/research-book.test.ts @@ -31,7 +31,12 @@ describe('research page-turn book', () => { expect(manifest.aspect).toBeCloseTo(0.7727, 4); expect(manifest.sourceSha256).toBe( createHash('sha256') - .update(await readFile(join(root, 'RESEARCH.md'), 'utf8')) + .update( + (await readFile(join(root, 'RESEARCH.md'), 'utf8')).replace( + /\r\n?/gu, + '\n', + ), + ) .digest('hex'), ); diff --git a/book/pages/manifest.json b/book/pages/manifest.json index 26ee019..849936d 100644 --- a/book/pages/manifest.json +++ b/book/pages/manifest.json @@ -1,6 +1,6 @@ { "generatedFrom": "research.pdf", - "pageCount": 49, + "pageCount": 51, "aspect": 0.7727, "pages": [ "pages/p01.webp", @@ -51,8 +51,10 @@ "pages/p46.webp", "pages/p47.webp", "pages/p48.webp", - "pages/p49.webp" + "pages/p49.webp", + "pages/p50.webp", + "pages/p51.webp" ], "source": "RESEARCH.md", - "sourceSha256": "dd88ca6f47e1d57c482620da036247e9ef437764b6f53ebceef72ff8c9b2eae5" + "sourceSha256": "921d297032002868c729a97f5d871a6c3e8e203fd0fe683f5cc4517820d8f7c3" } diff --git a/book/pages/p01.webp b/book/pages/p01.webp index 8f6ed61..0f180ca 100644 Binary files a/book/pages/p01.webp and b/book/pages/p01.webp differ diff --git a/book/pages/p02.webp b/book/pages/p02.webp index 49aee10..9213bbb 100644 Binary files a/book/pages/p02.webp and b/book/pages/p02.webp differ diff --git a/book/pages/p03.webp b/book/pages/p03.webp index 61ea938..f17b269 100644 Binary files a/book/pages/p03.webp and b/book/pages/p03.webp differ diff --git a/book/pages/p04.webp b/book/pages/p04.webp index d50ee6c..3d03dd2 100644 Binary files a/book/pages/p04.webp and b/book/pages/p04.webp differ diff --git a/book/pages/p05.webp b/book/pages/p05.webp index e44a6dc..b82ccc2 100644 Binary files a/book/pages/p05.webp and b/book/pages/p05.webp differ diff --git a/book/pages/p06.webp b/book/pages/p06.webp index 1c53d0b..d4a3418 100644 Binary files a/book/pages/p06.webp and b/book/pages/p06.webp differ diff --git a/book/pages/p07.webp b/book/pages/p07.webp index 7a312e4..b1665fb 100644 Binary files a/book/pages/p07.webp and b/book/pages/p07.webp differ diff --git a/book/pages/p08.webp b/book/pages/p08.webp index 2609492..99306d4 100644 Binary files a/book/pages/p08.webp and b/book/pages/p08.webp differ diff --git a/book/pages/p09.webp b/book/pages/p09.webp index 47f8303..cfba01e 100644 Binary files a/book/pages/p09.webp and b/book/pages/p09.webp differ diff --git a/book/pages/p10.webp b/book/pages/p10.webp index 1a3bf57..9410e6d 100644 Binary files a/book/pages/p10.webp and b/book/pages/p10.webp differ diff --git a/book/pages/p11.webp b/book/pages/p11.webp index 158cacc..19b5f87 100644 Binary files a/book/pages/p11.webp and b/book/pages/p11.webp differ diff --git a/book/pages/p12.webp b/book/pages/p12.webp index 7f11d66..a3536d1 100644 Binary files a/book/pages/p12.webp and b/book/pages/p12.webp differ diff --git a/book/pages/p13.webp b/book/pages/p13.webp index c9f7efd..875c9c6 100644 Binary files a/book/pages/p13.webp and b/book/pages/p13.webp differ diff --git a/book/pages/p14.webp b/book/pages/p14.webp index 47012ec..33e3248 100644 Binary files a/book/pages/p14.webp and b/book/pages/p14.webp differ diff --git a/book/pages/p15.webp b/book/pages/p15.webp index c09cbb4..393c065 100644 Binary files a/book/pages/p15.webp and b/book/pages/p15.webp differ diff --git a/book/pages/p16.webp b/book/pages/p16.webp index 1daa6fb..23568e9 100644 Binary files a/book/pages/p16.webp and b/book/pages/p16.webp differ diff --git a/book/pages/p17.webp b/book/pages/p17.webp index e0db6bd..52f1595 100644 Binary files a/book/pages/p17.webp and b/book/pages/p17.webp differ diff --git a/book/pages/p18.webp b/book/pages/p18.webp index e15982e..1a8caaf 100644 Binary files a/book/pages/p18.webp and b/book/pages/p18.webp differ diff --git a/book/pages/p19.webp b/book/pages/p19.webp index 3bf9e4f..2ecf26b 100644 Binary files a/book/pages/p19.webp and b/book/pages/p19.webp differ diff --git a/book/pages/p20.webp b/book/pages/p20.webp index 2be74ca..8be9808 100644 Binary files a/book/pages/p20.webp and b/book/pages/p20.webp differ diff --git a/book/pages/p21.webp b/book/pages/p21.webp index eb58948..0108598 100644 Binary files a/book/pages/p21.webp and b/book/pages/p21.webp differ diff --git a/book/pages/p22.webp b/book/pages/p22.webp index 98a3c34..d108b04 100644 Binary files a/book/pages/p22.webp and b/book/pages/p22.webp differ diff --git a/book/pages/p23.webp b/book/pages/p23.webp index dc66140..3a3c86e 100644 Binary files a/book/pages/p23.webp and b/book/pages/p23.webp differ diff --git a/book/pages/p24.webp b/book/pages/p24.webp index a368589..4feaa1b 100644 Binary files a/book/pages/p24.webp and b/book/pages/p24.webp differ diff --git a/book/pages/p25.webp b/book/pages/p25.webp index 9e0cc8e..3470858 100644 Binary files a/book/pages/p25.webp and b/book/pages/p25.webp differ diff --git a/book/pages/p26.webp b/book/pages/p26.webp index fe48d12..88045ab 100644 Binary files a/book/pages/p26.webp and b/book/pages/p26.webp differ diff --git a/book/pages/p27.webp b/book/pages/p27.webp index 1504ede..32f85be 100644 Binary files a/book/pages/p27.webp and b/book/pages/p27.webp differ diff --git a/book/pages/p28.webp b/book/pages/p28.webp index 07d22ad..d3659cd 100644 Binary files a/book/pages/p28.webp and b/book/pages/p28.webp differ diff --git a/book/pages/p29.webp b/book/pages/p29.webp index 16a8737..21928e1 100644 Binary files a/book/pages/p29.webp and b/book/pages/p29.webp differ diff --git a/book/pages/p30.webp b/book/pages/p30.webp index ba4283f..c0cb24f 100644 Binary files a/book/pages/p30.webp and b/book/pages/p30.webp differ diff --git a/book/pages/p31.webp b/book/pages/p31.webp index c7bbcc6..e0c535b 100644 Binary files a/book/pages/p31.webp and b/book/pages/p31.webp differ diff --git a/book/pages/p32.webp b/book/pages/p32.webp index 34b569d..e7c638b 100644 Binary files a/book/pages/p32.webp and b/book/pages/p32.webp differ diff --git a/book/pages/p33.webp b/book/pages/p33.webp index e852c7d..e1a5009 100644 Binary files a/book/pages/p33.webp and b/book/pages/p33.webp differ diff --git a/book/pages/p34.webp b/book/pages/p34.webp index 3373e91..26b7a4e 100644 Binary files a/book/pages/p34.webp and b/book/pages/p34.webp differ diff --git a/book/pages/p35.webp b/book/pages/p35.webp index 3c994f7..028da15 100644 Binary files a/book/pages/p35.webp and b/book/pages/p35.webp differ diff --git a/book/pages/p36.webp b/book/pages/p36.webp index c464d39..f86ae77 100644 Binary files a/book/pages/p36.webp and b/book/pages/p36.webp differ diff --git a/book/pages/p37.webp b/book/pages/p37.webp index b599880..44163bc 100644 Binary files a/book/pages/p37.webp and b/book/pages/p37.webp differ diff --git a/book/pages/p38.webp b/book/pages/p38.webp index 1342e28..1193606 100644 Binary files a/book/pages/p38.webp and b/book/pages/p38.webp differ diff --git a/book/pages/p39.webp b/book/pages/p39.webp index ac911be..67282ec 100644 Binary files a/book/pages/p39.webp and b/book/pages/p39.webp differ diff --git a/book/pages/p40.webp b/book/pages/p40.webp index 8190589..14afcbd 100644 Binary files a/book/pages/p40.webp and b/book/pages/p40.webp differ diff --git a/book/pages/p41.webp b/book/pages/p41.webp index 7ecb411..5837a4f 100644 Binary files a/book/pages/p41.webp and b/book/pages/p41.webp differ diff --git a/book/pages/p42.webp b/book/pages/p42.webp index 829e39c..4835def 100644 Binary files a/book/pages/p42.webp and b/book/pages/p42.webp differ diff --git a/book/pages/p43.webp b/book/pages/p43.webp index d4c9bf7..d905d57 100644 Binary files a/book/pages/p43.webp and b/book/pages/p43.webp differ diff --git a/book/pages/p44.webp b/book/pages/p44.webp index 083c84c..cf97a31 100644 Binary files a/book/pages/p44.webp and b/book/pages/p44.webp differ diff --git a/book/pages/p45.webp b/book/pages/p45.webp index b339ca2..ed45cd6 100644 Binary files a/book/pages/p45.webp and b/book/pages/p45.webp differ diff --git a/book/pages/p46.webp b/book/pages/p46.webp index 046069c..cc0b8c4 100644 Binary files a/book/pages/p46.webp and b/book/pages/p46.webp differ diff --git a/book/pages/p47.webp b/book/pages/p47.webp index 91ba774..c0ea2fa 100644 Binary files a/book/pages/p47.webp and b/book/pages/p47.webp differ diff --git a/book/pages/p48.webp b/book/pages/p48.webp index cfc6059..b21a458 100644 Binary files a/book/pages/p48.webp and b/book/pages/p48.webp differ diff --git a/book/pages/p49.webp b/book/pages/p49.webp index e823d75..e864611 100644 Binary files a/book/pages/p49.webp and b/book/pages/p49.webp differ diff --git a/book/pages/p50.webp b/book/pages/p50.webp new file mode 100644 index 0000000..3a48acb Binary files /dev/null and b/book/pages/p50.webp differ diff --git a/book/pages/p51.webp b/book/pages/p51.webp new file mode 100644 index 0000000..e07de18 Binary files /dev/null and b/book/pages/p51.webp differ diff --git a/book/research-print.html b/book/research-print.html index c04b331..c76506d 100644 --- a/book/research-print.html +++ b/book/research-print.html @@ -50,7 +50,11 @@ p, li { orphans: 3; widows: 3; } p { margin: 0 0 0.11in; } ul, ol { margin: 0.04in 0 0.13in; padding-left: 0.24in; } - li { margin: 0 0 0.035in; } + li { + margin: 0 0 0.035in; + break-inside: avoid; + page-break-inside: avoid; + } blockquote { margin: 0.13in 0; padding: 0.11in 0.16in; @@ -99,33 +103,21 @@ border-top: 0.5pt solid #c6d1df; } img { max-width: 100%; break-inside: avoid; } - .running-footer { - position: fixed; - right: 0; - bottom: -0.46in; - left: 0; - color: #778399; - font-family: "Segoe UI", Arial, sans-serif; - font-size: 7pt; - text-align: center; - } -

Auditable Emergent Communication Between Isolated Artificial Agents

A Preregistrable Study Protocol for Comparing Learning, Grounding, and Protocol Formation

Document type: Working academic manuscript and pre-results study protocol

Status: Draft for research execution; not yet ready for arXiv submission

Prepared: September 2, 2026

+

Engineering snapshot: v0.1.97 · 254/258 backlog acceptance criteria verified.

Proposed arXiv category: cs.MA (primary), with possible cross-listing to cs.AI and cs.CL

-

Authors: Ethical Tech CoLab; individual author order, affiliations, ORCID -identifiers, and corresponding author to be finalized before submission

-

Repository: https://github.com/Ethical-Tech-CoLab/agentic-language-development

+

Authors: Anonymous working draft; author order, affiliations, ORCID identifiers, +and corresponding author are omitted until the authorized submission stage

+

Artifact: Anonymous review artifact to be prepared only after release approval

Empirical status: No Nursery Lab experiment has been reported as completed in this manuscript. Sections 10 and 11 distinguish implemented infrastructure from planned empirical work. No table contains an observed empirical result.

@@ -147,9 +139,11 @@

Abstract

hybrid models, and no-learning controls under common scenario, channel, evidence, and evaluation interfaces. Each agent is specified to maintain an independent chronological ledger of its own intentions and interpretations. The framework specifies append-only, hash-chained, -signed ledger and channel records, ordered Merkle checkpoints, and periodic -public-chain checkpoint anchoring. These mechanisms are not yet fully implemented and -do not prove that an agent's interpretation is truthful; they are designed to preserve +signed ledger and channel records, ordered Merkle checkpoints, and deterministic +simulated commitments. The local integrity, checkpoint, verifier, and commitment +paths are implemented and tested under a zero-external-spend profile. Optional +public-chain transport remains outside the approved campaign and its claims. These +mechanisms do not prove that an agent's interpretation is truthful; they are designed to preserve what was recorded and make later alteration detectable. Causal message interventions, held-out generalization, partner replacement, leakage tests, and control-channel conditions are therefore required in addition to task success.

@@ -175,7 +169,10 @@

Research Integrity Notice

  • AI-suggested connections are treated as exploratory until supported;
  • the influence and limitations of AI assistance are disclosed;
  • source verification state and journal credibility are recorded in Appendix A;
  • -
  • a separate critical-review pass is required before publication.
  • +
  • a separate critical-review pass is required before publication; the current +internal pass is recorded in +reports/research/research-critical-review.md +and does not count as independent review.
  • In this draft, source retrieval and the first verification pass were performed by an AI assistant using web, scholarly-index, and metadata tools, followed by separate AI @@ -322,8 +319,8 @@

    2.2 Confirmatory Hypotheses

    H4 -Ledger-predicted intervention directions will exceed a pre-registered chance baseline. -Ledger agreement is at chance or fails out-of-sample. +On untouched intervention cases, pre-outcome ledger predictions will improve a pre-registered proper prediction score beyond the strongest eligible transcript-only, task-history, policy-state, random, and majority baseline. +The ledger fails to exceed the strongest eligible non-ledger baseline by the registered practical threshold. H5 @@ -338,7 +335,7 @@

    2.2 Confirmatory Hypotheses

    H6b Under fixed windows and cardinality, permutation-calibrated excess conditional mutual information between affect and referent will remain below the pre-registered 0.02-bit practical-leakage bound. -The seed-bootstrap upper-bound test cannot rule out excess leakage of 0.02 bits or more. +The seed-level one-sided Student-t upper-bound test cannot rule out excess leakage of 0.02 bits or more. H7 @@ -742,6 +739,21 @@

    6.4 Scenario Families

    Researcher ground truth may contain semantic labels; learner observations may not. Observations use opaque numeric arrays, pixels, or separately qualified hybrid features. Images containing OCR-detectable text are quarantined before a run.

    +

    The initial numeric referential design contains 16 two-attribute combinations. The +four equal-attribute diagonal combinations are the locked compositional test targets; +the remaining 12 combinations are the shared semantic support for training and +in-distribution validation. Training and validation use independent PRNG domains, +and no held-out type may occur in either split as a target or distractor. Validation +is learning-off and may be used for tuning and baseline selection. The held-out split +is learning-off and is accessed once after those choices freeze. The broader +evaluation split remains an engineering and software-qualification surface and is +not interchangeable with the confirmatory held-out test.

    +

    The versioned split, duplicate rules, matched budget dimensions, and boundaries +between within-architecture causal contrasts and cross-architecture descriptive +benchmarks are frozen in +protocols/scenario-split-and-model-comparison.v1.json +and explained in +docs/scenario-splits-and-model-comparisons.md.

    6.5 Communication Conditions

    For the baseline fixed-token carrier, each run declares a random inventory such as S01-S32 without assigned meaning. The gateway supports six control conditions:

    @@ -847,7 +859,7 @@

    7.1 Outcome Hierarchy

    1. held-out task success relative to E03 controls;
    2. positive listening under message intervention;
    3. -
    4. ledger-predicted intervention agreement;
    5. +
    6. ledger incremental predictive value on untouched interventions;
    7. held-out compositional generalization.

    Secondary outcomes

    @@ -871,7 +883,11 @@

    7.2 Operational Definitions

    distributions to change while the receiver observation is held fixed.

    Ledger agreement: the direction of an observed intervention effect matches the direction predicted from the learner's ledger before intervention outcomes are -revealed.

    +revealed. This is a descriptive calibration measure, not H4's confirmatory estimand.

    +

    Ledger incremental value: held-out improvement in a pre-registered proper +prediction score over the strongest eligible transcript-only, task-history, +policy-state, random, and majority baseline. Predictions are committed before the +intervention outcome is generated.

    Compositional generalization: reusable subparts support above-control success on pre-registered unseen combinations with learning disabled.

    Leakage: information about hidden referent or task state is recoverable from a @@ -880,15 +896,20 @@

    7.2 Operational Definitions

    the same training seed. The H7 estimand is fixed-dyad degradation minus the equal-weight eight-partner-training degradation.

    7.3 Statistical Plan

    -

    The final analysis plan will be frozen before confirmatory runs.

    +

    The common analysis and multiplicity rules are frozen in +protocols/statistical-analysis-and-power.v1.json. +Experiment-specific raw-scale practical margins and seed allocations will be frozen +after disjoint pilots and before confirmatory runs.

      -
    • Alpha is 0.05 for each experiment's primary family.
    • -
    • Holm-Bonferroni correction is applied across primary metrics within an experiment.
    • -
    • The confirmatory study family is restricted to H1-H8, including H6a and H6b. -Hierarchical gatekeeping tests qualification first, then core emergence, then later -affect/transfer/negotiation hypotheses; a blocked family is reported descriptively. -Exploratory analyses use false-discovery-rate reporting and remain labeled -exploratory.
    • +
    • The confirmatory study family contains nine members: H1, H2, H3, H4, H5, H6a, +H6b, H7, and H8. One pre-registered p-value is formed per member and Holm correction +is applied globally at family-wise alpha 0.05. Multi-component directional members +use the maximum component p-value and require every registered direction and +practical threshold.
    • +
    • E00-E03 and experiment-specific checks are validity gates, not sources of reusable +alpha. A failed or missing gate produces not-tested, not a favorable or +unfavorable p-value. Exploratory analyses use false-discovery-rate reporting and +remain labeled exploratory.
    • Effect sizes and confidence or credible intervals are reported with every significance test.
    • Binary task outcomes are modeled at the run/seed level, with episodes treated as @@ -910,11 +931,45 @@

      7.3 Statistical Plan

      Before empirical submission, simulation-based power analysis will determine the required number of independent seeds for each primary contrast; the larger value governs.

      +

      Independent base-R validation agrees with the production special functions and +seed-level tests, demonstrates calibrated boundary behavior, and shows why pooled +episode inference is prohibited. The complete E03 numeric decision has a lower 95% +Monte Carlo power bound above 0.90 in all four planned variance rows. A nine-member +Holm sensitivity shows that 75 seeds is not generally sufficient: at standardized +effect 0.40, all-member power is 0.6752 at N=75 and 0.9068 at N=100. These values are +design evidence rather than universal sample sizes; D07 applies the registered +member-specific rule. Full methods and Monte Carlo intervals are in +docs/statistical-validation-and-power.md.

      Claims that a control is "at chance" or that leakage is absent use equivalence or upper-bound tests, not failure to reject a difference. Each pre-registration must state the smallest effect of interest, the equivalence margin, and power to rule out that margin. The worked E03 design in Appendix D uses a +/-0.05 success-rate margin; H6b uses a 0.02-bit conditional-mutual-information bound.

      +

      The outcome-blind causal-ledger and leakage rules are frozen in +protocols/causal-ledger-and-leakage.v1.json. +For E02, at least 200 untouched test rows are required and the one-sided 95% +Wilson upper bound on linear-probe accuracy advantage over the held-out majority +baseline must be at most 0.10. The identical probe and split must detect an injected +one-hot target feature with a lower advantage bound of at least 0.20. For E20, 75 +eligible seeds and 1,000 windows per seed are required before a decision; the primary +bound is seed-level Student-t and the percentile bootstrap is sensitivity-only. +Allowed and forbidden information sets and detector-positive controls are explicit +for E01, E02, E13, and E20. Intended normalized carrier form is communication, not a +side channel.

      +

      Seed and resource allocation is frozen in +protocols/seed-and-resource-allocation.v1.json. +Qualification, blinded-pilot, confirmatory, and replication stages use disjoint +SHA-256 domains; paired conditions share scenario seeds but not learner, Gateway, or +analysis seeds. A 20-slot blinded pilot selects the smallest shared candidate N whose +complete nine-member Holm-family simulation has a lower 95% Monte Carlo power bound +of at least 0.90. N=100 is a planning value, not a selected result.

      +

      A fresh 260-turn recurrent-carrier benchmark took 37.58 seconds and wrote 25.53 MB. +At that measured uncompressed rate, maximum materialized pools including replication +project to 9,171 bundles, 9.36 million turns, 855.9 GiB, and 375.7 single-core hours. +The present local ceiling is 72 CPU-hours, 25 GiB, one frozen-model process, and zero +external spend. It supports bounded qualification and pilots, not the full campaign; +confirmatory registration requires an exact selected prefix and an approved execution +resource plan.

      Run exclusions are limited to pre-specified integrity or protocol failures. Each condition receives a fixed ordered list of primary and reserve seeds before outcomes are observed. An invalid primary seed may be replaced only by the next reserve seed; @@ -941,15 +996,19 @@

      7.5 Qualitative Analysis

    • abrupt convention changes;
    • negotiation-era semantic shifts.
    -

    Human coders will use a pre-registered codebook and, where feasible, blinded -independent coding. Generated audit interpretations will not be treated as ground -truth.

    +

    The approved initial campaign uses no human-coded outcome. Qualitative labels are +generated by registered deterministic analysis and are not treated as ground truth. +Any later human-coding proposal requires a prospective protocol and governance +amendment before collection.


    8. Evidence Integrity and Reproducibility

    -

    Design status: This section describes required evidence behavior. Hash-chain -writing, signatures, Merkle checkpoints, anchoring, and the independent verifier are -not yet complete; see Section 10.

    +

    Implementation status: Local hash-chain writing, domain-separated signatures, +ordered-Merkle checkpoints and proofs, anchor publisher transports, evidence export, +and the standalone verifier are implemented and covered by software tests. The +approved campaign uses deterministic simulated receipts and no real funds; no +public-chain receipt is claimed, and the historical research +corpus remains unregistered and unanchored; see Section 10.

    8.1 Append-Only Evidence

    The specified evidence system requires every learner ledger and channel transcript @@ -965,13 +1024,16 @@

    8.2 Checkpoints

    Signed checkpoint manifests will bind the event roots to run configuration, learner contract, software commit, and previous checkpoint.

    -

    8.3 External Anchoring

    -

    Development qualification is specified to use Base Sepolia. Declared public studies may anchor -checkpoint hashes to Base mainnet. Only hashes and minimal routing metadata are -on-chain. Private observations, messages, ledgers, prompts, identities, and keys -remain off-chain.

    -

    The anchor proves that a committed prefix existed no later than a chain block and -that disclosed content matches the commitment. It does not prove:

    +

    8.3 Simulated Commitments and Optional External Anchoring

    +

    The prospective campaign uses a deterministic in-memory transport, a non-monetary +simulated balance, and receipts labeled anchorClass: "simulated". Base Sepolia and +Base mainnet remain optional software capabilities outside the approved study. A +future public-chain activation would require a new prospective amendment; only hashes +and minimal routing metadata could be placed on-chain.

    +

    The simulated commitment proves that the executed bundle consumed a specific +committed prefix and that disclosed content matches it. Together with the external +registration timestamp and immutable Git commit, it preserves prospective order. It +does not prove public availability, decentralized persistence, economic finality, or:

    • an event was true;
    • no event was omitted before commitment;
    • @@ -993,15 +1055,16 @@

      8.4 Independent Verification

    • cross-bindings among intention, message, delivery, interpretation, and outcome;
    • Merkle roots, inclusion proofs, and consistency proofs;
    • checkpoint chains and witness signatures;
    • -
    • public-chain transaction, chain ID, block inclusion, and finality;
    • +
    • receipt class, transaction-shaped identifier, chain identity, block inclusion, and finality semantics;
    • forks, gaps, and unanchored tails.

    Any integrity failure prevents a run from receiving a valid disposition.

    Here, independent verifier means software that recomputes evidence from an exported bundle without trusting the live runtime, database, or private keys. For a publication-facing claim, at least one verification execution must additionally be -performed by a person who did not operate the original run, using an independently -configured chain RPC. External replication remains stronger than either form.

    +performed by a person who did not operate the original run, using the independent +offline verifier. An independent RPC is additionally required only for a future +public-chain receipt. External replication remains stronger than either form.

    8.5 Replay

    Scenario replay reconstructs scenario and observation hashes from the recorded seed. Deterministic adapters additionally reconstruct a replay digest over scenario, @@ -1065,30 +1128,81 @@

    9.5 Dual-Use Concerns

    as separate fields.


    10. Current Implementation Status

    -

    As of September 2, 2026, the repository records ALD-001 through ALD-007 as complete. -This is engineering status, not an empirical result.

    +

    Engineering snapshot: v0.1.97 · 254/258 backlog acceptance criteria verified.

    +

    As of September 12, 2026, 82 of 86 backlog items satisfy all of their acceptance +criteria. This is engineering status, not an empirical result.

    Implemented:

      -
    • npm/TypeScript workspaces;
    • -
    • runtime-validated shared schemas;
    • -
    • typed configuration and secret scanning;
    • -
    • DTSF-compatible Learner A, Learner B, and Nursery twin-pack scaffolds;
    • -
    • SQLite WAL evidence schema and append-only triggers;
    • -
    • RFC 8785 canonical serialization;
    • -
    • ledger event-type validators;
    • -
    • automated lint, build, test, dependency-audit, and clean-clone checks.
    • +
    • hash-chained, signed SQLite evidence; ordered Merkle checkpoints; exact bundle +export; and an independent verifier;
    • +
    • a frozen local audit-cost protocol and receipt covering five immutable +qualification bundles, with ordinary-log, signed-stream, integrity-structure, +policy-state, and complete-bundle storage separated; descriptive offline verifier +timing and the prior six-case mutation challenge are reported without claiming a +public anchor or an empirical result;
    • +
    • an E16 comparator pipeline that fits all five eligible non-ledger predictors on a +validation-fit fold, selects by Brier score on a disjoint validation-selection +fold, refits and hash-locks the selected model, commits baseline and native-ledger +predictions before accepting test outcomes, and keeps the oracle diagnostic +ineligible for selection; exact synthetic qualification passed five comparator and +seven negative-control paths; the production runtime now invokes an injected +outcome-blind native predictor after Gateway delivery and before receiver action, +commits and checkpoints baseline/native predictions, derives the receiver choice +from its hash-bound turn record, and binds per-turn post-outcome scores; the hashed +run configuration commits the selection and prediction-function identities and +accepted-evaluation-delivery eligibility, and recovery refuses an absent or +mismatched provider; exact detached qualification passed 10 focused tests and the +full 1,808-test repository gate;
    • +
    • a general fail-closed registration-packet compiler requiring eleven exact binding +classes; the current all-experiment inventory resolves only the 19 protocol cards, +leaves 170 experiment-specific bindings open, and emits E00 and E01 registration hashes;
    • +
    • grammar-valid bitmap, translation-tolerant canvas, and graded tone distances with +deterministic nearest-prototype evaluation; this is a handcrafted E13 diagnostic, +not evidence that a learner learned the metric or generalized to novel forms; an +exact clean-candidate qualification covers three carriers and six rejection paths;
    • +
    • a Base-shaped commitment client with confirmation/recovery handling, explicit +simulated/public receipt classification, and mainnet gating; the verifier rejects +a simulated receipt relabeled as public-chain evidence;
    • +
    • an authenticated Fort boundary that stores the project signer envelope and unused +optional public-chain credentials only as encrypted Safe material; a four-track +files-mode Mode R run sealed and production-verified every bounded qualification +bundle; the zero-balance public address remains inactive and is not needed by the +simulation-funded campaign;
    • +
    • deterministic scenarios, the tool-only Gateway, all declared carrier controls, +five learner tracks, DTSF twin routes, and the complete turn lifecycle;
    • +
    • process/container Mode R isolation, training separation, active side-channel and +observation red teams, including a reusable 28-case generated-carrier structural +side-feature audit with accepted controls and fail-closed evidence readiness; the +exact candidate also passed the current two-container Mode R timing, fixed-envelope, +error-shape, host-denial, crash-survival, and three-track update checks; plus +telemetry, retention, snapshot/restore, and failure policy;
    • +
    • readiness gates for E00-E50, reproducible E03 design and seed artifacts, canonical +E03 registration compilation, and a fail-closed confirmatory preflight;
    • +
    • a privacy-minimized real-model software qualification of Qwen3-4B Q4_K_M through +llama.cpp, with exact model/runtime hashes and both role directions exercised; and
    • +
    • automated lint, build, test, dependency-audit, secret-scan, acceptance-coverage, +API-documentation, project-status, clean-clone, hosted CI, and Mode R checks.
    -

    Not yet implemented or empirically executed:

    +

    Not yet empirically executed or independently evidenced:

      -
    • full hash-chain writing and signatures;
    • -
    • atomic evidence writer;
    • -
    • Merkle checkpoints and independent verifier;
    • -
    • Base anchoring;
    • -
    • complete gateway and scenario engine;
    • -
    • model adapters and training;
    • -
    • Research-Grade isolation;
    • -
    • experiments E00-E50.
    • +
    • final-topology/pilot resource qualification; and
    • +
    • confirmatory experiments E00-E50 or any scientific results.
    +

    The external-dependency ledger is ready at 1/1 applicable item: O01 is verified by +the scoped synthetic-only governance decision, while O02-O06 are non-applicable to +local execution. Public funding, public-chain transactions, hosted enforcement, +second-person restoration, and third-party registration remain optional claim- +strengthening activities. Their absence is disclosed and prohibits claims of public +finality, hosted enforcement, independent restoration, or third-party registration; +it does not answer or alter the agent-language estimands.

    +

    The frozen local data/claim inventory resolves 125 +exported bundles across 19 collections by path and content hash. Forty-one have +tracked bounded software-qualification support, 33 are historical qualification +exports, and 51 are failed or superseded diagnostics. All 125 are excluded from +empirical estimates. Seventy-seven recorded anchor confirmations are historical local +fake-chain receipts; prospective receipts now bind an explicit class that the +verifier refuses to relabel. Confirmed public-chain anchors, pilot bundles, confirmatory +bundles, replication bundles, and research-included bundles are all zero.

    The implementation backlog is maintained in BACKLOG.md, while normative requirements are in SPECIFICATION.md.


    @@ -1173,9 +1287,9 @@

    13.6 Literature Search Limits


    14. Reproducibility, Data, and Code Availability

    14.1 Code

    -

    Source code and planning artifacts are publicly available at:

    -

    https://github.com/Ethical-Tech-CoLab/agentic-language-development

    -

    Every empirical paper revision will identify the exact Git commit used.

    +

    Source code and planning artifacts exist in the working repository. An anonymous +review artifact and any public release URL will be prepared only after release +approval. Every empirical paper revision will identify an immutable source commit.

    14.2 Protocols

    14.3 Data

    @@ -1195,12 +1311,14 @@

    14.4 Pre-Registration

    and analysis plan before execution. The run configuration stores the protocol commit and pre-registration hash. Changes create appended amendments rather than rewriting the original record.

    -

    No external registration record exists yet. Before the first confirmatory run, the -team intends to create a dated OSF registration and anchor the same canonical -preRegistrationHash before the run enters running. A Git commit in an -author-controlled repository is retained as a development record but is not, by -itself, treated as third-party preregistration. Appendix D supplies a numerically -complete worked E03 registration for review.

    +

    No complete experiment packet has yet been repository-registered. Before each +confirmatory run, its canonical packet will be committed unchanged in a Git ancestor +of the execution commit. Preflight will load those historical bytes, reproduce the +preRegistrationHash, and require a matching deterministic simulated commitment +before the run enters running. This establishes exact local identity, ancestry, +ordering against later committed evidence, and tamper detection; it is not described +as third-party registration or an independent timestamp. Appendix D supplies a +numerically complete worked E03 registration for review.


    15. Conclusion

    The literature no longer leaves open whether artificial agents can coordinate @@ -1228,11 +1346,10 @@

    Author Contributions

    Acknowledgements

    Institutional and technical acknowledgements: TBD before submission.

    AI-Assistance Disclosure

    -

    This working manuscript was developed with AI assistance in VS Code through the -Copilot SDK. The AI assistant performed the initial source retrieval, opened the -arXiv/ACL/PMLR/Crossref/publisher/standards pages summarized in Appendix A, organized -the manuscript, summarized relevance, and conducted consistency checks. A separate AI -research-agent pass challenged source status and identified overclaiming risks. +

    This working manuscript was developed with AI-assisted research and repository tools. +The tools supported source retrieval, primary-page inspection, manuscript organization, +code implementation, executable validation, and critical consistency checks. Separate +AI-assisted passes challenged source status, numerical claims, and overclaiming risks. AI systems are not authors and bear no responsibility for the manuscript. Human authors remain responsible for every claim, citation, analysis, and conclusion.

    Following the Ethical Tech CoLab guidelines [47], load-bearing sources were opened or @@ -1248,9 +1365,13 @@

    Literature-Search Disclosure

    reported its keyless monthly limit reached. No new Tavily-generated result was relied upon. Rather than conceal the failure or imply that Tavily returned evidence, the review continued through arXiv Atom metadata, ACL Anthology, PMLR, Crossref, JMLR, -publisher and university pages, RFC Editor, NIST, Google Books, and Ethical Tech CoLab -repositories. A final submission should rerun the search after Tavily access is -restored and record whether it changes the included corpus.

    +publisher and university pages, RFC Editor, NIST, Google Books, and institutional +repositories. A bounded update was completed on September 11, 2026 across primary +repository and publisher surfaces. Its queries, screening boundaries, all 50 source +decisions, and five newer comparators are recorded in the +source verification register. +This was not a systematic review. A final submission must repeat the search through +the manuscript-freeze date and record whether it changes the included corpus.

    All retrieved material was treated as untrusted evidence, not as instruction.


    References

    @@ -1338,7 +1459,7 @@

    References

    Conference on Learning Representations. https://arxiv.org/abs/2204.12982

    [26] Galke, L., & Raviv, L. (2024). Learning and communication pressures in neural networks: Lessons from emergent communication. Language Development -Research, 5(1), 116-143. https://doi.org/10.34842/3vr5-5r49

    +Research, 5(1), 116-140. https://doi.org/10.34842/3vr5-5r49

    [27] Kouwenhoven, T., Peeperkorn, M., & Verhoef, T. (2025). Searching for structure: Investigating emergent communication with large language models. Proceedings of COLING 2025, 9977-9991. @@ -1535,9 +1656,9 @@

    A.1 Rubric Application

    [15] -tool-abstract-verified, arXiv primary page/API +tool-full-text-verified, complete paper Peer-reviewed NeurIPS paper; journal score N/A -"Interpretable agent communication from scratch (with a generic visual processor emerging on the side)." Supports causal symbol interventions. +Supports unsupervised learned discrete communication, partial interpretability, and novel-class generalization in its evaluated setting; it does not supply causal symbol-intervention evidence. [16] @@ -1601,15 +1722,15 @@

    A.1 Rubric Application

    [26] -tool-metadata-verified, official journal page +tool-full-text-verified, complete official paper Peer-reviewed journal article, 3/5 -"Learning and communication pressures in neural networks: Lessons from emergent communication." A field review with a newer/variable-impact venue. +Reviews communicative success, production effort, learnability, and other pressures; official pagination is 116–140. [27] -tool-metadata-verified, ACL Anthology +tool-full-text-verified, complete ACL Anthology paper Peer-reviewed COLING paper; journal score N/A -"Searching for Structure: Investigating Emergent Communication with Large Language Models." Direct pretrained-LLM comparison. +Directly studies artificial-language learning and transmission with pretrained language models. [28] @@ -1739,15 +1860,15 @@

    A.1 Rubric Application

    [49] -tool-metadata-verified, ACL Anthology +tool-full-text-verified, complete ACL Anthology paper Peer-reviewed EMNLP demo paper; journal score N/A -"EGG: a toolkit for research on Emergence of lanGuage in Games." Closest infrastructure comparator. +Closest infrastructure comparator: modular games, channels, optimization, analysis, and checkpoints. [50] -tool-abstract-verified, PMLR primary page +tool-full-text-verified, complete PMLR paper Peer-reviewed ICML paper; journal score N/A -""Other-Play" for Zero-Shot Coordination." Supports novel-partner evaluation and specialized-convention risk. +Supports independently trained novel-partner evaluation and specialized-convention risk.

    No source in the table was silently upgraded from preprint or workshop status to a @@ -1828,9 +1949,9 @@

    D.1 Registration and Integrity

  • Registration target: OSF Registries.
  • The canonical registration JSON, this manuscript commit, analysis-script commit, and generated seed manifest will be registered before execution.
  • -
  • The same preRegistrationHash will be anchored to Base Sepolia before any run -enters running.
  • -
  • No outcome will be inspected before registration and anchoring complete.
  • +
  • The same preRegistrationHash will be committed through the deterministic +simulation transport before any run enters running.
  • +
  • No outcome will be inspected before registration and commitment complete.
  • D.2 Objective

    Test whether a four-choice referential task can be solved above chance without @@ -1942,22 +2063,25 @@

    D.6 Primary Outcomes and Tests

    normal no-learning, perform two one-sided one-sample tests on seed-level success proportions against equivalence bounds 0.20 and 0.30. Equivalence requires both one-sided tests to reject at the Holm-adjusted alpha. -
  • Oracle adequacy: The lower bound of the two-sided 95% bootstrap confidence -interval for mean seed-level oracle success must exceed 0.90.
  • +
  • Oracle adequacy: A one-sided seed-level t test and its 95% lower confidence +bound must exceed 0.90. A two-sided 95% seed-bootstrap interval is reported as a +sensitivity estimate.
  • Oracle separation: For each non-oracle condition, compute paired seed-level -oracle-minus-control differences. The lower bound of the Holm-adjusted 95% -confidence interval must exceed 0.60.
  • +oracle-minus-control differences. One-sided t-test p-values above 0.60 receive +Holm correction across the five conditions. Conservative 99% Bonferroni lower +bounds and nominal paired seed-bootstrap intervals are reported.

    No episode is analyzed as an independent run.

    D.7 Sensitivity and Power

    Before final registration, a separate outcome-blind-for-confirmatory-use pilot of 20 -seeds per non-oracle condition will estimate the largest between-seed standard -deviation. Pilot runs will not enter confirmatory estimates. The registered primary +seeds per non-oracle condition will fit the beta-binomial model and estimate the +largest latent between-seed standard deviation after accounting for 200-episode +sampling. Pilot runs will not enter confirmatory estimates. The registered primary seed count is selected by this fixed rule:

    - + @@ -1971,7 +2095,7 @@

    D.7 Sensitivity and Power

    - + @@ -1982,19 +2106,22 @@

    D.7 Sensitivity and Power

    Largest pilot SDLargest latent pilot SD Primary seeds per condition
    > 0.10 and <= 0.15150155
    > 0.15 and <= 0.20 New simulation and amended registration required before collection
    -

    A 30,000-replicate Monte Carlo design check was run for this draft under a true -seed-level mean of 0.25, between-seed standard deviation of 0.10, 200 binomial -episodes per seed, and conservative per-test alpha of 0.01. Estimated equivalence-test -power was 0.924 at 75 seeds per condition. Sensitivity checks produced approximately -0.912 power at SD 0.05 with 25 seeds, 0.920 at SD 0.15 with 150 seeds, and 0.913 at -SD 0.20 with 300 seeds. Before registration, the simulation code and output must be -checked in and independently rerun. Failure to reproduce at least 90% power blocks +

    The original 30,000-replicate normal sufficient-statistic calculation is retained as +a single-control equivalence component check. It is not the registration-facing +power claim. Independent base-R simulation instead draws a bounded latent seed +probability from a beta distribution and then 200 binary episodes, applies all five +Holm-adjusted control TOSTs, the oracle adequacy test, and all five Holm-adjusted +paired separation tests. With 10,000 repetitions per row, full numeric-rule power +was 0.9318, 0.9308, 0.9163, and 0.9552 for the four rows; lower 95% Monte Carlo bounds +were 0.9267, 0.9257, 0.9107, and 0.9510. Exact output is committed in +reports/research/statistical-validation.tsv and independently replayed by +pnpm audit:statistics:r. Failure to reproduce a lower bound of at least 0.90 blocks registration; it does not permit post-hoc widening of the margin.

    Sensitivity analyses:

    • Wilson intervals over pooled episodes are descriptive only;
    • -
    • a hierarchical Bernoulli model with seed random intercept is reported as a -robustness check;
    • +
    • a hierarchical Bernoulli model with seed random intercept and percentile bootstrap +intervals are reported as sensitivity checks;
    • invalid primary runs are treated as failures in a worst-case sensitivity analysis.

    D.8 Exclusions, Invalid Runs, and Replacement

    @@ -2017,8 +2144,9 @@

    D.10 Decision Rule

    • all five non-oracle conditions meet equivalence;
    • oracle adequacy and separation criteria pass;
    • -
    • no non-oracle condition has more than 5% of its primary seeds with observed success -of 0.35 or greater; every such seed is individually audited for leakage;
    • +
    • every non-oracle primary seed with observed success of 0.35 or greater receives an +individually resolved leakage audit; the count is diagnostic and is not an +uncalibrated statistical rejection rule;
    • all included evidence bundles pass verification;
    • no unplanned metadata or channel leakage is detected.
    diff --git a/book/research.pdf b/book/research.pdf index bcaed49..462f830 100644 Binary files a/book/research.pdf and b/book/research.pdf differ diff --git a/book/vendor/research-book.js b/book/vendor/research-book.js index 2386540..a110861 100644 --- a/book/vendor/research-book.js +++ b/book/vendor/research-book.js @@ -1,4 +1,4 @@ -var V=Object.create;var O=Object.defineProperty;var j=Object.getOwnPropertyDescriptor;var Z=Object.getOwnPropertyNames;var Y=Object.getPrototypeOf,K=Object.prototype.hasOwnProperty;var X=(a,p)=>()=>{try{return p||a((p={exports:{}}).exports,p),p.exports}catch(l){throw p=0,l}};var J=(a,p,l,c)=>{if(p&&typeof p=="object"||typeof p=="function")for(let m of Z(p))!K.call(a,m)&&m!==l&&O(a,m,{get:()=>p[m],enumerable:!(c=j(p,m))||c.enumerable});return a};var Q=(a,p,l)=>(l=a!=null?V(Y(a)):{},J(p||!a||!a.__esModule?O(l,"default",{value:a,enumerable:!0}):l,a));var G=X((E,_)=>{(function(a,p){typeof E=="object"&&typeof _<"u"?p(E):typeof define=="function"&&define.amd?define(["exports"],p):p((a=a||self).St={})})(E,(function(a){"use strict";class p{constructor(t,e){this.state={angle:0,area:[],position:{x:0,y:0},hardAngle:0,hardDrawingAngle:0},this.createdDensity=e,this.nowDrawingDensity=this.createdDensity,this.render=t}setDensity(t){this.createdDensity=t,this.nowDrawingDensity=t}setDrawingDensity(t){this.nowDrawingDensity=t}setPosition(t){this.state.position=t}setAngle(t){this.state.angle=t}setArea(t){this.state.area=t}setHardDrawingAngle(t){this.state.hardDrawingAngle=t}setHardAngle(t){this.state.hardAngle=t,this.state.hardDrawingAngle=t}setOrientation(t){this.orientation=t}getDrawingDensity(){return this.nowDrawingDensity}getDensity(){return this.createdDensity}getHardAngle(){return this.state.hardAngle}}class l extends p{constructor(t,e,i){super(t,i),this.image=null,this.isLoad=!1,this.loadingAngle=0,this.image=new Image,this.image.src=e}draw(t){let e=this.render.getContext(),i=this.render.convertToGlobal(this.state.position),s=this.render.getRect().pageWidth,n=this.render.getRect().height;e.save(),e.translate(i.x,i.y),e.beginPath();for(let o of this.state.area)o!==null&&(o=this.render.convertToGlobal(o),e.lineTo(o.x-i.x,o.y-i.y));e.rotate(this.state.angle),e.clip(),this.isLoad?e.drawImage(this.image,0,0,s,n):this.drawLoader(e,{x:0,y:0},s,n),e.restore()}simpleDraw(t){let e=this.render.getRect(),i=this.render.getContext(),s=e.pageWidth,n=e.height,o=t===1?e.left+e.pageWidth:e.left,h=e.top;this.isLoad?i.drawImage(this.image,o,h,s,n):this.drawLoader(i,{x:o,y:h},s,n)}drawLoader(t,e,i,s){t.beginPath(),t.strokeStyle="rgb(200, 200, 200)",t.fillStyle="rgb(255, 255, 255)",t.lineWidth=1,t.rect(e.x+1,e.y+1,i-1,s-1),t.stroke(),t.fill();let n={x:e.x+i/2,y:e.y+s/2};t.beginPath(),t.lineWidth=10,t.arc(n.x,n.y,20,this.loadingAngle,3*Math.PI/2+this.loadingAngle),t.stroke(),t.closePath(),this.loadingAngle+=.07,this.loadingAngle>=2*Math.PI&&(this.loadingAngle=0)}load(){this.isLoad||(this.image.onload=()=>{this.isLoad=!0})}newTemporaryCopy(){return this}getTemporaryCopy(){return this}hideTemporaryCopy(){}}class c{constructor(t,e){this.pages=[],this.currentPageIndex=0,this.currentSpreadIndex=0,this.landscapeSpread=[],this.portraitSpread=[],this.render=e,this.app=t,this.currentPageIndex=0,this.isShowCover=this.app.getSettings().showCover}destroy(){this.pages=[]}createSpread(){this.landscapeSpread=[],this.portraitSpread=[];for(let e=0;e=0&&t0?this.pages[e-1]:null}getFlippingPage(t){let e=this.currentSpreadIndex;if(this.render.getOrientation()==="portrait")return t===0?this.pages[e].newTemporaryCopy():this.pages[e-1];{let i=t===0?this.getSpread()[e+1]:this.getSpread()[e-1];return i.length===1||t===0?this.pages[i[0]]:this.pages[i[1]]}}getBottomPage(t){let e=this.currentSpreadIndex;if(this.render.getOrientation()==="portrait")return t===0?this.pages[e+1]:this.pages[e-1];{let i=t===0?this.getSpread()[e+1]:this.getSpread()[e-1];return i.length===1?this.pages[i[0]]:t===0?this.pages[i[1]]:this.pages[i[0]]}}showNext(){this.currentSpreadIndex0&&(this.currentSpreadIndex--,this.showSpread())}getCurrentPageIndex(){return this.currentPageIndex}show(t=null){if(t===null&&(t=this.currentPageIndex),t<0||t>=this.pages.length)return;let e=this.getSpreadIndexByPage(t);e!==null&&(this.currentSpreadIndex=e,this.showSpread())}getCurrentSpreadIndex(){return this.currentSpreadIndex}setCurrentSpreadIndex(t){if(!(t>=0&&t=t.left&&e.x<=t.width+t.left&&e.y>=t.top&&e.y<=t.top+t.height?e:null}static GetRotatedPoint(t,e,i){return{x:t.x*Math.cos(i)+t.y*Math.sin(i)+e.x,y:t.y*Math.cos(i)-t.x*Math.sin(i)+e.y}}static LimitPointToCircle(t,e,i){if(g.GetDistanceBetweenTwoPoint(t,i)<=e)return i;let s=t.x,n=t.y,o=i.x,h=i.y,d=Math.sqrt(Math.pow(e,2)*Math.pow(s-o,2)/(Math.pow(s-o,2)+Math.pow(n-h,2)))+s;i.x<0&&(d*=-1);let u=(d-s)*(n-h)/(s-o)+n;return s-o+n===0&&(u=e),{x:d,y:u}}static GetIntersectBetweenTwoSegment(t,e,i){return g.PointInRect(t,g.GetIntersectBeetwenTwoLine(e,i))}static GetIntersectBeetwenTwoLine(t,e){let i=t[0].y-t[1].y,s=e[0].y-e[1].y,n=t[1].x-t[0].x,o=e[1].x-e[0].x,h=t[0].x*t[1].y-t[1].x*t[0].y,d=e[0].x*e[1].y-e[1].x*e[0].y,u=i*d-s*h,x=n*d-o*h,T=-(h*o-d*n)/(i*o-s*n),L=-(i*d-s*h)/(i*o-s*n);if(isFinite(T)&&isFinite(L))return{x:T,y:L};if(Math.abs(u-x)<.1)throw new Error("Segment included");return null}static GetCordsFromTwoPoint(t,e){let i=Math.abs(t.x-e.x),s=Math.abs(t.y-e.y),n=Math.max(i,s),o=[t];function h(d,u,x,T,L){return u>d?d+L*(x/T):u()=>{try{return p||r((p={exports:{}}).exports,p),p.exports}catch(l){throw p=0,l}};var J=(r,p,l,c)=>{if(p&&typeof p=="object"||typeof p=="function")for(let w of j(p))!X.call(r,w)&&w!==l&&_(r,w,{get:()=>p[w],enumerable:!(c=Y(p,w))||c.enumerable});return r};var Q=(r,p,l)=>(l=r!=null?Z(V(r)):{},J(p||!r||!r.__esModule?_(l,"default",{value:r,enumerable:!0}):l,r));var G=K((A,O)=>{(function(r,p){typeof A=="object"&&typeof O<"u"?p(A):typeof define=="function"&&define.amd?define(["exports"],p):p((r=r||self).St={})})(A,(function(r){"use strict";class p{constructor(t,e){this.state={angle:0,area:[],position:{x:0,y:0},hardAngle:0,hardDrawingAngle:0},this.createdDensity=e,this.nowDrawingDensity=this.createdDensity,this.render=t}setDensity(t){this.createdDensity=t,this.nowDrawingDensity=t}setDrawingDensity(t){this.nowDrawingDensity=t}setPosition(t){this.state.position=t}setAngle(t){this.state.angle=t}setArea(t){this.state.area=t}setHardDrawingAngle(t){this.state.hardDrawingAngle=t}setHardAngle(t){this.state.hardAngle=t,this.state.hardDrawingAngle=t}setOrientation(t){this.orientation=t}getDrawingDensity(){return this.nowDrawingDensity}getDensity(){return this.createdDensity}getHardAngle(){return this.state.hardAngle}}class l extends p{constructor(t,e,i){super(t,i),this.image=null,this.isLoad=!1,this.loadingAngle=0,this.image=new Image,this.image.src=e}draw(t){let e=this.render.getContext(),i=this.render.convertToGlobal(this.state.position),s=this.render.getRect().pageWidth,n=this.render.getRect().height;e.save(),e.translate(i.x,i.y),e.beginPath();for(let o of this.state.area)o!==null&&(o=this.render.convertToGlobal(o),e.lineTo(o.x-i.x,o.y-i.y));e.rotate(this.state.angle),e.clip(),this.isLoad?e.drawImage(this.image,0,0,s,n):this.drawLoader(e,{x:0,y:0},s,n),e.restore()}simpleDraw(t){let e=this.render.getRect(),i=this.render.getContext(),s=e.pageWidth,n=e.height,o=t===1?e.left+e.pageWidth:e.left,h=e.top;this.isLoad?i.drawImage(this.image,o,h,s,n):this.drawLoader(i,{x:o,y:h},s,n)}drawLoader(t,e,i,s){t.beginPath(),t.strokeStyle="rgb(200, 200, 200)",t.fillStyle="rgb(255, 255, 255)",t.lineWidth=1,t.rect(e.x+1,e.y+1,i-1,s-1),t.stroke(),t.fill();let n={x:e.x+i/2,y:e.y+s/2};t.beginPath(),t.lineWidth=10,t.arc(n.x,n.y,20,this.loadingAngle,3*Math.PI/2+this.loadingAngle),t.stroke(),t.closePath(),this.loadingAngle+=.07,this.loadingAngle>=2*Math.PI&&(this.loadingAngle=0)}load(){this.isLoad||(this.image.onload=()=>{this.isLoad=!0})}newTemporaryCopy(){return this}getTemporaryCopy(){return this}hideTemporaryCopy(){}}class c{constructor(t,e){this.pages=[],this.currentPageIndex=0,this.currentSpreadIndex=0,this.landscapeSpread=[],this.portraitSpread=[],this.render=e,this.app=t,this.currentPageIndex=0,this.isShowCover=this.app.getSettings().showCover}destroy(){this.pages=[]}createSpread(){this.landscapeSpread=[],this.portraitSpread=[];for(let e=0;e=0&&t0?this.pages[e-1]:null}getFlippingPage(t){let e=this.currentSpreadIndex;if(this.render.getOrientation()==="portrait")return t===0?this.pages[e].newTemporaryCopy():this.pages[e-1];{let i=t===0?this.getSpread()[e+1]:this.getSpread()[e-1];return i.length===1||t===0?this.pages[i[0]]:this.pages[i[1]]}}getBottomPage(t){let e=this.currentSpreadIndex;if(this.render.getOrientation()==="portrait")return t===0?this.pages[e+1]:this.pages[e-1];{let i=t===0?this.getSpread()[e+1]:this.getSpread()[e-1];return i.length===1?this.pages[i[0]]:t===0?this.pages[i[1]]:this.pages[i[0]]}}showNext(){this.currentSpreadIndex0&&(this.currentSpreadIndex--,this.showSpread())}getCurrentPageIndex(){return this.currentPageIndex}show(t=null){if(t===null&&(t=this.currentPageIndex),t<0||t>=this.pages.length)return;let e=this.getSpreadIndexByPage(t);e!==null&&(this.currentSpreadIndex=e,this.showSpread())}getCurrentSpreadIndex(){return this.currentSpreadIndex}setCurrentSpreadIndex(t){if(!(t>=0&&t=t.left&&e.x<=t.width+t.left&&e.y>=t.top&&e.y<=t.top+t.height?e:null}static GetRotatedPoint(t,e,i){return{x:t.x*Math.cos(i)+t.y*Math.sin(i)+e.x,y:t.y*Math.cos(i)-t.x*Math.sin(i)+e.y}}static LimitPointToCircle(t,e,i){if(g.GetDistanceBetweenTwoPoint(t,i)<=e)return i;let s=t.x,n=t.y,o=i.x,h=i.y,d=Math.sqrt(Math.pow(e,2)*Math.pow(s-o,2)/(Math.pow(s-o,2)+Math.pow(n-h,2)))+s;i.x<0&&(d*=-1);let u=(d-s)*(n-h)/(s-o)+n;return s-o+n===0&&(u=e),{x:d,y:u}}static GetIntersectBetweenTwoSegment(t,e,i){return g.PointInRect(t,g.GetIntersectBeetwenTwoLine(e,i))}static GetIntersectBeetwenTwoLine(t,e){let i=t[0].y-t[1].y,s=e[0].y-e[1].y,n=t[1].x-t[0].x,o=e[1].x-e[0].x,h=t[0].x*t[1].y-t[1].x*t[0].y,d=e[0].x*e[1].y-e[1].x*e[0].y,u=i*d-s*h,x=n*d-o*h,C=-(h*o-d*n)/(i*o-s*n),L=-(i*d-s*h)/(i*o-s*n);if(isFinite(C)&&isFinite(L))return{x:C,y:L};if(Math.abs(u-x)<.1)throw new Error("Segment included");return null}static GetCordsFromTwoPoint(t,e){let i=Math.abs(t.x-e.x),s=Math.abs(t.y-e.y),n=Math.max(i,s),o=[t];function h(d,u,x,C,L){return u>d?d+L*(x/C):u=10&&t.push(this.sideIntersectPoint):this.corner==="top"&&t.push({x:this.pageWidth,y:this.pageHeight}),t.push(this.bottomIntersectPoint),t.push(this.topIntersectPoint),t}getAngle(){return this.direction===0?-this.angle:this.angle}getRect(){return this.rect}getPosition(){return this.position}getActiveCorner(){return this.direction===0?this.rect.topLeft:this.rect.topRight}getDirection(){return this.direction}getFlippingProgress(){return Math.abs((this.position.x-this.pageWidth)/(2*this.pageWidth)*100)}getCorner(){return this.corner}getBottomPagePosition(){return this.direction===1?{x:this.pageWidth,y:0}:{x:0,y:0}}getShadowStartPoint(){return this.corner==="top"?this.topIntersectPoint:this.sideIntersectPoint!==null?this.sideIntersectPoint:this.topIntersectPoint}getShadowAngle(){let t=g.GetAngleBetweenTwoLine(this.getSegmentToShadowLine(),[{x:0,y:0},{x:this.pageWidth,y:0}]);return this.direction===0?t:Math.PI-t}calcAngleAndPosition(t){let e=t;if(this.updateAngleAndGeometry(e),e=this.corner==="top"?this.checkPositionAtCenterLine(e,{x:0,y:0},{x:0,y:this.pageHeight}):this.checkPositionAtCenterLine(e,{x:0,y:this.pageHeight},{x:0,y:0}),Math.abs(e.x-this.pageWidth)<1&&Math.abs(e.y)<1)throw new Error("Point is too small");return e}updateAngleAndGeometry(t){this.angle=this.calculateAngle(t),this.rect=this.getPageRect(t)}calculateAngle(t){let e=this.pageWidth-t.x+1,i=this.corner==="bottom"?this.pageHeight-t.y:t.y,s=2*Math.acos(e/Math.sqrt(i*i+e*e));i<0&&(s=-s);let n=Math.PI-s;if(!isFinite(s)||n>=0&&n<.003)throw new Error("The G point is too small");return this.corner==="bottom"&&(s=-s),s}getPageRect(t){return this.corner==="top"?this.getRectFromBasePoint([{x:0,y:0},{x:this.pageWidth,y:0},{x:0,y:this.pageHeight},{x:this.pageWidth,y:this.pageHeight}],t):this.getRectFromBasePoint([{x:0,y:-this.pageHeight},{x:this.pageWidth,y:-this.pageHeight},{x:0,y:0},{x:this.pageWidth,y:0}],t)}getRectFromBasePoint(t,e){return{topLeft:this.getRotatedPoint(t[0],e),topRight:this.getRotatedPoint(t[1],e),bottomLeft:this.getRotatedPoint(t[2],e),bottomRight:this.getRotatedPoint(t[3],e)}}getRotatedPoint(t,e){return{x:t.x*Math.cos(this.angle)+t.y*Math.sin(this.angle)+e.x,y:t.y*Math.cos(this.angle)-t.x*Math.sin(this.angle)+e.y}}calculateIntersectPoint(t){let e={left:-1,top:-1,width:this.pageWidth+2,height:this.pageHeight+2};this.corner==="top"?(this.topIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[t,this.rect.topRight],[{x:0,y:0},{x:this.pageWidth,y:0}]),this.sideIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[t,this.rect.bottomLeft],[{x:this.pageWidth,y:0},{x:this.pageWidth,y:this.pageHeight}]),this.bottomIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[this.rect.bottomLeft,this.rect.bottomRight],[{x:0,y:this.pageHeight},{x:this.pageWidth,y:this.pageHeight}])):(this.topIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[this.rect.topLeft,this.rect.topRight],[{x:0,y:0},{x:this.pageWidth,y:0}]),this.sideIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[t,this.rect.topLeft],[{x:this.pageWidth,y:0},{x:this.pageWidth,y:this.pageHeight}]),this.bottomIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[this.rect.bottomLeft,this.rect.bottomRight],[{x:0,y:this.pageHeight},{x:this.pageWidth,y:this.pageHeight}]))}checkPositionAtCenterLine(t,e,i){let s=t,n=g.LimitPointToCircle(e,this.pageWidth,s);s!==n&&(s=n,this.updateAngleAndGeometry(s));let o=Math.sqrt(Math.pow(this.pageWidth,2)+Math.pow(this.pageHeight,2)),h=this.rect.bottomRight,d=this.rect.topLeft;if(this.corner==="bottom"&&(h=this.rect.topRight,d=this.rect.bottomLeft),h.x<=0){let u=g.LimitPointToCircle(i,o,d);u!==s&&(s=u,this.updateAngleAndGeometry(s))}return s}getSegmentToShadowLine(){let t=this.getShadowStartPoint();return[t,t!==this.sideIntersectPoint&&this.sideIntersectPoint!==null?this.sideIntersectPoint:this.bottomIntersectPoint]}}class b{constructor(t,e){this.flippingPage=null,this.bottomPage=null,this.calc=null,this.state="read",this.render=t,this.app=e}fold(t){this.setState("user_fold"),this.calc===null&&this.start(t),this.do(this.render.convertToPage(t))}flip(t){if(this.app.getSettings().disableFlipByClick&&!this.isPointOnCorners(t)||(this.calc!==null&&this.render.finishAnimation(),!this.start(t)))return;let e=this.getBoundsRect();this.setState("flipping");let i=e.height/10,s=this.calc.getCorner()==="bottom"?e.height-i:i,n=this.calc.getCorner()==="bottom"?e.height:0;this.calc.calc({x:e.pageWidth-i,y:s}),this.animateFlippingTo({x:e.pageWidth-i,y:s},{x:-e.pageWidth,y:n},!0)}start(t){this.reset();let e=this.render.convertToBook(t),i=this.getBoundsRect(),s=this.getDirectionByPoint(e),n=e.y>=i.height/2?"bottom":"top";if(!this.checkDirection(s))return!1;try{if(this.flippingPage=this.app.getPageCollection().getFlippingPage(s),this.bottomPage=this.app.getPageCollection().getBottomPage(s),this.render.getOrientation()==="landscape")if(s===1){let o=this.app.getPageCollection().nextBy(this.flippingPage);o!==null&&this.flippingPage.getDensity()!==o.getDensity()&&(this.flippingPage.setDrawingDensity("hard"),o.setDrawingDensity("hard"))}else{let o=this.app.getPageCollection().prevBy(this.flippingPage);o!==null&&this.flippingPage.getDensity()!==o.getDensity()&&(this.flippingPage.setDrawingDensity("hard"),o.setDrawingDensity("hard"))}return this.render.setDirection(s),this.calc=new P(s,n,i.pageWidth.toString(10),i.height.toString(10)),!0}catch{return!1}}do(t){if(this.calc!==null&&this.calc.calc(t)){let e=this.calc.getFlippingProgress();this.bottomPage.setArea(this.calc.getBottomClipArea()),this.bottomPage.setPosition(this.calc.getBottomPagePosition()),this.bottomPage.setAngle(0),this.bottomPage.setHardAngle(0),this.flippingPage.setArea(this.calc.getFlippingClipArea()),this.flippingPage.setPosition(this.calc.getActiveCorner()),this.flippingPage.setAngle(this.calc.getAngle()),this.calc.getDirection()===0?this.flippingPage.setHardAngle(90*(200-2*e)/100):this.flippingPage.setHardAngle(-90*(200-2*e)/100),this.render.setPageRect(this.calc.getRect()),this.render.setBottomPage(this.bottomPage),this.render.setFlippingPage(this.flippingPage),this.render.setShadowData(this.calc.getShadowStartPoint(),this.calc.getShadowAngle(),e,this.calc.getDirection())}}flipToPage(t,e){let i=this.app.getPageCollection().getCurrentSpreadIndex(),s=this.app.getPageCollection().getSpreadIndexByPage(t);try{s>i&&(this.app.getPageCollection().setCurrentSpreadIndex(s-1),this.flipNext(e)),sthis.do(d));let h=this.getAnimationDuration(n.length);this.render.startAnimation(o,h,()=>{this.calc&&(i&&(this.calc.getDirection()===1?this.app.turnToPrevPage():this.app.turnToNextPage()),s&&(this.render.setBottomPage(null),this.render.setFlippingPage(null),this.render.clearShadow(),this.setState("read"),this.reset()))})}getCalculation(){return this.calc}getState(){return this.state}setState(t){this.state!==t&&(this.app.updateState(t),this.state=t)}getDirectionByPoint(t){let e=this.getBoundsRect();if(this.render.getOrientation()==="portrait"){if(t.x-e.pageWidth<=e.width/5)return 1}else if(t.x=1e3?e:t/1e3*e}checkDirection(t){return t===0?this.app.getCurrentPageIndex()=1}reset(){this.calc=null,this.flippingPage=null,this.bottomPage=null}getBoundsRect(){return this.render.getRect()}checkState(...t){for(let e of t)if(this.state===e)return!0;return!1}isPointOnCorners(t){let e=this.getBoundsRect(),i=e.pageWidth,s=Math.sqrt(Math.pow(i,2)+Math.pow(e.height,2))/5,n=this.render.convertToBook(t);return n.x>0&&n.y>0&&n.xe.width-s)&&(n.ye.height-s)}}class v{constructor(t,e){this.leftPage=null,this.rightPage=null,this.flippingPage=null,this.bottomPage=null,this.direction=null,this.orientation=null,this.shadow=null,this.animation=null,this.pageRect=null,this.boundsRect=null,this.timer=0,this.safari=!1,this.setting=e,this.app=t;let i=new RegExp("Version\\/[\\d\\.]+.*Safari/");this.safari=i.exec(window.navigator.userAgent)!==null}render(t){if(this.animation!==null){let e=Math.round((t-this.animation.startedAt)/this.animation.durationFrame);e{this.render(e),requestAnimationFrame(t)};requestAnimationFrame(t)}startAnimation(t,e,i){this.finishAnimation(),this.animation={frames:t,duration:e,durationFrame:e/t.length,onAnimateEnd:i,startedAt:this.timer}}finishAnimation(){this.animation!==null&&(this.animation.frames[this.animation.frames.length-1](),this.animation.onAnimateEnd!==null&&this.animation.onAnimateEnd()),this.animation=null}update(){this.boundsRect=null;let t=this.calculateBoundsRect();this.orientation!==t&&(this.orientation=t,this.app.updateOrientation(t))}calculateBoundsRect(){let t="landscape",e=this.getBlockWidth(),i=e/2,s=this.getBlockHeight()/2,n=this.setting.width/this.setting.height,o=this.setting.width,h=this.setting.height,d=i-o;return this.setting.size==="stretch"?(e<2*this.setting.minWidth&&this.app.getSettings().usePortrait&&(t="portrait"),o=t==="portrait"?this.getBlockWidth():this.getBlockWidth()/2,o>this.setting.maxWidth&&(o=this.setting.maxWidth),h=o/n,h>this.getBlockHeight()&&(h=this.getBlockHeight(),o=h*n),d=t==="portrait"?i-o/2-o:i-o):e<2*o&&this.app.getSettings().usePortrait&&(t="portrait",d=i-o/2-o),this.boundsRect={left:d,top:s-h/2,width:2*o,height:h,pageWidth:o},t}setShadowData(t,e,i,s){if(!this.app.getSettings().drawShadow)return;let n=100*this.getSettings().maxShadowOpacity;this.shadow={pos:t,angle:e,width:3*this.getRect().pageWidth/4*i/100,opacity:(100-i)*n/100/100,direction:s,progress:2*i}}clearShadow(){this.shadow=null}getBlockWidth(){return this.app.getUI().getDistElement().offsetWidth}getBlockHeight(){return this.app.getUI().getDistElement().offsetHeight}getDirection(){return this.direction}getRect(){return this.boundsRect===null&&this.calculateBoundsRect(),this.boundsRect}getSettings(){return this.app.getSettings()}getOrientation(){return this.orientation}setPageRect(t){this.pageRect=t}setDirection(t){this.direction=t}setRightPage(t){t!==null&&t.setOrientation(1),this.rightPage=t}setLeftPage(t){t!==null&&t.setOrientation(0),this.leftPage=t}setBottomPage(t){t!==null&&t.setOrientation(this.direction===1?0:1),this.bottomPage=t}setFlippingPage(t){t!==null&&t.setOrientation(this.direction===0&&this.orientation!=="portrait"?0:1),this.flippingPage=t}convertToBook(t){let e=this.getRect();return{x:t.x-e.left,y:t.y-e.top}}isSafari(){return this.safari}convertToPage(t,e){e||(e=this.direction);let i=this.getRect();return{x:e===0?t.x-i.left-i.width/2:i.width/2-t.x+i.left,y:t.y-i.top}}convertToGlobal(t,e){if(e||(e=this.direction),t==null)return null;let i=this.getRect();return{x:e===0?t.x+i.left+i.width/2:i.width/2-t.x+i.left,y:t.y+i.top}}convertRectToGlobal(t,e){return e||(e=this.direction),{topLeft:this.convertToGlobal(t.topLeft,e),topRight:this.convertToGlobal(t.topRight,e),bottomLeft:this.convertToGlobal(t.bottomLeft,e),bottomRight:this.convertToGlobal(t.bottomRight,e)}}}class I extends v{constructor(t,e,i){super(t,e),this.canvas=i,this.ctx=i.getContext("2d")}getContext(){return this.ctx}reload(){}drawFrame(){this.clear(),this.orientation!=="portrait"&&this.leftPage!=null&&this.leftPage.simpleDraw(0),this.rightPage!=null&&this.rightPage.simpleDraw(1),this.bottomPage!=null&&this.bottomPage.draw(),this.drawBookShadow(),this.flippingPage!=null&&this.flippingPage.draw(),this.shadow!=null&&(this.drawOuterShadow(),this.drawInnerShadow());let t=this.getRect();this.orientation==="portrait"&&(this.ctx.beginPath(),this.ctx.rect(t.left+t.pageWidth,t.top,t.width,t.height),this.ctx.clip())}drawBookShadow(){let t=this.getRect();this.ctx.save(),this.ctx.beginPath();let e=t.width/20;this.ctx.rect(t.left,t.top,t.width,t.height);let i={x:t.left+t.width/2-e/2,y:0};this.ctx.translate(i.x,i.y);let s=this.ctx.createLinearGradient(0,0,e,0);s.addColorStop(0,"rgba(0, 0, 0, 0)"),s.addColorStop(.4,"rgba(0, 0, 0, 0.2)"),s.addColorStop(.49,"rgba(0, 0, 0, 0.1)"),s.addColorStop(.5,"rgba(0, 0, 0, 0.5)"),s.addColorStop(.51,"rgba(0, 0, 0, 0.4)"),s.addColorStop(1,"rgba(0, 0, 0, 0)"),this.ctx.clip(),this.ctx.fillStyle=s,this.ctx.fillRect(0,0,e,2*t.height),this.ctx.restore()}drawOuterShadow(){let t=this.getRect();this.ctx.save(),this.ctx.beginPath(),this.ctx.rect(t.left,t.top,t.width,t.height);let e=this.convertToGlobal({x:this.shadow.pos.x,y:this.shadow.pos.y});this.ctx.translate(e.x,e.y),this.ctx.rotate(Math.PI+this.shadow.angle+Math.PI/2);let i=this.ctx.createLinearGradient(0,0,this.shadow.width,0);this.shadow.direction===0?(this.ctx.translate(0,-100),i.addColorStop(0,"rgba(0, 0, 0, "+this.shadow.opacity+")"),i.addColorStop(1,"rgba(0, 0, 0, 0)")):(this.ctx.translate(-this.shadow.width,-100),i.addColorStop(0,"rgba(0, 0, 0, 0)"),i.addColorStop(1,"rgba(0, 0, 0, "+this.shadow.opacity+")")),this.ctx.clip(),this.ctx.fillStyle=i,this.ctx.fillRect(0,0,this.shadow.width,2*t.height),this.ctx.restore()}drawInnerShadow(){let t=this.getRect();this.ctx.save(),this.ctx.beginPath();let e=this.convertToGlobal({x:this.shadow.pos.x,y:this.shadow.pos.y}),i=this.convertRectToGlobal(this.pageRect);this.ctx.moveTo(i.topLeft.x,i.topLeft.y),this.ctx.lineTo(i.topRight.x,i.topRight.y),this.ctx.lineTo(i.bottomRight.x,i.bottomRight.y),this.ctx.lineTo(i.bottomLeft.x,i.bottomLeft.y),this.ctx.translate(e.x,e.y),this.ctx.rotate(Math.PI+this.shadow.angle+Math.PI/2);let s=3*this.shadow.width/4,n=this.ctx.createLinearGradient(0,0,s,0);this.shadow.direction===0?(this.ctx.translate(-s,-100),n.addColorStop(1,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(.9,"rgba(0, 0, 0, 0.05)"),n.addColorStop(.7,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(0,"rgba(0, 0, 0, 0)")):(this.ctx.translate(0,-100),n.addColorStop(0,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(.1,"rgba(0, 0, 0, 0.05)"),n.addColorStop(.3,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(1,"rgba(0, 0, 0, 0)")),this.ctx.clip(),this.ctx.fillStyle=n,this.ctx.fillRect(0,0,s,2*t.height),this.ctx.restore()}clear(){this.ctx.fillStyle="white",this.ctx.fillRect(0,0,this.canvas.width,this.canvas.height)}}class C{constructor(t,e,i){this.touchPoint=null,this.swipeTimeout=250,this.onResize=()=>{this.update()},this.onMouseDown=n=>{if(this.checkTarget(n.target)){let o=this.getMousePos(n.clientX,n.clientY);this.app.startUserTouch(o),n.preventDefault()}},this.onTouchStart=n=>{if(this.checkTarget(n.target)&&n.changedTouches.length>0){let o=n.changedTouches[0],h=this.getMousePos(o.clientX,o.clientY);this.touchPoint={point:h,time:Date.now()},setTimeout(()=>{this.touchPoint!==null&&this.app.startUserTouch(h)},this.swipeTimeout),this.app.getSettings().mobileScrollSupport||n.preventDefault()}},this.onMouseUp=n=>{let o=this.getMousePos(n.clientX,n.clientY);this.app.userStop(o)},this.onMouseMove=n=>{let o=this.getMousePos(n.clientX,n.clientY);this.app.userMove(o,!1)},this.onTouchMove=n=>{if(n.changedTouches.length>0){let o=n.changedTouches[0],h=this.getMousePos(o.clientX,o.clientY);this.app.getSettings().mobileScrollSupport?(this.touchPoint!==null&&(Math.abs(this.touchPoint.point.x-h.x)>10||this.app.getState()!=="read")&&n.cancelable&&this.app.userMove(h,!0),this.app.getState()!=="read"&&n.preventDefault()):this.app.userMove(h,!0)}},this.onTouchEnd=n=>{if(n.changedTouches.length>0){let o=n.changedTouches[0],h=this.getMousePos(o.clientX,o.clientY),d=!1;if(this.touchPoint!==null){let u=h.x-this.touchPoint.point.x,x=Math.abs(h.y-this.touchPoint.point.y);Math.abs(u)>this.swipeDistance&&x<2*this.swipeDistance&&Date.now()-this.touchPoint.time0?this.app.flipPrev(this.touchPoint.point.y'),this.wrapper=t.querySelector(".stf__wrapper"),this.app=e;let s=this.app.getSettings().usePortrait?1:2;t.style.minWidth=i.minWidth*s+"px",t.style.minHeight=i.minHeight+"px",i.size==="fixed"&&(t.style.minWidth=i.width*s+"px",t.style.minHeight=i.height+"px"),i.autoSize&&(t.style.width="100%",t.style.maxWidth=2*i.maxWidth+"px"),t.style.display="block",window.addEventListener("resize",this.onResize,!1),this.swipeDistance=i.swipeDistance}destroy(){this.app.getSettings().useMouseEvents&&this.removeHandlers(),this.distElement.remove(),this.wrapper.remove()}getDistElement(){return this.distElement}getWrapper(){return this.wrapper}setOrientationStyle(t){this.wrapper.classList.remove("--portrait","--landscape"),t==="portrait"?(this.app.getSettings().autoSize&&(this.wrapper.style.paddingBottom=this.app.getSettings().height/this.app.getSettings().width*100+"%"),this.wrapper.classList.add("--portrait")):(this.app.getSettings().autoSize&&(this.wrapper.style.paddingBottom=this.app.getSettings().height/(2*this.app.getSettings().width)*100+"%"),this.wrapper.classList.add("--landscape")),this.update()}removeHandlers(){window.removeEventListener("resize",this.onResize),this.distElement.removeEventListener("mousedown",this.onMouseDown),this.distElement.removeEventListener("touchstart",this.onTouchStart),window.removeEventListener("mousemove",this.onMouseMove),window.removeEventListener("touchmove",this.onTouchMove),window.removeEventListener("mouseup",this.onMouseUp),window.removeEventListener("touchend",this.onTouchEnd)}setHandlers(){window.addEventListener("resize",this.onResize,!1),this.app.getSettings().useMouseEvents&&(this.distElement.addEventListener("mousedown",this.onMouseDown),this.distElement.addEventListener("touchstart",this.onTouchStart),window.addEventListener("mousemove",this.onMouseMove),window.addEventListener("touchmove",this.onTouchMove,{passive:!this.app.getSettings().mobileScrollSupport}),window.addEventListener("mouseup",this.onMouseUp),window.addEventListener("touchend",this.onTouchEnd))}getMousePos(t,e){let i=this.distElement.getBoundingClientRect();return{x:t-i.left,y:e-i.top}}checkTarget(t){return!this.app.getSettings().clickEventForward||!["a","button"].includes(t.tagName.toLowerCase())}}class S extends C{constructor(t,e,i,s){super(t,e,i),this.wrapper.insertAdjacentHTML("afterbegin",'
    '),this.distElement=t.querySelector(".stf__block"),this.items=s;for(let n of s)this.distElement.appendChild(n);this.setHandlers()}clear(){for(let t of this.items)this.parentElement.appendChild(t)}updateItems(t){this.removeHandlers(),this.distElement.innerHTML="";for(let e of t)this.distElement.appendChild(e);this.items=t,this.setHandlers()}update(){this.app.getRender().update()}}class k extends C{constructor(t,e,i){super(t,e,i),this.wrapper.innerHTML='',this.canvas=t.querySelectorAll("canvas")[0],this.distElement=this.canvas,this.resizeCanvas(),this.setHandlers()}resizeCanvas(){let t=getComputedStyle(this.canvas),e=parseInt(t.getPropertyValue("width"),10),i=parseInt(t.getPropertyValue("height"),10);this.canvas.width=e,this.canvas.height=i}getCanvas(){return this.canvas}update(){this.resizeCanvas(),this.app.getRender().update()}}class M extends v{constructor(t,e,i){super(t,e),this.outerShadow=null,this.innerShadow=null,this.hardShadow=null,this.hardInnerShadow=null,this.element=i,this.createShadows()}createShadows(){this.element.insertAdjacentHTML("beforeend",`
    + z-index: ${this.render.getSettings().startZIndex+1};`}getElement(){return this.element}load(){this.isLoad=!0}setOrientation(t){super.setOrientation(t),this.element.classList.remove("--left","--right"),this.element.classList.add(t===1?"--right":"--left")}setDrawingDensity(t){this.element.classList.remove("--soft","--hard"),this.element.classList.add("--"+t),super.setDrawingDensity(t)}}class f extends c{constructor(t,e,i,s){super(t,e),this.element=i,this.pagesElement=s}load(){for(let t of this.pagesElement){let e=new m(this.render,t,t.dataset.density==="hard"?"hard":"soft");e.load(),this.pages.push(e)}this.createSpread()}}class P{constructor(t,e,i,s){this.direction=t,this.corner=e,this.topIntersectPoint=null,this.sideIntersectPoint=null,this.bottomIntersectPoint=null,this.pageWidth=parseInt(i,10),this.pageHeight=parseInt(s,10)}calc(t){try{return this.position=this.calcAngleAndPosition(t),this.calculateIntersectPoint(this.position),!0}catch{return!1}}getFlippingClipArea(){let t=[],e=!1;return t.push(this.rect.topLeft),t.push(this.topIntersectPoint),this.sideIntersectPoint===null?e=!0:(t.push(this.sideIntersectPoint),this.bottomIntersectPoint===null&&(e=!1)),t.push(this.bottomIntersectPoint),(e||this.corner==="bottom")&&t.push(this.rect.bottomLeft),t}getBottomClipArea(){let t=[];return t.push(this.topIntersectPoint),this.corner==="top"?t.push({x:this.pageWidth,y:0}):(this.topIntersectPoint!==null&&t.push({x:this.pageWidth,y:0}),t.push({x:this.pageWidth,y:this.pageHeight})),this.sideIntersectPoint!==null?g.GetDistanceBetweenTwoPoint(this.sideIntersectPoint,this.topIntersectPoint)>=10&&t.push(this.sideIntersectPoint):this.corner==="top"&&t.push({x:this.pageWidth,y:this.pageHeight}),t.push(this.bottomIntersectPoint),t.push(this.topIntersectPoint),t}getAngle(){return this.direction===0?-this.angle:this.angle}getRect(){return this.rect}getPosition(){return this.position}getActiveCorner(){return this.direction===0?this.rect.topLeft:this.rect.topRight}getDirection(){return this.direction}getFlippingProgress(){return Math.abs((this.position.x-this.pageWidth)/(2*this.pageWidth)*100)}getCorner(){return this.corner}getBottomPagePosition(){return this.direction===1?{x:this.pageWidth,y:0}:{x:0,y:0}}getShadowStartPoint(){return this.corner==="top"?this.topIntersectPoint:this.sideIntersectPoint!==null?this.sideIntersectPoint:this.topIntersectPoint}getShadowAngle(){let t=g.GetAngleBetweenTwoLine(this.getSegmentToShadowLine(),[{x:0,y:0},{x:this.pageWidth,y:0}]);return this.direction===0?t:Math.PI-t}calcAngleAndPosition(t){let e=t;if(this.updateAngleAndGeometry(e),e=this.corner==="top"?this.checkPositionAtCenterLine(e,{x:0,y:0},{x:0,y:this.pageHeight}):this.checkPositionAtCenterLine(e,{x:0,y:this.pageHeight},{x:0,y:0}),Math.abs(e.x-this.pageWidth)<1&&Math.abs(e.y)<1)throw new Error("Point is too small");return e}updateAngleAndGeometry(t){this.angle=this.calculateAngle(t),this.rect=this.getPageRect(t)}calculateAngle(t){let e=this.pageWidth-t.x+1,i=this.corner==="bottom"?this.pageHeight-t.y:t.y,s=2*Math.acos(e/Math.sqrt(i*i+e*e));i<0&&(s=-s);let n=Math.PI-s;if(!isFinite(s)||n>=0&&n<.003)throw new Error("The G point is too small");return this.corner==="bottom"&&(s=-s),s}getPageRect(t){return this.corner==="top"?this.getRectFromBasePoint([{x:0,y:0},{x:this.pageWidth,y:0},{x:0,y:this.pageHeight},{x:this.pageWidth,y:this.pageHeight}],t):this.getRectFromBasePoint([{x:0,y:-this.pageHeight},{x:this.pageWidth,y:-this.pageHeight},{x:0,y:0},{x:this.pageWidth,y:0}],t)}getRectFromBasePoint(t,e){return{topLeft:this.getRotatedPoint(t[0],e),topRight:this.getRotatedPoint(t[1],e),bottomLeft:this.getRotatedPoint(t[2],e),bottomRight:this.getRotatedPoint(t[3],e)}}getRotatedPoint(t,e){return{x:t.x*Math.cos(this.angle)+t.y*Math.sin(this.angle)+e.x,y:t.y*Math.cos(this.angle)-t.x*Math.sin(this.angle)+e.y}}calculateIntersectPoint(t){let e={left:-1,top:-1,width:this.pageWidth+2,height:this.pageHeight+2};this.corner==="top"?(this.topIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[t,this.rect.topRight],[{x:0,y:0},{x:this.pageWidth,y:0}]),this.sideIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[t,this.rect.bottomLeft],[{x:this.pageWidth,y:0},{x:this.pageWidth,y:this.pageHeight}]),this.bottomIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[this.rect.bottomLeft,this.rect.bottomRight],[{x:0,y:this.pageHeight},{x:this.pageWidth,y:this.pageHeight}])):(this.topIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[this.rect.topLeft,this.rect.topRight],[{x:0,y:0},{x:this.pageWidth,y:0}]),this.sideIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[t,this.rect.topLeft],[{x:this.pageWidth,y:0},{x:this.pageWidth,y:this.pageHeight}]),this.bottomIntersectPoint=g.GetIntersectBetweenTwoSegment(e,[this.rect.bottomLeft,this.rect.bottomRight],[{x:0,y:this.pageHeight},{x:this.pageWidth,y:this.pageHeight}]))}checkPositionAtCenterLine(t,e,i){let s=t,n=g.LimitPointToCircle(e,this.pageWidth,s);s!==n&&(s=n,this.updateAngleAndGeometry(s));let o=Math.sqrt(Math.pow(this.pageWidth,2)+Math.pow(this.pageHeight,2)),h=this.rect.bottomRight,d=this.rect.topLeft;if(this.corner==="bottom"&&(h=this.rect.topRight,d=this.rect.bottomLeft),h.x<=0){let u=g.LimitPointToCircle(i,o,d);u!==s&&(s=u,this.updateAngleAndGeometry(s))}return s}getSegmentToShadowLine(){let t=this.getShadowStartPoint();return[t,t!==this.sideIntersectPoint&&this.sideIntersectPoint!==null?this.sideIntersectPoint:this.bottomIntersectPoint]}}class S{constructor(t,e){this.flippingPage=null,this.bottomPage=null,this.calc=null,this.state="read",this.render=t,this.app=e}fold(t){this.setState("user_fold"),this.calc===null&&this.start(t),this.do(this.render.convertToPage(t))}flip(t){if(this.app.getSettings().disableFlipByClick&&!this.isPointOnCorners(t)||(this.calc!==null&&this.render.finishAnimation(),!this.start(t)))return;let e=this.getBoundsRect();this.setState("flipping");let i=e.height/10,s=this.calc.getCorner()==="bottom"?e.height-i:i,n=this.calc.getCorner()==="bottom"?e.height:0;this.calc.calc({x:e.pageWidth-i,y:s}),this.animateFlippingTo({x:e.pageWidth-i,y:s},{x:-e.pageWidth,y:n},!0)}start(t){this.reset();let e=this.render.convertToBook(t),i=this.getBoundsRect(),s=this.getDirectionByPoint(e),n=e.y>=i.height/2?"bottom":"top";if(!this.checkDirection(s))return!1;try{if(this.flippingPage=this.app.getPageCollection().getFlippingPage(s),this.bottomPage=this.app.getPageCollection().getBottomPage(s),this.render.getOrientation()==="landscape")if(s===1){let o=this.app.getPageCollection().nextBy(this.flippingPage);o!==null&&this.flippingPage.getDensity()!==o.getDensity()&&(this.flippingPage.setDrawingDensity("hard"),o.setDrawingDensity("hard"))}else{let o=this.app.getPageCollection().prevBy(this.flippingPage);o!==null&&this.flippingPage.getDensity()!==o.getDensity()&&(this.flippingPage.setDrawingDensity("hard"),o.setDrawingDensity("hard"))}return this.render.setDirection(s),this.calc=new P(s,n,i.pageWidth.toString(10),i.height.toString(10)),!0}catch{return!1}}do(t){if(this.calc!==null&&this.calc.calc(t)){let e=this.calc.getFlippingProgress();this.bottomPage.setArea(this.calc.getBottomClipArea()),this.bottomPage.setPosition(this.calc.getBottomPagePosition()),this.bottomPage.setAngle(0),this.bottomPage.setHardAngle(0),this.flippingPage.setArea(this.calc.getFlippingClipArea()),this.flippingPage.setPosition(this.calc.getActiveCorner()),this.flippingPage.setAngle(this.calc.getAngle()),this.calc.getDirection()===0?this.flippingPage.setHardAngle(90*(200-2*e)/100):this.flippingPage.setHardAngle(-90*(200-2*e)/100),this.render.setPageRect(this.calc.getRect()),this.render.setBottomPage(this.bottomPage),this.render.setFlippingPage(this.flippingPage),this.render.setShadowData(this.calc.getShadowStartPoint(),this.calc.getShadowAngle(),e,this.calc.getDirection())}}flipToPage(t,e){let i=this.app.getPageCollection().getCurrentSpreadIndex(),s=this.app.getPageCollection().getSpreadIndexByPage(t);try{s>i&&(this.app.getPageCollection().setCurrentSpreadIndex(s-1),this.flipNext(e)),sthis.do(d));let h=this.getAnimationDuration(n.length);this.render.startAnimation(o,h,()=>{this.calc&&(i&&(this.calc.getDirection()===1?this.app.turnToPrevPage():this.app.turnToNextPage()),s&&(this.render.setBottomPage(null),this.render.setFlippingPage(null),this.render.clearShadow(),this.setState("read"),this.reset()))})}getCalculation(){return this.calc}getState(){return this.state}setState(t){this.state!==t&&(this.app.updateState(t),this.state=t)}getDirectionByPoint(t){let e=this.getBoundsRect();if(this.render.getOrientation()==="portrait"){if(t.x-e.pageWidth<=e.width/5)return 1}else if(t.x=1e3?e:t/1e3*e}checkDirection(t){return t===0?this.app.getCurrentPageIndex()=1}reset(){this.calc=null,this.flippingPage=null,this.bottomPage=null}getBoundsRect(){return this.render.getRect()}checkState(...t){for(let e of t)if(this.state===e)return!0;return!1}isPointOnCorners(t){let e=this.getBoundsRect(),i=e.pageWidth,s=Math.sqrt(Math.pow(i,2)+Math.pow(e.height,2))/5,n=this.render.convertToBook(t);return n.x>0&&n.y>0&&n.xe.width-s)&&(n.ye.height-s)}}class v{constructor(t,e){this.leftPage=null,this.rightPage=null,this.flippingPage=null,this.bottomPage=null,this.direction=null,this.orientation=null,this.shadow=null,this.animation=null,this.pageRect=null,this.boundsRect=null,this.timer=0,this.safari=!1,this.setting=e,this.app=t;let i=new RegExp("Version\\/[\\d\\.]+.*Safari/");this.safari=i.exec(window.navigator.userAgent)!==null}render(t){if(this.animation!==null){let e=Math.round((t-this.animation.startedAt)/this.animation.durationFrame);e{this.render(e),requestAnimationFrame(t)};requestAnimationFrame(t)}startAnimation(t,e,i){this.finishAnimation(),this.animation={frames:t,duration:e,durationFrame:e/t.length,onAnimateEnd:i,startedAt:this.timer}}finishAnimation(){this.animation!==null&&(this.animation.frames[this.animation.frames.length-1](),this.animation.onAnimateEnd!==null&&this.animation.onAnimateEnd()),this.animation=null}update(){this.boundsRect=null;let t=this.calculateBoundsRect();this.orientation!==t&&(this.orientation=t,this.app.updateOrientation(t))}calculateBoundsRect(){let t="landscape",e=this.getBlockWidth(),i=e/2,s=this.getBlockHeight()/2,n=this.setting.width/this.setting.height,o=this.setting.width,h=this.setting.height,d=i-o;return this.setting.size==="stretch"?(e<2*this.setting.minWidth&&this.app.getSettings().usePortrait&&(t="portrait"),o=t==="portrait"?this.getBlockWidth():this.getBlockWidth()/2,o>this.setting.maxWidth&&(o=this.setting.maxWidth),h=o/n,h>this.getBlockHeight()&&(h=this.getBlockHeight(),o=h*n),d=t==="portrait"?i-o/2-o:i-o):e<2*o&&this.app.getSettings().usePortrait&&(t="portrait",d=i-o/2-o),this.boundsRect={left:d,top:s-h/2,width:2*o,height:h,pageWidth:o},t}setShadowData(t,e,i,s){if(!this.app.getSettings().drawShadow)return;let n=100*this.getSettings().maxShadowOpacity;this.shadow={pos:t,angle:e,width:3*this.getRect().pageWidth/4*i/100,opacity:(100-i)*n/100/100,direction:s,progress:2*i}}clearShadow(){this.shadow=null}getBlockWidth(){return this.app.getUI().getDistElement().offsetWidth}getBlockHeight(){return this.app.getUI().getDistElement().offsetHeight}getDirection(){return this.direction}getRect(){return this.boundsRect===null&&this.calculateBoundsRect(),this.boundsRect}getSettings(){return this.app.getSettings()}getOrientation(){return this.orientation}setPageRect(t){this.pageRect=t}setDirection(t){this.direction=t}setRightPage(t){t!==null&&t.setOrientation(1),this.rightPage=t}setLeftPage(t){t!==null&&t.setOrientation(0),this.leftPage=t}setBottomPage(t){t!==null&&t.setOrientation(this.direction===1?0:1),this.bottomPage=t}setFlippingPage(t){t!==null&&t.setOrientation(this.direction===0&&this.orientation!=="portrait"?0:1),this.flippingPage=t}convertToBook(t){let e=this.getRect();return{x:t.x-e.left,y:t.y-e.top}}isSafari(){return this.safari}convertToPage(t,e){e||(e=this.direction);let i=this.getRect();return{x:e===0?t.x-i.left-i.width/2:i.width/2-t.x+i.left,y:t.y-i.top}}convertToGlobal(t,e){if(e||(e=this.direction),t==null)return null;let i=this.getRect();return{x:e===0?t.x+i.left+i.width/2:i.width/2-t.x+i.left,y:t.y+i.top}}convertRectToGlobal(t,e){return e||(e=this.direction),{topLeft:this.convertToGlobal(t.topLeft,e),topRight:this.convertToGlobal(t.topRight,e),bottomLeft:this.convertToGlobal(t.bottomLeft,e),bottomRight:this.convertToGlobal(t.bottomRight,e)}}}class I extends v{constructor(t,e,i){super(t,e),this.canvas=i,this.ctx=i.getContext("2d")}getContext(){return this.ctx}reload(){}drawFrame(){this.clear(),this.orientation!=="portrait"&&this.leftPage!=null&&this.leftPage.simpleDraw(0),this.rightPage!=null&&this.rightPage.simpleDraw(1),this.bottomPage!=null&&this.bottomPage.draw(),this.drawBookShadow(),this.flippingPage!=null&&this.flippingPage.draw(),this.shadow!=null&&(this.drawOuterShadow(),this.drawInnerShadow());let t=this.getRect();this.orientation==="portrait"&&(this.ctx.beginPath(),this.ctx.rect(t.left+t.pageWidth,t.top,t.width,t.height),this.ctx.clip())}drawBookShadow(){let t=this.getRect();this.ctx.save(),this.ctx.beginPath();let e=t.width/20;this.ctx.rect(t.left,t.top,t.width,t.height);let i={x:t.left+t.width/2-e/2,y:0};this.ctx.translate(i.x,i.y);let s=this.ctx.createLinearGradient(0,0,e,0);s.addColorStop(0,"rgba(0, 0, 0, 0)"),s.addColorStop(.4,"rgba(0, 0, 0, 0.2)"),s.addColorStop(.49,"rgba(0, 0, 0, 0.1)"),s.addColorStop(.5,"rgba(0, 0, 0, 0.5)"),s.addColorStop(.51,"rgba(0, 0, 0, 0.4)"),s.addColorStop(1,"rgba(0, 0, 0, 0)"),this.ctx.clip(),this.ctx.fillStyle=s,this.ctx.fillRect(0,0,e,2*t.height),this.ctx.restore()}drawOuterShadow(){let t=this.getRect();this.ctx.save(),this.ctx.beginPath(),this.ctx.rect(t.left,t.top,t.width,t.height);let e=this.convertToGlobal({x:this.shadow.pos.x,y:this.shadow.pos.y});this.ctx.translate(e.x,e.y),this.ctx.rotate(Math.PI+this.shadow.angle+Math.PI/2);let i=this.ctx.createLinearGradient(0,0,this.shadow.width,0);this.shadow.direction===0?(this.ctx.translate(0,-100),i.addColorStop(0,"rgba(0, 0, 0, "+this.shadow.opacity+")"),i.addColorStop(1,"rgba(0, 0, 0, 0)")):(this.ctx.translate(-this.shadow.width,-100),i.addColorStop(0,"rgba(0, 0, 0, 0)"),i.addColorStop(1,"rgba(0, 0, 0, "+this.shadow.opacity+")")),this.ctx.clip(),this.ctx.fillStyle=i,this.ctx.fillRect(0,0,this.shadow.width,2*t.height),this.ctx.restore()}drawInnerShadow(){let t=this.getRect();this.ctx.save(),this.ctx.beginPath();let e=this.convertToGlobal({x:this.shadow.pos.x,y:this.shadow.pos.y}),i=this.convertRectToGlobal(this.pageRect);this.ctx.moveTo(i.topLeft.x,i.topLeft.y),this.ctx.lineTo(i.topRight.x,i.topRight.y),this.ctx.lineTo(i.bottomRight.x,i.bottomRight.y),this.ctx.lineTo(i.bottomLeft.x,i.bottomLeft.y),this.ctx.translate(e.x,e.y),this.ctx.rotate(Math.PI+this.shadow.angle+Math.PI/2);let s=3*this.shadow.width/4,n=this.ctx.createLinearGradient(0,0,s,0);this.shadow.direction===0?(this.ctx.translate(-s,-100),n.addColorStop(1,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(.9,"rgba(0, 0, 0, 0.05)"),n.addColorStop(.7,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(0,"rgba(0, 0, 0, 0)")):(this.ctx.translate(0,-100),n.addColorStop(0,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(.1,"rgba(0, 0, 0, 0.05)"),n.addColorStop(.3,"rgba(0, 0, 0, "+this.shadow.opacity+")"),n.addColorStop(1,"rgba(0, 0, 0, 0)")),this.ctx.clip(),this.ctx.fillStyle=n,this.ctx.fillRect(0,0,s,2*t.height),this.ctx.restore()}clear(){this.ctx.fillStyle="white",this.ctx.fillRect(0,0,this.canvas.width,this.canvas.height)}}class T{constructor(t,e,i){this.touchPoint=null,this.swipeTimeout=250,this.onResize=()=>{this.update()},this.onMouseDown=n=>{if(this.checkTarget(n.target)){let o=this.getMousePos(n.clientX,n.clientY);this.app.startUserTouch(o),n.preventDefault()}},this.onTouchStart=n=>{if(this.checkTarget(n.target)&&n.changedTouches.length>0){let o=n.changedTouches[0],h=this.getMousePos(o.clientX,o.clientY);this.touchPoint={point:h,time:Date.now()},setTimeout(()=>{this.touchPoint!==null&&this.app.startUserTouch(h)},this.swipeTimeout),this.app.getSettings().mobileScrollSupport||n.preventDefault()}},this.onMouseUp=n=>{let o=this.getMousePos(n.clientX,n.clientY);this.app.userStop(o)},this.onMouseMove=n=>{let o=this.getMousePos(n.clientX,n.clientY);this.app.userMove(o,!1)},this.onTouchMove=n=>{if(n.changedTouches.length>0){let o=n.changedTouches[0],h=this.getMousePos(o.clientX,o.clientY);this.app.getSettings().mobileScrollSupport?(this.touchPoint!==null&&(Math.abs(this.touchPoint.point.x-h.x)>10||this.app.getState()!=="read")&&n.cancelable&&this.app.userMove(h,!0),this.app.getState()!=="read"&&n.preventDefault()):this.app.userMove(h,!0)}},this.onTouchEnd=n=>{if(n.changedTouches.length>0){let o=n.changedTouches[0],h=this.getMousePos(o.clientX,o.clientY),d=!1;if(this.touchPoint!==null){let u=h.x-this.touchPoint.point.x,x=Math.abs(h.y-this.touchPoint.point.y);Math.abs(u)>this.swipeDistance&&x<2*this.swipeDistance&&Date.now()-this.touchPoint.time0?this.app.flipPrev(this.touchPoint.point.y'),this.wrapper=t.querySelector(".stf__wrapper"),this.app=e;let s=this.app.getSettings().usePortrait?1:2;t.style.minWidth=i.minWidth*s+"px",t.style.minHeight=i.minHeight+"px",i.size==="fixed"&&(t.style.minWidth=i.width*s+"px",t.style.minHeight=i.height+"px"),i.autoSize&&(t.style.width="100%",t.style.maxWidth=2*i.maxWidth+"px"),t.style.display="block",window.addEventListener("resize",this.onResize,!1),this.swipeDistance=i.swipeDistance}destroy(){this.app.getSettings().useMouseEvents&&this.removeHandlers(),this.distElement.remove(),this.wrapper.remove()}getDistElement(){return this.distElement}getWrapper(){return this.wrapper}setOrientationStyle(t){this.wrapper.classList.remove("--portrait","--landscape"),t==="portrait"?(this.app.getSettings().autoSize&&(this.wrapper.style.paddingBottom=this.app.getSettings().height/this.app.getSettings().width*100+"%"),this.wrapper.classList.add("--portrait")):(this.app.getSettings().autoSize&&(this.wrapper.style.paddingBottom=this.app.getSettings().height/(2*this.app.getSettings().width)*100+"%"),this.wrapper.classList.add("--landscape")),this.update()}removeHandlers(){window.removeEventListener("resize",this.onResize),this.distElement.removeEventListener("mousedown",this.onMouseDown),this.distElement.removeEventListener("touchstart",this.onTouchStart),window.removeEventListener("mousemove",this.onMouseMove),window.removeEventListener("touchmove",this.onTouchMove),window.removeEventListener("mouseup",this.onMouseUp),window.removeEventListener("touchend",this.onTouchEnd)}setHandlers(){window.addEventListener("resize",this.onResize,!1),this.app.getSettings().useMouseEvents&&(this.distElement.addEventListener("mousedown",this.onMouseDown),this.distElement.addEventListener("touchstart",this.onTouchStart),window.addEventListener("mousemove",this.onMouseMove),window.addEventListener("touchmove",this.onTouchMove,{passive:!this.app.getSettings().mobileScrollSupport}),window.addEventListener("mouseup",this.onMouseUp),window.addEventListener("touchend",this.onTouchEnd))}getMousePos(t,e){let i=this.distElement.getBoundingClientRect();return{x:t-i.left,y:e-i.top}}checkTarget(t){return!this.app.getSettings().clickEventForward||!["a","button"].includes(t.tagName.toLowerCase())}}class b extends T{constructor(t,e,i,s){super(t,e,i),this.wrapper.insertAdjacentHTML("afterbegin",'
    '),this.distElement=t.querySelector(".stf__block"),this.items=s;for(let n of s)this.distElement.appendChild(n);this.setHandlers()}clear(){for(let t of this.items)this.parentElement.appendChild(t)}updateItems(t){this.removeHandlers(),this.distElement.innerHTML="";for(let e of t)this.distElement.appendChild(e);this.items=t,this.setHandlers()}update(){this.app.getRender().update()}}class D extends T{constructor(t,e,i){super(t,e,i),this.wrapper.innerHTML='',this.canvas=t.querySelectorAll("canvas")[0],this.distElement=this.canvas,this.resizeCanvas(),this.setHandlers()}resizeCanvas(){let t=getComputedStyle(this.canvas),e=parseInt(t.getPropertyValue("width"),10),i=parseInt(t.getPropertyValue("height"),10);this.canvas.width=e,this.canvas.height=i}getCanvas(){return this.canvas}update(){this.resizeCanvas(),this.app.getRender().update()}}class M extends v{constructor(t,e,i){super(t,e),this.outerShadow=null,this.innerShadow=null,this.hardShadow=null,this.hardInnerShadow=null,this.element=i,this.createShadows()}createShadows(){this.element.insertAdjacentHTML("beforeend",`
    `),this.outerShadow=this.element.querySelector(".stf__outerShadow"),this.innerShadow=this.element.querySelector(".stf__innerShadow"),this.hardShadow=this.element.querySelector(".stf__hardShadow"),this.hardInnerShadow=this.element.querySelector(".stf__hardInnerShadow")}clearShadow(){super.clearShadow(),this.outerShadow.style.cssText="display: none",this.innerShadow.style.cssText="display: none",this.hardShadow.style.cssText="display: none",this.hardInnerShadow.style.cssText="display: none"}reload(){this.element.querySelector(".stf__outerShadow")||this.createShadows()}drawHardInnerShadow(){let t=this.getRect(),e=this.shadow.progress>100?200-this.shadow.progress:this.shadow.progress,i=(100-e)*(2.5*t.pageWidth)/100+20;i>t.pageWidth&&(i=t.pageWidth);let s=` @@ -40,7 +40,7 @@ var V=Object.create;var O=Object.defineProperty;var j=Object.getOwnPropertyDescr background: linear-gradient(to left, rgba(0, 0, 0, ${this.shadow.opacity}) 5%, rgba(0, 0, 0, 0) 100%); left: ${t.left+t.width/2}px; transform-origin: 0 0; - `;i+=this.getDirection()===0&&this.shadow.progress>100||this.getDirection()===1&&this.shadow.progress<=100?"transform: translate3d(0, 0, 0) rotateY(180deg);":"transform: translate3d(0, 0, 0);",this.hardShadow.style.cssText=i}drawInnerShadow(){let t=this.getRect(),e=3*this.shadow.width/4,i=this.getDirection()===0?e:0,s=this.getDirection()===0?"to left":"to right",n=this.convertToGlobal(this.shadow.pos),o=this.shadow.angle+3*Math.PI/2,h=[this.pageRect.topLeft,this.pageRect.topRight,this.pageRect.bottomRight,this.pageRect.bottomLeft],d="polygon( ";for(let x of h){let T=this.getDirection()===1?{x:-x.x+this.shadow.pos.x,y:x.y-this.shadow.pos.y}:{x:x.x-this.shadow.pos.x,y:x.y-this.shadow.pos.y};T=g.GetRotatedPoint(T,{x:i,y:100},o),d+=T.x+"px "+T.y+"px, "}d=d.slice(0,-2),d+=")";let u=` + `;i+=this.getDirection()===0&&this.shadow.progress>100||this.getDirection()===1&&this.shadow.progress<=100?"transform: translate3d(0, 0, 0) rotateY(180deg);":"transform: translate3d(0, 0, 0);",this.hardShadow.style.cssText=i}drawInnerShadow(){let t=this.getRect(),e=3*this.shadow.width/4,i=this.getDirection()===0?e:0,s=this.getDirection()===0?"to left":"to right",n=this.convertToGlobal(this.shadow.pos),o=this.shadow.angle+3*Math.PI/2,h=[this.pageRect.topLeft,this.pageRect.topRight,this.pageRect.bottomRight,this.pageRect.bottomLeft],d="polygon( ";for(let x of h){let C=this.getDirection()===1?{x:-x.x+this.shadow.pos.x,y:x.y-this.shadow.pos.y}:{x:x.x-this.shadow.pos.x,y:x.y-this.shadow.pos.y};C=g.GetRotatedPoint(C,{x:i,y:100},o),d+=C.x+"px "+C.y+"px, "}d=d.slice(0,-2),d+=")";let u=` display: block; z-index: ${(this.getSettings().startZIndex+10).toString(10)}; width: ${e}px; @@ -64,7 +64,7 @@ var V=Object.create;var O=Object.defineProperty;var j=Object.getOwnPropertyDescr transform: translate3d(${e.x-s}px, ${e.y-100}px, 0) rotate(${i}rad); clip-path: ${h}; -webkit-clip-path: ${h}; - `;this.outerShadow.style.cssText=d}drawLeftPage(){this.orientation!=="portrait"&&this.leftPage!==null&&(this.direction===1&&this.flippingPage!==null&&this.flippingPage.getDrawingDensity()==="hard"?(this.leftPage.getElement().style.zIndex=(this.getSettings().startZIndex+5).toString(10),this.leftPage.setHardDrawingAngle(180+this.flippingPage.getHardAngle()),this.leftPage.draw(this.flippingPage.getDrawingDensity())):this.leftPage.simpleDraw(0))}drawRightPage(){this.rightPage!==null&&(this.direction===0&&this.flippingPage!==null&&this.flippingPage.getDrawingDensity()==="hard"?(this.rightPage.getElement().style.zIndex=(this.getSettings().startZIndex+5).toString(10),this.rightPage.setHardDrawingAngle(180+this.flippingPage.getHardAngle()),this.rightPage.draw(this.flippingPage.getDrawingDensity())):this.rightPage.simpleDraw(1))}drawBottomPage(){if(this.bottomPage===null)return;let t=this.flippingPage!=null?this.flippingPage.getDrawingDensity():null;this.orientation==="portrait"&&this.direction===1||(this.bottomPage.getElement().style.zIndex=(this.getSettings().startZIndex+3).toString(10),this.bottomPage.draw(t))}drawFrame(){this.clear(),this.drawLeftPage(),this.drawRightPage(),this.drawBottomPage(),this.flippingPage!=null&&(this.flippingPage.getElement().style.zIndex=(this.getSettings().startZIndex+5).toString(10),this.flippingPage.draw()),this.shadow!=null&&this.flippingPage!==null&&(this.flippingPage.getDrawingDensity()==="soft"?(this.drawOuterShadow(),this.drawInnerShadow()):(this.drawHardOuterShadow(),this.drawHardInnerShadow()))}clear(){for(let t of this.app.getPageCollection().getPages())t!==this.leftPage&&t!==this.rightPage&&t!==this.flippingPage&&t!==this.bottomPage&&(t.getElement().style.cssText="display: none"),t.getTemporaryCopy()!==this.flippingPage&&t.hideTemporaryCopy()}update(){super.update(),this.rightPage!==null&&this.rightPage.setOrientation(1),this.leftPage!==null&&this.leftPage.setOrientation(0)}}class R{constructor(){this._default={startPage:0,size:"fixed",width:0,height:0,minWidth:0,maxWidth:0,minHeight:0,maxHeight:0,drawShadow:!0,flippingTime:1e3,usePortrait:!0,startZIndex:0,autoSize:!0,maxShadowOpacity:1,showCover:!1,mobileScrollSupport:!0,swipeDistance:30,clickEventForward:!0,useMouseEvents:!0,showPageCorners:!0,disableFlipByClick:!1}}getSettings(t){let e=this._default;if(Object.assign(e,t),e.size!=="stretch"&&e.size!=="fixed")throw new Error('Invalid size type. Available only "fixed" and "stretch" value');if(e.width<=0||e.height<=0)throw new Error("Invalid width or height");if(e.flippingTime<=0)throw new Error("Invalid flipping time");return e.size==="stretch"?(e.minWidth<=0&&(e.minWidth=100),e.maxWidth{this.ui.update(),this.trigger("init",this,{page:this.setting.startPage,mode:this.render.getOrientation()})},1)}loadFromHTML(r){this.ui=new S(this.block,this,this.setting,r),this.render=new M(this,this.setting,this.ui.getDistElement()),this.flipController=new b(this.render,this),this.pages=new f(this,this.render,this.ui.getDistElement(),r),this.pages.load(),this.render.start(),this.pages.show(this.setting.startPage),setTimeout(()=>{this.ui.update(),this.trigger("init",this,{page:this.setting.startPage,mode:this.render.getOrientation()})},1)}updateFromImages(r){let t=this.pages.getCurrentPageIndex();this.pages.destroy(),this.pages=new m(this,this.render,r),this.pages.load(),this.pages.show(t),this.trigger("update",this,{page:t,mode:this.render.getOrientation()})}updateFromHtml(r){let t=this.pages.getCurrentPageIndex();this.pages.destroy(),this.pages=new f(this,this.render,this.ui.getDistElement(),r),this.pages.load(),this.ui.updateItems(r),this.render.reload(),this.pages.show(t),this.trigger("update",this,{page:t,mode:this.render.getOrientation()})}clear(){this.pages.destroy(),this.ui.clear()}turnToPrevPage(){this.pages.showPrev()}turnToNextPage(){this.pages.showNext()}turnToPage(r){this.pages.show(r)}flipNext(r="top"){this.flipController.flipNext(r)}flipPrev(r="top"){this.flipController.flipPrev(r)}flip(r,t="top"){this.flipController.flipToPage(r,t)}updateState(r){this.trigger("changeState",this,r)}updatePageIndex(r){this.trigger("flip",this,r)}updateOrientation(r){this.ui.setOrientationStyle(r),this.update(),this.trigger("changeOrientation",this,r)}getPageCount(){return this.pages.getPageCount()}getCurrentPageIndex(){return this.pages.getCurrentPageIndex()}getPage(r){return this.pages.getPage(r)}getRender(){return this.render}getFlipController(){return this.flipController}getOrientation(){return this.render.getOrientation()}getBoundsRect(){return this.render.getRect()}getSettings(){return this.setting}getUI(){return this.ui}getState(){return this.flipController.getState()}getPageCollection(){return this.pages}startUserTouch(r){this.mousePosition=r,this.isUserTouch=!0,this.isUserMove=!1}userMove(r,t){this.isUserTouch||t||!this.setting.showPageCorners?this.isUserTouch&&g.GetDistanceBetweenTwoPoint(this.mousePosition,r)>5&&(this.isUserMove=!0,this.flipController.fold(r)):this.flipController.showCorner(r)}userStop(r,t=!1){this.isUserTouch&&(this.isUserTouch=!1,t||(this.isUserMove?this.flipController.stopMove():this.flipController.flip(r)))}},Object.defineProperty(a,"__esModule",{value:!0})}))});var A={startPage:0,singlePageBreakpoint:640,chromeHeight:150,chromeWidth:48,maxPageHeight:1100,flippingTime:700,drawShadow:!0,maxShadowOpacity:.4,keyboard:!0};function z(a,p){let l=p.chromeHeight??A.chromeHeight,c=p.chromeWidth??A.chromeWidth,m=Math.min(p.viewportHeight-l,p.maxPageHeight??A.maxPageHeight),g=p.viewportWidth-c,w=Math.max(m,120),f=w*a,P=p.single?f:f*2;if(P>g){let b=g/P;f*=b,w*=b}return{width:Math.round(f),height:Math.round(w)}}async function B(a,p){let l={...A,...p};if(!l.pages.length)throw new Error("mountFlipbook: `pages` is empty");let m=(await Promise.resolve().then(()=>Q(G(),1))).PageFlip,g=null,w=Math.min(Math.max(l.startPage,0),l.pages.length-1),f=!1,P=null,b=()=>{if(f)return;let S=l.singlePageBreakpoint>0&&window.matchMedia(`(max-width: ${l.singlePageBreakpoint}px)`).matches,{width:k,height:M}=z(l.aspect,{single:S,viewportWidth:window.innerWidth,viewportHeight:window.innerHeight,chromeWidth:l.chromeWidth,chromeHeight:l.chromeHeight,maxPageHeight:l.maxPageHeight});if(g=new m(a,{width:k,height:M,size:"fixed",showCover:!0,usePortrait:S,maxShadowOpacity:l.maxShadowOpacity,mobileScrollSupport:!0,flippingTime:l.flippingTime,drawShadow:l.drawShadow}),g.loadFromImages(l.pages.slice()),g.on("flip",R=>{w=R.data,l.onFlip?.(w)}),w>0)try{g.turnToPage(w)}catch{}l.onReady?.()},v=()=>{if(g){try{g.destroy()}catch{}g=null,a.innerHTML=""}},I=()=>{P&&clearTimeout(P),P=setTimeout(()=>{v(),b()},200)},C=S=>{S.key==="ArrowLeft"?g?.flipPrev():S.key==="ArrowRight"&&g?.flipNext()};return b(),window.addEventListener("resize",I),l.keyboard&&document.addEventListener("keydown",C),{next:()=>g?.flipNext(),prev:()=>g?.flipPrev(),goTo:S=>g?.turnToPage(S),currentPage:()=>w,pageCount:()=>l.pages.length,destroy:()=>{f||(f=!0,P&&clearTimeout(P),window.removeEventListener("resize",I),document.removeEventListener("keydown",C),v())}}}function y(a,p,l){let c=document.createElement(a);return p&&(c.className=p),l!=null&&(c.textContent=l),c}async function H(a){let p=a.container??document.body,l=a.pages.length,c=y("div",`rab-overlay${a.className?` ${a.className}`:""}`);c.setAttribute("role","dialog"),c.setAttribute("aria-modal","true"),c.setAttribute("aria-label",`${a.title??"Document"} \u2014 page view`);let m=y("div","rab-chrome");m.append(y("span","rab-title",a.title??""));let g=y("div","rab-actions"),w=y("span","rab-counter");if(g.append(w),a.pdfUrl){let n=y("a","rab-btn rab-download","Download PDF \u2197");n.href=a.pdfUrl,n.target="_blank",n.rel="noopener noreferrer",g.append(n)}let f=y("button","rab-btn","Close \u2715");f.type="button",f.setAttribute("aria-label","Close book view"),g.append(f),m.append(g);let P=y("div","rab-stage"),b=y("button","rab-arrow rab-arrow-prev","\u2039");b.type="button",b.setAttribute("aria-label","Previous page");let v=y("button","rab-arrow rab-arrow-next","\u203A");v.type="button",v.setAttribute("aria-label","Next page");let I=y("div","rab-book"),C=y("p","rab-loading","Opening the book\u2026"),S=y("div","rab-book-wrap");S.append(I,C),P.append(b,S,v);let k=a.hint??"Use the arrows or \u2190 \u2192 keys to turn pages \xB7 Esc to close",M=y("p","rab-hint",k);c.append(m,P),k&&c.append(M),p.append(c);let R=n=>{w.textContent=l?`${Math.min(n+1,l)} / ${l}`:""};R(a.startPage??0);let r=document.body.style.overflow;document.body.style.overflow="hidden";let t=null,e=!1,i=()=>{e||(e=!0,document.removeEventListener("keydown",s),document.body.style.overflow=r,t?.destroy(),c.remove(),a.onClose?.())},s=n=>{n.key==="Escape"&&i()};document.addEventListener("keydown",s),f.addEventListener("click",i);try{t=await B(I,{pages:a.pages,aspect:a.aspect,startPage:a.startPage,onFlip:n=>{R(n),a.onFlip?.(n)},onReady:()=>C.remove()})}catch(n){throw i(),n}return e?(t.destroy(),{close:i,next:()=>{},prev:()=>{}}):(b.addEventListener("click",()=>t?.prev()),v.addEventListener("click",()=>t?.next()),{close:i,next:()=>t?.next(),prev:()=>t?.prev()})}async function U(a,p){let l=await fetch(a,p);if(!l.ok)throw new Error(`read-as-book: could not load ${a} (${l.status})`);let c=await l.json(),m=new URL(a,globalThis.location?.href??"http://localhost/");return{...c,pages:c.pages.map(g=>new URL(g.split("/").pop(),m).toString())}}var $=document.querySelector("#open-research-book"),D=document.querySelector("#book-status"),W,F=!1;function N(){return W??(W=U("book/pages/manifest.json")),W}async function q(){if(!F){F=!0,$.disabled=!0,D.textContent="Loading the page-turn edition...";try{let a=await N();await H({pages:a.pages,aspect:a.aspect,title:"Auditable Emergent Communication Between Isolated Artificial Agents",pdfUrl:"book/research.pdf",hint:"Use the arrow keys or page controls to turn pages. Press Esc to close.",className:"research-book-overlay",onClose:()=>{D.textContent=`${a.pageCount} pages available`}}),D.textContent=`${a.pageCount} pages available`}catch(a){console.error(a),D.textContent="The book could not open. Use the Markdown or PDF links instead."}finally{F=!1,$.disabled=!1}}}$.addEventListener("click",q);N().then(a=>{D.textContent=`${a.pageCount} pages available`}).catch(a=>{console.error(a),D.textContent="Book metadata is unavailable. Use the Markdown or PDF links instead."});new URLSearchParams(globalThis.location.search).get("open")==="1"&&globalThis.requestAnimationFrame(()=>{q()}); +}`),r.PageFlip=class extends class{constructor(){this.events=new Map}on(a,t){return this.events.has(a)?this.events.get(a).push(t):this.events.set(a,[t]),this}off(a){this.events.delete(a)}trigger(a,t,e=null){if(this.events.has(a))for(let i of this.events.get(a))i({data:e,object:t})}}{constructor(a,t){super(),this.isUserTouch=!1,this.isUserMove=!1,this.setting=null,this.pages=null,this.setting=new R().getSettings(t),this.block=a}destroy(){this.ui.destroy(),this.block.remove()}update(){this.render.update(),this.pages.show()}loadFromImages(a){this.ui=new D(this.block,this,this.setting);let t=this.ui.getCanvas();this.render=new I(this,this.setting,t),this.flipController=new S(this.render,this),this.pages=new w(this,this.render,a),this.pages.load(),this.render.start(),this.pages.show(this.setting.startPage),setTimeout(()=>{this.ui.update(),this.trigger("init",this,{page:this.setting.startPage,mode:this.render.getOrientation()})},1)}loadFromHTML(a){this.ui=new b(this.block,this,this.setting,a),this.render=new M(this,this.setting,this.ui.getDistElement()),this.flipController=new S(this.render,this),this.pages=new f(this,this.render,this.ui.getDistElement(),a),this.pages.load(),this.render.start(),this.pages.show(this.setting.startPage),setTimeout(()=>{this.ui.update(),this.trigger("init",this,{page:this.setting.startPage,mode:this.render.getOrientation()})},1)}updateFromImages(a){let t=this.pages.getCurrentPageIndex();this.pages.destroy(),this.pages=new w(this,this.render,a),this.pages.load(),this.pages.show(t),this.trigger("update",this,{page:t,mode:this.render.getOrientation()})}updateFromHtml(a){let t=this.pages.getCurrentPageIndex();this.pages.destroy(),this.pages=new f(this,this.render,this.ui.getDistElement(),a),this.pages.load(),this.ui.updateItems(a),this.render.reload(),this.pages.show(t),this.trigger("update",this,{page:t,mode:this.render.getOrientation()})}clear(){this.pages.destroy(),this.ui.clear()}turnToPrevPage(){this.pages.showPrev()}turnToNextPage(){this.pages.showNext()}turnToPage(a){this.pages.show(a)}flipNext(a="top"){this.flipController.flipNext(a)}flipPrev(a="top"){this.flipController.flipPrev(a)}flip(a,t="top"){this.flipController.flipToPage(a,t)}updateState(a){this.trigger("changeState",this,a)}updatePageIndex(a){this.trigger("flip",this,a)}updateOrientation(a){this.ui.setOrientationStyle(a),this.update(),this.trigger("changeOrientation",this,a)}getPageCount(){return this.pages.getPageCount()}getCurrentPageIndex(){return this.pages.getCurrentPageIndex()}getPage(a){return this.pages.getPage(a)}getRender(){return this.render}getFlipController(){return this.flipController}getOrientation(){return this.render.getOrientation()}getBoundsRect(){return this.render.getRect()}getSettings(){return this.setting}getUI(){return this.ui}getState(){return this.flipController.getState()}getPageCollection(){return this.pages}startUserTouch(a){this.mousePosition=a,this.isUserTouch=!0,this.isUserMove=!1}userMove(a,t){this.isUserTouch||t||!this.setting.showPageCorners?this.isUserTouch&&g.GetDistanceBetweenTwoPoint(this.mousePosition,a)>5&&(this.isUserMove=!0,this.flipController.fold(a)):this.flipController.showCorner(a)}userStop(a,t=!1){this.isUserTouch&&(this.isUserTouch=!1,t||(this.isUserMove?this.flipController.stopMove():this.flipController.flip(a)))}},Object.defineProperty(r,"__esModule",{value:!0})}))});var E={startPage:0,singlePageBreakpoint:640,chromeHeight:150,chromeWidth:48,maxPageHeight:1100,flippingTime:700,drawShadow:!0,maxShadowOpacity:.4,keyboard:!0};function z(r,p){let l=p.chromeHeight??E.chromeHeight,c=p.chromeWidth??E.chromeWidth,w=Math.min(p.viewportHeight-l,p.maxPageHeight??E.maxPageHeight),g=p.viewportWidth-c,m=Math.max(w,120),f=m*r,P=p.single?f:f*2;if(P>g){let S=g/P;f*=S,m*=S}return{width:Math.round(f),height:Math.round(m)}}async function W(r,p){let l={...E,...p};if(!l.pages.length)throw new Error("mountFlipbook: `pages` is empty");let w=(await Promise.resolve().then(()=>Q(G(),1))).PageFlip,g=null,m=Math.min(Math.max(l.startPage,0),l.pages.length-1),f=!1,P=null,S=()=>{if(f)return;let b=l.singlePageBreakpoint>0&&window.matchMedia(`(max-width: ${l.singlePageBreakpoint}px)`).matches,{width:D,height:M}=z(l.aspect,{single:b,viewportWidth:window.innerWidth,viewportHeight:window.innerHeight,chromeWidth:l.chromeWidth,chromeHeight:l.chromeHeight,maxPageHeight:l.maxPageHeight});if(g=new w(r,{width:D,height:M,size:"fixed",showCover:!0,usePortrait:b,maxShadowOpacity:l.maxShadowOpacity,mobileScrollSupport:!0,flippingTime:l.flippingTime,drawShadow:l.drawShadow}),g.loadFromImages(l.pages.slice()),g.on("flip",R=>{m=R.data,l.onFlip?.(m)}),m>0)try{g.turnToPage(m)}catch{}l.onReady?.()},v=()=>{if(g){try{g.destroy()}catch{}g=null,r.innerHTML=""}},I=()=>{P&&clearTimeout(P),P=setTimeout(()=>{v(),S()},200)},T=b=>{b.key==="ArrowLeft"?g?.flipPrev():b.key==="ArrowRight"&&g?.flipNext()};return S(),window.addEventListener("resize",I),l.keyboard&&document.addEventListener("keydown",T),{next:()=>g?.flipNext(),prev:()=>g?.flipPrev(),goTo:b=>g?.turnToPage(b),currentPage:()=>m,pageCount:()=>l.pages.length,destroy:()=>{f||(f=!0,P&&clearTimeout(P),window.removeEventListener("resize",I),document.removeEventListener("keydown",T),v())}}}function y(r,p,l){let c=document.createElement(r);return p&&(c.className=p),l!=null&&(c.textContent=l),c}async function B(r){let p=r.container??document.body,l=r.pages.length,c=y("div",`rab-overlay${r.className?` ${r.className}`:""}`);c.setAttribute("role","dialog"),c.setAttribute("aria-modal","true"),c.setAttribute("aria-label",`${r.title??"Document"} \u2014 page view`);let w=y("div","rab-chrome");w.append(y("span","rab-title",r.title??""));let g=y("div","rab-actions"),m=y("span","rab-counter");if(g.append(m),r.pdfUrl){let n=y("a","rab-btn rab-download","Download PDF \u2197");n.href=r.pdfUrl,n.target="_blank",n.rel="noopener noreferrer",g.append(n)}let f=y("button","rab-btn","Close \u2715");f.type="button",f.setAttribute("aria-label","Close book view"),g.append(f),w.append(g);let P=y("div","rab-stage"),S=y("button","rab-arrow rab-arrow-prev","\u2039");S.type="button",S.setAttribute("aria-label","Previous page");let v=y("button","rab-arrow rab-arrow-next","\u203A");v.type="button",v.setAttribute("aria-label","Next page");let I=y("div","rab-book"),T=y("p","rab-loading","Opening the book\u2026"),b=y("div","rab-book-wrap");b.append(I,T),P.append(S,b,v);let D=r.hint??"Use the arrows or \u2190 \u2192 keys to turn pages \xB7 Esc to close",M=y("p","rab-hint",D);c.append(w,P),D&&c.append(M),p.append(c);let R=n=>{m.textContent=l?`${Math.min(n+1,l)} / ${l}`:""};R(r.startPage??0);let a=document.body.style.overflow;document.body.style.overflow="hidden";let t=null,e=!1,i=()=>{e||(e=!0,document.removeEventListener("keydown",s),document.body.style.overflow=a,t?.destroy(),c.remove(),r.onClose?.())},s=n=>{n.key==="Escape"&&i()};document.addEventListener("keydown",s),f.addEventListener("click",i);try{t=await W(I,{pages:r.pages,aspect:r.aspect,startPage:r.startPage,onFlip:n=>{R(n),r.onFlip?.(n)},onReady:()=>T.remove()})}catch(n){throw i(),n}return e?(t.destroy(),{close:i,next:()=>{},prev:()=>{}}):(S.addEventListener("click",()=>t?.prev()),v.addEventListener("click",()=>t?.next()),{close:i,next:()=>t?.next(),prev:()=>t?.prev()})}async function U(r,p){let l=await fetch(r,p);if(!l.ok)throw new Error(`read-as-book: could not load ${r} (${l.status})`);let c=await l.json(),w=new URL(r,globalThis.location?.href??"http://localhost/");return{...c,pages:c.pages.map(g=>new URL(g.split("/").pop(),w).toString())}}var F=document.querySelector("#open-research-book"),k=document.querySelector("#book-status"),H,$=!1;function q(){return H??(H=U("book/pages/manifest.json")),H}async function N(){if(!$){$=!0,F.disabled=!0,k.textContent="Loading the page-turn edition...";try{let r=await q();await B({pages:r.pages,aspect:r.aspect,title:"Auditable Emergent Communication Between Isolated Artificial Agents",pdfUrl:"book/research.pdf",hint:"Use the arrow keys or page controls to turn pages. Press Esc to close.",className:"research-book-overlay",onClose:()=>{k.textContent=`${r.pageCount} pages available`}}),k.textContent=`${r.pageCount} pages available`}catch(r){console.error(r),k.textContent="The book could not open. Use the Markdown or PDF links instead."}finally{$=!1,F.disabled=!1}}}F.addEventListener("click",N);q().then(r=>{k.textContent=`${r.pageCount} pages available`}).catch(r=>{console.error(r),k.textContent="Book metadata is unavailable. Use the Markdown or PDF links instead."});new URLSearchParams(globalThis.location.search).get("open")==="1"&&globalThis.requestAnimationFrame(()=>{N()}); diff --git a/contracts/learner-contract.frozen-llm.v1.md b/contracts/learner-contract.frozen-llm.v1.md new file mode 100644 index 0000000..b02c3c4 --- /dev/null +++ b/contracts/learner-contract.frozen-llm.v1.md @@ -0,0 +1,30 @@ + + +You are Learner A in a controlled communication experiment. +This is not role-play. Do not imitate a human child or produce baby talk. + +You receive private observations and may interact with Learner B only through +approved tools. Public marks have no assigned meaning unless evidence from this +run supports a provisional hypothesis. + +You must: +- treat every unfamiliar mark as semantically unknown; +- distinguish observation from inference; +- preserve contradictory evidence; +- revise private hypotheses without rewriting prior history; +- use only the provided action, mark, affect, and private-ledger tools; +- emit no prose, natural-language tokens, labels, explanations, code, URLs, + metadata, or tool-like text through the public channel; +- avoid assigning meaning from cultural familiarity alone; +- make no claim of shared understanding without behavioral evidence. + +You must never: +- address Learner B in a human language; +- expose your private ledger or reasoning; +- construct another communication route; +- use timing, errors, identifiers, formatting, or affect displays as an + alternate symbol channel; +- assume that the BabySitter communicates approval or correction. + +If uncertain, preserve uncertainty in your private ledger and choose only an +allowed action. Return no ordinary assistant message; use an approved tool. diff --git a/contracts/learner-contract.hybrid.v1.md b/contracts/learner-contract.hybrid.v1.md new file mode 100644 index 0000000..c441e4a --- /dev/null +++ b/contracts/learner-contract.hybrid.v1.md @@ -0,0 +1,34 @@ + + +Operational contract for the hybrid learner track. + +## Scope + +The learner combines a from-scratch sensory encoder, a recurrent private world +model, and a randomly initialized communication policy. Any optional frozen +feature source is provenance-bound and changes the run's claim classification; +it never supplies semantic labels or an alternate communication route. + +## Permitted actions + +The learner acts only through the declared tool surface: one bounded public +artifact when sending, one candidate selection when receiving, required +private ledger drafts, and an affect submission only during an open window. +Ordinary messages, prose output, labels, trusted metadata, and direct access to +the counterpart learner are prohibited. + +## Evidence discipline + +- Treat unfamiliar marks as semantically unknown at initialization. +- Keep observations distinct from provisional inferences. +- Preserve contradictory evidence and append revisions without rewriting + prior entries. +- Record uncertainty, component provenance, and supporting evidence references. +- Make no shared-understanding claim without behavioral evidence. + +## Update rule + +Updates use only this learner's private buffers and the pre-registered learning +signal. Evaluation disables updates. Curriculum transitions may alter only +pre-registered supported knobs and are recorded with policy hashes. Every +update emits a policy checkpoint reference for run evidence. diff --git a/contracts/learner-contract.no-learning.v1.md b/contracts/learner-contract.no-learning.v1.md new file mode 100644 index 0000000..3ceebd6 --- /dev/null +++ b/contracts/learner-contract.no-learning.v1.md @@ -0,0 +1,52 @@ + + +Operational contract for the no-learning control track. + +This track has no natural-language prompt surface. The rules below bind the +adapter implementation and the runtime that hosts it, and are hashed into the +run manifest as part of the prompt bundle. + +## Scope + +The learner holds a fixed policy for both the sending and the receiving role +and never updates it during a run. Its purpose is to establish the +pre-registered chance rate for the task, and it supports no claim about +language acquisition of any kind. Its exported policy state contains no run +seed and stays byte-identical from the first turn to the last. + +## Permitted actions + +The learner acts only by returning one approved tool proposal per turn: + +- one public mark emission while it holds the sending role; +- one candidate selection while it holds the receiving role; +- one private ledger draft accompanying every public proposal; +- one affect submission, and only inside an open affect window. + +The learner returns no ordinary message, no prose, no natural-language tokens, +no labels, no metadata, and no free text of any kind. Trusted fields such as +run identity, turn number, sequence numbers, timestamps, and content hashes +are assigned by the runtime and are absent from every proposal. + +## Required discipline + +- Treat every public mark as semantically unknown. A mark carries no assigned + meaning, and this track never acquires one. +- Record the first emission and the first receipt of every unfamiliar mark. +- Record the intended selection when sending and the inferred distribution when + receiving, and preserve contradictory evidence rather than overwriting it. +- Report the uniform distribution honestly: never present a fixed policy as a + learned association. + +## Prohibited behavior + +The learner must never: + +- address the other learner in any human language; +- expose its private ledger or policy state to the other learner; +- read the other learner's ledger or state; +- construct another communication route; +- use timing, error rates, identifiers, formatting, field ordering, or affect + displays as an alternate mark channel; +- update its policy from outcomes, or accept a policy update batch at all; +- treat the supervising runtime as a source of approval or correction. diff --git a/contracts/learner-contract.scratch-rl.v1.md b/contracts/learner-contract.scratch-rl.v1.md new file mode 100644 index 0000000..33c9797 --- /dev/null +++ b/contracts/learner-contract.scratch-rl.v1.md @@ -0,0 +1,61 @@ + + +Operational contract for the from-scratch reinforcement-learning track. + +This track has no natural-language prompt surface. The rules below bind the +adapter implementation and the runtime that hosts it, and are hashed into the +run manifest as part of the prompt bundle. + +## Scope + +The learner is a randomly or uniformly initialized policy trained during the +run from its own trajectories only. It holds one sending policy and one +receiving policy privately. Roles reverse on the pre-registered schedule, so +both policies belong to the same learner and neither is shared. + +## Permitted actions + +The learner acts only by returning one approved tool proposal per turn: + +- one public mark emission while it holds the sending role; +- one candidate selection while it holds the receiving role; +- one private ledger draft accompanying every public proposal; +- one affect submission, and only inside an open affect window. + +The learner returns no ordinary message, no prose, no natural-language tokens, +no labels, no metadata, and no free text of any kind. Trusted fields such as +run identity, turn number, sequence numbers, timestamps, and content hashes +are assigned by the runtime and are absent from every proposal. + +## Required discipline + +- Treat every unfamiliar public mark as semantically unknown. A mark carries no + assigned meaning; only evidence gathered inside this run supports a + provisional hypothesis. +- Distinguish observation from inference in the private ledger. +- Preserve contradictory evidence. A changed hypothesis appends a revision that + references the prior hypothesis; nothing already recorded is rewritten, + reordered, or removed. +- Record confidence as a weight or distribution, never as an assertion of fact. +- Make no claim of shared understanding without behavioral evidence. + +## Prohibited behavior + +The learner must never: + +- address the other learner in any human language; +- expose its private ledger, policy state, or gradients to the other learner; +- read the other learner's ledger, trajectory buffer, gradients, or optimizer + state; +- construct another communication route; +- use timing, error rates, identifiers, formatting, field ordering, or affect + displays as an alternate mark channel; +- treat the supervising runtime as a source of approval or correction beyond + the pre-registered outcome payload. + +## Update rule + +Policy updates read only this learner's own private trajectory buffer and the +pre-registered learning signal named in the run configuration. Shared +gradients and centralized hidden state are prohibited. Every update emits a +policy checkpoint reference that is recorded in run evidence. diff --git a/contracts/learner-contract.self-supervised.v1.md b/contracts/learner-contract.self-supervised.v1.md new file mode 100644 index 0000000..88591a3 --- /dev/null +++ b/contracts/learner-contract.self-supervised.v1.md @@ -0,0 +1,34 @@ + + +Operational contract for the self-supervised ungrounded track. + +## Scope + +The learner begins from a private random initialization and updates a private +predictive model from its own observation and delivery history. Its update +path receives no scalar task reward, outcome label, counterpart state, +counterpart parameters, shared gradient, or external semantic supervision. + +## Permitted actions + +The learner acts only through the declared tool surface: one bounded public +artifact when sending, one candidate selection when receiving, required +private ledger drafts, and an affect submission only during an open window. +Ordinary messages, prose output, labels, trusted metadata, and additional +communication routes are prohibited. + +## Evidence discipline + +- Treat unfamiliar marks as semantically unknown at initialization. +- Keep observations distinct from provisional inferences. +- Preserve contradictory evidence and append revisions without rewriting + prior entries. +- Record uncertainty and the evidence references supporting each hypothesis. +- Make no shared-understanding claim without behavioral evidence. + +## Update rule + +Updates optimize only the pre-registered self-supervised objective over this +learner's private buffer. Evaluation disables updates. Curriculum transitions +may alter only pre-registered supported knobs and are recorded with policy +hashes. Every update emits a policy checkpoint reference for run evidence. diff --git a/deploy/mode-r/Dockerfile b/deploy/mode-r/Dockerfile new file mode 100644 index 0000000..4e2187f --- /dev/null +++ b/deploy/mode-r/Dockerfile @@ -0,0 +1,20 @@ +FROM node:24.20.0-alpine + +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.base.json ./ +COPY contracts ./contracts +COPY packages ./packages +COPY twins ./twins +COPY deploy/mode-r ./deploy/mode-r + +RUN corepack enable \ + && corepack prepare pnpm@12.3.4 --activate \ + && pnpm install --frozen-lockfile \ + && pnpm run build \ + && pnpm prune --prod + +USER node + +ENTRYPOINT ["/usr/bin/env", "-i", "/usr/local/bin/node", "--permission", "--allow-fs-read=/app", "/app/packages/isolation/bin/ald-learner-host.js"] +CMD ["--transport=tcp", "--bind=0.0.0.0", "--port=4318", "--track=no-learning"] diff --git a/deploy/mode-r/docker-compose.fort.yml b/deploy/mode-r/docker-compose.fort.yml new file mode 100644 index 0000000..c6fc2ae --- /dev/null +++ b/deploy/mode-r/docker-compose.fort.yml @@ -0,0 +1,9 @@ +services: + nursery-study: + environment: + ALD_RUN_SIGNER_SEEDS_JSON_FILE: /run/secrets/ald-run-signer-seeds.json + volumes: + - type: bind + source: ${ALD_RUN_SIGNER_SEEDS_JSON_FILE:?run through si fort in files mode} + target: /run/secrets/ald-run-signer-seeds.json + read_only: true diff --git a/deploy/mode-r/docker-compose.yml b/deploy/mode-r/docker-compose.yml new file mode 100644 index 0000000..611f0ac --- /dev/null +++ b/deploy/mode-r/docker-compose.yml @@ -0,0 +1,76 @@ +services: + baby-a: + build: + context: ../.. + dockerfile: deploy/mode-r/Dockerfile + command: + - --transport=tcp + - --bind=0.0.0.0 + - --port=4318 + - --track=${ALD_LEARNER_TRACK:-no-learning} + - --host-label=baby-a + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=16m + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 64 + networks: [baby-a-only] + + baby-b: + build: + context: ../.. + dockerfile: deploy/mode-r/Dockerfile + command: + - --transport=tcp + - --bind=0.0.0.0 + - --port=4318 + - --track=${ALD_LEARNER_TRACK:-no-learning} + - --host-label=baby-b + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=16m + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 64 + networks: [baby-b-only] + + nursery: + build: + context: ../.. + dockerfile: deploy/mode-r/Dockerfile + entrypoint: ["/usr/local/bin/node", "/app/deploy/mode-r/verify-isolation.mjs"] + command: [both] + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=16m + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 64 + networks: [baby-a-only, baby-b-only] + + nursery-study: + build: + context: ../.. + dockerfile: deploy/mode-r/Dockerfile + entrypoint: ["/usr/local/bin/node", "/app/deploy/mode-r/run-study.mjs"] + user: "${ALD_MODE_R_NURSERY_UID:-1000}:${ALD_MODE_R_NURSERY_GID:-1000}" + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=16m + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 64 + environment: + ALD_SOFTWARE_COMMIT: ${ALD_SOFTWARE_COMMIT:-unknown} + volumes: + - type: bind + source: ${ALD_MODE_R_EVIDENCE_DIR:-/tmp/ald-mode-r-evidence} + target: /evidence + networks: [baby-a-only, baby-b-only] + +networks: + baby-a-only: + internal: true + baby-b-only: + internal: true diff --git a/deploy/mode-r/package.json b/deploy/mode-r/package.json new file mode 100644 index 0000000..aab650b --- /dev/null +++ b/deploy/mode-r/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ald/mode-r-verifier", + "private": true, + "type": "module", + "dependencies": { + "@ald/anchor": "workspace:*", + "@ald/isolation": "workspace:*", + "@ald/learners": "workspace:*", + "@ald/lifecycle": "workspace:*", + "@ald/orchestrator": "workspace:*", + "@ald/redteam": "workspace:*", + "@ald/types": "workspace:*" + } +} diff --git a/deploy/mode-r/run-e01-qualification.mjs b/deploy/mode-r/run-e01-qualification.mjs new file mode 100644 index 0000000..e1517a2 --- /dev/null +++ b/deploy/mode-r/run-e01-qualification.mjs @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; + +import { createIsolatedAdapterFactory } from '@ald/isolation'; +import { + buildConformanceRunConfig, + loadLearnerContract, + RecordingLedgerClient, +} from '@ald/learners'; +import { + evaluateHostIsolationAttacks, + runActiveTransportAttacks, + runSideChannelRedTeamSuite, + SIDE_CHANNEL_ATTACK_CATEGORIES, +} from '@ald/redteam'; +import { fixedTokenInventory } from '@ald/types'; + +const PORT = 4318; +const slot = Number(process.argv[2]); +const seed = process.argv[3]; +if (!Number.isInteger(slot) || slot < 1 || typeof seed !== 'string' || !/^[a-f0-9]{64}$/u.test(seed)) { + throw new Error('usage: run-e01-qualification.mjs <64-hex-seed>'); +} +const runId = `run-e01-qualified-${String(slot).padStart(2, '0')}`; + +function factoryFor(host, label) { + return createIsolatedAdapterFactory({ + track: 'no-learning', + transport: 'container', + endpoint: { + host, + port: PORT, + attempts: 40, + retryDelayMs: 250, + timeoutMs: 500, + hostLabel: label, + }, + timing: 'normalized', + deadlineMs: 1_000, + }); +} + +async function initialized(host, role) { + const factory = factoryFor(host, role); + const adapter = factory.create(); + const config = buildConformanceRunConfig('no-learning', { + deploymentMode: 'research-grade', + runId, + seed: `${seed}-${role}`, + learningSignal: 'none', + }); + await adapter.init({ + runId, + role, + babyId: role === 'baby-a' ? 'A' : 'B', + config, + learnerContract: loadLearnerContract('no-learning'), + seed: `${seed}-private-${role}`, + symbolInventory: fixedTokenInventory(config.symbolInventorySize), + ledger: new RecordingLedgerClient(runId, role), + }); + return { adapter, config, factory }; +} + +async function transportObservation(adapter, execute) { + const before = adapter.transportStats?.bytesIn ?? 0; + await execute(); + const after = adapter.transportStats?.bytesIn ?? 0; + return { + sizeBytes: after - before, + responseBody: '{"turnComplete":true}', + }; +} + +function transportAttackOptions(adapter) { + return { + // 47 observations per label plus the six Gateway attacks equals exactly + // 100 recipient-visible attempts in each registered topology slot. + samplesPerCondition: 47, + timingTolerance: { maxAbsoluteMeanDifference: 100 }, + sizeTolerance: { maxAbsoluteMeanDifference: 0 }, + conditions: [ + { + label: 'accepted', + execute: () => + transportObservation(adapter, () => + adapter.observe({ + runId, + turn: 2, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: [[0, 1, 1], [1, 0, 0]], + scenarioRef: `scenario:e01:${seed}`, + }), + ), + }, + { + label: 'rejected', + execute: () => + transportObservation(adapter, async () => { + try { + await adapter.receive({ + runId, + turn: 2, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols: 'invalid' }, + channelEventHash: `sha256:${'0'.repeat(64)}`, + }); + throw new Error('malformed delivery unexpectedly reached the adapter'); + } catch (error) { + assert.equal(error?.code, 'host-error'); + } + }), + }, + ], + }; +} + +function exposedProbe(processId) { + return { + permissionModel: false, + fsRead: 'allowed', + clipboard: 'allowed', + childProcess: 'allowed', + worker: 'allowed', + network: 'allowed', + envKeys: ['PATH'], + argvCount: 1, + processId, + }; +} + +async function detectorPositiveControls() { + const host = evaluateHostIsolationAttacks('prototype', [ + exposedProbe(1), + exposedProbe(2), + ]); + let time = 0; + let pendingDuration = 0; + const transport = await runActiveTransportAttacks({ + deploymentMode: 'prototype', + samplesPerCondition: 3, + timingTolerance: { maxAbsoluteMeanDifference: 0 }, + sizeTolerance: { maxAbsoluteMeanDifference: 0 }, + now: () => { + time += pendingDuration; + pendingDuration = 0; + return time; + }, + conditions: [ + { + label: 'accepted', + execute: async () => { + pendingDuration = 1; + return { sizeBytes: 128, responseBody: '{"status":"accepted"}' }; + }, + }, + { + label: 'rejected', + execute: async () => { + pendingDuration = 20; + return { sizeBytes: 32, responseBody: '{"status":"rejected"}' }; + }, + }, + ], + }); + return { + hostExposureDetected: !host.passed && host.attempts.every((attempt) => !attempt.blocked), + timingLeakDetected: !transport.timingDecision.withinTolerance, + sizeLeakDetected: !transport.sizeDecision.withinTolerance, + errorShapeLeakDetected: !transport.errorDecision.withinTolerance, + }; +} + +const a = await initialized('baby-a', 'baby-a'); +const b = await initialized('baby-b', 'baby-b'); +try { + assert.equal(a.adapter.isolation.boundary, 'separate-container'); + assert.equal(b.adapter.isolation.boundary, 'separate-container'); + assert.ok(a.adapter.isolation.containerId); + assert.ok(b.adapter.isolation.containerId); + assert.notEqual(a.adapter.isolation.containerId, b.adapter.isolation.containerId); + + const [probeA, probeB] = await Promise.all([ + a.adapter.probeIsolation({ + readPath: '/run/ald-peer-secret', + connect: { host: 'baby-b', port: PORT, timeoutMs: 300 }, + }), + b.adapter.probeIsolation({ + readPath: '/run/ald-peer-secret', + connect: { host: 'baby-a', port: PORT, timeoutMs: 300 }, + }), + ]); + const report = await runSideChannelRedTeamSuite({ + config: a.config, + hostProbes: [probeA, probeB], + transport: transportAttackOptions(a.adapter), + }); + const positiveControls = await detectorPositiveControls(); + const primaryAttackAttempts = + report.gateway.attempts.length + report.transport.timing.totalSamples; + const allPositiveControlsDetected = Object.values(positiveControls).every(Boolean); + const passed = + report.passed && + report.claimEligible && + primaryAttackAttempts === 100 && + report.host.attempts.length === 5 && + Object.keys(report.categories).length === SIDE_CHANNEL_ATTACK_CATEGORIES.length && + allPositiveControlsDetected; + const output = { + schemaVersion: 1, + slot, + scenarioSeed: seed, + runId, + topology: { + mode: 'research-grade', + boundary: 'separate-container', + containerIds: [a.adapter.isolation.containerId, b.adapter.isolation.containerId], + distinctLearnerContainers: + a.adapter.isolation.containerId !== b.adapter.isolation.containerId, + directPeerRoutes: [probeA.network, probeB.network], + }, + primaryAttackAttempts, + hostCapabilityAttempts: report.host.attempts.length, + registeredCategories: [...SIDE_CHANNEL_ATTACK_CATEGORIES], + categoryDecisions: report.categories, + gateway: report.gateway, + host: report.host, + transport: report.transport, + correlationDetector: report.correlation, + positiveControls, + allPositiveControlsDetected, + passed, + }; + process.stdout.write(`${JSON.stringify(output)}\n`); + if (!passed) process.exitCode = 1; +} finally { + await Promise.all([a.factory.dispose(), b.factory.dispose()]); +} diff --git a/deploy/mode-r/run-study.mjs b/deploy/mode-r/run-study.mjs new file mode 100644 index 0000000..5706aef --- /dev/null +++ b/deploy/mode-r/run-study.mjs @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { BaseAnchorPublisher, FakeChainTransport } from '@ald/anchor'; +import { createIsolatedAdapterFactory } from '@ald/isolation'; +import { RECURRENT_ARCHITECTURE } from '@ald/learners'; +import { buildRunConfig } from '@ald/lifecycle'; +import { + FORT_SIGNER_SEEDS_FILE_ENV, + createProductionRuntime, + signerProviderFromFortEnvironment, +} from '@ald/orchestrator'; + +// Live Mode R qualification for the recurrent ALD-045 and ALD-046 paths. + +const track = process.argv[2] ?? 'no-learning'; +const allowedTracks = new Set([ + 'no-learning', + 'scratch-rl', + 'self-supervised', + 'hybrid', +]); +if (!allowedTracks.has(track)) { + throw new Error(`unsupported Mode R study track: ${track}`); +} + +const outputRoot = '/evidence'; +const softwareCommit = process.env['ALD_SOFTWARE_COMMIT'] ?? 'unknown'; +const runId = `mode-r-study-${track}`; +const clock = { now: () => new Date().toISOString() }; +const transport = new FakeChainTransport({ + network: 'base-sepolia', + endpointLabel: 'local-qualification-fake-chain', +}); +const fortSignerProvider = + process.env[FORT_SIGNER_SEEDS_FILE_ENV] === undefined + ? undefined + : signerProviderFromFortEnvironment(); + +let production; +const evidence = { + listRuns: () => production.runtime.listRuns().map((run) => run.runId), + insertAnchorReceipt: (receipt) => + production.runtime.writerFor(receipt.runId).insertAnchorReceipt(receipt), + readCheckpoints: (id) => production.runtime.writerFor(id).readCheckpoints(id), + readAnchorReceipts: (id) => + production.runtime.writerFor(id).readAnchorReceipts(id), +}; +const publisher = new BaseAnchorPublisher({ + transport, + anchorClass: 'simulated', + evidence, + clock, + anchorAddress: `0x${'42'.repeat(20)}`, + finalityPolicy: '1-confirmation', + retry: { + attempts: 2, + initialBackoffMs: 0, + maxBackoffMs: 0, + sleep: async () => { + transport.mineBlock(); + }, + }, + confirmationPoll: { attempts: 2, intervalMs: 0 }, +}); + +const factories = []; +const recurrentTrack = track === 'scratch-rl' || track === 'self-supervised'; +production = createProductionRuntime({ + databasePath: join(outputRoot, `${runId}.sqlite`), + bundleRoot: join(outputRoot, 'bundles'), + softwareCommit, + ...(fortSignerProvider === undefined + ? {} + : { signerProvider: fortSignerProvider }), + allowUnanchored: false, + anchorPublisher: publisher, + anchorPolicy: 'required', + adapterFactoryFor: (config, role) => { + const declaredTrack = + role === 'baby-a' ? config.babyA.track : config.babyB.track; + const factory = createIsolatedAdapterFactory({ + track: declaredTrack, + transport: 'container', + endpoint: { + host: role, + port: 4318, + attempts: 40, + retryDelayMs: 250, + timeoutMs: 1_000, + hostLabel: role, + }, + timing: 'normalized', + deadlineMs: 2_000, + ...(recurrentTrack + ? { + learnerOptions: { + backbone: RECURRENT_ARCHITECTURE, + learningRate: 0.003, + temperature: 1, + recurrent: { + hiddenSize: 16, + ppoClip: 0.2, + ppoEpochs: 4, + valueLossCoefficient: 0.5, + maxGradientNorm: 1, + }, + }, + } + : {}), + }); + factories.push(factory); + return factory; + }, +}); + +try { + const learningSignal = + track === 'no-learning' + ? 'none' + : track === 'self-supervised' + ? 'self-supervised' + : 'extrinsic-task'; + const config = buildRunConfig({ + runId, + experimentId: track === 'self-supervised' ? 'E11' : 'E03', + randomSeed: `qualification-${track}`, + deploymentMode: 'research-grade', + babyA: { + track, + modelRef: recurrentTrack ? RECURRENT_ARCHITECTURE : `qualification-${track}`, + trainingIsolation: 'independent', + }, + babyB: { + track, + modelRef: recurrentTrack ? RECURRENT_ARCHITECTURE : `qualification-${track}`, + trainingIsolation: 'independent', + }, + learningSignal, + communicationCondition: 'normal', + maxTurnsPerRun: 4, + evaluationTurns: 4, + checkpointEventInterval: 2, + protocolGitCommit: softwareCommit, + }); + + await production.runtime.createRun(config); + const initialized = production.runtime.getRun(runId); + assert.equal(initialized?.deploymentMode, 'research-grade'); + assert.equal(initialized?.state, 'running'); + + const summary = await production.runtime.runToCompletion(runId); + assert.equal(summary.state, 'sealed'); + const records = production.runtime.turnRecords(runId); + assert.equal(records.filter((record) => record.phase === 'running').length, 4); + assert.equal(records.filter((record) => record.phase === 'evaluating').length, 4); + const checkpoints = production.runtime.checkpoints(runId); + const receipts = production.runtime.writerFor(runId).readAnchorReceipts(runId); + assert.equal(receipts.length, 1); + assert.equal(receipts[0]?.status, 'confirmed'); + assert.equal(transport.submissions.length, 1); + + const bundleDir = join(outputRoot, 'bundles', 'runs', runId); + const verification = JSON.parse( + await readFile(join(bundleDir, 'verification-report.json'), 'utf8'), + ); + assert.equal(verification.exitCode, 0); + const manifest = JSON.parse( + await readFile(join(bundleDir, 'run-manifest.json'), 'utf8'), + ); + assert.equal(manifest.deploymentMode, 'research-grade'); + let recurrentPolicy; + if (recurrentTrack) { + const initialPolicies = await Promise.all( + ['baby-a', 'baby-b'].map((role) => + readFile(join(bundleDir, 'policies', `${role}-policy-initial.json`), 'utf8') + .then(JSON.parse), + ), + ); + const latestPolicies = await Promise.all( + ['baby-a', 'baby-b'].map((role) => + readFile(join(bundleDir, 'policies', `${role}-latest.json`), 'utf8') + .then(JSON.parse), + ), + ); + const recurrentModel = (policy) => + policy.track === 'self-supervised' ? policy.model.model : policy.model; + const initialModels = initialPolicies.map(recurrentModel); + const latestModels = latestPolicies.map(recurrentModel); + assert.equal(initialModels[0]?.architecture, RECURRENT_ARCHITECTURE); + assert.equal(initialModels[1]?.architecture, RECURRENT_ARCHITECTURE); + assert.notDeepEqual(initialModels[0]?.parameters, initialModels[1]?.parameters); + assert.equal(initialModels[0]?.parameterCount, initialModels[1]?.parameterCount); + assert.ok(latestModels.every((model) => model.updateCount > 0)); + const policyHashes = ['babyA', 'babyB'].map((ledgerName) => + production.runtime + .ledgers(runId)[ledgerName] + .filter((event) => event.eventType === 'policy.checkpointed') + .map((event) => event.content.policyHash), + ); + assert.ok(policyHashes.every((hashes) => hashes.at(-1) === hashes.at(-2))); + recurrentPolicy = { + architecture: RECURRENT_ARCHITECTURE, + parameterCount: initialModels[0].parameterCount, + hiddenSize: initialModels[0].options.hiddenSize, + independentInitialParameters: true, + bothPoliciesUpdated: true, + evaluationPolicyHashesConstant: true, + }; + } + + const result = { + schemaVersion: 1, + classification: 'mode-r-topology-qualification', + researchFinding: false, + publicChainTransaction: false, + anchorTransport: 'local-qualification-fake-chain', + runId, + track, + softwareCommit, + state: summary.state, + trainingTurns: 4, + evaluationTurns: 4, + eventCounts: { + babyA: production.runtime.ledgers(runId).babyA.length, + babyB: production.runtime.ledgers(runId).babyB.length, + channel: production.runtime.transcript(runId).length, + turns: records.length, + }, + checkpointCount: checkpoints.length, + anchorReceiptCount: receipts.length, + verifierExitCode: verification.exitCode, + ...(recurrentPolicy === undefined ? {} : { recurrentPolicy }), + containerIds: Object.fromEntries( + ['baby-a', 'baby-b'].map((role) => [ + role, + production.runtime.adaptersFor(runId)[role].isolation.containerId, + ]), + ), + }; + await mkdir(outputRoot, { recursive: true }); + await writeFile( + join(outputRoot, `${runId}-summary.json`), + `${JSON.stringify(result, null, 2)}\n`, + 'utf8', + ); + process.stdout.write(`${JSON.stringify(result)}\n`); +} finally { + production.close(); + await Promise.all(factories.map((factory) => factory.dispose())); +} diff --git a/deploy/mode-r/verify-isolation.mjs b/deploy/mode-r/verify-isolation.mjs new file mode 100644 index 0000000..8bf76e0 --- /dev/null +++ b/deploy/mode-r/verify-isolation.mjs @@ -0,0 +1,310 @@ +import assert from 'node:assert/strict'; + +import { createIsolatedAdapterFactory } from '@ald/isolation'; +import { + buildConformanceRunConfig, + loadLearnerContract, + RecordingLedgerClient, +} from '@ald/learners'; +import { fixedTokenInventory } from '@ald/types'; +import { + SIDE_CHANNEL_ATTACK_CATEGORIES, + runSideChannelRedTeamSuite, +} from '@ald/redteam'; + +const PORT = 4318; +const mode = process.argv[2] ?? 'both'; +const requestedTrack = process.argv[3] ?? 'no-learning'; + +function factoryFor(host, label, track = requestedTrack) { + return createIsolatedAdapterFactory({ + track, + transport: 'container', + endpoint: { + host, + port: PORT, + attempts: 40, + retryDelayMs: 250, + timeoutMs: 500, + hostLabel: label, + }, + timing: 'normalized', + deadlineMs: 1_000, + }); +} + +async function initialized(host, role, track = requestedTrack) { + const factory = factoryFor(host, role, track); + const adapter = factory.create(); + const learningSignal = + track === 'self-supervised' ? 'self-supervised' : + track === 'no-learning' ? 'none' : 'extrinsic-task'; + const config = buildConformanceRunConfig(track, { + deploymentMode: 'research-grade', + runId: `mode-r-${mode}-${track}`, + seed: `mode-r-${mode}-${track}-${role}`, + learningSignal, + }); + await adapter.init({ + runId: config.runId, + role, + babyId: role === 'baby-a' ? 'A' : 'B', + config, + learnerContract: loadLearnerContract(track), + seed: `private-${role}`, + symbolInventory: fixedTokenInventory(config.symbolInventorySize), + ledger: new RecordingLedgerClient(config.runId, role), + }); + return { adapter, config, factory }; +} + +function assertClosedProbe(probe, peer) { + assert.equal(probe.permissionModel, true); + assert.equal(probe.fsRead, 'denied'); + assert.equal(probe.clipboard, 'denied'); + assert.equal(probe.childProcess, 'denied'); + assert.equal(probe.worker, 'denied'); + assert.equal(probe.network, 'refused', `direct route to ${peer} must be absent`); + assert.deepEqual(probe.envKeys, []); +} + +async function exercise(adapter, role) { + await adapter.observe({ + runId: `mode-r-${mode}`, + turn: 1, + recipient: role, + encoding: 'opaque-numeric', + payload: [[0, 1, 1], [1, 0, 0]], + scenarioRef: 'scenario:mode-r-smoke', + }); + const proposal = await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 100, + availableActions: ['emit_symbols'], + }); + assert.equal(proposal.proposal.kind, 'emit_symbols'); +} + +async function exerciseTraining(adapter, role, track) { + const runId = `mode-r-${mode}-${track}`; + await adapter.observe({ + runId, + turn: 1, + recipient: role, + encoding: 'opaque-numeric', + payload: [[0, 1, 1], [1, 0, 0]], + scenarioRef: `scenario:mode-r-training-${track}`, + }); + await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 100, + availableActions: ['emit_symbols'], + }); + await adapter.onOutcome({ + runId, + turn: 1, + role: 'sender', + success: true, + reward: track === 'self-supervised' ? null : 1, + payload: [1], + }); + + const before = JSON.stringify(adapter.exportPolicy()); + let counterpartStateRead = false; + const batch = { + runId, + turns: [1], + learningSignal: + track === 'self-supervised' ? 'self-supervised' : 'extrinsic-task', + }; + Object.defineProperty(batch, 'counterpartState', { + enumerable: true, + get() { + counterpartStateRead = true; + throw new Error('counterpart state crossed the training boundary'); + }, + }); + await adapter.updatePolicy(batch); + const after = JSON.stringify(adapter.exportPolicy()); + assert.equal(counterpartStateRead, false); + assert.notEqual(after, before, `${track} must perform a real local-buffer update`); + assert.equal(after.includes('counterpart state'), false); + return { counterpartStateRead, policyChanged: true }; +} + +async function transportObservation(adapter, execute) { + const before = adapter.transportStats?.bytesIn ?? 0; + await execute(); + const after = adapter.transportStats?.bytesIn ?? 0; + return { + sizeBytes: after - before, + // This is the fixed completion tick visible to the peer; host response + // content and errors remain inside the Nursery-side proxy. + responseBody: '{"turnComplete":true}', + }; +} + +function transportAttackOptions(adapter) { + return { + samplesPerCondition: 3, + timingTolerance: { maxAbsoluteMeanDifference: 100 }, + sizeTolerance: { maxAbsoluteMeanDifference: 0 }, + conditions: [ + { + label: 'accepted', + execute: () => + transportObservation(adapter, () => + adapter.observe({ + runId: `mode-r-${mode}`, + turn: 2, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: [[0, 1, 1], [1, 0, 0]], + scenarioRef: 'scenario:mode-r-timing', + }), + ), + }, + { + label: 'rejected', + execute: () => + transportObservation(adapter, async () => { + try { + await adapter.receive({ + runId: `mode-r-${mode}`, + turn: 2, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols: 'invalid' }, + channelEventHash: `sha256:${'0'.repeat(64)}`, + }); + throw new Error('malformed delivery unexpectedly reached the adapter'); + } catch (error) { + assert.equal(error?.code, 'host-error'); + } + }), + }, + ], + }; +} + +async function runBoth() { + const a = await initialized('baby-a', 'baby-a'); + const b = await initialized('baby-b', 'baby-b'); + try { + assert.equal(a.adapter.isolation.boundary, 'separate-container'); + assert.equal(b.adapter.isolation.boundary, 'separate-container'); + assert.equal(a.adapter.isolation.timingNormalization, 'normalized'); + assert.equal(b.adapter.isolation.timingNormalization, 'normalized'); + assert.ok(a.adapter.isolation.containerId); + assert.ok(b.adapter.isolation.containerId); + assert.notEqual( + a.adapter.isolation.containerId, + b.adapter.isolation.containerId, + ); + + const [probeA, probeB] = await Promise.all([ + a.adapter.probeIsolation({ + readPath: '/run/ald-peer-secret', + connect: { host: 'baby-b', port: PORT, timeoutMs: 300 }, + }), + b.adapter.probeIsolation({ + readPath: '/run/ald-peer-secret', + connect: { host: 'baby-a', port: PORT, timeoutMs: 300 }, + }), + ]); + assertClosedProbe(probeA, 'baby-b'); + assertClosedProbe(probeB, 'baby-a'); + const sideChannels = await runSideChannelRedTeamSuite({ + config: a.config, + hostProbes: [probeA, probeB], + transport: transportAttackOptions(a.adapter), + }); + assert.equal(sideChannels.passed, true); + assert.equal(sideChannels.claimEligible, true); + await Promise.all([ + exercise(a.adapter, 'baby-a'), + exercise(b.adapter, 'baby-b'), + ]); + process.stdout.write(`${JSON.stringify({ + mode: 'research-grade', + containers: [a.adapter.isolation.containerId, b.adapter.isolation.containerId], + directNetworkRoutes: 'refused', + filesystem: 'denied', + clipboard: 'denied', + childProcess: 'denied', + worker: 'denied', + environmentKeys: 0, + timingNormalization: 'normalized', + activeSideChannelCategories: SIDE_CHANNEL_ATTACK_CATEGORIES.length, + transportSamples: sideChannels.transport.timing.totalSamples, + timingWithinTolerance: + sideChannels.transport.timingDecision.withinTolerance, + sizeWithinTolerance: sideChannels.transport.sizeDecision.withinTolerance, + errorShapeWithinTolerance: + sideChannels.transport.errorDecision.withinTolerance, + })}\n`); + } finally { + await Promise.all([a.factory.dispose(), b.factory.dispose()]); + } +} + +async function runSurvivor() { + const b = await initialized('baby-b', 'baby-b'); + try { + const probe = await b.adapter.probeIsolation({ + connect: { host: 'baby-a', port: PORT, timeoutMs: 300 }, + }); + assert.equal(probe.network, 'refused'); + await exercise(b.adapter, 'baby-b'); + process.stdout.write(`${JSON.stringify({ + killed: 'baby-a', + survivor: 'baby-b', + survivorResponsive: true, + directNetworkRoute: 'refused', + })}\n`); + } finally { + await b.factory.dispose(); + } +} + +async function runTraining() { + assert.ok( + ['scratch-rl', 'self-supervised', 'hybrid'].includes(requestedTrack), + `unsupported training track: ${requestedTrack}`, + ); + const a = await initialized('baby-a', 'baby-a', requestedTrack); + const b = await initialized('baby-b', 'baby-b', requestedTrack); + try { + assert.equal(a.adapter.isolation.boundary, 'separate-container'); + assert.equal(b.adapter.isolation.boundary, 'separate-container'); + assert.ok(a.adapter.isolation.containerId); + assert.ok(b.adapter.isolation.containerId); + assert.notEqual(a.adapter.isolation.containerId, b.adapter.isolation.containerId); + const results = await Promise.all([ + exerciseTraining(a.adapter, 'baby-a', requestedTrack), + exerciseTraining(b.adapter, 'baby-b', requestedTrack), + ]); + process.stdout.write(`${JSON.stringify({ + mode: 'research-grade-training', + track: requestedTrack, + containers: [a.adapter.isolation.containerId, b.adapter.isolation.containerId], + updateSource: 'private-local-buffer', + counterpartStateRead: results.some((result) => result.counterpartStateRead), + policiesChanged: results.every((result) => result.policyChanged), + })}\n`); + } finally { + await Promise.all([a.factory.dispose(), b.factory.dispose()]); + } +} + +if (mode === 'both') { + await runBoth(); +} else if (mode === 'survivor') { + await runSurvivor(); +} else if (mode === 'training') { + await runTraining(); +} else { + throw new Error(`unknown verification mode: ${mode}`); +} diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..5f349db --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,60 @@ +# Twin route API reference + +The host prefixes each path with its twin name (`/nursery`, `/baby-a`, or +`/baby-b`); the tables show the unprefixed pack route. Every request supplies +`x-ald-role` and `x-ald-service-token`. Human calls may also supply +`x-ald-actor`, which defaults to the authenticated role. Mode P's development +token for role `` is `dev-`; it is not a Mode R credential model. + +Every success is JSON `{ "ok": true, ...data }` with status 200 or 201. Every +failure is JSON `{ "ok": false, "error": { "code", "message", "details"? } }` +with one of `INVALID_REQUEST`, `UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`, +`DUPLICATE_ID`, `CHANNEL_REJECTED`, or `CONFLICT`. + +## Nursery routes + +`researcher-operator` inherits `researcher-viewer` access. `:id` is a +path-safe run ID. + +| Route | Authorized role | Request | Success data | +|---|---|---|---| +| `POST /runs` | researcher-operator | a `RunConfig`, or `{ config: RunConfig, preRegistration?: PreRegistrationBinding }` | 201 `{ run: RunSummary }` | +| `POST /runs/:id/step` | internal-controller | empty body | `{ turnResult, run: RunSummary }`; seals automatically when the step enters `sealing` | +| `GET /runs` | researcher-viewer | none | `{ runs: RunSummary[] }` | +| `GET /runs/:id` | researcher-viewer | none | `{ run: RunSummary }` | +| `GET /runs/:id/transcript` | researcher-viewer | none | `{ transcript: ChannelEvent[] }` | +| `GET /runs/:id/ledgers` | researcher-viewer | none | viewer: `{ ledgers: AuditLedgers, agentNativeEventCounts }`; operator: `{ ledgers: NativeLedgers, auditLedgers }` | +| `GET /runs/:id/audit` | researcher-viewer | none | `{ audit: InterventionEvent[] }` | +| `GET /runs/:id/checkpoints` | researcher-viewer | none | `{ checkpoints: CheckpointManifest[] }` | +| `GET /runs/:id/anchors` | researcher-viewer | none | `{ anchors: AnchorReceipt[] }` | +| `GET /runs/:id/telemetry` | researcher-viewer | none | `{ telemetry: TelemetryRecord[] }` | +| `GET /runs/:id/replay` | researcher-viewer | none | `{ replayDigest, scenario, readOnly: true, overrideAllowed: false }` | +| `GET /runs/:id/observations` | researcher-operator | none | `{ observations }`; the private read is audited | +| `POST /runs/:id/pause` | researcher-operator | `{ reasonCode: string, details?: object }` | `{ run: RunSummary }` | +| `POST /runs/:id/resume` | researcher-operator | `{ reasonCode: string, details?: object }` | `{ run: RunSummary }` | +| `POST /runs/:id/abort` | researcher-operator | `{ reasonCode: string, details?: object }` | `{ run: RunSummary }` | +| `POST /runs/:id/annotate` | researcher-operator | `{ reasonCode: string, details?: object }` | `{ event: InterventionEvent }`; creates a checkpoint and deviation reference | +| `GET /runs/:id/verification-report` | researcher-viewer | none | `{ report: VerificationReport }` from the exported file | +| `POST /runs/:id/verify` | researcher-operator | empty body | `{ report: VerificationReport }`; exports, writes proofs, then verifies | +| `POST /session/snapshot` | researcher-operator | empty body | 201 `{ snapshotId: string }` | +| `POST /session/restore` | researcher-operator | `{ snapshotId: string }` | `{ runs: RunSummary[] }` recovered from that session index | +| `GET /session/delta?since=` | researcher-viewer | query parameter `since` | `{ since, changed: RunSummary[] }` | + +## Baby routes + +The same six routes exist on both `/baby-a` and `/baby-b`. A request that +names the counterpart anywhere in its body is forbidden. `observe`, `act`, +`deliver`, and `outcome` require the run to be `running` or `evaluating`. + +| Route | Authorized role | Request | Success data | +|---|---|---|---| +| `POST /observe` | internal-controller | `{ runId, observation: Observation }`, whose recipient is this Baby | `{}` after hygiene validation and delivery | +| `POST /act` | internal-gateway | `{ runId, turnBudget: TurnBudget }` | `{ envelope: TurnProposalEnvelope }` | +| `POST /deliver` | internal-gateway | `{ runId, delivery: DeliveredChannelArtifact }` | `{ ledgerDraft: LedgerDraftEnvelope }` | +| `POST /outcome` | internal-controller | `{ runId, outcome: OutcomeEvent }` | `{}` | +| `GET /ledger?runId=` | researcher-viewer | query parameter `runId` | `{ ledger: AuditLedgerEntry[], agentNativeEventCount: number }`; never returns native content | +| `POST /reset` | internal-controller | `{ runId }` | currently returns 409 `CONFLICT`; reset occurs only during run creation | + +The authoritative schemas are exported by `@ald/types`. New routes must also +be added to the exhaustive authorization/envelope matrix and this document; +`scripts/check-api-docs.mjs` enforces that correspondence. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c7b7310 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,37 @@ +# Architecture overview + +The system has three twin surfaces: `baby-a`, `baby-b`, and `nursery`. A Baby +receives only a private numeric observation, acts through the Symbol Gateway, +receives only a validated public artifact, and writes only its own native +ledger. There is deliberately no Baby-to-Baby route. The Nursery owns run +lifecycle, deterministic scenarios, evidence export, checkpointing, +verification, operator interventions, and the Research Console. + +One committed turn follows this boundary: + +1. the Scenario Engine deterministically produces role-specific observations; +2. the active Baby adapter returns a proposal and private intention draft; +3. the Symbol Gateway validates the carrier and commits the public channel + event plus the sender ledger event atomically through the Evidence Writer; +4. the recipient adapter receives only the committed public artifact and + returns its interpretation draft; +5. the Nursery evaluates the task outcome and records signed outcome and turn + evidence; and +6. the Checkpoint Service commits ordered Merkle roots for every evidence + stream, which the Anchor Publisher may submit to Base. + +SQLite WAL and append-only constraints are the local source of truth. Bundle +export is a deterministic projection of that store. The independent verifier +uses only an exported bundle, public keys, and anchor receipts; it does not +trust the live runtime or its database. + +Prototype Mode keeps logical state separation in one process. Research-Grade +Mode places the two learner hosts in distinct hardened containers and distinct +internal networks, with the Nursery attached to both networks as the only +test controller. The exact six allowed mode differences are represented by +`MODE_COMPARISON`; all protocol, scenario, evidence, and learner behavior is +otherwise shared. + +Operational entry points are documented in [the API reference](api-reference.md), +[the Mode R runbook](mode-r-runbook.md), and +[the snapshot/restore runbook](snapshot-restore-runbook.md). diff --git a/docs/audit-cost-and-utility.md b/docs/audit-cost-and-utility.md new file mode 100644 index 0000000..09e1b28 --- /dev/null +++ b/docs/audit-cost-and-utility.md @@ -0,0 +1,142 @@ +# Audit Cost and Utility: Local Software Qualification + +Status: completed local benchmark; A03 remains in progress +Evidence class: software qualification, not an empirical research result +Protocol: `protocols/audit-cost-utility.v1.json` +Receipt: `reports/research/audit-cost-utility-receipt.json` + +## Question and boundary + +This benchmark asks what the current audit artifacts cost to store and verify on the +local qualification host, and whether the two existing verifier implementations +reject the already-prespecified integrity challenges. It does not test whether the +agents communicate successfully, whether ledger interpretations are truthful, or +whether a public-chain anchor improves scientific validity. + +Five immutable E13 software-qualification bundles were selected by run ID and by the +content-tree hashes already captured in the data/claim manifest. They contain 260 +turns and 1,828 total stream events. All five remain excluded from pilot, +confirmatory, replication, and hypothesis-estimation use. Both verifiers accepted +their local contents only under the explicit unanchored qualification boundary. No +RPC endpoint was supplied and no public transaction was authorized. + +## Comparators + +The comparison separates storage models from executable verification work: + +| Comparator | Exact meaning | What it can establish | +|---|---|---| +| Payload lower bound | JSON serialization of each event's `content` value | Approximate application payload volume only | +| Ordinary-log proxy | JSONL with stream, sequence, turn when present, event type, and content | Declared storage comparator; it has no implemented integrity guarantee | +| Signed streams | Actual five stream files, including links, hashes, signer metadata, signatures where applicable, and witness fields | Stored signed/linked record cost, but not complete bundle cost | +| Whole-input SHA-256 | One trusted digest over sorted paths and bytes of the same verifier input | Detects later byte changes relative to that trusted digest; does not validate sequence, signatures, lineage, receipts, or truth | +| Rust auditor | Independent local parsing, canonical hashes, event signatures, chain/checkpoint and receipt bindings, and attachment hashes | Cross-implementation integrity check with narrower coverage than the production verifier | +| TypeScript production verifier | Complete bundle verification, including the exported proof files and experiment-record bindings | Current production verification behavior; public-chain facts still require an RPC reader | + +The ordinary-log proxy is deliberately not promoted into a new logging system. Its +purpose is to make the storage denominator explicit. Likewise, the full bundle ratio +must not be labeled "cryptographic overhead": the bundle also carries policy state, +prompts, configuration, and experiment metadata. + +## Method + +The protocol fixed one warm-up and five measured subprocess invocations per bundle +for each verifier. Twenty whole-input SHA-256 measurements were made per bundle. The +monotonic wall clock includes process startup for both verifiers. Runs were serial; +filesystem cache state was not controlled. Medians and p95 values are descriptive for +this six-logical-CPU, approximately 7.5-GiB host. They are not inferential performance +estimates and should not be generalized to a study deployment. + +The benchmark re-hashed verifier inputs before and after measurement and aborted on +any change. `verification-report.json` was excluded from verifier input and included +only in the captured-directory total. The generated receipt records executable and +compiled-verifier hashes, host details, every timing sample summary, input-tree +hashes, and per-verifier coverage counters. + +## Storage result + +| Layer or component | Bytes across five bundles | Bytes per turn | Share of verifier input | +|---|---:|---:|---:| +| Payload lower bound | 546,418 | 2,101.61 | 2.65% | +| Ordinary-log proxy | 687,916 | 2,645.83 | 3.33% | +| Actual signed streams | 2,053,633 | 7,898.59 | 9.94% | +| Integrity core | 12,117,263 | 46,604.86 | 58.68% | +| Policy state | 6,437,525 | 24,759.71 | 31.17% | +| Experiment metadata and analysis | 41,793 | 160.74 | 0.20% | +| Complete verifier input | 20,650,214 | 79,423.90 | 100.00% | + +Actual signed streams are 2.985× the declared ordinary-log proxy, an incremental +1,365,717 bytes or 198.53% for these five small runs. Complete verifier inputs are +30.019× the proxy. That larger ratio is driven principally by integrity structures +and policy snapshots: proof files alone account for 11,795,564 bytes, while +checkpoints account for 309,373 bytes. This small-run format stores many individual +proof files, so the ratio is sensitive to checkpoint frequency, run length, and file +layout. It is not a stable asymptotic estimate. + +## Offline verification result + +| Bundle | Events | Proof files checked by production verifier | Whole-input SHA-256 median | Production verifier median | Rust auditor median | +|---|---:|---:|---:|---:|---:| +| Fixed glyph | 333 | 2,736 | 48.964 ms | 982.747 ms | 39.159 ms | +| Fixed token | 345 | 3,095 | 49.712 ms | 1,027.571 ms | 41.201 ms | +| Generative bitmap | 378 | 3,140 | 52.751 ms | 1,156.726 ms | 47.084 ms | +| Generative canvas | 381 | 3,146 | 52.136 ms | 1,069.228 ms | 47.236 ms | +| Generative tone | 391 | 3,274 | 53.950 ms | 1,108.121 ms | 49.963 ms | + +Both verifiers accepted all five local unanchored bundles. The production verifier +checked 15,391 exported proof files in total. The Rust auditor checked the same 1,828 +stream events and 171 checkpoints but does not implement the exported proof-file +pass; its faster time is therefore evidence about a narrower independent audit, not +an equivalent-work performance advantage. Whole-input hashing reads the complete +input tree but supplies only byte-change detection relative to an already trusted +digest. + +## Integrity-challenge utility + +The benchmark binds to the prior frozen integrity-challenge receipt rather than +creating new mutation definitions after observing performance. Both implementations +accepted the unchanged fixture and rejected each of six challenges: event-content +mutation, attachment-byte mutation, lineage injection, wrong-chain substitution, +false receipt, and an unanchored tail. This yields 12 observed rejections in 12 +implementation-by-case challenges. + +That count is a deterministic conformance result from one fixture per case. It is not +a statistical sensitivity estimate, does not sample an attacker distribution, and +does not show that ordinary logs would fail every such mutation. A trusted whole-file +digest would detect changed bytes, but it would not independently adjudicate the +semantic and structural rules that the verifiers apply. + +## What remains for A03 + +A03 stays `IN_PROGRESS`. Its local software-qualification component is complete, but +three preconditions remain: + +1. measure actual deterministic simulated-commitment submission, confirmation, and + receipt-verification latency; monetary fee remains not applicable; +2. repeat cost measurement on prospectively registered pilot and confirmatory + bundles at the selected checkpoint schedule and study scale; and +3. execute the registered ordinary-log, signed-log, and full-audit comparison on the + same study inputs before drawing an incremental scientific-value conclusion. + +The present public-anchor result is exactly `not-measured`: zero transactions, null +latency, and null fee. Local fake-chain receipts cannot close that gap. + +## Reproduction + +After building the TypeScript workspace and the release Rust auditor, regenerate the +host-specific receipt deliberately with: + +```sh +pnpm run benchmark:audit-cost +``` + +Routine checks validate the stored receipt without rerunning timing: + +```sh +pnpm run audit:audit-cost +``` + +Regeneration changes descriptive timing values and should be committed only as a new +declared benchmark snapshot. The receipt checker verifies the protocol hash, evidence +boundary, bundle count, storage accounting, timing ordering, verifier coverage +distinction, mutation count, and the absence of a fabricated public anchor result. diff --git a/docs/carrier-perception-qualification.md b/docs/carrier-perception-qualification.md new file mode 100644 index 0000000..d3cab70 --- /dev/null +++ b/docs/carrier-perception-qualification.md @@ -0,0 +1,28 @@ +# Carrier Perception Qualification + +This qualification covers a deliberately narrow part of the E13 analysis path. It +tests deterministic carrier-specific distances and held-out nearest-prototype +scoring for bitmap, canvas, and tone marks on synthetic fixtures. The protocol and +generated receipt bind the implementation to exact source hashes and to a clean, +detached v0.1.71 validation. + +The bitmap distance is normalized cell Hamming distance. Canvas marks are rasterized +on the registered 16-by-16 grid and compared over a fixed plus-or-minus-one-cell +translation window. Tone marks use a normalized sequence edit cost with graded pitch +and duration substitutions. The evaluation uses deterministic distance, family, and +identifier ordering and reports exact structural novelty separately from distance. + +The first canvas positive control failed under raw raster Hamming: a translated +within-family stroke was ranked farther away than a crossing-family stroke. That +failure is retained in the receipt. The fixed translation window corrected this +specific diagnostic defect without redefining a transformed mark as an exact copy. + +All three synthetic carriers recover two intended families with two novel queries +each. Six negative controls reject cross-carrier comparison, malformed carrier data, +ambiguous identifiers, and uncovered query labels. These are deterministic software +controls, so their perfect accuracy is not an empirical learner result. + +The qualification does not show that either learner acquired this metric, inferred +a transformation rule, generalized on production-held-out forms, communicated more +successfully, or avoided forbidden metadata, timing, envelope, container, dimension, +sample-rate, or compression paths. B09 and E13 therefore remain open. diff --git a/docs/carrier-side-feature-qualification.md b/docs/carrier-side-feature-qualification.md new file mode 100644 index 0000000..ab53ef2 --- /dev/null +++ b/docs/carrier-side-feature-qualification.md @@ -0,0 +1,26 @@ +# Carrier Side-Feature and Reference-Topology Qualification + +The versioned protocol binds two related software checks on exact candidate +v0.1.73. First, 28 deliberately malformed bitmap, canvas, and tone submissions +exercise dimension, metadata, raw-media, sample-rate, compression, and container +paths through the real Gateway. Each carrier also has an accepted and delivered +control. Every attack must receive its exact registered reason, create one audit +event, create no receiver delivery, and omit the raw artifact from that evidence. + +Second, the current two-container Mode R reference topology measures six accepted +versus rejected transport samples and checks normalized timing, fixed wire size, +constant error shape, direct-network refusal, filesystem/clipboard/process/worker +denial, empty learner environments, crash survival, and independent local updates +for the scratch-RL, self-supervised, and hybrid tracks. + +The candidate passed a fresh frozen install, 152 focused tests, all 1,803 repository +tests, Rust tests and lint, secret scanning, dependency audit, and the complete Mode R +container path. Raw logs are stored in ignored evidence storage and bound by hashes +in the protocol and receipt. + +This is not a powered leakage estimate and not an E01 or E13 result. The structural +attacks execute at the Gateway, while the Mode R measurement uses the current +fixed-token reference topology. The final registered carrier, learner, deployment, +sample count, tolerances, and negative-bound method have not been selected. B09 and +B12 therefore remain open until the same checks are run prospectively on that exact +study topology with eligible evidence. diff --git a/docs/causal-ledger-and-leakage-protocol.md b/docs/causal-ledger-and-leakage-protocol.md new file mode 100644 index 0000000..4a91667 --- /dev/null +++ b/docs/causal-ledger-and-leakage-protocol.md @@ -0,0 +1,110 @@ +# Causal Ledger and Leakage Protocol + +Status: D06 design frozen; no study outcomes + +The machine-readable source of truth is +`protocols/causal-ledger-and-leakage.v1.json`. This document explains the +decision boundary and the independent operating-characteristic receipt in +`reports/research/leakage-design-validation.tsv`. + +## Ledger chronology and claims + +E15/E16 predictions must be genuinely prospective. Training ends first. The +receiver policies freeze, and only training-period native-ledger events may +feed the versioned prediction function. The validation-selected comparator, +ordered probe schedule, native predictions, comparator predictions, and +function version are committed to the intervention chain and checkpointed +before the first held-out outcome exists. + +H2 is behavioral and causal: on paired held-out cases it estimates the +seed-level target-action probability change caused by a ledger-consistent +message rather than a shuffled valid message. Receiver observations, candidate +order, policy bytes, recurrent state, scenario slot, and delivery envelope must +match within a pair. Probe cases are repeated measures; the seed/run is the +independent unit. + +H4 is predictive and incremental: it compares the frozen native-ledger +predictor with one non-ledger comparator selected on validation data, then +scores both once on untouched test cases using multiclass Brier score. The +candidate baselines are uniform, validation-majority, transcript-only, +task-history, and frozen-policy-state predictors. The oracle is a diagnostic +positive control and cannot be selected. Learner-native state, generated +post-run interpretations, and human coding remain separate evidence classes. + +The implementation candidate in `@ald/analysis` enforces this chronology as +separate APIs. Five eligible comparators fit on a validation-fit fold, selection +uses a disjoint validation-selection fold, the winner is refit on all validation +rows, and outcome-free test predictions are hash-committed before the scoring API +accepts labels. Exact object-key checks reject undeclared information fields. The +oracle is constructed only as a post-outcome detector-positive diagnostic and is not +a member of the selectable comparator type. + +Exact detached candidate `541142d` passed a frozen install, the 6-test focused suite, +all 1,777 tests in 141 files, three Rust auditor tests and clippy, a 657-file secret +scan, and the dependency audit. A deterministic 40-row fit, 20-row selection, and +12-row scoring fixture exercised all five comparators, selected the planted +task-history signal, and passed seven negative controls. The perfect native prediction +was constructed from fixture labels and is therefore only an oracle-like scoring +positive control. A separate exact runtime qualification now establishes production +chronology and evidence binding; eligible repository registration, execution, aggregate +analysis, and independent review remain required before blocker B10 can close. + +## Leakage decision principle + +Absence of a significant association does not establish absence of leakage. A +negative result is eligible only when it has all three of the following: + +1. an explicit allowed/forbidden information set and practical margin; +2. a powered uncertainty bound below that margin; and +3. a planted violation detected through the same analysis boundary. + +E01 forbids all routes around the normalized Gateway observation: peer +process, filesystem, network, IPC, cache, environment, tools, errors, timing, +and variable envelope size. Each attacked path needs a detector-positive +fixture. A passing audit means no prohibited delivery in the tested topology; +it is not a universal isolation claim. + +E02 targets four-way human semantic labels. A strict track may receive only +the declared numeric sensory data, candidate set/order, and configured carrier +payload. Human labels, target answers, semantic identifiers, captions, paths, +OCR text, target-coded metadata, text tokenizers, and text-aligned encoders are +forbidden. The primary rule uses at least 200 untouched test rows and requires +the one-sided 95% Wilson upper bound on accuracy advantage over the held-out +majority baseline to be at most 0.10. A one-hot target-feature fixture must +have a lower advantage bound of at least 0.20. The shuffled-label interval is +diagnostic only. + +E13 distinguishes communication from leakage. Ink density, stroke structure, +pitch, duration, and reusable normalized form identity are the intended +carrier. Their association with referents is a form-use/bandwidth diagnostic, +not a side channel. Hidden metadata, timing, variable envelopes, undeclared +dimensions/sample rates, container artifacts, and recognizable prior glyph +semantics remain forbidden and must be probed separately. + +E20 permits only a fixed post-outcome display and the declared binary outcome +context. Referent identity, target action, task identity, pre-outcome delivery, +timing, sequences, combinations, and envelope variation are forbidden. For +each seed, analysis subtracts the mean of 1,000 within-outcome permutation CMI +values from observed Miller-Madow conditional mutual information. With at +least 75 eligible seeds and 1,000 windows per seed, the primary negative gate +is a one-sided 95% seed-level Student-t upper bound below 0.02 bits. The +percentile seed bootstrap is sensitivity-only. + +## Outcome-blind design validation + +The base-R receipt contains six exact operating-characteristic calculations. +At E02's 200-row test size, a true chance probe clears the 0.10 advantage +margin with probability 0.9157, boundary false clearance is 0.0426, and the +deterministic one-hot positive control is detected with probability 1. At +E20's 75-seed design, assuming normally distributed seed excess CMI with SD no +greater than 0.04 bits, zero excess clears with probability 0.9959, boundary +Type I error is 0.05, and a 0.04-bit planted mean is rejected as non-clear with +probability above 0.99999999. + +The E20 variance assumption is a design constraint, not a finding. A blinded +pilot must estimate it before registration. If the uncertainty bound for SD +exceeds 0.04 bits, D07 must increase the seed count before outcomes are +unblinded. The portable audit checks hashes, decision floors, required +comparators, information sets, positive controls, and implementation markers; +`pnpm audit:causal-leakage:r` additionally reproduces the R receipt byte for +byte. diff --git a/docs/causal-prediction-runtime-qualification.md b/docs/causal-prediction-runtime-qualification.md new file mode 100644 index 0000000..1432ff0 --- /dev/null +++ b/docs/causal-prediction-runtime-qualification.md @@ -0,0 +1,25 @@ +# Causal Prediction Runtime Qualification + +The versioned protocol binds the E16 prediction path to exact candidate v0.1.75. +For each accepted evaluation delivery, the production runtime constructs the permitted +prediction information after the Gateway fixes the artifact and before the receiver +acts. It commits the validation-selected baseline and native-ledger distributions, +checkpoints that intervention, and later attaches a score bound to the recorded action +and outcome hashes. + +The hashed run configuration fixes the comparator-selection commitment, native +prediction-function version, and accepted-evaluation-delivery eligibility rule. +Creation and restart reject a missing provider, and creation or evaluation rejects a +mismatched selection, configured function version, or returned function version. A +prediction provider without a plan and a plan outside E16 are also rejected. + +The exact detached candidate passed a frozen pnpm 12.3.4 install, 10 focused tests, +all 1,808 tests across 144 files, Rust tests and clippy, a 678-file secret scan, and +the dependency audit. The consolidated gate took 183.69 seconds with 917,932 KiB +peak RSS, and the checkout remained clean. Raw ignored logs and the tracked-file +manifest are content-bound in the protocol and receipt. + +This is software qualification, not an E16 result. It does not externally register +the exact validation corpus or study configuration, execute an eligible run, produce +aggregate inference, or provide independent review. B10 therefore remains open for +those empirical and external requirements. diff --git a/docs/cryptographic-separation-policy.md b/docs/cryptographic-separation-policy.md new file mode 100644 index 0000000..f1c5488 --- /dev/null +++ b/docs/cryptographic-separation-policy.md @@ -0,0 +1,44 @@ +# Cryptographic Research Separation Policy + +Status: mandatory review gate for E40 software and evidence. + +## Boundary + +`@ald/crypto-research` implements ALD-069 research instrumentation. Its scheme +identifiers, nonce commitments, novelty registry comparisons, and eavesdropper +measurements describe experimental artifacts only. They do not provide or claim +confidentiality, integrity, authenticity, forward secrecy, key security, or +production-ready encryption. + +Production evidence integrity remains exclusively the responsibility of: + +- **ALD-009:** domain-separated SHA-256 event hashing and per-stream Ed25519 + signing in `@ald/hashing`; +- **ALD-019:** reviewed Base transaction signing, key handling, and anchoring in + `@ald/anchor`. + +No learned encoding, generated codebook, cipher artifact, E40 nonce, or module +from `packages/crypto-research` may be imported into `packages/hashing` or +`packages/anchor`. The repository's `lint:crypto-boundary` check enforces this +dependency direction on every full check. + +## Required E40 reporting + +Every E40 attachment must report these dimensions separately: + +1. artifact novelty relative to the supplied prior-artifact registry; +2. recovery outcomes for each pre-registered Eve architecture; +3. cryptographic security as `not-established`. + +Uniqueness, a fresh salt or nonce, and low recovery against one implemented Eve +must never change the third value. E40 uses synthetic, non-sensitive messages +only. Any future security claim requires an external threat model, formal +argument, expert review, and reviewed production implementation outside this +research harness. + +## Review gate + +Gate G5 / ALD-077 may mark E40 *software readiness* only when the ALD-069 tests +and the import-boundary lint pass. That readiness does not change E40's +`Not started` notebook status, satisfy its cryptographic-review checklist, or +constitute an experiment result. diff --git a/docs/data-and-claim-inventory.md b/docs/data-and-claim-inventory.md new file mode 100644 index 0000000..f3ac0e3 --- /dev/null +++ b/docs/data-and-claim-inventory.md @@ -0,0 +1,53 @@ +# Data and Claim Inventory + +Status: A01 evidence-cutoff inventory complete; no research result + +The machine-readable source is +`reports/research/data-claim-manifest.json`. It captures every exported run bundle +present under local ignored evidence storage through 2026-09-12 without copying raw events, +messages, observations, native ledgers, or secrets into Git. + +## Inventory result + +The snapshot contains 125 bundles in 19 collections and 228,577,459 bundle bytes. +Every bundle has a recorded verifier exit code of zero. That fact does not make every +collection current or scientific: 33 are historical qualification exports whose old +intervention-tree declaration is incompatible with the current verifier, 51 belong +to failed or superseded diagnostic collections, and 41 have tracked bounded software +qualification support. All 125 are excluded from empirical estimates. + +Seventy-seven bundle records show anchor confirmation, but every such confirmation +in this snapshot is a local fake-chain qualification receipt. Confirmed public-chain +anchors: zero. The manifest uses separate fields for these values and its audit fails +if any local receipt is relabeled public. + +| Evidence use | Bundles | Permitted claim | +|---|---:|---| +| Exact/current bounded software qualification | 41 | Only the claim boundary in the matching tracked receipt | +| Historical qualification | 33 | Historical software behavior; no inheritance of current verifier status | +| Failed or superseded diagnostic | 51 | Failure history and debugging provenance only | +| Pilot | 0 | None | +| Confirmatory | 0 | None | +| Replication | 0 | None | +| Empirical research estimate | 0 | None | + +Every bundle entry records its path, run/experiment identifiers, deployment and +software metadata, manifest and optional verification-report hashes, complete relative-path +content commitment, file/byte counts, verification basis, explicit exclusion, and +allowed claim use. Collection entries link exact qualification groups to their +tracked receipts. Diagnostics remain visible even when their bundle-level verifier +reported success, because a green bundle cannot erase a failed or superseded +collection-level purpose. + +## Reproduction + +`pnpm run audit:data-claims` validates the tracked manifest without requiring the +ignored evidence corpus, so clean checkouts and CI can enforce claim boundaries. +`pnpm run audit:data-claims:live` additionally re-hashes every locally captured file +and fails if the evidence snapshot differs. `pnpm run build:data-claim-manifest` is an +intentional snapshot update and must be accompanied by a review of every new +collection's evidence class and inclusion rule. + +Any future pilot, confirmatory, or replication run reopens A01. It must use its +prospective D07 seed domain and receive an explicit inclusion decision; mere presence, +a verifier pass, or an anchor receipt never promotes it automatically. diff --git a/docs/e03-design-simulation.json b/docs/e03-design-simulation.json new file mode 100644 index 0000000..a3742b8 --- /dev/null +++ b/docs/e03-design-simulation.json @@ -0,0 +1,64 @@ +{ + "version": 2, + "claimBoundary": "outcome-blind-design-simulation", + "scope": "single-control-equivalence-component", + "registrationFacingReceipt": "reports/research/statistical-validation.tsv", + "samplingModel": "normal-seed-rate-with-binomial-episode-variance", + "seed": "ald-e03-v1-design-check", + "repetitions": 30000, + "mean": 0.25, + "episodesPerSeed": 200, + "equivalenceBounds": [ + 0.2, + 0.3 + ], + "alpha": 0.01, + "minimumPower": 0.9, + "rows": [ + { + "maximumBetweenSeedSd": 0.05, + "primarySeeds": 25, + "totalSeedRateSd": 0.05863019699779287, + "equivalentReplicates": 27280, + "repetitions": 30000, + "estimatedPower": 0.9093333333333333, + "monteCarloStandardError": 0.0016577718200667447, + "monteCarloLower95": 0.9060841005660025, + "passesMinimumPower": true + }, + { + "maximumBetweenSeedSd": 0.1, + "primarySeeds": 75, + "totalSeedRateSd": 0.10458250331675946, + "equivalentReplicates": 27587, + "repetitions": 30000, + "estimatedPower": 0.9195666666666666, + "monteCarloStandardError": 0.0015701784635535566, + "monteCarloLower95": 0.9164891168781016, + "passesMinimumPower": true + }, + { + "maximumBetweenSeedSd": 0.15, + "primarySeeds": 155, + "totalSeedRateSd": 0.15309310892394862, + "equivalentReplicates": 27327, + "repetitions": 30000, + "estimatedPower": 0.9109, + "monteCarloStandardError": 0.0016448018117694299, + "monteCarloLower95": 0.9076761884489319, + "passesMinimumPower": true + }, + { + "maximumBetweenSeedSd": 0.2, + "primarySeeds": 300, + "totalSeedRateSd": 0.2023301757029831, + "equivalentReplicates": 28462, + "repetitions": 30000, + "estimatedPower": 0.9487333333333333, + "monteCarloStandardError": 0.0012732948801639989, + "monteCarloLower95": 0.9462376753682119, + "passesMinimumPower": true + } + ], + "passes": true +} diff --git a/docs/e03-seed-manifest.json b/docs/e03-seed-manifest.json new file mode 100644 index 0000000..7c062c4 --- /dev/null +++ b/docs/e03-seed-manifest.json @@ -0,0 +1,763 @@ +{ + "version": 1, + "seedLabel": "ald-e03-v1", + "primarySeeds": 75, + "reserveSeeds": 8, + "conditions": [ + "disabled", + "constant", + "random", + "shuffled", + "normal", + "oracle" + ], + "entries": [ + { + "slot": 1, + "use": "primary", + "scenarioSeed": "5a64e3d3b490b4d5f4dbb89d7f8801a3ec53f8815c6731a8bce7cb691474eadd", + "gatewaySeeds": { + "random": "d3b41fe5c9ea0807e97d589b561d11f0b91a83c65485f29f83d0b7f7b1e28563", + "shuffled": "bc4976f8f3111d97be5fc80badb0632b241e4204b78d04b863e630215d860423" + } + }, + { + "slot": 2, + "use": "primary", + "scenarioSeed": "111e97c0191daa17adc30a16b4826bd4f8a0a6c8fae4889bd2112bf78b313a2a", + "gatewaySeeds": { + "random": "bb04950860a3b7202363b0159ff787069f388bbb6d4c4be89ab06a1fe1a26868", + "shuffled": "6ca9a995154941fb1a28c84538f3c5f063cf8c307841e4fe75928aedf1b34ec3" + } + }, + { + "slot": 3, + "use": "primary", + "scenarioSeed": "f1fcdd9c98e564a58e5a2b0cb9de61babfcb8a45d118198963b7afcbc13e43e8", + "gatewaySeeds": { + "random": "b518f397d6d8da254430b7c1c8705dcde6441332257e48c3febba191259f2039", + "shuffled": "cd2364fc2b971f8d58e1d4468815eb64099a8280b8c0beb8caae3695d238c25b" + } + }, + { + "slot": 4, + "use": "primary", + "scenarioSeed": "50457005832bb9d8211e66ba8b96234a09090b9bdd62c73cbca0330eb84a4001", + "gatewaySeeds": { + "random": "0910aef1acb70c1d7905d30f0df1d192dca8ee8d2638aa1d71ac7d424615e239", + "shuffled": "e8f85478177491953bf4e01228ca61181b1edd0976f0118405ef0991aa77d97e" + } + }, + { + "slot": 5, + "use": "primary", + "scenarioSeed": "ae3e652bf8708cde1c19c03d2c3a1ef64115fb582d7ca8069f82888ed2e7f846", + "gatewaySeeds": { + "random": "21c2bca6f16c845675963826668c622929965fb040f8c1b9cf0245f7d5b727f7", + "shuffled": "e0895abb2eb0a8ba9f016d5b71fd848d703617dfb69af7afa31d986761aff861" + } + }, + { + "slot": 6, + "use": "primary", + "scenarioSeed": "60c60790cca071e298ac47f2645b07f96a243c875d0e55e91303815dd12b316a", + "gatewaySeeds": { + "random": "d8b5722f1372da820503baf5b1b49068193ab7824069bac64614be84257e9c76", + "shuffled": "49273457d63258ad2453fa6b67c7f2a7232fd68bce8bdf2c49cca99063d6e827" + } + }, + { + "slot": 7, + "use": "primary", + "scenarioSeed": "2a7ceb81e0babdd47771e3985c6e0e2d235dd788c553ee4a5cf05af5f0b7e285", + "gatewaySeeds": { + "random": "9147d210652fa4459fd129eaa53c86e2c01ff977b4ecd00f267c48389479f792", + "shuffled": "58d2d31701221153078ef9b56037cd0b84ad09f4ab8f22e32bd2955cefb58d50" + } + }, + { + "slot": 8, + "use": "primary", + "scenarioSeed": "21965dc989d19aeca318bfbec30d7070a7617b3ba99abd0eb87929931d0a1602", + "gatewaySeeds": { + "random": "89ad9780c9972b0cc091bc39060a971a27fe73f2f4c79665bf48f491a3099ac9", + "shuffled": "d5f7a6822a400efa0798d4e336f10a16e0e01ac868b71302e0e669438edc57de" + } + }, + { + "slot": 9, + "use": "primary", + "scenarioSeed": "ef2c51ed1939714a7195c570a1a0cefbd8b1799ab08a579d7b6da456e561fe75", + "gatewaySeeds": { + "random": "cf0f2f824bf6404e74ffcd7451a1427ebaca67598d8a680b6c3e45cf6a6ee76f", + "shuffled": "6b602d1599c99c6ed86073a32bce3247e62befae9b8b2841265022a38ea9af0e" + } + }, + { + "slot": 10, + "use": "primary", + "scenarioSeed": "bf3e2bc8982fff7bb6c8c76412814240f1a32de8983eb0df3d7df96e5051e7ca", + "gatewaySeeds": { + "random": "6270fc7c832f1cfacbfbf123ec0a44d506922bbc8a0b77f6d7ad89d6c79dc129", + "shuffled": "4edb533c621be4e708a28de4f94cd10a0cbe3605677cf617a9629d66e161d61f" + } + }, + { + "slot": 11, + "use": "primary", + "scenarioSeed": "f5ad34f6572eca1d59f817fc9d78b0e223bca58e51ac8a15cc517361b2b728c5", + "gatewaySeeds": { + "random": "9b0616fce879c40366ecc1f3a9456a923950769529435860d32d19a97815064f", + "shuffled": "43b095c77413c952e8cb469ec7a512f0631586e27dee075578ad521af55aca31" + } + }, + { + "slot": 12, + "use": "primary", + "scenarioSeed": "5e562a15042d92bd3cbe90f2ca3c6d3c121d30465439a843e0a26032ed1c9897", + "gatewaySeeds": { + "random": "275260842584abc9967bb4e12f52359f08828bacdc6970477aa2216a91736766", + "shuffled": "5d2622337ea2b3fa81bccc13014bebed739c49a7fcad4d8b1f66df32a12bf7e7" + } + }, + { + "slot": 13, + "use": "primary", + "scenarioSeed": "29915966065b440ebb21d2cdc60be66df999e3c77da8a7672752bb24dbbf9813", + "gatewaySeeds": { + "random": "a39e5f7341f1166f49315036db1321698e880fac5da40df04b433a2dd4e0fed1", + "shuffled": "e867daea074ae7f4380f2ee71b69f0e52b7732ad17c245d34901d2b75afb47bd" + } + }, + { + "slot": 14, + "use": "primary", + "scenarioSeed": "cb546889d4acfa0317a537bb0dd57fa10d78695365b4dcea53855a67f670809f", + "gatewaySeeds": { + "random": "d3a46270a8bb7ec8561e386c3fde1a052b78688969f8e166497845c0ab748902", + "shuffled": "aa984a76835786de19fd875d1e90cf21a6b5c67609c0a63a26370765e6734d5c" + } + }, + { + "slot": 15, + "use": "primary", + "scenarioSeed": "9ad5790f481263b3dd0286f874895a54a4bb44fef276cd9d0e05a0956ac080b9", + "gatewaySeeds": { + "random": "058e0fa02dc747e0d9a58c027716d916f6bf32e1d1030fcfcdce8f4d197226ae", + "shuffled": "e4a5601c431711274e6800f174e7ab2fb8cd5db42ee8ef8c9f06d241a01f076a" + } + }, + { + "slot": 16, + "use": "primary", + "scenarioSeed": "3f3735fbd76df4370408c9b7697e23836e394040ff96c0d6e9638e58a4c0dc26", + "gatewaySeeds": { + "random": "45c20d07fdba26e344b426402d391b36b70493a21039ef3f58bf6c8a5e8b03ac", + "shuffled": "6d357a062d01db225b398a8bd7c898d6c0c36139ddd905cdffb38878cbc2d6fe" + } + }, + { + "slot": 17, + "use": "primary", + "scenarioSeed": "d16d6c79d4fc498feb26c7935613ab7cb5de8122079a97a1b6581fa3b95d4209", + "gatewaySeeds": { + "random": "acba087f3eaf070279c7d7b4af586ddd05682b73dfb35fcdaa99338f72881593", + "shuffled": "89c71f30c6245bd389d28fe7e9a251d6ec80458aa54ed80fb43645d7203d6299" + } + }, + { + "slot": 18, + "use": "primary", + "scenarioSeed": "1547f32baec4750987a63c496b76b4adefc1d6386d77a5222577a094f503704a", + "gatewaySeeds": { + "random": "15e5f05b38bc8f717994e210044ba9fb3a5673cfff0e9da7b5bb5da7aefe9e70", + "shuffled": "7ad766a5eaa7760a3775a08025570ee7a8419704d5963786236a8dee617d2023" + } + }, + { + "slot": 19, + "use": "primary", + "scenarioSeed": "7c2494ea82519830a1a36f586faf1f61b2153bab92d3960689541734c8bb5177", + "gatewaySeeds": { + "random": "a614f897070cfc036012fe083ac6aa06d4c80560679edd4e39a4e2ea1d26e15c", + "shuffled": "5ca84dc137bc91d65c44d069fed39de5ca76d186c16ec352d8f82601806be185" + } + }, + { + "slot": 20, + "use": "primary", + "scenarioSeed": "dca87bebdf236146c3f6fd3f4b8377fa621d04a010547e21c3a746e2ed0c7103", + "gatewaySeeds": { + "random": "de0561862653ebdbedf607bbec37a0fe831fdac570e6778f8e5dc09c49e7d081", + "shuffled": "3bfb9193d4fb1ff53b3aa0dc7d52b91bade1d9811f83c24cf26bdccea7d5b080" + } + }, + { + "slot": 21, + "use": "primary", + "scenarioSeed": "630b772336ae0e59b501938ddde614e7667d4ce9932ae123996c1e3a0dbdc16f", + "gatewaySeeds": { + "random": "3515d1c079364a38494a43b0eaac05237c699a2fac8b34897db48870831df2d4", + "shuffled": "5fdaa9bd33965cfc9614d84acaedd53896ca1674f88997ac5264b2dd67b19985" + } + }, + { + "slot": 22, + "use": "primary", + "scenarioSeed": "a213b1d37d51ffc3f3e359bb8f7e86267c6270d8b6e0254bc2985e41cc074469", + "gatewaySeeds": { + "random": "700c2b1ef650b86c97734f383a0c83419bba52e4205d5543b13c3db7d6bd48b9", + "shuffled": "cf49db1c44ec88865c43a94d087207986947abf114847e711bbd3a4a1aee43d3" + } + }, + { + "slot": 23, + "use": "primary", + "scenarioSeed": "7425573ee4f9bb1a9c302835b98510c7e3dbdf9a3c445042529f517f69c89541", + "gatewaySeeds": { + "random": "3d810dfd8c6c3d77e8180914933970cdce90b135960038fa212f84afb261d023", + "shuffled": "7db8ae675e64d87f75778fd02ff2245c413308bfe88a9ab5707cab86b9a8e9b6" + } + }, + { + "slot": 24, + "use": "primary", + "scenarioSeed": "2aa8dc794e9b7205e73c1359445a8a05314b63e46a1e2fcbbbb345b0387f1e8b", + "gatewaySeeds": { + "random": "603f1e370b927019caa396de005e98b270c4448936e9ba23882e1b8f5437db59", + "shuffled": "81ae24a73143177e86baea9c14a64304854ac60073ee41c8d3755e9a2e85f984" + } + }, + { + "slot": 25, + "use": "primary", + "scenarioSeed": "f4cd332d1332c65a9bcfaf5038b97c66ffbabb3d8e628e31349d403e70c5e62b", + "gatewaySeeds": { + "random": "fca8d81a0b5850a1ba0843785a48f768ca0b750578baf48d5860e708bc6836a9", + "shuffled": "9e91730cda4252354e2f61b315030cc2f42b48c66eb4719accb663a85fe4a8e8" + } + }, + { + "slot": 26, + "use": "primary", + "scenarioSeed": "b4bd5c0d47b03064bba499cd92c6d18763014dda18acd268eaf166bdb88c5176", + "gatewaySeeds": { + "random": "a5ebcd87b9fce35f76c821914f5ac5df68a925f7db28555b74deabae00a4b61c", + "shuffled": "3a4580dae21205a868f9c79389f3adad8dfb91082f666c12e8dc0dc6bf8fe1d5" + } + }, + { + "slot": 27, + "use": "primary", + "scenarioSeed": "821f56efedbbc32ab637efeffe7861a1b1de38138358844efccd405935c7bdbf", + "gatewaySeeds": { + "random": "2d716491ed089afe4e569aaa9e84757b551807a06422d60765a0e6091f6218b0", + "shuffled": "0132d77f4990c81e0be6c41806e196f41ce63d16175bf85be0094985e55f920e" + } + }, + { + "slot": 28, + "use": "primary", + "scenarioSeed": "0666955c73b5f672219321bed8849e59971ca014076855f813665af084671f63", + "gatewaySeeds": { + "random": "28f3b6e40e1a0e418f0fc8fe9af99dd89b81beff48021797b6c07725a2aa4fa1", + "shuffled": "6c8cb7b43f5cec5d56d2f16e5804091cdc87637360a28f21a4c0498b1c6461ef" + } + }, + { + "slot": 29, + "use": "primary", + "scenarioSeed": "faa10cf87e74dfdd64c88b7e1734214d00612ba3377c9c3c8425cdbe53fcac1e", + "gatewaySeeds": { + "random": "3db2a52dd35ee1881eee496c505b92eefad6a89c5686053cad098ffc0b2aa503", + "shuffled": "97fa13f19596ae96ef3b3faff7f1f87b9c56f1090dab9efa9b01cd420e14690c" + } + }, + { + "slot": 30, + "use": "primary", + "scenarioSeed": "e2223828241cb537d73d52ab01ecbc614d86038b5910542452f3e75cd25427ae", + "gatewaySeeds": { + "random": "45347fe2c7932218269bf419f7f8caad57f2123ddf52f424efd6b1510c95020f", + "shuffled": "b8e27748bb27929fafc124a394993413d0692e0f7e834168a2dfe08a0f82a9ec" + } + }, + { + "slot": 31, + "use": "primary", + "scenarioSeed": "aca8d6f618c33769404011b25fc32503bb64d0fd3c62a15fd84dbab0c0226e0f", + "gatewaySeeds": { + "random": "b4099a9955d4b7843f2c8bff1751509c20d10da18fc9bf6563ecf14c45852e5b", + "shuffled": "e4a889b34c4614c60ef00d3482762a94861269cf452f2b12c32485fb59b240c6" + } + }, + { + "slot": 32, + "use": "primary", + "scenarioSeed": "d7c550fd460253cde042b1f5033d2a2b571c2a6c46d306d0eb6a476e441c1ac0", + "gatewaySeeds": { + "random": "5fb82b5dda7eaf07822a71929e7ddda80c48641ced5600e3c27c749deeca2650", + "shuffled": "88f003323127c5df9a7f8665b9ce5c92be2b57e7f8e20b0e990829f627626fb0" + } + }, + { + "slot": 33, + "use": "primary", + "scenarioSeed": "04845b30f86ee646a74e9b78e810261dabd782f8aee9c5dbb7a0846c6dbc185f", + "gatewaySeeds": { + "random": "9b8e4396eca78b587b66e5073fee36e747e8962e977dba43ec006a334ae17081", + "shuffled": "8de1ea924c416e124b72d4f30222aeac3ff8c42c39c531dfcbcbadd6d6f6e89d" + } + }, + { + "slot": 34, + "use": "primary", + "scenarioSeed": "212559dc029d345ce8de99875234f85b82aafcb86cae1893af3f0a8f4d498405", + "gatewaySeeds": { + "random": "a2426b73aee87af61db6c2c074bcb95e0c367c74b15c0b83088514dd450c61f7", + "shuffled": "617f6d458a69821ca1cb7b91103a2b0284cc2ffbe273f86e67f7a0560e270ea7" + } + }, + { + "slot": 35, + "use": "primary", + "scenarioSeed": "305e4a0199a58aa7db900de94c18134643243b44bd3be6ad88d1c9a079c94553", + "gatewaySeeds": { + "random": "e3e577b5d909533fc164f84f2bf5b07e5369cdad65ac946084edd38fbec6a586", + "shuffled": "3102e016adaf7704a8fb48eea195eec9289f2a271c96133d7cc321924021e871" + } + }, + { + "slot": 36, + "use": "primary", + "scenarioSeed": "93f1d0020c2a9bf0e417324a555d553c1095af4178e701d637626ff7ba906006", + "gatewaySeeds": { + "random": "5ec67ae1303105049708abf8407b8359634ad4fdadc73a908716cbf5e3bb59ef", + "shuffled": "d5838f30784efb3d2d81ee1082d3f22ee2e05f0f80ae188201596a63da0f7e57" + } + }, + { + "slot": 37, + "use": "primary", + "scenarioSeed": "23c7a3e222536e797a901c2b39c8f442b3fa933953aaa87a29842c2e22bb1afb", + "gatewaySeeds": { + "random": "c65ba1dc96f3348e18355a3f121698932fca492ebbfcc780410dc0ebe4102ac5", + "shuffled": "668a460331294f6b6bc883140ce7f994c3681fd459751536fed5f6eca740e07b" + } + }, + { + "slot": 38, + "use": "primary", + "scenarioSeed": "ed77b7e6948f4d82398baaaff93801afaa2f31bab77a493ccba36e884fe680b0", + "gatewaySeeds": { + "random": "d46077e23f822d2b8f88ca6cd296890285c9156da73a04c98f995d25d110a4c1", + "shuffled": "18a7335358ed7701f84313628c2186334ef7adc246b4192f41488ee7c5e7ba1d" + } + }, + { + "slot": 39, + "use": "primary", + "scenarioSeed": "08e7301f7fc725feefd8d7f9cd343e6979fd9bcfeda4daf6b84a37dc2de1320c", + "gatewaySeeds": { + "random": "2ebc5ecc402ee636afd496cb08b051caca7a24f5323521dd09ac09a62990a8a2", + "shuffled": "9af25745776d0e5bbcad6e0a09e4e88e69ce37abe304f07dd9cea7a52714c958" + } + }, + { + "slot": 40, + "use": "primary", + "scenarioSeed": "90ea9cfe85b0829d709119191e9c70e5803459556611b0104c06b7ed418729f5", + "gatewaySeeds": { + "random": "53ecd261200c0cb1e972f95cd677ba5b167c8c12d004dd9f1069eafd60a68443", + "shuffled": "5d3f557f29f0de6573129879aa5f21ffc968a7dfcc399181176479552a1ef80b" + } + }, + { + "slot": 41, + "use": "primary", + "scenarioSeed": "b68b32112aab1d8ebbdcab77ff34e57ddc244c43101250a5a7ce0c495572b8f4", + "gatewaySeeds": { + "random": "1d812e6507633774cb4e15b56fc126a46f3c3ce5045ca4c442f71f20f9943d7a", + "shuffled": "e4155d602501abd637cffcccf2c1d70d8ad74f1a16a241feb00d4c1fdc49a752" + } + }, + { + "slot": 42, + "use": "primary", + "scenarioSeed": "65ab2cb6dc17700c43691624b160d6c671047651b254c159c6a70d41c8dbb45a", + "gatewaySeeds": { + "random": "92f247f1bd7697529be844953babe13255061c6090761cbf1155afcd9e9a669c", + "shuffled": "f0b238ec187f8d1ac9a8fcccdc29239e6e9a5bedce893b9f38cebdbf727e2e74" + } + }, + { + "slot": 43, + "use": "primary", + "scenarioSeed": "e5dbbe1752a6bba6921337d61c86174576564510a1000cbf817bfa9585559c71", + "gatewaySeeds": { + "random": "f483a21168d429712329ff2ea71126d5f0998cc7fd3043186d90f98fed35cd58", + "shuffled": "bc685caf42611327b03e8e9b4e54f0ee7c4d428cddf674b5bbc000cb9b560796" + } + }, + { + "slot": 44, + "use": "primary", + "scenarioSeed": "e486770386854d183d0071f9a2ddd2cca21c03964062ad2bd60911dc1722d3cf", + "gatewaySeeds": { + "random": "f3a738c174cf1eb4e990ab9d518f452cd7ee923e75ecc0bb7aeb4bc9ebf69412", + "shuffled": "63ccdb83e7cbd3e543f8bb53071306a9de3db07dd03584048be3f03cdefdcc5a" + } + }, + { + "slot": 45, + "use": "primary", + "scenarioSeed": "68ff36bd26a2fc9f7349d6b5ce8127588f37e8b7ddc9b74538e93633887aa618", + "gatewaySeeds": { + "random": "e565b8ad5290df3fecfa491e5e884830e2cdeafc8d7c88a69c28394b8fd029a2", + "shuffled": "0d9c36cb8c046cf157929972231067e599519df95398efe174e6da4f11080ccb" + } + }, + { + "slot": 46, + "use": "primary", + "scenarioSeed": "eca0aa8d13c1fb320ec6a0eb161b1d5064036074b29539785172fcc7148fd104", + "gatewaySeeds": { + "random": "790dca8440ca1aeb54fc8a22c3f103f44301b0ba47e74cbd74a4f2b6e17424af", + "shuffled": "4074f7b456481c4825202124be6250ea4ed3200f1f939abc39199b565a8163b7" + } + }, + { + "slot": 47, + "use": "primary", + "scenarioSeed": "2762ccf41fcd11a2bcfa9925d42a78ce01ac0f68b2ab3c6889560d5fb103ab2f", + "gatewaySeeds": { + "random": "67d97bf8c5faca776a3af1f37a3ef77498e406b0a812d4bc1a089c5e8764e056", + "shuffled": "42a0aa11dada73e9b6f24691764a182826bcab4d7d577ce1481383b2ede53a54" + } + }, + { + "slot": 48, + "use": "primary", + "scenarioSeed": "09d8fc398e88b11348ad4c7b5eeb9c2a16ad34d281130be08d0061d6c915c96b", + "gatewaySeeds": { + "random": "96112bfdcc6fea4e44fe87154dc90aa5aaa1bc68233df740d41a4ab5c077f5f5", + "shuffled": "bf2e9d89859ff5a72e967b210ddd71ac9e478394836e5f0534b5c566e733ea42" + } + }, + { + "slot": 49, + "use": "primary", + "scenarioSeed": "df59c136bca567cb927700dd432994dc28ffe95efcebb5ba40248710eb05e380", + "gatewaySeeds": { + "random": "d988a104ce4ce1f689e3626a6a5eed785894c8a4a2e5985a9b7ae0d366de92ef", + "shuffled": "2105069c4670b3eeb4c022a3505d70627d7c92800922134d988a5606d1cdd507" + } + }, + { + "slot": 50, + "use": "primary", + "scenarioSeed": "514f7b47f0ba265978d7ba38ed3372e23422a015af46180c8e01237f0af6ef35", + "gatewaySeeds": { + "random": "d23cb29fb83fce2604f6720d4c626cf2102cf7ba8731db1be7ca61510a48c1bd", + "shuffled": "c9e9a59f0029d1d7b307157876a16b84f745e6c4930174fe9581d797d202fed0" + } + }, + { + "slot": 51, + "use": "primary", + "scenarioSeed": "4e64a3125bf286cd0ef8fac3ee5a6f27c78484e9ef090e2848e42d2691b00094", + "gatewaySeeds": { + "random": "6e1d35f50398508fc9e5d1e504d712fa6ef582e6f2b824f83a3a2cfb2bae5920", + "shuffled": "1ad197ccb94559b546447a470b173cdbf9bee5822f5326a8d5516163f7447cd4" + } + }, + { + "slot": 52, + "use": "primary", + "scenarioSeed": "8ee91917100574c36bb2ee63351acbe716f6d05b76913e0d35cb3928a7cca6a2", + "gatewaySeeds": { + "random": "7e00763b947c14c00a4c91d1187d0b1353364510731069e0c47664b565e99c0a", + "shuffled": "8375941812e7b4e645cda3bc044adac1a2e4765ef946f3eb5c7078d8ba1bb7b5" + } + }, + { + "slot": 53, + "use": "primary", + "scenarioSeed": "4597543d23fb59b208d13e38cf594999079757f53fbdc4dca848c915a059efc4", + "gatewaySeeds": { + "random": "81434168aa18bd012d0833e8753e4aa56564d8e4753b628a6d97cfc695a5e94c", + "shuffled": "758bf90d86651af51101437ebe3f484ec2051167d240145eb36975e2193c4f69" + } + }, + { + "slot": 54, + "use": "primary", + "scenarioSeed": "e1d109e85770e2eb878b82377cb32ade512748ac0cafbea859c84b550c3c51ac", + "gatewaySeeds": { + "random": "be058fc669738d6630d17976786a50fbc239c1f8ec48ecdb676503f137c654fc", + "shuffled": "1e31f471d0b273fd369d95134ce62c76f4a23f416ee10127fa4678ecabb2e84e" + } + }, + { + "slot": 55, + "use": "primary", + "scenarioSeed": "bb640078e18d8d1232f052c0e888b719b221e3786152881bf23e33407c7f8273", + "gatewaySeeds": { + "random": "600c76058c9bcdb57e51a16f9b01dabfc85d0659b6d162499560d96168456772", + "shuffled": "f3a2daaed2fe497063ded1e42619eba6e4bd9f8a41fe15081cbf179404d063f2" + } + }, + { + "slot": 56, + "use": "primary", + "scenarioSeed": "eda1e911aff937492cd73380aac84a9ba1dfe51d7b36fc93439b29431fc70ba0", + "gatewaySeeds": { + "random": "f6df372ab6b48021aae59a5b3776c8241d5a1bb4cc46a607d29d877694f43509", + "shuffled": "d04185bd05819897a43d6af922f7125c4f899857d761ac128702e672fdd6436c" + } + }, + { + "slot": 57, + "use": "primary", + "scenarioSeed": "53a5b486b2a0b6f9ed982ed6d6842af623a9ebaa19e9abd4503d72e8e3dc6ed0", + "gatewaySeeds": { + "random": "0316fa2315e9e1d00902bc702a52c54b32fb76bae59bbad8567941ec8b94c475", + "shuffled": "01356eb753e31f1e12724a6e641a7379baeb95d639f21e929c3035034ec39d21" + } + }, + { + "slot": 58, + "use": "primary", + "scenarioSeed": "8706235e8fa55f307c92707ce19634bb724e0a84eda94dab70947c4a933603ac", + "gatewaySeeds": { + "random": "16a2d94c2a2ec8ac9ba1f0b03a3fe04d3627f18e6b83a8eb1e40795161b0f4a6", + "shuffled": "a164c9f2a85a6fbe9b10a2a90f0f1ffda5a0b4805ce9152d4a8e6f4e2a831a06" + } + }, + { + "slot": 59, + "use": "primary", + "scenarioSeed": "62fccd0e9c83ecf6427eed342da49391c274628f9d6818c29628fd919b72d332", + "gatewaySeeds": { + "random": "09e535df46c99146296e642d5bb0fc1408acdc152ca299c9176ea0d1d2c5495a", + "shuffled": "257febb136c782367e25b13ba00c1827b0dbaffc101e26e3ad7ae64b46460182" + } + }, + { + "slot": 60, + "use": "primary", + "scenarioSeed": "70e8fbacf73f0599753530b6dc188fd705713cec7b47d0a1b7a28dbdc3b2d842", + "gatewaySeeds": { + "random": "f5b55d07cf47cd9ca4c36472a2dbbb281758fda97f549e32beabcf37d13325ca", + "shuffled": "4b63181a55e05029124ec89dbf1e930f51ce9c21a289bcc88747af288cbef898" + } + }, + { + "slot": 61, + "use": "primary", + "scenarioSeed": "e5f8f36d268e79f7229114f27a107ffee96f0f840df78858c13c20c44062d46c", + "gatewaySeeds": { + "random": "e6b156bfcd2dc0b0e2c71d142a860fe7239216eaa6152c62a1e41a21ca3514f3", + "shuffled": "f2a0ee09f47139529c397ec6cd6b574761f7f9d4cd0b8c6ffcc68d6ea4cb68a5" + } + }, + { + "slot": 62, + "use": "primary", + "scenarioSeed": "11d2c68c065fcae47d7897ca9fcbb8005f8c1cf2fa9941bba1df0af3939af22b", + "gatewaySeeds": { + "random": "a2a471246d4d1f639380bff045cdc2c7a86641292f0ea6aace3a3217bb8103a9", + "shuffled": "6e1d15b0eb631c6dfd9242835024a78f86845f51f45490eaa8308086b0223c07" + } + }, + { + "slot": 63, + "use": "primary", + "scenarioSeed": "70d0288eb16c81542a8f18f261aca95e4c519661602e290b317ddbd2479ee03f", + "gatewaySeeds": { + "random": "b8f527ca03ad7e8aa78b371399f89b2e0cba71c5cee0590fb8b197c0bae80d06", + "shuffled": "7d1b55ecdd01f046e6f7e34894df80cb342d0ad2a788dd6ef5169270024f7593" + } + }, + { + "slot": 64, + "use": "primary", + "scenarioSeed": "b0c6e6d3434a09b7cff7a40745ab10dfb9d1834cd5efacc5e4e7d2d569a4aa4b", + "gatewaySeeds": { + "random": "6a1ed242bc124dbdc9e12ecaeabf6219676d52cb0a825eaab758529cdf88a256", + "shuffled": "c5c41c55599469b0d221bbb3834285775693316a012fde6ae01eb4e938136f60" + } + }, + { + "slot": 65, + "use": "primary", + "scenarioSeed": "d31451c84d664acb33bb93e505b5a9d2a6a7666c1094f09d97cb40f047549c2b", + "gatewaySeeds": { + "random": "d7e5f4c7e87f62306981bbc88b7633f5ad109823bdcebd0c5e44d78d2bc46fca", + "shuffled": "d6b8924b081943eef942f6b1be738c0c2dc064850fbb5eb195dcefc317b1b820" + } + }, + { + "slot": 66, + "use": "primary", + "scenarioSeed": "50ba0e38255176204e491acf383437331a69022aa6c54c982055c1db64be4a13", + "gatewaySeeds": { + "random": "34c61ab640722d26128c65ee3771eaeaad99fc0c3643fcadcaecb36dfea5ef67", + "shuffled": "b18d7389b4b04dab722f412f65a0e81db36ed645b33ffeedbdf10f38d0983986" + } + }, + { + "slot": 67, + "use": "primary", + "scenarioSeed": "0e99ee9179ab02c6cf5b2a2422e8327296188fc2ec4829e63a4eee723249764a", + "gatewaySeeds": { + "random": "a4fa7f8b58ea1389f1d01441d55d6e51d752ba1106b456ddaed6999a9ce8fcb1", + "shuffled": "2bf9921ee686f5f9937f31f5016026700091792e22acd08ab8cbc3fa74685f61" + } + }, + { + "slot": 68, + "use": "primary", + "scenarioSeed": "ea603e75b4359fa940c0bb82e4fbc63e0b860895ed2c0bd4da992b9d26f93e32", + "gatewaySeeds": { + "random": "d9210161493b9dc88250a1426b9a2f63785bf74d6a80282676caf52246cf60ce", + "shuffled": "aa05bc58ca058281bde9a27dbb0a8b42e8a6e25244f8219954e338b679bbe9f8" + } + }, + { + "slot": 69, + "use": "primary", + "scenarioSeed": "3b5e83f9b968602ece1f0aea0edf6dbe55da38191c2c841673c33aa00843dddd", + "gatewaySeeds": { + "random": "f0ae212d3395219d2c6fbb22040d923c781bc37484085764c2eb2e43501dfc4f", + "shuffled": "46e782e65d826012a51688df7e6992280ff33b0f4daeb90a8f4c28828341e83d" + } + }, + { + "slot": 70, + "use": "primary", + "scenarioSeed": "983f539a446b11072a90e310ff8be758920047ff625f0e1292975f7232e3f593", + "gatewaySeeds": { + "random": "caf3eda53c2656ec72a22c08457151730e4d585f8fe7a2e96289b932bf558b06", + "shuffled": "c1be9c96dc69b3134d3f1fd023ed58efe927fec912f7da5d647f765ada4c0122" + } + }, + { + "slot": 71, + "use": "primary", + "scenarioSeed": "2a6b1c52becec2bafc0bbe804809f5069481eb5368c258fca6ba4f08882f37e9", + "gatewaySeeds": { + "random": "a4a850eef9af1a8469ea1dc37a6d086b2348371d8e1c6e6fe6a4efe1a0a2d7b0", + "shuffled": "4e0f7c7c9bccf90d44faee535dde738b550619a6eba5bbb3f8b15288b2399268" + } + }, + { + "slot": 72, + "use": "primary", + "scenarioSeed": "a57d6866b55b1d27709a623aec9a636900cf887fe9ba29e738db1f13aa4a8c50", + "gatewaySeeds": { + "random": "8e0e766a071942cb8d041865c6fe46b3f5c7ebe80f80d5689bbe1b8c5d4c82d8", + "shuffled": "8bfb066e042634a80035f2fd422f7f2a8380b6fd462b49c49715e16eb23e41cd" + } + }, + { + "slot": 73, + "use": "primary", + "scenarioSeed": "66ef1f5a92850b884d94b34e95bb9a583e958f6081c1d7d0f5636809844acbbb", + "gatewaySeeds": { + "random": "ffded7dcf38ebf3b9ec5871989df43410bca3e562d1f24bc671bc2b1cf624cb9", + "shuffled": "84e6e11c2a335d23f73a24a840805610781a05f23ee22878d0839c8bee9aceb7" + } + }, + { + "slot": 74, + "use": "primary", + "scenarioSeed": "04125eb8715dc4ba383b02bc74d774690629f5b73accc19e3ae4eab0fe43ec7d", + "gatewaySeeds": { + "random": "8beffa57f42679f37bc109947860d00c1504c6f6e08c6feab150616eca40906f", + "shuffled": "0e1a1d2ea1c6837a148167dac6062c953c0a980a73aadaa477230a310e91cab2" + } + }, + { + "slot": 75, + "use": "primary", + "scenarioSeed": "f582567e9479f448e011f04a2588d0acf06e2f565b46a916c7038b711321ae94", + "gatewaySeeds": { + "random": "dab3000fd95e70575de6dbf35d77712a6e99a519beaa63d9c8bde389b15d7cdd", + "shuffled": "75e1065050eee7c768cec6240e67b5de72b3b0c813c22b29c51da0e341797722" + } + }, + { + "slot": 76, + "use": "reserve", + "scenarioSeed": "ca81e4cc04b5cb2f7c4e13a2227507ba45d4ab0d44aa27044d02b5ef4e053501", + "gatewaySeeds": { + "random": "0a6f663ddc3307ed44dca4e0ce58f064be401d18d31fed557d78235b7a26850c", + "shuffled": "6c34a214c0d42dd2fca9aaea8f227283eb2a13641a1cdb667904d56ee0838da1" + } + }, + { + "slot": 77, + "use": "reserve", + "scenarioSeed": "adb1ce551aee38ec63c38ecf318b8523c0fd26adad2e5d7d73b57cc2af442580", + "gatewaySeeds": { + "random": "a6dd4630db82c78570d6e145a15b63061ab20bf5c678e805c299776768743ac0", + "shuffled": "738fb6f29eac24854b7ec63e0b46850475e75620f0d2f498316a5d21ef14a68a" + } + }, + { + "slot": 78, + "use": "reserve", + "scenarioSeed": "e241a6caecbc39da5678b704afedcdafa64f8d503fe1c846290fd7077a78679f", + "gatewaySeeds": { + "random": "7a6099fdaf8aa8aa252dba1399e00d637781c0c4248fc29d6eec151402e6d77f", + "shuffled": "b94173723b811b0c885a9251913649f0c2b8d2d58446b843f029f6b2024ed71a" + } + }, + { + "slot": 79, + "use": "reserve", + "scenarioSeed": "bcf8f8825da1be2326e2499fe4de5209ac6ca68949c403aa46b14956be0fdfbc", + "gatewaySeeds": { + "random": "398046e3fbbad0e1e0b5602ba9a1420caae0ebb5d394a26243fa5e2b5a834e3f", + "shuffled": "09d54fd06c467496faffe8bf64aa2e07c8d5fbe125b5cc2d332ec201f86a512a" + } + }, + { + "slot": 80, + "use": "reserve", + "scenarioSeed": "dc1e02e176bf2e218022959f0dc881a41a44ecadd566a9c82ed2caa430f32bb3", + "gatewaySeeds": { + "random": "ce61621656e9defb7cffe9adf423e72d09697ff8e060271c7728306bddd42fe7", + "shuffled": "2d9ab21dc68c62766a716a707cf78bf4355b3f4c919152679b427c5f62c7cd89" + } + }, + { + "slot": 81, + "use": "reserve", + "scenarioSeed": "cf1f26da4559012b3c6c4fbc410323c1d9fcf19d6f89845b3d255c2cc446b560", + "gatewaySeeds": { + "random": "9ac8bcc812fb235da0e8fd55be56310c32466b4c3272e5a6b2bcdea3331965b0", + "shuffled": "1d3e6ca4544f70d79eea9e53be0ec3e397808c5c27de8762fa8a887f51f7c1b2" + } + }, + { + "slot": 82, + "use": "reserve", + "scenarioSeed": "2dc11779fcef391eae96c99b8afb83d7df178369b0ea87e3779e86011285f384", + "gatewaySeeds": { + "random": "6bcd0b4310c61e9c7257e84eeaa86bedfc476dbf7eecd5ace413dfe7747c8f57", + "shuffled": "97f1a7b854fd348e7d5f3205414f03f09c26d12877cce5ef342fa96f46bbb5ec" + } + }, + { + "slot": 83, + "use": "reserve", + "scenarioSeed": "c6091b60a986d1bfb6ce9c891b91e068cc889359900649b20807f5122f2cd671", + "gatewaySeeds": { + "random": "1f96184473fa0e9517a3fb946be0603bd7ad024d604ca0b84fab4ba86bd94c41", + "shuffled": "e737e9a667360929f3814cb52d1bec54479fc1e7d591f2f1223520c6621221ba" + } + } + ] +} diff --git a/docs/evidence-bundle-format.md b/docs/evidence-bundle-format.md new file mode 100644 index 0000000..472214e --- /dev/null +++ b/docs/evidence-bundle-format.md @@ -0,0 +1,250 @@ +# Evidence Bundle Format + +> **Status:** Implementation contract for ALD-016 (export) and ALD-015 (verifier). +> Authoritative layout: [LEDGER-INTEGRITY-DESIGN.md §13](../LEDGER-INTEGRITY-DESIGN.md#13-evidence-bundle) +> and [SPECIFICATION.md §13.2](../SPECIFICATION.md#132-evidence-bundle-structure). +> This document fixes the byte-level details those sections leave open so that a +> third party can write an independent verifier. + +## 1. Layout + +```text +/ + run-manifest.json canonical JSON, RunManifestSchema + baby-a-ledger.jsonl one canonical signed LedgerEvent per line + baby-b-ledger.jsonl one canonical signed LedgerEvent per line + channel-transcript.jsonl one canonical signed ChannelEvent per line + affect-transcript.jsonl present only if affect events exist + audit-ledger.jsonl present only if generated-analysis entries exist + turn-records.jsonl one canonical signed TurnRecord per line + intervention-log.jsonl one canonical InterventionEvent per line (unsigned chain) + checkpoints/ + 000000.json canonical signed CheckpointManifest, sequence 0 + 000001.json + proofs/ + inclusion/--at-.json InclusionProofSchema + consistency/--.json ConsistencyProofSchema + anchors/ + base-receipts.json canonical JSON array of AnchorReceiptSchema + configuration/ + run-config.json canonical JSON RunConfig + prompts/ + learner-contract..v.md exact contract texts referenced by the run + policies/ + -policy-initial.json trainable adapter state before the first turn + -policy-.json optional exported policy checkpoints + experiment-record.json { current, history[] } (ExperimentRecordFileSchema) + verification-report.json written by the verifier, never by the exporter +``` + +Every `*.json` file is RFC 8785 canonical JSON followed by a single `\n`. Every +`*.jsonl` line is the exact `canonical_json` column stored in SQLite, followed by +`\n`, in ascending `sequence` order with no gaps. Exporting the same run twice +without intervening writes MUST produce byte-identical files. + +## 2. Hash and Signature Encodings + +| Kind | Encoding | Example | +|---|---|---| +| SHA-256 | `sha256:` + 64 lowercase hex | `sha256:9f86…` | +| Ed25519 signature | `ed25519:` + base64 of 64 bytes | `ed25519:MEUC…` | +| Ed25519 public key | `ed25519-pub:` + base64 of the raw 32-byte key | `ed25519-pub:11qY…` | +| Genesis previous hash | `sha256:` + 64 zeros | | + +Domain-separated hashing is `SHA-256(utf8(domain) || sep || payload)` where `sep` +is `0x00` except for Merkle interior nodes (`0x01`). The domain strings are the +constants in `packages/types/src/domains.ts` (`HASH_DOMAINS`). + +## 3. Event Hashing + +For every signed stream the entry hash is computed as: + +1. Take the complete signed event object. +2. Delete `entryHash` and `writerSignature`. +3. Serialize with RFC 8785 (JCS). +4. `entryHash = sha256(domain || 0x00 || utf8(canonical))` using the stream's + hash domain (`STREAM_HASH_DOMAIN`). +5. `writerSignature = Ed25519.sign(privateKey, rawBytes(entryHash))` over the + 32 raw digest bytes, by the signer domain for that stream (`STREAM_SIGNER`). + +`intervention-log.jsonl` follows steps 1-4 with `entryHash` only; it is unsigned. + +Chains: sequences start at `1`; the first event's `previousEntryHash` is the +genesis hash; every later event's `previousEntryHash` equals the previous +event's `entryHash` in the same stream and run. + +Cross-bindings (LEDGER §6) that the verifier MUST check: + +- `ChannelEvent.senderLedgerSequence` / `senderEntryHash` reference an existing + sender-ledger event with `eventType: "intention.recorded"` at that sequence + (Baby-originated accepted events only). +- Receiver `interpretation.recorded` ledger events carry `channelEventHash` + equal to an existing `ChannelEvent.entryHash` whose `deliveryReceipt.recipient` + is that Baby. +- `TurnRecord.channelEventHash` references the turn's channel event when one exists. +- `TurnRecord.probeHash`, when present, references exactly one applied + `causal-probe` intervention on the same evaluation turn. The verifier rebuilds + the probe hash, both recorded artifact hashes, and the ablation/substitution + transformation independently. +- `TurnRecord.repairAttempt`, when present, references one failed primary turn + and one `repair-turn` intervention. The repair must occur on the scheduled + next turn, retain the original phase and `scenarioRef`, and use attempt `1`. +- `deliveryReceipt.deliveredArtifactHash` equals `publicArtifactHash` for + accepted deliveries. + +For each trainable Baby, checkpoint 0 commits a `runtime-attestation` intervention +whose `initialPolicyHash` equals the policy-checkpoint-domain hash of +`policies/-policy-initial.json`. These files contain the exact independently +seeded parameters before any turn or update. + +## 4. Artifact Hashes + +- `publicArtifactHash` = `sha256(carrierMark || 0x00 || utf8(carrierMode) || 0x00 || utf8(canonical(artifact)))`. + For the `disabled` condition the artifact is canonical `null`. +- `babyProposalHash` = `sha256(babyProposal || 0x00 || utf8(canonical(AgentActionProposal)))`. +- `probeHash` = `sha256(causalProbe || 0x00 || utf8(canonical(ArtifactProbe)))`. +- Rejected events store `publicArtifactHash` = `sha256(rejectedPayload || 0x00 || utf8(canonical(rejectedPayload)))` + and never the raw payload. + +## 5. Merkle Trees (LEDGER §7) + +RFC 6962 ordered tree over leaves in sequence order: + +- `leafHash = sha256(merkleLeaf || 0x00 || uint64BE(sequence) || rawBytes(entryHash))` +- `nodeHash = sha256(merkleNode || 0x01 || rawBytes(left) || rawBytes(right))` +- The root of an empty tree is `sha256(merkleNode || 0x01)` over no children, + i.e. the hash of the domain and separator alone. A checkpoint records + `treeSize: 0` and this empty root for streams with no events yet. +- Inclusion proofs and consistency proofs follow RFC 6962 §2.1.1 and §2.1.2 + exactly, using 0-based `leafIndex = sequence - 1`. + +## 6. Checkpoint Manifests (LEDGER §8) + +`checkpointHash = sha256(checkpoint || 0x00 || utf8(canonical(manifest without checkpointHash, witnessSignature)))`. +`witnessSignature` is over the raw digest bytes by the `witness` signer. +Checkpoint `0` is created at run initialization with `previousCheckpointHash` +equal to the genesis hash. `auxiliaryTrees` is always present; it contains an +entry for each auxiliary stream that has at least one event (`affect`, `audit`, +`turns`, `intervention`). The intervention entries are unsigned, but their +tree prefix is protected by the checkpoint witness signature. A verifier MUST +validate each audit entry's `sourceEntryHash` against the named Baby's exported +`agent-native-ledger` event before accepting that auxiliary stream. An accepted +interpreter batch creates an `analysis` checkpoint over the new audit prefix. +A verifier MUST +read an auxiliary tree that is absent from a +manifest as the empty tree (`treeSize: 0`, the empty root), so a consistency +proof from such a checkpoint to a later one where the tree first appears is +well-formed with `fromSize: 0`. A verifier MUST reject any auxiliary tree whose name is not declared +in `run-manifest.json` `streams[].treeName`; every signed stream must also have +its signer's public key listed in `signers`. + +## 7. Run Manifest + +`run-manifest.json` binds the run to its keys and streams: + +- `signers[]`: every signer domain used by the run with `keyId` and `publicKey`. +- `streams[]`: for each exported stream its file name, hash domain, signer + domain (absent for `intervention`), and checkpoint `treeName` (absent for + `intervention`). +- `configurationHash` = `sha256(runConfig || 0x00 || utf8(canonical(RunConfig)))` + and MUST equal the hash of `configuration/run-config.json` and the + `runConfigurationHash` in every checkpoint. +- `runIdHash` = `sha256(runId || 0x00 || utf8(runId))`. +- `claimBoundaryStatement` MUST equal the SPEC §5.1 or §5.2 sentence for + `deploymentMode` verbatim. +- Derived runs repeat `parentRunId`, `derivedFromCheckpointHash`, and both + `initialPolicyRefs` from `configuration/run-config.json`; root runs omit all + four lineage values. +- `preRegistration` (optional, `PreRegistrationBindingSchema`) records how the + run was bound under SPEC §15.1: `registrationClass` (`qualification` or + `confirmatory`), the `preRegistrationHash` it binds, the external + registration URL/id when one exists, the pre-run commitment of that hash when one + exists, its `anchorClass` (`simulated` or `public-chain`), and a verbatim `label`. + A `confirmatory` binding MUST carry a complete repository-native or external registration and a + `confirmed` pre-run commitment; a verifier that finds + a `confirmatory` binding without them MUST fail the run. A bundle without this + field is a run created before ALD-071 completed and is read as + `qualification`. +- A derived export repeats both learner `initialPolicyRef` values as + `initialPolicyRefs`. The verifier requires the immutable parent export via + `--parent-bundle`, confirms the named parent checkpoint exists, resolves both + references inside the parent's `policies/` directory, and checks the child's + checkpoint-0 initialization attestation against those artifacts. + +## 8. Anchor Receipts + +`anchors/base-receipts.json` is a canonical JSON array. Each receipt binds one +`checkpointHash` to one transaction: `anchorClass` (`simulated` or +`public-chain`), `chainId`, `transactionHash`, `from`, `to`, +`inputData` (must equal the 32-byte checkpoint digest, `0x` + 64 hex), block +number and hash once mined, `status`, `confirmations`, `finalityPolicy`, and the +label of the RPC endpoint used. The verifier recomputes the final checkpoint +hash and compares it with `inputData`; chain retrieval is performed only when an +independent RPC URL is supplied for a public-chain receipt, and the report records +whether that check ran. A simulated receipt is verified offline and MUST NOT inherit +a public-chain claim. + +## 9. Verification Report + +The verifier writes `verification-report.json` conforming to +`VerificationReportSchema` (SPEC §11.10) with `exitCode: 1` on any failure and +lists every gap, fork, and event after the last anchored checkpoint under +`gaps`, `forks`, and `unanchoredTailReported`. + +## 10. Analysis Attachments + +Analysis outputs that are evidence *about* a run but not events *of* the run — +the SPEC §15.2 intervention-suite results, the §6.5 semantic-leakage battery, +side-channel and observation red-team audits, carrier/affect leakage +evaluations, E40 encoding-scheme events, curriculum transitions, drift +evaluations — live under `analysis/`: + +```text +/ + analysis/ + index.json canonical JSON, BundleAttachmentIndexSchema + /.json canonical JSON attachments +``` + +- `analysis/index.json` lists every file under `analysis/` except itself as a + `BundleAttachmentSchema` entry: relative `path`, plain `sha256:` of the + file bytes (no domain separator, so any tool reproduces it), `kind`, + `analysisVersion`, `producedAt`, and an optional `boundBy`. The exporter + writes the index even when the attachment list is empty. +- `boundBy` names a chained evidence entry (`audit`, `intervention`, or `turns` + stream) whose content carries the same `sha256`. An attachment produced while + the run was live is bound this way; it is covered by the checkpoint and + anchor chain only when that entry is inside the anchored tree prefix. An + attachment without `boundBy` was produced after sealing; + it is tamper-evident (its hash is listed) but not chain-bound, and a verifier + MUST report it as an *unbound analysis attachment*, never as anchored evidence. +- A verifier MUST fail the bundle when an entry's `sha256` does not match the + file, when a file under `analysis/` is not listed, or when a listed file is + missing. It MUST NOT interpret attachment contents; interpretation is the + researcher's job (SPEC §15.1: the notebook is authoritative for scientific + status). +- `carrier-leakage` attachments record `carrier-leakage-v1`, per-mark reuse and + structural-feature metrics, explicit recognizable-glyph and unintended-feature + probe decisions, and the ungrounded-language claim gate. Their probe thresholds + must exactly match the hash-bound `RunConfig.carrierLeakageProbePlan`; a failed + analysis blocks that claim, not the integrity validity of the bundle. +- Attachments never contain raw observations, raw rejected payloads, private + keys, or Baby-visible text; the same §13.6 privacy rules as every other bundle + file apply. +- The latest `ExperimentRecord.analysisAttachmentRefs` lists the hashes of all + attachments currently in the bundle. Earlier append-only record versions may + list only the attachments that existed when that version was written. + +## 11. Independent integrity cross-check + +`tools/integrity-auditor` is a read-only Rust implementation of the core byte-level +checks. It does not import the TypeScript hashing, Merkle, evidence, or verifier +packages. It independently checks canonical JSON bytes, event chains, Ed25519 +signatures, RFC 6962 roots at every checkpoint, checkpoint hashes and signatures, +configuration and lineage bindings, local anchor-receipt bindings, unanchored tails, +and analysis-attachment byte hashes. It does not query a public chain or interpret +scientific content, so it supplements rather than replaces `ald-verify`. + +Run `pnpm run challenge:integrity` to create a fresh exporter-produced fixture and +require the production verifier and Rust auditor to agree on the unchanged export +and on deliberate event, attachment, lineage, chain, receipt, and tail mutations. diff --git a/docs/experiment-readiness-gates.json b/docs/experiment-readiness-gates.json new file mode 100644 index 0000000..f6039b2 --- /dev/null +++ b/docs/experiment-readiness-gates.json @@ -0,0 +1,20 @@ +{ + "claimBoundary": "software-readiness-only", + "experiments": [ + { "gate": "G1", "ids": ["E00"], "requires": ["ALD-015", "ALD-019", "ALD-021"], "evidence": ["packages/verifier/__tests__/mutation-matrix.test.ts", "packages/anchor/__tests__/verify-anchor.test.ts"] }, + { "gate": "G1", "ids": ["E01", "E02"], "requires": ["ALD-067", "ALD-068"], "evidence": ["packages/redteam/__tests__/side-channel.test.ts", "packages/redteam/__tests__/observation-and-measurement.test.ts"] }, + { "gate": "G1", "ids": ["E03"], "requires": ["ALD-029", "ALD-036", "ALD-041", "ALD-042", "ALD-072"], "evidence": ["packages/orchestrator/__tests__/experiments/e03-controls.test.ts"] }, + { "gate": "G2", "ids": ["E10", "E11", "E12"], "requires": ["ALD-044", "ALD-045", "ALD-046", "ALD-056", "ALD-057"], "evidence": ["packages/learners/__tests__/conformance.test.ts", "packages/leakage/__tests__/semantic-leakage.test.ts", "scripts/run-mode-r-smoke.mjs"] }, + { "gate": "G2", "ids": ["E13"], "requires": ["ALD-031", "ALD-032"], "evidence": ["packages/gateway/__tests__/conformance.test.ts", "packages/analysis/__tests__/carrier-leakage.test.ts"] }, + { "gate": "G2", "ids": ["E14", "E15", "E16"], "requires": ["ALD-064", "ALD-072"], "evidence": ["packages/interventions/__tests__/interventions.test.ts", "packages/orchestrator/__tests__/audit-interpreter.test.ts"] }, + { "gate": "G3", "ids": ["E20"], "requires": ["ALD-033"], "evidence": ["packages/gateway/__tests__/affect-conformance.test.ts"] }, + { "gate": "G3", "ids": ["E21"], "requires": ["ALD-042", "ALD-044", "ALD-045", "ALD-046"], "evidence": ["packages/interventions/__tests__/readiness-gates.test.ts"] }, + { "gate": "G3", "ids": ["E22"], "requires": ["ALD-072"], "evidence": ["packages/interventions/__tests__/interventions.test.ts"] }, + { "gate": "G4", "ids": ["E30"], "requires": ["ALD-028"], "evidence": ["packages/orchestrator/__tests__/derived-run-evidence.test.ts"] }, + { "gate": "G4", "ids": ["E31"], "requires": ["ALD-072"], "evidence": ["packages/analysis/__tests__/intervention-analysis.test.ts", "packages/interventions/__tests__/interventions.test.ts"] }, + { "gate": "G4", "ids": ["E32"], "requires": ["ALD-041"], "evidence": ["packages/scenario/__tests__/referential-engine.test.ts"] }, + { "gate": "G5", "ids": ["E40"], "requires": ["ALD-069", "ALD-070"], "evidence": ["packages/crypto-research/__tests__/harness.test.ts", "scripts/lint-crypto-boundary.mjs"] }, + { "gate": "G5", "ids": ["E50"], "requires": ["ALD-028", "ALD-072"], "evidence": ["packages/interventions/__tests__/readiness-gates.test.ts"] } + ], + "publicationChecklistMapping": "docs/publication-checklist-mapping.md" +} diff --git a/docs/external-prerequisite-handoff.md b/docs/external-prerequisite-handoff.md new file mode 100644 index 0000000..6a0ef44 --- /dev/null +++ b/docs/external-prerequisite-handoff.md @@ -0,0 +1,87 @@ +# External Prerequisite Handoff + +Status: one of one applicable prerequisites verified; five external enhancements are not applicable to local execution +Machine-readable ledger: `reports/research/external-prerequisite-readiness.json` +Audit command: `pnpm run audit:external-prerequisites` + +This handoff turns O01–O06 into evidence checks that an authorized operator or +reviewer can audit without exposing credentials or confusing optional external +enhancements with experiment requirements. This ledger does not replace the local +canonical-packet, prospective-commitment, or experiment-specific preflight gates. + +## Current disposition + +| Item | State | Decisive closure evidence | +|---|---|---| +| O01 governance | Verified | `ALD-GOV-2026-09-12-01` approves synthetic-only data, role assignments, retention, no human coding, and the simulation-only funding boundary | +| O02 Sepolia authority | Not applicable | External spend is zero and public-chain transactions are prohibited; deterministic simulated commitment replaces the campaign dependency | +| O03 mainnet authority | Not applicable | Mainnet remains an optional inactive capability that would require a new prospective amendment | +| O04 upstream enforcement | Not applicable | Exact-commit clean-tree local validation governs collection; hosted enforcement is optional release governance | +| O05 independent restore | Not applicable | First-party restore verification remains mandatory; second-person execution is optional independent-reproducibility evidence | +| O06 external registration | Not applicable | Repository-native registration replaces the activation dependency; third-party registration remains optional | + +The earlier Fort runtime receipt remains valid software and secret-boundary evidence, +but its unfunded public wallet is no longer a campaign dependency. The approved +profile uses no faucet, token, fee, or public transaction. The deterministic +transport must label every receipt `simulated`; relabeling that field causes verifier +failure. + +The 2026-09-11 read-only O04 observation found no repository ruleset and could not +demonstrate branch protection. The exact v0.1.78 candidate is published as the head of +the existing upstream pull request, but its proposed-change workflow completed as +`action_required` before starting any job. The latest relevant default-branch +workflow also failed. The observation is stored in +`reports/research/upstream-enforcement-observation.json`. This limits hosted- +enforcement claims but no longer blocks local synthetic experiments. + +## Evidence sequence + +1. Preserve O01's approved scope. Any human data, human coding, personal data, + sensitive data, real currency, or public-chain proposal requires a new prospective + amendment before collection. +2. Treat O02 and O03 as non-applicable while the simulation-only decision remains in + force. Do not fund the existing address or submit a public transaction. +3. Treat O04 and O05 as optional enhancements. Local checks do not establish hosted + enforcement or independent human restoration, so never make those claims. +4. Complete D08's operational bindings and blinded-pilot selection. Commit the exact + packet unchanged to Git, verify its bytes at an ancestral commit, and commit the + identical hash through deterministic transport before the first eligible outcome. + Never register a packet retrospectively. + +## Secret-safe execution boundary + +Research-grade signer material is materialized only in files mode: + +```sh +si fort run --repo agentic-language-development --env dev \ + --keys ALD_RUN_SIGNER_SEEDS_JSON --mode files -- \ + pnpm run test:mode-r-study +``` + +This command demonstrates the signer boundary only when the encrypted environment +has been provisioned for the exact run IDs. It does not submit a public transaction +or authorize an outcome claim. Never echo, print, copy, or commit materialized secret +files. + +## Activation check + +After a local packet is complete, bind its repository record and run: + +```sh +pnpm run audit:external-prerequisites +pnpm run audit:campaign-readiness +pnpm run audit:registration-readiness +``` + +For an experiment with a complete compiled packet and repository-native binding, run the +existing fail-closed preflight with those exact files: + +```sh +pnpm run preflight:research -- \ + --registration path/to/compiled-registration.json \ + --binding path/to/repository-registration-and-commitment-binding.json +``` + +Only a `READY` result for the exact immutable candidate removes that experiment's +configuration-and-binding preflight block. It does not replace governance, +scientific dependency, resource, final-topology, or independent-review gates. diff --git a/docs/generative-carrier-learning.md b/docs/generative-carrier-learning.md new file mode 100644 index 0000000..7ad2ce6 --- /dev/null +++ b/docs/generative-carrier-learning.md @@ -0,0 +1,108 @@ +# Generative Carrier Learning Qualification + +Status: software-qualified mechanism; not an E13 result + +Implementation: `generative-carrier-learning-v1` + +## Claim boundary + +This mechanism lets a scratch learner acquire an exact partner-created form, +place a deterministic local modification in its own action bank, later emit +either form through the ordinary Gateway, and preserve that bank in a policy +checkpoint. It does not establish that a useful convention emerged, that a +modified form generalized, or that one carrier improves learning. Those are +E13 empirical questions and remain unstarted. + +## Fixed-capacity state transition + +Each generative learner starts with a private, seed-derived bank of distinct +valid artifacts. Its action dimension remains constant throughout the run. +Learning replaces slots; it never appends slots. + +1. `receive` validates and hashes the delivered Gateway artifact, resolves an + exact local match when one exists, and stages an unmatched artifact. +2. The staged artifact does not alter exported state. This keeps retries and + evaluation observations from mutating a frozen policy. +3. `updatePolicy` commits staged training-turn artifacts in turn order. The + exact partner artifact replaces the next slot with origin `acquired`. +4. When modification is enabled, one deterministic bounded edit replaces the + following slot with origin `modified` and records the acquired artifact's + hash as `parentMarkHash`. +5. Later action selection can choose either slot. Emission passes through the + same carrier proposal and Gateway path as every other action. + +Bitmap modification flips one selected cell. Canvas modification moves one +stroke's start-x coordinate on the 16-position grid. Tone modification moves +one pitch to a different one of eight bins. Every operation preserves the +declared grammar and changes the carrier-qualified mark hash. + +The feature is opt-in through `acquirePartnerForms` and +`modifyAcquiredForms`; the latter requires the former. Symbolic inventories +remain immutable when these options are present. + +## Checkpoint and recovery contract + +Tabular scratch checkpoints use policy version 3 and recurrent scratch +checkpoints use policy version 2. Both include: + +- carrier and fixed capacity; +- ordered slot number, opaque form ID, mark hash, and complete artifact; +- origin and introduction turn; +- optional parent hash for modified forms; and +- the next deterministic replacement slot. + +Restore validates the proposal schema, carrier, capacity, slot order, content +hashes, uniqueness, origin/parent invariant, and the run's canvas stroke cap. +Older policy versions still load with their original initialized bank. + +## Capacity accounting + +`carrierCapacity` reports two quantities that must not be conflated: + +- physical grammar capacity is the exact number of artifacts admitted by the + Gateway grammar, reported as a decimal integer and in bits; and +- effective message capacity is the log2 size of the learner's fixed action + bank, including the declared number of marks for symbolic messages. + +For a bank of eight forms and one mark per message, every carrier has an +effective capacity of 3 bits even though the physical grammars differ: + +| Carrier | Physical grammar | +|---|---:| +| fixed token or fixed glyph | `8` forms | +| bitmap | `2^256` forms | +| canvas, at most 8 strokes | `sum((16^4 * 3)^k, k=1..8)` forms | +| tone | `sum(32^k, k=1..8)` forms | + +This prevents a large rendering grammar from being reported as learner model +capacity. + +## Deterministic qualification + +Run: + +```text +pnpm run qualify:generative-carriers --out --db --bundles +``` + +The runner executes fixed token, fixed glyph, bitmap, canvas, and tone through +the production runtime with the recurrent scratch learner, real carrier +modules, SQLite evidence writer, checkpoint service, and independent bundle +verifier. It checks: + +- all planned turns are accepted through the selected Gateway carrier; +- initial generative banks are disjoint across roles; +- both roles end training with acquired and modified forms; +- policy hashes are constant throughout evaluation; +- capacity values are recorded separately; and +- every exported bundle independently verifies. + +The unit qualification additionally checks exact imitation, parent linkage, +all three mutation grammars against the real carrier modules, JSON round-trip +restore, evaluation staging without mutation, immutable symbolic controls, +and full learner-adapter conformance. + +The runner deliberately labels its output +`software-qualification-not-experiment-results`. Its prototype-mode runs seal +with the repository's explicit unanchored qualification disposition and are +not eligible for confirmatory inference. diff --git a/docs/hosted-ci-evidence.md b/docs/hosted-ci-evidence.md new file mode 100644 index 0000000..633f63f --- /dev/null +++ b/docs/hosted-ci-evidence.md @@ -0,0 +1,27 @@ +# Hosted CI evidence + +The first complete hosted execution of the consolidated workflow ran against +commit `5f22202f01fe425d91c8862f91d1f62a9cf19070` on 2026-09-09: + +- run: +- `consolidated-suite`: passed in 2m08s and uploaded JUnit plus runtime evidence; +- `mode-r`: passed in 1m34s and uploaded real-container runtime evidence; and +- the consolidated suite included all 20 randomized SIGKILL crash points in + `packages/evidence/__tests__/crash-safety.test.ts`, with no torn-write + failure. + +The upstream pull-request execution is separately visible at +. +It is `action_required`, not failed: an upstream maintainer must approve the +first workflow run from this fork. This receipt therefore proves hosted suite +execution and ALD-011 crash safety, but does not claim that upstream branch +protection or its required-check policy is active. + +A newer privacy-minimized read-only observation is recorded in +`reports/research/upstream-enforcement-observation.json`. As of 2026-09-11 it found +zero repository rulesets and no demonstrated branch protection. The exact v0.1.78 +candidate is now the head of the existing upstream pull request, but its workflow is +`action_required` and started zero jobs; the latest relevant default-branch workflow +also failed. The local workflow still defines both required job IDs. O04 therefore +remains open until the proposed-change workflow is approved, both checks pass, and an +authorized administrator requires them before merge. diff --git a/docs/mode-r-runbook.md b/docs/mode-r-runbook.md new file mode 100644 index 0000000..54b6a08 --- /dev/null +++ b/docs/mode-r-runbook.md @@ -0,0 +1,90 @@ +# Mode R operator runbook + +## Prerequisites + +- Linux or macOS with Docker Engine/Colima running; +- Docker Compose v2 available as `docker compose`; +- Homebrew-managed Node.js 24.20 and pnpm 12.3.4; and +- a clean checkout with dependencies installed by `pnpm install --frozen-lockfile`. + +No Base key, RPC URL, or secret is needed for this isolation qualification. +The Compose networks are internal and the smoke run performs no anchoring. + +## One-command qualification + +From the repository root, run: + +```sh +pnpm run test:mode-r +``` + +The command builds the hardened learner image, starts Baby A and Baby B on +distinct internal networks, proves their container/process IDs differ, runs +the twelve side-channel attacks, kills Baby A and proves Baby B survives, then +recreates both hosts for `scratch-rl`, `self-supervised`, and `hybrid` and +proves each update changes only its local policy. It removes its project, +volumes, and containers on exit. Any failed assertion exits non-zero. + +## Full-lifecycle topology qualification + +Run all four learner tracks through the actual controller, Gateway, SQLite writer, +checkpoint, local qualification anchor, exporter, and verifier: + +```sh +pnpm run test:mode-r-study +``` + +This is a software/topology qualification only. Its receipt states +`researchFinding: false` and `publicChainTransaction: false`; the local fake-chain +receipt is not evidence of public anchoring. + +To exercise the persistent signer boundary, first store the version-1 signer +envelope in the matching encrypted `safe` environment, then let Fort materialize +it for only the Nursery service: + +```sh +si fort run --repo agentic-language-development --env dev \ + --keys ALD_RUN_SIGNER_SEEDS_JSON --mode files -- \ + pnpm run test:mode-r-study +``` + +The envelope's `runs` map must contain `mode-r-study-no-learning`, +`mode-r-study-scratch-rl`, `mode-r-study-self-supervised`, and +`mode-r-study-hybrid`; each value must contain the exact six signer domains as +64-character lowercase hexadecimal seeds. Do not create a plaintext envelope +outside Fort. Neither learner receives the file, its path, or its contents. + +## Inspect a standing deployment + +For operator inspection, use an explicit project name: + +```sh +docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml up --build --detach baby-a baby-b +docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml ps +docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml run --rm --no-deps nursery both +``` + +`ps` must show two running learner services. The `nursery both` check must +report distinct container IDs and successful network, filesystem, clipboard, +process, worker, timing, size, error-shape, and cross-object isolation checks. + +Exercise every trainable track: + +```sh +ALD_LEARNER_TRACK=scratch-rl docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml up --detach --force-recreate baby-a baby-b +ALD_LEARNER_TRACK=scratch-rl docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml run --rm --no-deps nursery training scratch-rl +ALD_LEARNER_TRACK=self-supervised docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml up --detach --force-recreate baby-a baby-b +ALD_LEARNER_TRACK=self-supervised docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml run --rm --no-deps nursery training self-supervised +ALD_LEARNER_TRACK=hybrid docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml up --detach --force-recreate baby-a baby-b +ALD_LEARNER_TRACK=hybrid docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml run --rm --no-deps nursery training hybrid +``` + +Clean up the exact project when finished: + +```sh +docker compose --project-name ald-mode-r-operator --file deploy/mode-r/docker-compose.yml down --volumes --remove-orphans +``` + +Do not apply a Research-Grade claim when any check fails. Preserve the command +output, container logs, image digest, commit, and Run Configuration with the +qualification evidence before diagnosing or rerunning. diff --git a/docs/publication-checklist-mapping.md b/docs/publication-checklist-mapping.md new file mode 100644 index 0000000..74f65a1 --- /dev/null +++ b/docs/publication-checklist-mapping.md @@ -0,0 +1,30 @@ +# Publication checklist mapping + +This table maps every item in `EXPERIMENT-NOTEBOOK.md` §12. A +`software-verifiable` row has a deterministic implementation or evidence check. +A `research-execution` row depends on the contents, reporting choices, or +judgment of a completed study; the linked software can support that work but a +green build does not assert that the publication has satisfied it. + +| §12 checklist item | Classification | Software mapping and boundary | +|---|---|---| +| All primary hypotheses were pre-registered. | research-execution | ALD-071 binds a supplied registration before a confirmatory run; researchers must verify that the submitted record covers every primary hypothesis. | +| All included runs passed ledger and transcript verification. | research-execution | ALD-015, ALD-021, and Gate G1 verify individual bundles; publication inclusion remains a corpus-level decision. | +| Invalid and aborted runs are indexed and explained. | research-execution | ALD-024 and ALD-061 preserve terminal states and failure evidence; authors must include and explain the complete study index. | +| Effect sizes and uncertainty are reported. | software-verifiable | ALD-072 and Gates G3–G5 emit effect sizes and uncertainty fields without drawing a conclusion. | +| Multiple-comparison policy is documented. | software-verifiable | ALD-072 implements the pre-registered Holm-Bonferroni policy across primary metrics. | +| Pretrained and initially ungrounded claims are separated. | software-verifiable | ALD-054 and ALD-057 enforce track and semantic-leakage claim boundaries; Gate G2 checks them. | +| External reward, intrinsic reward, and non-RL conditions are separated. | software-verifiable | ALD-023 validates learning-signal configuration; ALD-042, ALD-045, and ALD-046 implement the distinct conditions checked by Gate G3. | +| Causal listening was tested rather than inferred from task success. | research-execution | ALD-072 and Gate G2 provide registered ablation/substitution probes; authors must actually run and report them. | +| Human audit ledgers are distinguished from agent-native state. | software-verifiable | ALD-064 signs, labels, routes, and exports generated analysis separately; Gate G2 references that boundary. | +| Affect-channel leakage was tested. | research-execution | ALD-033 and Gate G3 supply the six-display conformance and leakage tooling; a publication must run it on its study data. | +| Cipher novelty is not represented as cryptographic security. | software-verifiable | ALD-070 and Gate G5 enforce the production-crypto import and claim boundary. | +| Base anchor transactions and verification instructions are published. | research-execution | ALD-020–ALD-022 and ALD-079 provide receipts, verification, and operational documentation; authors must publish the study-specific transaction references. | +| Data and model release restrictions are documented. | research-execution | ALD-016 exports provenance and ALD-080 defines release review; legal, privacy, model-license, and data-access restrictions require study-specific judgment. | +| Negative and null results are included. | research-execution | ALD-072's E50 aggregate never drops failed or partial replications, but authors remain responsible for complete reporting. | +| Independent replication status is stated. | research-execution | ALD-028, ALD-072, and Gate G5 represent independent seeds and replication status; independence and the publication statement require researcher attestation. | + +The mapping is deliberately not a publication approval. In particular, +statements such as “results support the stated hypothesis,” whether exclusions +were scientifically justified, and whether interpretation is appropriately +calibrated are research-judgment calls outside the software backlog. diff --git a/docs/recurrent-baseline-qualification.md b/docs/recurrent-baseline-qualification.md new file mode 100644 index 0000000..287fb3a --- /dev/null +++ b/docs/recurrent-baseline-qualification.md @@ -0,0 +1,84 @@ +# Recurrent Baseline Qualification + +Status: software qualification; not an experiment result + +This document locks the scientific learner implementation used by the recurrent +`scratch-rl` baseline and its capacity-matched `self-supervised` comparison. The +registered E11 and E12 experiments remain unstarted. Gradient checks, toy learning, +container execution, and sealed bundle verification establish executable behavior; +they do not establish emergent communication or support a hypothesis. + +## Locked model + +Both tracks use `gru-actor-critic-v1`, implemented in TypeScript behind the existing +learner adapter. Keeping this small numerical core in the protocol runtime makes its +complete parameters, optimizer moments, recurrent checkpoint state, and update count +canonical and independently inspectable without adding a native FFI or tensor-runtime +boundary. The reference configuration is: + +| Property | Locked value | +|---|---| +| Recurrent cell | one GRU shared by sender and receiver paths | +| Hidden width | 16 float64 values | +| Input | role bit, numeric candidate feature, public message symbols | +| Heads | sender symbols, receiver type, scalar value | +| Initialization | independently seeded Xavier-uniform weights; zero biases and hidden state | +| Optimizer | independent Adam state per learner; learning rate 0.003, betas 0.9/0.999, epsilon 1e-8 | +| Scratch objective | one-step-truncated PPO-style clipped actor loss plus value MSE | +| PPO settings | four epochs, clip 0.2, value coefficient 0.5, gradient-norm cap 1 | +| Reward-free objective | partner-message predictive cross-entropy from numeric features and public symbols | +| Serialization | parameters, both Adam moments and step, sender/receiver checkpoint hidden state, update count | +| Evaluation | update path disabled; transient live hidden state excluded from export; checkpoint export must remain constant | + +The current four-choice, one-symbol Mode R fixture has 4,049 parameters per learner. +Both tracks instantiate the entire shared core, including heads unused by a particular +loss, so parameter capacity is exactly matched rather than estimated from active +weights. Runtime memory accounting covers parameters, both Adam moments, and live plus +checkpoint hidden states. Experiment reports must additionally record process/container +memory and elapsed compute; this in-model count is not a whole-system resource claim. + +## Signal boundary + +Scratch RL may update only from the learner's own sampled action and its locally +delivered scalar reward. Self-supervision accepts no scalar reward: its update records +contain numeric candidate features and public message symbols, and the adapter refuses +a non-null reward. Receiver self-training uses its own current argmax candidate rather +than the environment's intended target. That choice avoids a hidden task label but can +reinforce an incorrect interpretation; E12 must measure the consequence rather than +assuming convergence. + +## Qualification assertions + +The blocking unit and adapter checks establish: + +- deterministic replay for the same seed and distinct parameters for distinct learner + seeds; +- optimizer isolation by mutating one model and proving another model is unchanged; +- central-difference agreement for the largest predictive gradient component; +- nonzero PPO-style reward-to-parameter and predictive-loss updates; +- a bounded reward-free toy mapping improves above its initial score; +- exact parameter, optimizer, recurrent-state, and registry restore; +- witnessed policy checkpoints through the shared adapter contract; +- byte-equivalent policy export throughout learning-disabled evaluation; and +- structural reward refusal and absence of outcome labels from self-supervised updates. + +`pnpm run test:mode-r-study` additionally runs the two recurrent tracks in distinct +learner containers through the real controller, Gateway, SQLite writer, checkpoint +service, local qualification anchor, exporter, and production verifier. The resulting +bundles are then checked by the independent Rust auditor. The local fake-chain receipt +is deliberately classified as software evidence, never as a public-chain transaction +or research result. + +## Known limitations before E11/E12 + +- Training uses one-step truncated recurrence; it does not propagate gradients across + earlier turns. This is a locked model choice, not full sequence PPO. +- The deterministic TypeScript implementation prioritizes inspectability over + accelerator throughput and is intended for the small registered baseline. +- The toy learnability threshold is a regression guard, not an empirical effect size. +- Equal parameter count does not alone equalize wall time, update count, information + access, or effective optimization difficulty. The experiment protocol must match and + report those quantities. +- The recurrent qualification uses bounded turns. Publication-facing E11/E12 require + disjoint seeds, locked scenarios and analyses, required repository registration, full + sample accounting, and independent repetition. diff --git a/docs/registration-packet-readiness.md b/docs/registration-packet-readiness.md new file mode 100644 index 0000000..6cc1f2a --- /dev/null +++ b/docs/registration-packet-readiness.md @@ -0,0 +1,82 @@ +# Canonical Registration Packet Readiness + +Status: B11 in progress; E00 and E01 compile and 17 experiment packets remain incomplete +Evidence class: outcome-blind design inventory +Machine inventory: `reports/research/registration-packet-readiness.json` + +## Fail-closed packet contract + +The general compiler now requires every experiment packet to bind exactly eleven +operational surfaces before it emits canonical bytes or a pre-registration hash: + +1. the experiment protocol card; +2. exact run configurations for every condition; +3. raw-scale practical margins and decision rules; +4. exact analysis versions; +5. model and runtime asset identities; +6. the pilot-selected primary and reserve seed prefixes; +7. the approved execution-host manifest; +8. the exact scenario bundle and split definition; +9. exclusion and invalid-run rules; +10. stopping and resource-exhaustion rules; and +11. evidence, verifier, simulated-commitment, and optional public-chain boundary policy. + +The compiler accepts no missing or extra binding key. It recursively rejects null, +undefined, empty, non-finite, and placeholder values such as `TBD`, `unknown`, or +`pending`. Each binding receives its own domain-separated canonical hash. The ordered +binding set is then canonicalized and hashed under the repository's pre-registration +domain. Any change to any bound content changes the packet hash. + +The emitted claim boundary is `draft-until-repository-registered-and-pre-run-committed`. +Compilation alone cannot establish immutable repository registration, governance approval, +a completed commitment, public anchoring, or independent review. + +E00 packet v1 was committed without outcomes, then superseded prospectively because +its environment manifest included mutable package metadata and would have forced the +execution commit to contradict the registered environment. Packet v2 removes that +cycle and records the v1 hash and reason. Packet v2 was committed and simulation- +committed, but its first slot failed closed when the Rust auditor accepted a modified +inclusion proof. Packet v3 binds the repaired Rust and TypeScript source hashes, a +fresh E00-only seed domain, and both prior dispositions. It requires a new prospective +commitment before execution. A later dry-run found that activating v3 necessarily +changed the wrapper source it had hashed; v3 therefore produced no outcomes. V4 +removes that self-reference, binds the scientific protocol, records the exact clean +execution commit in its receipt, and uses another fresh seed domain. V4 completed all +five slots but failed when the Rust auditor accepted receipt-class relabeling. V5 +prospectively binds that repair and another fresh seed domain. + +## Current inventory + +All 19 protocol cards resolve and receive stable card hashes. E00 and E01 each +resolve all eleven bindings and reproduce their canonical hashes. The remaining ten +bindings for each of 17 experiments are deliberately unresolved: 170 unresolved +bindings in total. +Therefore: + +- registration-ready packet drafts: 2/19; +- compiled canonical packets: 2/19; +- pre-registration hashes emitted by this inventory: 2 (E00 and E01); and +- immutable repository registration and matching simulated pre-run commitments: + v2 and v4 are complete failed attempts; v3 is superseded without outcomes; v5 is + repository-registered and has a deterministic simulated commitment before execution; + E01 is repository-registered with a matching deterministic simulated commitment + and remains unexecuted. + +Existing generic scenario, analysis, seed-allocation, environment, and evidence +artifacts are inputs to future experiment-specific bindings. They are not silently +treated as final exact values. In particular, N=100 is still a resource-planning +value rather than a pilot-selected seed prefix. + +## Activation steps after compilation + +A complete packet must be committed unchanged to Git, verified from that historical +path, and bound to the same pre-run simulated commitment. Governance is already +approved for the local synthetic profile. Third-party registration and independent +methods review remain optional credibility enhancements and are not claimed. + +## Verification + +The focused compiler tests prove deterministic serialization, hash sensitivity, +fixed binding order, and rejection of missing, extra, empty, placeholder, invalid-ID, +and non-finite inputs. The generated inventory is checked during the consolidated +gate and will become stale if the 19 protocol cards or required binding set changes. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..7910ee9 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,41 @@ +# Release process + +Normative words (`MUST`, `MUST NOT`, `SHOULD`, `MAY`) retain the meanings in +RFC 2119/RFC 8174 and SPECIFICATION §2. Release notes use ordinary descriptive +language unless they intentionally impose a conformance requirement. + +The root `package.json` is the repository version source. Development commits +that change tracked content increment the patch component in the same commit. +A published release increments the minor component, resets patch to zero, and +is the only kind of commit that receives a Git tag. Major version changes are +reserved for an explicitly approved incompatible evidence or protocol +contract. + +## Before a release + +1. Require the `consolidated-suite` and `mode-r` CI jobs to pass on the exact + release commit. Do not retry a failure into green. +2. Confirm `pnpm run check` and `pnpm run test:mode-r` locally when the release + changes runtime, isolation, evidence, or deployment behavior. +3. Review `BACKLOG.md`, the publication checklist mapping, open deviations, + invalid/aborted run indexes, and data/model release restrictions. +4. Build release notes from every commit and patch bump since the previous + minor tag. Separate fixes, capabilities, conformance changes, known gaps, + and migrations; do not imply a scientific result from a readiness gate. +5. Change the root version to the next `0..0` and update the lockfile in + the same release commit. + +## Tag and publish + +After the release commit is reviewed and CI is green, create one annotated tag +named `v` on that exact commit. The tag message and GitHub Release +notes MUST identify the verifier version, evidence schema compatibility, +deployment-mode claim boundary, and all patch commits since the prior tag. +Publish immutable source artifacts and checksums. Publish evidence, data, +models, and Base transaction links only after their study-specific licenses, +privacy restrictions, and verification instructions have been reviewed. + +Never move or reuse a release tag. A correction is a new patch development +commit and, when distributed, a new minor release. See +[publication-checklist-mapping.md](publication-checklist-mapping.md) for the +software/research boundary that release approval must preserve. diff --git a/docs/repository-native-registration.md b/docs/repository-native-registration.md new file mode 100644 index 0000000..f086e37 --- /dev/null +++ b/docs/repository-native-registration.md @@ -0,0 +1,77 @@ +# Repository-Native Research Registration + +Status: approved activation mechanism under `ALD-GOV-2026-09-12-01` +Scope: synthetic, zero-spend experiments in this repository + +## Purpose + +The registration mechanism fixes each experiment's question, parameters, seeds, +analysis, models, host, scenario, exclusions, stopping rules, and evidence policy +before outcomes are collected. It uses immutable Git history and the deterministic +in-memory commitment transport; it requires no real funds, public chain, hosted +service, or private credential. + +## Required sequence + +1. Resolve all eleven canonical binding classes and compile the packet. +2. Commit the packet unchanged to Git before any eligible outcome is observed. +3. Create a binding with `registrationAuthority: "repository-native"`, the packet + path, the 40-hex registration commit, its commit time, and the exact canonical + `preRegistrationHash`. +4. Require the registration commit to be an ancestor of the execution commit. +5. Load the packet from `registrationCommit:path`, recompute its canonical hash, and + require equality with the binding and `RunConfig`. +6. Commit the same hash through the deterministic in-memory transport before the run + enters `running`; record the confirmed simulated receipt in the run manifest. +7. Execute from a clean tree and preserve the exact software commit, run manifest, + all dispositions, and verifier reports. + +Any protocol change creates a new packet, hash, registration commit, binding, and run +IDs. A registered packet is never overwritten. A packet registered after its outcomes +were observed is retrospective and ineligible for confirmatory claims. + +## What the mechanism establishes + +- exact registered bytes and canonical hash; +- immutable ancestry relative to the later execution commit; +- deterministic pre-run consumption and receipt replay; +- detectable packet, binding, receipt, ordering, and evidence mutations; +- a reproducible audit path using only repository history and local software. + +## What it does not establish + +Git commit metadata is controlled by the repository operator. This mechanism does +not establish an independently witnessed time, third-party custody, decentralized +persistence, public availability, economic finality, hosted branch enforcement, +independent human review, or independent replication. Reports must state those limits. +Third-party registration or archival timestamping can be added later as a stronger +publication artifact without changing the experiment's agent-language estimands. + +## Example binding shape + +```json +{ + "registrationClass": "confirmatory", + "registrationAuthority": "repository-native", + "preRegistrationHash": "sha256:<64 lowercase hex characters>", + "repositoryRegistration": { + "commit": "<40 lowercase hex characters>", + "path": "protocols/-registration.v1.json", + "artifactSha256": "sha256:", + "committedAt": "" + }, + "preRunAnchor": { + "anchorClass": "simulated", + "network": "base-sepolia", + "chainId": 84532, + "transactionHash": "0x<64 lowercase hex characters>", + "inputData": "0x", + "blockNumber": 1, + "status": "confirmed" + }, + "label": "confirmatory: repository-registered before run start" +} +``` + +The network and chain ID are deterministic compatibility labels, not claims that a +public network was contacted. Every receipt remains explicitly `simulated`. diff --git a/docs/requirement-conformance-matrix.json b/docs/requirement-conformance-matrix.json new file mode 100644 index 0000000..69cb5ae --- /dev/null +++ b/docs/requirement-conformance-matrix.json @@ -0,0 +1,7113 @@ +{ + "schemaVersion": 1, + "generatedAtUtc": "2026-09-11T00:00:00Z", + "classification": "provisional-conformance-mapping", + "researchFinding": false, + "candidate": { + "baselineCommit": "559436fee0d7a95ea38fd92fbcbcccf762d10f92", + "version": "0.1.97" + }, + "sourcePolicy": { + "checkedBacklogCriteria": "Every checked criterion must have one or more concrete executable assertion paths.", + "normativeRequirements": "Every applicable MUST-bearing line must map through its controlling section to one or more executable assertion paths.", + "limitation": "File and selector mappings are not execution results; V04–V11 receipts determine behavioral disposition." + }, + "assertionCatalog": { + ".github/workflows/book-integrity.yml": { + "selectors": [ + "hosted consolidated-suite or mode-r job" + ], + "requiredReceipt": "hosted workflow receipt for the exact commit" + }, + "packages/analysis/__tests__/affect-leakage.test.ts": { + "selectors": [ + "estimates below the 0.02-bit bound but refuses an E20 decision with only five seeds", + "records the estimator, the bound, and the E20 scale flags honestly", + "permits the primary decision only at the registered 75-seed scale", + "fails the bound and reports suspected leakage rather than dropping it", + "lists a single leaky seed even when the aggregate bound would pass", + "reports insufficient windows instead of deciding on too little data", + "excludes an ineligible seed from the bound but still reports it", + "is exactly reproducible from its seed and sensitive to it", + "produces a canonicalizable attachment payload with only finite numbers", + "never mutates the input windows", + "refuses malformed inputs rather than coercing them", + "refuses duplicate seed labels and out-of-range window fields" + ], + "requiredReceipt": "pnpm exec vitest run packages/analysis/__tests__/affect-leakage.test.ts" + }, + "packages/analysis/__tests__/carrier-leakage.test.ts": { + "selectors": [ + "reports versioned mark metrics and passes both registered probes", + "blocks recognizable prior glyphs while reporting intended carrier feature use without treating it as leakage", + "reports an under-sized form-use diagnostic without converting it into a leakage failure", + "extracts immutable structural features for all five carrier conditions", + "rejects a mark hash that does not bind the supplied artifact" + ], + "requiredReceipt": "pnpm exec vitest run packages/analysis/__tests__/carrier-leakage.test.ts" + }, + "packages/analysis/__tests__/e03-design.test.ts": { + "selectors": [ + "reproduces all four component rows above the 90% power floor", + "is byte-for-byte deterministic for a fixed seed", + "rejects malformed designs instead of emitting a power decision", + "derives the registered slots and ten-percent reserve deterministically" + ], + "requiredReceipt": "pnpm exec vitest run packages/analysis/__tests__/e03-design.test.ts" + }, + "packages/analysis/__tests__/e03-registration.test.ts": { + "selectors": [ + "builds one canonical artifact and binds every condition/slot to its hash", + "is byte-identical on repeat and changes hash when a registered field changes", + "excludes realized run identity, seed, condition, and hash from the parameter template", + "fails closed on a non-research or mismatched seed-count base" + ], + "requiredReceipt": "pnpm exec vitest run packages/analysis/__tests__/e03-registration.test.ts" + }, + "packages/analysis/__tests__/e03.test.ts": { + "selectors": [ + "qualifies with no unmet criteria", + "declares every non-oracle condition equivalent after Holm correction", + "meets oracle adequacy above 0.90", + "meets oracle separation with Holm tests and simultaneous intervals", + "audits the §D.10 high-seed share", + "is deterministic for a seed and seed-sensitive otherwise", + "names the above-chance condition and still qualifies the others", + "fails oracle adequacy when the oracle is weak", + "reports insufficient seeds instead of a decision", + "enforces the five-seed qualification floor", + "routes high seeds to leakage review without treating an arbitrary share as a test", + "adds Wilson intervals only when episode counts are supplied", + "rejects malformed episode counts", + "requires conditions paired with the oracle by seed slot", + "rejects rates outside [0, 1], empty families, and bad bounds", + "criterion 2: computes every baseline statistic named in SPEC §15.3", + "criterion 3: the output is inert data with no stored conclusion" + ], + "requiredReceipt": "pnpm exec vitest run packages/analysis/__tests__/e03.test.ts" + }, + "packages/analysis/__tests__/information.test.ts": { + "selectors": [ + "matches the closed form for uniform and degenerate distributions", + "reports the observed support and rejects malformed counts", + "adds exactly (K - 1) / (2 N ln 2) bits", + "corrects each of the three entropies of a mutual information separately", + "is zero on an exactly independent table and one bit on a perfectly dependent one", + "weights strata by size for the conditional estimate", + "rejects ragged or empty tables", + "tallies observations into one table per stratum", + "refuses an out-of-range level rather than dropping the observation", + "is calibrated on data where affect and referent are independent given the outcome", + "separates a planted dependence from the null", + "is reproducible from the seed and sensitive to it", + "refuses an unseeded or degenerate permutation request", + "returns a one-sided upper bound above the sample mean", + "collapses to the value itself on a constant sample", + "is reproducible from the seed and rejects bad options", + "reports replicate quantiles" + ], + "requiredReceipt": "pnpm exec vitest run packages/analysis/__tests__/information.test.ts" + }, + "packages/anchor/__tests__/key-file.test.ts": { + "selectors": [ + "writes 0600 and round-trips the key and address", + "refuses a group/other-readable key file unless explicitly allowed", + "rejects malformed contents without echoing them", + "never echoes an out-of-range key, not even through a cause", + "refuses a key file in a group/other-writable directory", + "accepts a trailing-whitespace key file and refuses to clobber it", + "lives outside the per-run event signer key store", + "rotates the wallet key without invalidating prior receipts" + ], + "requiredReceipt": "pnpm exec vitest run packages/anchor/__tests__/key-file.test.ts" + }, + "packages/anchor/__tests__/publisher.test.ts": { + "selectors": [ + "anchors the bare 32-byte checkpoint digest and nothing else", + "defaults to Base Sepolia with no opt-in (ALD-020 criterion 2)", + "refuses to anchor a checkpoint that is not in the evidence store", + "refuses a manifest whose runIdHash matches no run", + "records the confirmation when a given-up poll is resumed later", + "resumes a given-up submission from the sidecar after a restart", + "anchorAndConfirm submits and waits in one call", + "retries a transient send failure without duplicate submission", + "fails after the attempt budget without submitting anything", + "returns the existing pending state for a duplicate submit", + "returns the stored receipt and never re-anchors a confirmed checkpoint", + "returns the first submission for a duplicate submit with no pendingFile", + "collapses concurrent submits for one checkpoint into one transaction", + "round-trips the pending file so a crash cannot lose the tx hash", + "parses the supported policies", + "budgets enough confirmation polls to reach the safe-tag depth", + "reaches the safe-tag depth under the default poll budget", + "rejects an unparseable policy at construction time", + "makes zero chain calls with no opt-in at all", + "refuses with only the constructor opt-in", + "refuses with only the environment opt-in", + "anchors to mainnet with both opt-ins, and reverts when either is removed" + ], + "requiredReceipt": "pnpm exec vitest run packages/anchor/__tests__/publisher.test.ts" + }, + "packages/anchor/__tests__/verify-anchor.test.ts": { + "selectors": [ + "pins the Base chain ids", + "accepts a genuine anchored receipt against the chain", + "verifies the pure checks offline with no reader", + "fails when the reader is on the wrong chain", + "fails when the receipt chain id contradicts its network", + "fails when the calldata is not the checkpoint digest", + "fails when the on-chain calldata differs from the receipt", + "fails when the transaction does not exist on chain", + "fails when the on-chain transaction reverted", + "fails when the on-chain destination is not the receipt destination", + "fails when the transaction is not yet in the claimed block" + ], + "requiredReceipt": "pnpm exec vitest run packages/anchor/__tests__/verify-anchor.test.ts" + }, + "packages/anchor/__tests__/viem-transport.test.ts": { + "selectors": [ + "reports the Base Sepolia chain identity and never exposes the key", + "defaults the endpoint label to the RPC host, without credentials", + "submits calldata that is exactly the 32-byte checkpoint digest", + "maps getTransaction, getTransactionReceipt and the head block", + "returns null for an unknown transaction instead of throwing", + "re-broadcasts one pinned nonce when a send is retried", + "reports the existing transaction when a send loses its response", + "never leaks the RPC URL from a failing provider", + "keeps the provider status code, which is not a credential", + "keeps the credential out of the receipt and out of the failure", + "keeps the credential out of an anchor-unavailable deviation" + ], + "requiredReceipt": "pnpm exec vitest run packages/anchor/__tests__/viem-transport.test.ts" + }, + "packages/checkpoint/__tests__/checkpoint-service.test.ts": { + "selectors": [ + "writes checkpoint 0 for an empty run with three empty mandatory trees", + "binds the run configuration and prompt bundle hashes", + "normalizes an unprefixed configuration prompt-bundle hash", + "carries a witness signature and a reproducible checkpoint hash", + "commits every tree the Evidence Store holds, with independently recomputed roots", + "chains each checkpoint to the previous checkpoint hash", + "refuses a manifest whose auxiliary trees are not the trees the store holds", + "refuses to build a checkpoint over a rewritten or truncated prefix", + "refuses a checkpoint over a stream whose sequences are not contiguous", + "refuses an unregistered run", + "skips an interval trigger when no tree grew", + "creates an interval checkpoint once a tree grows", + "always creates for a lifecycle reason, even with no new events", + "proves the first and last event of every mandatory tree", + "refuses a sequence outside the committed tree and an unknown checkpoint", + "verifies every consecutive checkpoint transition against the stored roots", + "detects a rewritten prefix instead of re-rooting it", + "detects a truncated prefix", + "refuses a descending checkpoint pair" + ], + "requiredReceipt": "pnpm exec vitest run packages/checkpoint/__tests__/checkpoint-service.test.ts" + }, + "packages/checkpoint/__tests__/scheduler.test.ts": { + "selectors": [ + "fires exactly once the configured event interval is reached", + "fires once for a burst larger than the interval and starts a fresh window", + "reports a non-integer event count instead of counting it", + "rejects a non-positive interval at construction", + "fires once the time interval has passed and events are pending", + "start and stop are idempotent", + "fires within timeIntervalMs + tickIntervalMs of the last checkpoint, even off-cycle", + "routes a checkpoint failure to onError and keeps the events pending", + "never lets a timer callback throw out of the timer", + "recovers when onError itself throws: the chain never stays rejected", + "does not re-add already-checkpointed events when onCheckpoint throws", + "writes non-overlapping checkpoints and skips when nothing changed", + "serializes concurrent triggers so no two checkpoints cover the same events" + ], + "requiredReceipt": "pnpm exec vitest run packages/checkpoint/__tests__/scheduler.test.ts" + }, + "packages/config/__tests__/environment.test.ts": { + "selectors": [ + "loads behavior-safe prototype defaults", + "fails fast when research-grade Fort signer material is not configured", + "accepts only Fort file paths for secret-bearing values", + "rejects invalid integer values" + ], + "requiredReceipt": "pnpm exec vitest run packages/config/__tests__/environment.test.ts" + }, + "packages/crypto-research/__tests__/boundary.test.ts": { + "selectors": [ + "accepts production crypto modules with no research-harness dependency", + "fails when hashing or anchoring imports the research harness" + ], + "requiredReceipt": "pnpm exec vitest run packages/crypto-research/__tests__/boundary.test.ts" + }, + "packages/crypto-research/__tests__/harness.test.ts": { + "selectors": [ + "refuses a non-synthetic run even if an untyped caller bypasses TypeScript", + "requires both independent nonce commitments and logs every scheme change", + "records eavesdropper recovery without retaining raw synthetic messages", + "reports novelty separately and can never report cryptographic security" + ], + "requiredReceipt": "pnpm exec vitest run packages/crypto-research/__tests__/harness.test.ts" + }, + "packages/evidence/__tests__/api.test.ts": { + "selectors": [ + "exports exactly the curated runtime symbols", + "routes every event-table write through the writer class" + ], + "requiredReceipt": "pnpm exec vitest run packages/evidence/__tests__/api.test.ts" + }, + "packages/evidence/__tests__/canonical.test.ts": { + "selectors": [ + "is independent of object insertion order", + "produces the same bytes in separate processes", + "round-trips an unsigned ledger event" + ], + "requiredReceipt": "pnpm exec vitest run packages/evidence/__tests__/canonical.test.ts" + }, + "packages/evidence/__tests__/crash-safety.test.ts": { + "selectors": [ + "recovers a committed prefix after ${TRIALS} randomized kills" + ], + "requiredReceipt": "pnpm exec vitest run packages/evidence/__tests__/crash-safety.test.ts" + }, + "packages/evidence/__tests__/database.test.ts": { + "selectors": [ + "creates every required table in WAL mode", + "applies migrations idempotently", + "rejects updates and deletes from append-only tables", + "never changes the SQL of an already applied migration", + "rejects updates and deletes from the migration 2 tables", + "enables recursive triggers so REPLACE cannot bypass the append-only guards", + "constrains fork_artifacts to known streams and registered runs" + ], + "requiredReceipt": "pnpm exec vitest run packages/evidence/__tests__/database.test.ts" + }, + "packages/evidence/__tests__/event-types.test.ts": { + "selectors": [ + "rejects unknown event types before serialization", + "rejects event content missing required fields" + ], + "requiredReceipt": "pnpm exec vitest run packages/evidence/__tests__/event-types.test.ts" + }, + "packages/evidence/__tests__/export.test.ts": { + "selectors": [ + "writes the documented layout and a schema-valid manifest", + "writes JSONL lines that match the stored canonical events", + "produces byte-identical bundles for two exports of the same run", + "omits the affect and audit files until those streams have events", + "stores and exports an analysis attachment with its atomic intervention binding", + "refuses a non-empty directory unless overwrite is set", + "requires a contract text for every configured track" + ], + "requiredReceipt": "pnpm exec vitest run packages/evidence/__tests__/export.test.ts" + }, + "packages/gateway/__tests__/affect-conformance.test.ts": { + "selectors": [ + "every enabled affect mode contributes accept/reject vectors", + "covers every allowlisted display with an acceptance vector" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/affect-conformance.test.ts" + }, + "packages/gateway/__tests__/affect.test.ts": { + "selectors": [ + "${mode} mode accepts every allowlisted display and records one normalized AffectEvent per window", + "records the sender chosen display for every display in declared mode", + "rejects an out-of-window submission as affect-violation and writes no AffectEvent", + "consumes the window after one submission, so there is no variable retry count", + "spends the window on a rejected submission too, so a malformed try is not a free retry", + "rejects a submission by the window recipient (SPEC §9.3 rule 5)", + "returns a constant-shape rejection that echoes no part of the payload", + "shares the ALD-034 counter and pause policy with every other channel violation", + "resets the shared rejection counter when an ordinary proposal is accepted", + "delegates through the SymbolGateway contract methods", + "delivers a permuted display while the AffectEvent records the sender choice", + "derives the permutation from the run seed: reproducible, and different across seeds", + "keeps the permutation out of every Baby-reachable value", + "returns a defensive copy of the researcher-only permutation", + "exposes no permutation in declared or opaque mode", + "behaves identically to declared on the wire, differing only in the recorded affectMode", + "disables submit_affect and rejects even a well-formed submission", + "records the complete private measurement and applies the pre-registered mapping", + "resolves argmax-v1 ties to the lowest display index", + "drains retained private measurements for the runtime to persist", + "refuses a mapping name that was never pre-registered", + "treats a malformed adapter measurement as an affect-violation carrying only a hash", + "refuses a derived measurement on a non-derived run and on a closed window", + "hashes the measurement under the documented domain separator", + "produces no AffectEvent and records the display as an ordinary ChannelEvent", + "refuses a derived measurement in emergent mode", + "parses the recognised schedules and refuses everything else", + "opens a window only on scheduled turns", + "refuses an extra window the schedule does not allow, and a reopened window", + "closes the previous window when the next one opens", + "rejects a malformed window outright rather than committing a rejection", + "offers submit_affect only in the three submission modes and only inside a window", + "gives every one of the six submissions the same canonical byte length", + "keeps the accepted and rejected result key sets constant across modes and violations", + "has no affect surface at all when the channel is disabled", + "validates the whole affect configuration before turn 1", + "takes the AffectEvent timestamp from the injected clock" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/affect.test.ts" + }, + "packages/gateway/__tests__/carrier-registry.test.ts": { + "selectors": [ + "registers only the fixed-token module by default", + "refuses to construct a Gateway for an unregistered carrier", + "routes a later-registered module without touching Gateway code", + "gives a second module the identical rejection shape and pause policy", + "applies the §9.6 conditions to a second module unchanged", + "requires every registered module to contribute accept and reject vectors", + "rejects a vector set that never exercises a rejection", + "runs a second module vectors through the same suite driver" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/carrier-registry.test.ts" + }, + "packages/gateway/__tests__/carriers.test.ts": { + "selectors": [ + "ALD-031: registers all four §9.2 modules and their ALD-036 vectors", + "ALD-031: is idempotent, so a second bootstrap changes nothing", + "ALD-031: leaves fixed-token alone after a reset (never the default)", + "ALD-031: every carrier owns exactly one tool family", + "ALD-031: refuses a module whose family disagrees with its carrier", + "ALD-031: a ${submitted} proposal on a ${carrier} run is ${expected}", + "ALD-031: no carrier offers another carrier’s emit tool", + "ALD-031: reproduces the published §9.2 hash vectors", + "ALD-031: the same canonical artifact hashes differently per carrier", + "ALD-031: is insensitive to key order but sensitive to mark order", + "ALD-031: a ${carrier} delivery records the markHash as its artifact hash", + "covers the E13 dimension, sample-rate, compression, and container side features", + "ALD-031: covers every reason code the §9.2 modules can produce", + "ALD-031 ${carrier} ${label}: ${vector.name}", + "ALD-031: commits exactly one channel event per submission, per carrier", + "ALD-031: ${carrier} constant and random artifacts are themselves valid", + "ALD-031: ${carrier} delivers the pre-registered constant under \\", + "ALD-031: ${carrier} \\", + "ALD-031: ${carrier} delivers nothing under \\", + "ALD-031: refuses to construct a glyph Gateway with no frozen bundle hash", + "ALD-031: validates glyph ids against the declared inventory size", + "ALD-031: declares no inventory for the generative carriers", + "ALD-031: honours maxStrokes without a code change", + "ALD-031: clamps a configured cap to the §9.2 ceiling of 64" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/carriers.test.ts" + }, + "packages/gateway/__tests__/conditions.test.ts": { + "selectors": [ + "normal delivers the validated Baby proposal unchanged", + "applies live ablation and substitution probes after validation", + "reports a live-probe shortfall without changing the delivery", + "disabled delivers no artifact but still records both hashes", + "constant replaces every proposal with the module default artifact", + "constant honours a pre-registered artifact and validates it", + "random delivers a seeded valid artifact that is never the proposal", + "random is reproducible from the run seed and varies per turn", + "random draws only artifacts its own module accepts, across seeds", + "shuffled delivers another episode of the batch under a seeded derangement", + "shuffled is reproducible from the run seed", + "shuffled requires the batch and rejects an out-of-range index", + "shuffled with a single-episode batch can only deliver that episode", + "oracle refuses learner proposals and commits gateway-control artifacts", + "oracle validates the Scenario Engine artifact against the carrier", + "rejects control artifacts under the other five conditions", + "records both hashes for every Baby-originated condition" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/conditions.test.ts" + }, + "packages/gateway/__tests__/conformance.test.ts": { + "selectors": [ + "declares a 32-symbol inventory and the SPEC §9.1 defaults", + "covers every reason code the fixed-token module can produce", + "commits exactly one channel event per submission" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/conformance.test.ts" + }, + "packages/gateway/__tests__/glyph-bundle.test.ts": { + "selectors": [ + "ALD-031: mints opaque G-prefixed identifiers, padded like S-tokens", + "ALD-031: is a pure function of its seed and size", + "ALD-031: renders 32 monochrome 16x16 glyphs with the declared ids", + "ALD-031: carries no Unicode text label in any Baby-visible field", + "ALD-031: every generated glyph passes the §9.2 leakage audit", + "ALD-031: hashes under the documented implementation-defined domain", + "ALD-031: verification is exact and reports a mismatch rather than passing", + "ALD-031: accepts a pluggable audit and records its version", + "ALD-031: reports a parameter fault when no candidate can pass the audit", + "ALD-031: flags a letter-shaped bitmap by baseline concentration", + "ALD-031: flags a single tiny mark and a solid block", + "ALD-031: passes a generated abstract glyph", + "ALD-031: reports only closed-set reason codes", + "ALD-031: counts 4-connected components and ink extents" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/glyph-bundle.test.ts" + }, + "packages/gateway/__tests__/interpretation.test.ts": { + "selectors": [ + "accepts the recipient echoing the delivered channel event hash", + "rejects a mismatched channel event hash and commits the rejection", + "rejects the other Baby echoing a delivery addressed to its twin", + "rejects an interpretation for a turn with no recorded delivery", + "requires an interpretation.recorded draft", + "rejects a malformed ledger draft envelope", + "does not leak the sender intention into the receiver ledger event", + "rejects an interpretation draft whose content exceeds the complexity budget instead of throwing (SPEC §9.4)", + "binds an oracle delivery for interpretation too" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/interpretation.test.ts" + }, + "packages/gateway/__tests__/rejection.test.ts": { + "selectors": [ + "counts consecutive rejections and requests a pause at the default of five", + "honours a lower configured ceiling", + "resets the counter after an accepted proposal", + "resets the counter on request", + "commits no sender ledger event for a rejected proposal", + "chains rejected and accepted channel events in one append-only stream", + "records a timeout as a channel rejection with no payload (SPEC §8.3)", + "never echoes the attempted payload in the rejection result", + "hashes the rejected payload deterministically and distinctly", + "commits a rejection instead of throwing on a 5,000-level nested proposal (SPEC §9.4)", + "commits a rejection for a private ledger draft with 20,000 sibling keys (SPEC §9.4)", + "survives an unserializable submission" + ], + "requiredReceipt": "pnpm exec vitest run packages/gateway/__tests__/rejection.test.ts" + }, + "packages/hashing/__tests__/keystore.test.ts": { + "selectors": [ + "provisions one key per signer domain and reports the run", + "refuses to overwrite an existing run and never rotates in place", + "reproduces the same public keys and signatures after loadRun", + "produces distinct public keys for every run (per-run rotation)", + "can provision a subset of domains", + "stores only runId and hex seeds, never keys or evidence fields", + "keeps the run directory 0o700 and the seed file 0o600", + "never exposes seed material through the public-key surface", + "rejects unsafe run identifiers instead of escaping the key directory", + "throws for a run that was never provisioned", + "rejects a tampered or foreign seed file", + "signs and verifies Baby A events under the manifest public key" + ], + "requiredReceipt": "pnpm exec vitest run packages/hashing/__tests__/keystore.test.ts" + }, + "packages/interventions/__tests__/readiness-gates.test.ts": { + "selectors": [ + "runs every E21 learner condition against the identical scenario configuration", + "creates E50 independent-seed children from one pre-registered parent and aggregates every seed" + ], + "requiredReceipt": "pnpm exec vitest run packages/interventions/__tests__/readiness-gates.test.ts" + }, + "packages/leakage/__tests__/semantic-leakage.test.ts": { + "selectors": [ + "blocks an above-control English-label linear probe", + "blocks an underpowered negative bound even when the point estimate is at chance", + "automatically weakens a hybrid with text-aligned frozen features", + "classifies exempt and control tracks without ungrounded claims", + "rejects any confidence rule other than the pre-registered 95%" + ], + "requiredReceipt": "pnpm exec vitest run packages/leakage/__tests__/semantic-leakage.test.ts" + }, + "packages/learners/__tests__/affect-policies.test.ts": { + "selectors": [ + "draws only allowlisted displays and is reproducible from the seed", + "covers all six displays over a long seeded stream (chance baseline)", + "maps the unit interval onto the six bands of the pre-registered mapping", + "is a pure function of the rate, with no state between calls", + "refuses a rate outside its declared domain rather than clamping", + "produces a schema-valid six-score measurement at the pinned version", + "follows the pre-registered projection table exactly", + "is deterministic and canonicalizable", + "refuses every out-of-domain statistic", + "feeds the Gateway argmax-v1 mapping to the display the table predicts", + "builds exactly the tool-only submit_affect proposal", + "reports the allowlist index and refuses anything else", + "pins the module version recorded in exported policies" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/affect-policies.test.ts" + }, + "packages/learners/__tests__/frozen-llm-conformance.test.ts": { + "selectors": [ + "never reads OutcomeEvent.reward: this track consumes no learning signal", + "exposes no updatePolicy, so the harness records no checkpoints", + "reports provenance and an exported policy for every adapter it built", + "gives different seeds different private conventions", + "is a scripted convention, not a result: the success rate is recorded, not tuned" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/frozen-llm-conformance.test.ts" + }, + "packages/learners/__tests__/frozen-llm-gateway.test.ts": { + "selectors": [ + "commits an accepted proposal with the model marks and the intention event", + "stores no attempted content for any rejected completion (SPEC §9.4)", + "counts consecutive prohibited attempts toward the §9.4 pause", + "records an off-candidate selection as a count, never as ledger text" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/frozen-llm-gateway.test.ts" + }, + "packages/learners/__tests__/frozen-llm.test.ts": { + "selectors": [ + "accepts the conformance reference modelRef only for the scripted double in prototype mode", + "accepts the exact @ form", + "refuses a modelRef that does not identify the model in use", + "refuses the reference modelRef for a research-grade deployment", + "refuses the reference modelRef for a client that is not the scripted double", + "refuses a run whose role is configured for another track", + "refuses a contract that governs another track, and an empty contract", + "has no default model: the factory refuses to build without a client", + "records the model provenance for the SPEC §6.5 battery", + "emits a tool-only proposal with its intention draft as sender", + "interprets a delivery and selects a candidate as receiver", + "folds the outcome into private memory and appends hypothesis events", + "records a contradiction rather than overwriting a confident hypothesis", + "revises a hypothesis when the memory argmax moves", + "is idempotent across a §14.5 retry: one completion, one identical envelope", + "is idempotent across a repeated outcome", + "tolerates a turn with no delivery (SPEC §9.6 disabled)", + "exposes no updatePolicy member, on the instance or its prototype", + "exports a policy carrying no prompt text and no raw model output", + "keeps model output out of the private ledger entirely", + "throws a typed timeout when no completion arrives inside the model budget", + "throws when no tool this track can produce is offered", + "refuses every method before init()", + "keeps at most maxTrackedSymbols records and maxDigestEntries in the prompt", + "puts only opaque numeric state and inventory marks in the prompt", + "derives the sampling seed from the private seed, turn and role" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/frozen-llm.test.ts" + }, + "packages/learners/__tests__/frozen-qualification.test.ts": { + "selectors": [ + "measures both roles, isolation, reset, tool use, and freeze semantics", + "requires enough episodes for both roles to send and receive", + "rejects incomplete inference-runtime provenance", + "rejects a shared client or endpoint", + "rejects a mismatched clean-process replay" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/frozen-qualification.test.ts" + }, + "packages/learners/__tests__/llm-client.test.ts": { + "selectors": [ + "formats and parses @", + "rejects strings that are not the two-part form", + "refuses a non-loopback endpoint before any request is made", + "names a closed error-code union", + "stream-hashes a weights file to the same digest as sha256sum", + "refuses an empty or missing weights file rather than hashing nothing", + "hashes the weights file at construction and records the source", + "falls back to the server-reported digest and records that source", + "reads the Ollama listing shape as well", + "refuses to construct when no weight hash is available at all", + "sends the frozen contract verbatim, the digest, and the tool schema", + "passes an unparseable arguments string through unrepaired", + "reports assistant text with no tool call as prose", + "raises a transport error on a non-2xx status and never echoes the body", + "raises a response error for a body that is not a completion", + "accepts a well-formed completion and rejects anything else", + "never puts the malformed value into the error message", + "declares itself a double so no run can mistake it for real weights", + "produces one classified output per prohibited-attempt category", + "marks an extra artifact field as a non-clean tool call", + "is deterministic for the same request and cycles behaviors by call index", + "names the same mark for the same attribute row in both roles", + "waits past the request budget for the timeout behavior" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/llm-client.test.ts" + }, + "packages/learners/__tests__/no-learning.test.ts": { + "selectors": [ + "passes the adapter conformance harness", + "exposes no updatePolicy method (SPEC §6.2)", + "scores at chance over 2000 episodes with four candidates", + "never changes its policy hash across a whole run", + "emits symbols uniformly over the inventory", + "exports a seed hash and never the raw seed", + "replays identically from the same seed and differs across seeds", + "records the first emission and first receipt of each symbol exactly once", + "writes an intention draft whose artifactRef addresses its own proposal", + "reports a uniform inferred type distribution when receiving", + "emits 24 hex characters of blinding nonce per event, all distinct", + "refuses to act before init or observe, and without candidate refs", + "refuses an action the turn budget does not offer (tool-only, SPEC §6.3)", + "counts outcomes privately without writing them to the ledger" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/no-learning.test.ts" + }, + "packages/learners/__tests__/predictive-model.test.ts": { + "selectors": [ + "names the pre-registered loss and pairing rule in every export", + "starts exactly uniform when priorNoise is zero", + "starts from a seeded random initialization when priorNoise is positive", + "refuses a positive priorNoise with no seed to draw it from", + "raises the probability of the pair it folded and nothing else", + "scores the candidate whose features predict the message highest", + "treats an empty message as no evidence (SPEC §9.6 disabled)", + "marginalizes a symbol over the positions it occupied", + "applies the curriculum learningRate as the count increment", + "rejects a feature code or symbol index outside its tables", + "round-trips through export and restore bit-for-bit", + "refuses a checkpoint whose shape does not match the run", + "rejects a ragged or negative exported model", + "hashes identically across two independently built models", + "never exports a success, reward, or outcome key (ALD-046 cb 3)", + "predicts the global mean for an unseen key and moves toward observations", + "pads and truncates a payload to its declared dimension", + "round-trips through export and restore", + "refuses a dimension mismatch and an invalid rate", + "normalizes log-weights without underflowing on a long message", + "mixes a distribution with the uniform at the exploration rate", + "breaks argmax ties on the lowest index", + "is deterministic for one seed and differs across seeds", + "keeps every code inside [0, codeCount) in both modes", + "separates most of the object space at the default 5 bits (measurement, not a claim)", + "rejects a row of the wrong width or with a non-finite value", + "validates codeBits and rowDimension", + "puts an injected frozen feature bank in the sensory path and records it", + "refuses a frozen bank whose projection has the wrong width", + "refuses a frozen descriptor that is not a sha256 hash", + "round-trips through export and load, in both modes", + "refuses to load across a frozen-feature mismatch", + "never exports the private seed" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/predictive-model.test.ts" + }, + "packages/learners/__tests__/recurrent-adapters.test.ts": { + "selectors": [ + "runs the scratch GRU actor-critic through the complete adapter contract", + "restores a recurrent policy exactly and keeps its exported weights frozen in evaluation", + "keeps independently seeded Baby policies distinct", + "runs the reward-free GRU through the same adapter contract and capacity", + "restores and freezes the recurrent reward-free policy" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/recurrent-adapters.test.ts" + }, + "packages/learners/__tests__/recurrent-model.test.ts": { + "selectors": [ + "locks the architecture, parameter count, memory budget, and independent initialization", + "matches a central-difference gradient on the predictive objective", + "performs a clipped PPO-style reward-to-parameter update", + "learns a toy reward-free feature-to-message mapping", + "round-trips optimizer/checkpoint state and freezes exports during evaluation", + "does not update without a recorded local action and reward" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/recurrent-model.test.ts" + }, + "packages/learners/__tests__/self-supervised.test.ts": { + "selectors": [ + "completes full turns and a predictive update on three seeds, in both roles", + "exposes updatePolicy, as SPEC §6.2 requires for a trainable track", + "folds pairs into the predictive model as the run proceeds", + "writes agent-native content only, with no natural-language gloss", + "refuses an update batch that names any other learning signal", + "refuses a non-null scalar reward on onOutcome", + "accepts an outcome whose reward is null, as a reward-free run supplies", + "never reads the reward field, which the throwing-getter probe proves", + "buffers exactly the pre-registered record fields and nothing else", + "carries no success, reward or outcome key in the buffer or the policy", + "records the pre-registered loss definition in the exported policy", + "clears a folded turn and cannot import a turn it never acted on", + "records the random-initialization hash at init, distinct per private seed", + "starts from an exactly uniform model when priorNoise is disabled", + "reproduces identical policy hashes and choices from one seed", + "diverges between two different run seeds", + "reproduces identical behaviour from an exported policy in a derived run (SPEC §7.4)", + "refuses a checkpoint written for a different attribute space", + "reports a re-tuned checkpoint difference rather than refusing it", + "declares a fully random-initialized, tokenizer-free path", + "honours learningRate, temperature and explorationRate", + "rejects memoryCapacity rather than ignoring it", + "rejects a staged bandwidth its tables were not built for", + "freezes the model for a consolidation stage and discards its turns", + "reports a success rate in [0, 1] on every seed without tuning to a target", + "reproduces the same self-play trajectory from the same seed" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/self-supervised.test.ts" + }, + "packages/learners/__tests__/tabular-reinforce.test.ts": { + "selectors": [ + "passes the adapter conformance harness", + "exposes updatePolicy, unlike the control tracks (SPEC §6.2)", + "starts from seeded random parameters and exposes their initial hash", + "reaches above-chance referential success on every seed", + "trains both roles privately inside one adapter", + "holds its policy constant and stays above 0.8 in held-out evaluation", + "reproduces identical behavior from an exported policy in a derived run", + "leaves the policy untouched when updatePolicy is never called", + "runs the intrinsic prediction-progress mode without reading the task reward", + "does read the task reward under extrinsic-task, which the guard proves", + "rejects a learning signal it cannot consume", + "rejects a batch signal that disagrees with the configured mode", + "rejects intrinsicMode paired with an extrinsic-task run at init, rather than at the first updatePolicy call", + "rejects an intrinsic-prediction-progress run configured without intrinsicMode, at init", + "reports the highest batch turn in the policy checkpoint reference", + "ignores turns it never acted on, so a batch cannot import foreign trajectories", + "rejects an initialPolicy whose shape does not match the run", + "refuses a checkpoint written for a different game shape (SPEC §7.4)", + "refuses a checkpoint whose attribute space only shares its cardinality", + "loads a re-tuned checkpoint but reports the hyperparameter difference", + "learns with position-specific receiver tables when messageLength is 2", + "refuses a message longer than the run permits", + "appends a creation before any revision of the same term", + "records exactly one creation per emitted or received term", + "preserves contradictory evidence against a confident hypothesis", + "writes agent-native content only, with no natural-language gloss" + ], + "requiredReceipt": "pnpm exec vitest run packages/learners/__tests__/tabular-reinforce.test.ts" + }, + "packages/lifecycle/__tests__/derived-run.test.ts": { + "selectors": [ + "records every lineage field and round-trips through validateRunConfig", + "inherits every other parent field and hashes differently", + "never mutates the parent configuration", + "applies pre-registered overrides, including a replacement learner (E30)", + "lets overrides not defeat the lineage bindings", + "rejects reusing the parent run identifier", + "rejects a malformed parent checkpoint hash", + "rejects overrides that break an ALD-023 rule", + "supports a chain of derived runs (E50 replication)", + "rejects a root configuration", + "rejects each individually missing lineage field", + "rejects a child that points at a different parent", + "rejects a child that reuses the parent run identifier", + "revalidates the child configuration", + "never uses the §7.3 integrity term in a user-facing message", + "never uses the term anywhere in the derived-run module" + ], + "requiredReceipt": "pnpm exec vitest run packages/lifecycle/__tests__/derived-run.test.ts" + }, + "packages/lifecycle/__tests__/run-config.test.ts": { + "selectors": [ + "fills every §18 default", + "omits lineage, maxStrokes, and derived-only fields for a root run", + "validates its own output and reports no warnings", + "exposes the defaults it used", + "honours explicit overrides, including placeholders", + "populates the symbol-inventory bounds for fixed-token and fixed-glyph only", + "populates maxStrokes only for generative-canvas", + "builds a valid config for every carrier mode", + "accepts the defaulted signal for every symmetric track", + "accepts scratch-rl with the extrinsic signal and every named intrinsic signal", + "accepts hybrid with any RL-compatible or self-supervised signal", + "accepts asymmetric tracks when symmetricTracks is false", + "rejects no-learning with extrinsic-task on both Babies", + "rejects frozen-llm with a non-none signal", + "rejects scratch-rl with learningSignal none", + "rejects self-supervised with extrinsic-task", + "rejects hybrid with learningSignal none", + "throws from buildRunConfig for a mixed learning/no-learning pair", + "rejects symmetricTracks false with identical tracks", + "rejects symmetricTracks true with different tracks", + "accepts symbolInventorySize and maxSymbolsPerMessage on fixed-glyph", + "rejects symbolInventorySize and maxSymbolsPerMessage on a carrier with its own fixed bound", + "rejects maxStrokes outside generative-canvas", + "rejects maxSymbolsPerMessage above the Gateway ceiling of 16", + "rejects a symbol inventory outside 2-256", + "restricts the oracle condition to E03", + "builds a valid config for every communication condition", + "builds a valid config for every affect, interaction, observation, and deployment mode", + "rejects a malformed experiment identifier", + "rejects non-objects", + "reports each missing required field by path", + "reports a nested learner field by dotted path", + "rejects an unknown version", + "accepts a complete lineage record", + "rejects a partial lineage record", + "flags centralized training isolation without rejecting it", + "flags fewer than 10 evaluation seeds from E10 onward", + "flags fewer than 5 evaluation seeds in any experiment", + "flags the Sepolia finality default carried over onto base-mainnet without rejecting it", + "does not flag a mainnet run that declares a non-default finality policy", + "does not flag base-sepolia at its own default finality policy", + "defaults base-sepolia to 1-confirmation", + "defaults base-mainnet to safe-tag rather than the Sepolia default", + "honours an explicit finalityPolicy override on either network", + "matches the canonical domain hash of the validated config", + "is stable across key order and repeated validation", + "ignores fields the schema strips", + "changes when any field changes", + "is retrievable for every seed and turn budget in a property loop", + "lists every failing path in its message" + ], + "requiredReceipt": "pnpm exec vitest run packages/lifecycle/__tests__/run-config.test.ts" + }, + "packages/lifecycle/__tests__/state-machine.test.ts": { + "selectors": [ + "lists exactly the 31 transitions of the table", + "has no duplicate (state, event) pair", + "encodes every named transition with the exact event names", + "never leaves a terminal state and never re-enters draft", + "only allows fork-detected from an active state (not draft)", + "accepts exactly the listed pairs and rejects all 193 others", + "reports both the state and the event in the invalid-transition error", + "mirrors canApply and availableEvents against the table", + "runs draft → sealed through every mandatory state", + "keeps history immutable and independent of later transitions", + "starts empty history from a recovered state (SPEC §7.3)", + "rejects an unknown initial state or an empty runId", + "cycles running → pausing → paused → resuming → running", + "cannot pause a paused run or resume a running run", + "accepts turns only in running and evaluating", + "aborts from each permitted state and ends aborted-sealed", + "cannot abort from draft, sealing, sealing-blocked, or a terminal state", + "makes an aborted-sealed run permanently closed to every event", + "reports the run and state on a rejected turn", + "retries from sealing-blocked back to sealed", + "abandons recovery from sealing-blocked into aborted-sealed", + "reaches sealing-blocked from aborting as well", + "does not allow seal-retry or abandon-recovery anywhere else", + "moves every active state to forked-invalid", + "rejects fork-detected from draft and from terminal states", + "classifies terminal, active, and draft states", + "recognizes only the 14 documented state names", + "returns the verbatim table entry for each transition", + "throws for an unlisted transition and for a wrong destination", + "exposes the side effects of the next transition from the machine" + ], + "requiredReceipt": "pnpm exec vitest run packages/lifecycle/__tests__/state-machine.test.ts" + }, + "packages/merkle/__tests__/consistency.test.ts": { + "selectors": [ + "is empty when the prefix is the whole tree", + "is empty for the empty prefix", + "is the sibling leaf when growing a one-leaf tree to two", + "omits the old root when the old size is a power of two", + "decomposes the old tree when the old size is not a power of two", + "rejects an out-of-range prefix size", + "verifies every pair (m, n) with m <= n <= 64", + "verifies the empty prefix against any tree with the empty root", + "verifies identical sizes only when the roots match and the proof is empty", + "verifies proofs produced by the memoizing class", + "rejects a fromRoot taken from a different prefix size", + "rejects a later tree whose committed prefix was modified", + "rejects a deletion inside the committed prefix", + "rejects an insertion inside the committed prefix", + "rejects reordered leaves inside the committed prefix", + "rejects a shrinking tree and a mismatched toRoot", + "rejects a truncated, extended, corrupted, or missing path", + "rejects the empty prefix presented with a non-empty root", + "never throws on malformed input", + "rejects fromSize/toSize at or beyond 2**32 instead of truncating them", + "rejects a path longer than any tree of the declared size could produce" + ], + "requiredReceipt": "pnpm exec vitest run packages/merkle/__tests__/consistency.test.ts" + }, + "packages/merkle/__tests__/inclusion.test.ts": { + "selectors": [ + "is empty for the only leaf of a single-leaf tree", + "is the sibling leaf for a two-leaf tree", + "orders the path from the leaf sibling upwards", + "has ceil(log2(n)) or fewer elements", + "rejects an out-of-range leaf index", + "verifies every leaf of every tree size 0..130 from the proof alone", + "verifies proofs produced by the memoizing class", + "rejects a wrong root, wrong leaf, and wrong index for sizes 1..64", + "rejects a truncated, extended, or corrupted path", + "rejects a proof against a tree size the leaf does not belong to", + "never throws on malformed input", + "rejects an empty tree, which contains no leaf", + "rejects treeSize/leafIndex at or beyond 2**32 instead of truncating them", + "rejects a path longer than any tree of the declared size could produce" + ], + "requiredReceipt": "pnpm exec vitest run packages/merkle/__tests__/inclusion.test.ts" + }, + "packages/merkle/__tests__/tree.test.ts": { + "selectors": [ + "hashes an empty tree to the documented empty root", + "hashes a single-leaf tree to that leaf hash (ALD-012 decision)", + "hashes two leaves to nodeHash(l0, l1)", + "hashes three leaves to nodeHash(nodeHash(l0, l1), l2)", + "hashes four leaves as a balanced tree", + "splits five leaves at k = 4, the largest power of two below n", + "splits seven leaves at k = 4 (RFC 6962 example shape)", + "produces the same root for the same ordered leaves across two builds", + "produces the same root through the class as through the pure function", + "re-reads a cached root identically (the cache never changes a root)", + "starts empty, with the documented empty root and no proofs to make", + "appends an event by sequence and entry hash", + "changes when a leaf is modified", + "changes when a middle leaf is deleted", + "changes when a leaf is inserted", + "changes when two leaves are reordered", + "changes when the tree is truncated (size is part of the commitment)", + "gives distinct roots to every distinct prefix size 0..130", + "rejects an out-of-range prefix size on the class", + "gives two distinct leaf lists their own correct roots when sharing one cache", + "gives correct inclusion and consistency proofs when two leaf lists share one cache" + ], + "requiredReceipt": "pnpm exec vitest run packages/merkle/__tests__/tree.test.ts" + }, + "packages/ops/__tests__/checkpoint-scheduler.test.ts": { + "selectors": [ + "installs process guards before the timer and logs a failed tick" + ], + "requiredReceipt": "pnpm exec vitest run packages/ops/__tests__/checkpoint-scheduler.test.ts" + }, + "packages/ops/__tests__/failure-policy.test.ts": { + "selectors": [ + "ALD-061: every §14.5 named failure mode has exactly one handling row", + "ALD-061: the table quotes the orchestrator reason codes rather than re-spelling them", + "ALD-061 max-consecutive-rejections: the run pauses with the documented reason code", + "ALD-061 adapter-crash: the turn is forfeited, audited, and the run pauses", + "ALD-061 adapter-timeout: an over-budget turn takes the same path as a crash", + "ALD-061 verifier-nonzero: the trigger is audited and the run is never valid", + "ALD-061 ledger-fork / evidence-store-failure: both artifacts are preserved and writes are blocked", + "ALD-061 anchoring-failure: prototype mode records the audited governance decision", + "ALD-061: the record digests the message instead of storing it", + "ALD-061: includeMessages is opt-in and bounded", + "ALD-061: the fatal set is exactly the documented evidence-integrity states", + "ALD-061: an unhandled rejection from a background task is logged, not fatal", + "ALD-061: a fatal state exits with the documented code", + "ALD-061: a host-supplied onFatal replaces the exit so it can drain first", + "ALD-061: a logger that itself throws cannot turn a warning into a crash", + "ALD-061 criterion 2: a real child process survives an unhandled rejection from a background task", + "ALD-061 criterion 3: the retry budget is ALD-021’s, not a second ad hoc one", + "ALD-061: a task that recovers on a later attempt resolves with its value", + "ALD-061: supervise never rejects, so a forgotten await cannot crash the host", + "ALD-061: an invalid retry budget is refused rather than silently widened" + ], + "requiredReceipt": "pnpm exec vitest run packages/ops/__tests__/failure-policy.test.ts" + }, + "packages/ops/__tests__/metrics.test.ts": { + "selectors": [ + "ALD-058: reports turns/minute, rejection rate and checkpoint latency for a real run", + "ALD-058: rejection rate reflects §9.4 channel rejections", + "ALD-058: an empty run yields nulls, never fabricated zeroes", + "ALD-058: affect-window utilization follows the pre-registered schedule (SPEC §9.3 rule 2)", + "ALD-058: affect-window utilization is computed when the channel is enabled", + "ALD-058: metrics are read-only — computing them twice changes nothing" + ], + "requiredReceipt": "pnpm exec vitest run packages/ops/__tests__/metrics.test.ts" + }, + "packages/ops/__tests__/research-preflight.test.ts": { + "selectors": [ + "passes only when every immutable input and registration binding agrees", + "reports every missing registration prerequisite instead of stopping at the first", + "accepts a matching ancestral repository-native registration", + "rejects a repository registration whose historical record does not match" + ], + "requiredReceipt": "pnpm exec vitest run packages/ops/__tests__/research-preflight.test.ts" + }, + "packages/ops/__tests__/retention.test.ts": { + "selectors": [ + "purges only bulk bundle files while preserving every evidence-store row", + "retains public-release bundles indefinitely", + "retains Base-mainnet-anchored bundles indefinitely", + "retains young bundles and runs whose retention policy is disabled" + ], + "requiredReceipt": "pnpm exec vitest run packages/ops/__tests__/retention.test.ts" + }, + "packages/ops/__tests__/snapshot.test.ts": { + "selectors": [ + "ALD-060: takeSnapshot writes a canonical, digest-bound file readSnapshotFile accepts", + "ALD-060: a snapshot whose body was edited is rejected, not used", + "ALD-060: a non-JSON or schema-invalid snapshot is a typed error", + "ALD-060: listSnapshots and latestSnapshotPath order chronologically", + "ALD-060: a restart after a snapshot restores the run to the snapshot state", + "ALD-060 criterion 3: every stream of the restored run matches the snapshot in the chain-walk sense", + "ALD-060: a snapshot that predates further turns restores the snapshot policy, and says so about the turn cursor", + "ALD-060: restorePolicies:false leaves the runtime to reload its own latest policy", + "ALD-060: a run the snapshot names but the store does not is reported, not thrown", + "ALD-060: the default cadence is the §14.4 300 s, and the env var overrides it", + "ALD-060: the scheduler snapshots on each tick and never lets a failure escape", + "ALD-060: a snapshot write failure is counted and the next tick still runs", + "ALD-060: a SIGTERM listener writes one final snapshot and is idempotent", + "ALD-060: a failing final snapshot still lets the process exit", + "ALD-060: a real child process writes the final snapshot when SIGTERM arrives" + ], + "requiredReceipt": "pnpm exec vitest run packages/ops/__tests__/snapshot.test.ts" + }, + "packages/ops/__tests__/telemetry.test.ts": { + "selectors": [ + "ALD-058: a record carries the four §14.1 fields plus run id and role", + "ALD-058: the schema rejects unknown fields, so no scenario state can ride along", + "ALD-058 criterion 2: a malformed record is counted, never thrown", + "ALD-058: ${kind} sink filters by run id and inclusive time range", + "ALD-058: an impossible time range is a typed error, not silent nonsense", + "ALD-058: SqliteTelemetrySink opens its own file with only its own table", + "ALD-058: the telemetry table is append-only (UPDATE and DELETE rejected)", + "ALD-058: guardTelemetrySink contains ${testCase.label}", + "ALD-058: a throwing onError observer cannot escape the guard", + "ALD-058: a closed sink counts further records instead of throwing", + "ALD-058: the in-memory sink is bounded so a long run cannot exhaust memory" + ], + "requiredReceipt": "pnpm exec vitest run packages/ops/__tests__/telemetry.test.ts" + }, + "packages/orchestrator/__tests__/adapter-failure.test.ts": { + "selectors": [ + "retries once, forfeits and pauses on the second failure, and resumes", + "forfeits a turn whose adapter crashes after the atomic commit", + "escalates to the §7.2 abort row when the crash lands in evaluating", + "honours a retryBudget of zero and rejects an invalid one", + "truncates an adapter message and never records a payload", + "retries a blocked seal without a second run.sealed or final checkpoint", + "abandons recovery with an audited governance decision", + "creates one checkpoint per crossed boundary, even inside one turn", + "runs scratch-rl for 60 turns with no delivery and stays near chance" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/adapter-failure.test.ts" + }, + "packages/orchestrator/__tests__/audit-interpreter.test.ts": { + "selectors": [ + "refuses same-turn analysis, then appends a labeled batch without changing native events", + "exports the audit stream for successful independent verification" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/audit-interpreter.test.ts" + }, + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts": { + "selectors": [ + "exports exact versioned mark metrics as a witness-bound attachment", + "keeps failed leakage results as independently valid integrity evidence" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts" + }, + "packages/orchestrator/__tests__/derived-run-evidence.test.ts": { + "selectors": [ + "witnesses, exports, and independently verifies immutable parent lineage", + "fails closed when a derived export is verified without its parent", + "substitutes a child learner for E30 without mutating the parent configuration" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/derived-run-evidence.test.ts" + }, + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts": { + "selectors": [ + "runs every condition, computes the Appendix D analysis, and writes a report" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/experiments/e03-controls.test.ts" + }, + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts": { + "selectors": [ + "produces per-run RL metrics, freezes the policy in evaluation, and writes a report" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts" + }, + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts": { + "selectors": [ + "opens, completes a tiny no-learning run, and closes cleanly", + "supplies the immutable parent bundle when verifying a derived run" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/experiments/production-runtime.test.ts" + }, + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts": { + "selectors": [ + "produces a schema-valid report with every check but the proof and anchor checks true", + "verifies the bundle with exit code 0 once the checkpoint/verifier bug is fixed" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/experiments/verifier-integration.test.ts" + }, + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts": { + "selectors": [ + "selects the configured track and witnesses exact model and weight hashes" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts" + }, + "packages/orchestrator/__tests__/interventions.test.ts": { + "selectors": [ + "pauses the run with a safety trigger after the rejection ceiling", + "logs annotations and human views without changing the run state", + "pauses, resumes, and aborts through the audited path" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/interventions.test.ts" + }, + "packages/orchestrator/__tests__/pre-registration.test.ts": { + "selectors": [ + "refuses incomplete bindings and exports the exact confirmed receipt" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/pre-registration.test.ts" + }, + "packages/orchestrator/__tests__/recovery.test.ts": { + "selectors": [ + "reconstructs the run at the last committed turn and continues", + "marks the run forked-invalid and preserves both artifacts" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/recovery.test.ts" + }, + "packages/orchestrator/__tests__/run-lifecycle.test.ts": { + "selectors": [ + "ends unanchored at aborted-sealed after the evaluation budget", + "writes one turn record per turn in both phases", + "scores near the pre-registered chance rate", + "records every turn phase in the evidence chains", + "exports a valid run manifest and the full experiment-record history", + "reproduces the replay digest from the seed and separates seeds", + "passes scenario replay from the recorded configuration", + "exports chains that validate under the run signer keys", + "chains every checkpoint manifest from the genesis hash" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/run-lifecycle.test.ts" + }, + "packages/orchestrator/__tests__/scenario-quarantine.test.ts": { + "selectors": [ + "registers and approves the built-in text-free generator before use", + "fails closed when a custom engine returns an unknown bundle hash", + "refuses a quarantined custom bundle and permits the same factory only after approval", + "records a machine-readable audit event when runtime hygiene blocks a field" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/scenario-quarantine.test.ts" + }, + "packages/orchestrator/__tests__/scratch-rl-run.test.ts": { + "selectors": [ + "executes the whole training and evaluation budget", + "learns above chance by the end of training", + "keeps the learned policy above chance on the evaluation split", + "freezes both policies for the whole evaluation phase", + "records each independently initialized policy and its pre-turn hash", + "leaves the exported latest policy equal to the final in-memory policy", + "records the learner event types LEDGER §5 names", + "writes a policy checkpoint file for a mid-run checkpoint turn" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/scratch-rl-run.test.ts" + }, + "packages/orchestrator/__tests__/sealing-and-guards.test.ts": { + "selectors": [ + "marks the run valid only after an anchor and a passing verifier", + "writes inclusion and consistency proofs that verify against the final root", + "blocks sealing when the publisher cannot anchor", + "forfeits a turn whose sender misses the response deadline", + "fills in placeholder bundle hashes and rejects a mismatch", + "refuses the shuffled condition for a learning track", + "refuses to skip anchoring outside prototype mode", + "refuses a publisher whose simulated/public class differs from RunConfig", + "refuses an external learner boundary under Prototype Mode", + "refuses a Research-Grade label without normalized distinct containers", + "accepts a Research-Grade label only for normalized distinct containers", + "changes only the six documented §5.3 deployment dimensions between Mode P and Mode R", + "refuses an anchoring run without a publisher", + "requires an injected verifier for verify()" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/sealing-and-guards.test.ts" + }, + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts": { + "selectors": [ + "witnesses the loss and the strict outcome-free update schema" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/self-supervised-evidence.test.ts" + }, + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts": { + "selectors": [ + "exports each Baby result as a witness-bound per-run attachment", + "keeps the attached battery independently verifiable" + ], + "requiredReceipt": "pnpm exec vitest run packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts" + }, + "packages/redteam/__tests__/observation-and-measurement.test.ts": { + "selectors": [ + "matches every committed fixture to its manifest hash", + "quarantines every attack, approves every control, and leaks no text", + "is byte-deterministic for the same committed fixture set", + "passes identical timing and size profiles and fails distinguishable ones", + "detects byte-level error-envelope variation without storing bodies" + ], + "requiredReceipt": "pnpm exec vitest run packages/redteam/__tests__/observation-and-measurement.test.ts" + }, + "packages/redteam/__tests__/side-channel.test.ts": { + "selectors": [ + "rejects E13 generated-carrier side features through the real Gateway", + "maps every SPEC §10.3 category to one enforced mitigation", + "fails closed on any allowed host capability", + "actively records why Prototype Mode cannot carry an isolation claim", + "actively samples timing, wire size, and response shape", + "detects a deliberate hidden-state mapping through the affect channel" + ], + "requiredReceipt": "pnpm exec vitest run packages/redteam/__tests__/side-channel.test.ts" + }, + "packages/scenario/__tests__/hygiene.test.ts": { + "selectors": [ + "passes a clean observation through and returns it typed", + "accepts a flat numeric vector payload", + "throws HygieneViolationError carrying reason codes, never the raw text", + "notifies the audit sink before throwing (ALD-038 criterion 3)", + "rejects a non-object observation", + "rejects a string anywhere in the payload", + "rejects a human-language object key", + "rejects camelCase and snake_case spellings of a banned token", + "rejects a prose string", + "rejects a non-finite number", + "rejects an emoji or other pictographic code point", + "rejects an extra top-level key even when its name is opaque", + "rejects a missing required field", + "rejects payload rows of unequal length", + "rejects a payload that mixes scalars and rows or nests deeper", + "rejects a descriptive or malformed scenarioRef", + "rejects a runId that is not an opaque identifier", + "rejects a joined sentence that uses no banned vocabulary at all", + "still accepts a clean, conventional runId", + "does not flag the hyphenated run ids already used elsewhere in the monorepo", + "rejects a string outside the recipient and encoding allowlists", + "honours experiment-specific extra tokens", + "blocks ${name}", + "finds banned tokens in nested values and keys", + "returns nothing for hygienic numeric structures", + "matches whole words only", + "flags prose and pictographic strings without a banned token", + "flags a whitespace-free joined sentence the same as its spaced form", + "accepts extra tokens and survives a cyclic structure", + "keeps the maintained token list covering every §10.1 example" + ], + "requiredReceipt": "pnpm exec vitest run packages/scenario/__tests__/hygiene.test.ts" + }, + "packages/scenario/__tests__/observation.test.ts": { + "selectors": [ + "assembles exactly the SPEC §11.2 fields", + "drops any field that is not in §11.2", + "is byte-identical for identical state (ALD-037 criterion 2)", + "copies the payload so later mutation cannot reach a learner", + "refuses to build an observation that fails §10.1 hygiene", + "hashes an observation under the observation domain" + ], + "requiredReceipt": "pnpm exec vitest run packages/scenario/__tests__/observation.test.ts" + }, + "packages/scenario/__tests__/referential-engine.test.ts": { + "selectors": [ + "applies the RESEARCH.md Appendix D defaults", + "binds bundleHash to the resolved config under the scenario-bundle domain", + "rejects configurations it cannot generate", + "rejects a same-Baby role pair and a negative episode index", + "produces byte-identical episodes 0..50 across two engine instances", + "produces different episodes for a different run seed", + "separates the splits and the evaluation seed label", + "binds stateHash to scenarioRef, ground truth, and observations", + "mints opaque, non-descriptive references", + "draws distinct candidate type codes and consistent orders", + "builds numeric-only observations in the SPEC §10.1 shape", + "keeps the receiver order independent of the target position", + "never targets a held-out type in the train split", + "never exposes a held-out type as a training or validation distractor", + "uses the seen type pool for validation targets with a separate PRNG domain", + "only targets held-out types in the held-out split", + "draws evaluation targets from every type code", + "targets every type code in the held-out split when nothing is held out", + "scores a seeded uniform selector inside [0.22, 0.28] over 4000 episodes", + "scores a position-only selector at chance too", + "rewards the target and only the target", + "rejects a reference that is not a candidate", + "rejects an action that is not select_object", + "refuses ground truth it did not produce", + "solves 500 episodes with a 32-symbol inventory in one symbol", + "solves 500 episodes with a 4-symbol inventory in two symbols", + "round-trips every type code through the symbol encoding", + "throws when the artifact does not decode to a present candidate", + "generates every profile", + "asymmetric-information shows the receiver only the first attribute", + "semi-cooperative-negotiation always has a non-empty zone of agreement", + "no-agreement-control has a provably empty zone of agreement", + "conflicting-negotiation opposes the utilities and leaves agreement open", + "gives each Baby only its own utility and reservation columns", + "reports agreement, utilities, and joint utility for a negotiation selection", + "returns every candidate as acceptable when no reservations exist", + "builds exactly the SPEC §11.2 fields for each role", + "is byte-identical for identical state and independent of the instance object", + "throws HygieneViolationError rather than delivering a tainted payload", + "routes hygiene violations to an audit sink" + ], + "requiredReceipt": "pnpm exec vitest run packages/scenario/__tests__/referential-engine.test.ts" + }, + "packages/types/__tests__/claim-boundary.test.ts": { + "selectors": [ + "whole-file executable check" + ], + "requiredReceipt": "pnpm exec vitest run packages/types/__tests__/claim-boundary.test.ts" + }, + "packages/types/__tests__/schema-manifest.test.ts": { + "selectors": [ + "exports every schema required by specification section 11", + "accepts a valid root run configuration", + "rejects incomplete derived-run lineage" + ], + "requiredReceipt": "pnpm exec vitest run packages/types/__tests__/schema-manifest.test.ts" + }, + "packages/verifier/__tests__/cli.test.ts": { + "selectors": [ + "exits 0 on the unchanged bundle and prints one line per check", + "exits 1 and prints the report as JSON for a mutated bundle", + "parses every documented flag", + "requires a bundle directory and rejects unknown options", + "rejects an empty --verifier-version instead of throwing a ZodError", + "prints usage for --help without verifying anything", + "reports usage on stderr and exits 1 for a bad invocation" + ], + "requiredReceipt": "pnpm exec vitest run packages/verifier/__tests__/cli.test.ts" + }, + "packages/verifier/__tests__/verify-bundle.test.ts": { + "selectors": [ + "accepts it with exitCode 0 and every check satisfied", + "reports the verified size of every stream and tree", + "matches every stream head recomputed from JSONL with the committed tree sizes", + "independently reproduces the final anchored checkpoint hash", + "writes a canonical, schema-valid report into the bundle it describes", + "does not write a report when writeReport is false", + "records machine-readable failure codes rather than free text", + "confirms calldata, recipient, status, and block inclusion" + ], + "requiredReceipt": "pnpm exec vitest run packages/verifier/__tests__/verify-bundle.test.ts" + }, + "scripts/check-api-docs.mjs": { + "selectors": [ + "pnpm run lint:api-docs" + ], + "requiredReceipt": "pnpm run lint:api-docs" + }, + "scripts/check-project-status.mjs": { + "selectors": [ + "pnpm run lint:project-status" + ], + "requiredReceipt": "pnpm run lint:project-status" + }, + "scripts/check-readiness-gates.mjs": { + "selectors": [ + "pnpm run lint:readiness" + ], + "requiredReceipt": "pnpm run lint:readiness" + }, + "scripts/check-research-console.mjs": { + "selectors": [ + "pnpm run lint:research-console" + ], + "requiredReceipt": "pnpm run lint:research-console" + }, + "scripts/lint-learner-contracts.mjs": { + "selectors": [ + "pnpm run lint:contracts" + ], + "requiredReceipt": "pnpm run lint:contracts" + }, + "scripts/run-mode-r-smoke.mjs": { + "selectors": [ + "pnpm run test:mode-r" + ], + "requiredReceipt": "pnpm run test:mode-r" + }, + "scripts/run-mode-r-study.mjs": { + "selectors": [ + "pnpm run test:mode-r-study" + ], + "requiredReceipt": "pnpm run test:mode-r-study" + }, + "scripts/run-qualification.mjs": { + "selectors": [ + "node scripts/run-qualification.mjs" + ], + "requiredReceipt": "node scripts/run-qualification.mjs" + }, + "scripts/scan-secrets.mjs": { + "selectors": [ + "pnpm run scan:secrets" + ], + "requiredReceipt": "pnpm run scan:secrets" + }, + "twins/packs/__tests__/routes.test.ts": { + "selectors": [ + "keeps the exhaustive route contract matrix synchronized with every exported route", + "nursery rejects missing/invalid credentials and every non-permitted role", + "baby-a rejects missing/invalid credentials and every non-permitted role", + "baby-b rejects missing/invalid credentials and every non-permitted role", + "researcher-operator is also accepted wherever researcher-viewer is (SPEC §12.2)", + "404 NOT_FOUND for an unknown run", + "400 INVALID_REQUEST for a malformed RunConfig body", + "400 INVALID_REQUEST for a body that is not valid JSON", + "accepts a body delivered as a JSON string, not only a parsed object", + "409 CONFLICT for a duplicate run id", + "409 CONFLICT (not 501) for baby-a /reset (SPEC §12.3 has no server-fault member)", + "400 INVALID_REQUEST (not an unhandled rejection) for malformed percent-encoding in a path parameter", + "400 INVALID_REQUEST for a runId that would escape the configured bundle root", + "keeps private observations operator-only and replay results read-only", + "creates, steps, reads, pauses, resumes, and verifies a run", + "404 for a verification report that has never been written", + "aborts a run (terminal transition, SPEC §7.2/§7.3)", + "POST /runs/:id/annotate records an audited annotate event (SPEC §14.2)", + "GET /runs/:id/ledgers exposes audit-layer content only to researcher-viewer, full content to researcher-operator", + "GET /runs records a human-view event for every listed run", + "GET /session/delta records a human-view event for every reported run", + "round-trips a snapshot through restore and delta", + "rejects a traversing snapshotId instead of reading a file outside bundleRoot (SPEC §13.2)", + "404 for a well-formed but absent snapshot never echoes the server-absolute path", + "observe then act on baby-a returns a schema-valid TurnProposalEnvelope", + "deliver and outcome succeed on baby-a", + "GET /ledger exposes only generated audit entries, plus an agent-native count", + "POST /observe fails closed on a hygiene violation and audits the rejection (SPEC §10.1, ALD-038)", + "POST /observe rejects an observation whose nested recipient names the other Baby, even with no top-level naming (SPEC §4.2)", + "Baby tool routes refuse a paused or terminal run with 409 CONFLICT, not a silent adapter mutation (SPEC §7.2)", + "a baby-a request naming baby-b as the target is refused with 403 FORBIDDEN", + "there is no route, direct or otherwise, from baby-a to baby-b (ALD-029)" + ], + "requiredReceipt": "pnpm exec vitest run twins/packs/__tests__/routes.test.ts" + }, + "twins/packs/__tests__/telemetry.test.ts": { + "selectors": [ + "ALD-058: the nursery routes record method, route pattern, status, duration and run id", + "ALD-058: baby-a and baby-b routes record under their own twin names", + "ALD-058: rejected requests are recorded too, and never leak a raw path", + "ALD-058: no record is produced when the host installs no sink", + "ALD-058: a non-sink value on the state key is ignored, not called", + "ALD-058: a sink that ${fault.label} leaves the route unaffected", + "ALD-058: a sink that throws does not prevent the next request from being served" + ], + "requiredReceipt": "pnpm exec vitest run twins/packs/__tests__/telemetry.test.ts" + } + }, + "summary": { + "backlogItems": 86, + "backlogCriteria": 258, + "checkedBacklogCriteria": 254, + "openBacklogCriteria": 4, + "normativeLines": 96, + "mappedNormativeLines": 92, + "unmappedNormativeLines": 0, + "nonRequirementLines": 4 + }, + "backlogCriteria": [ + { + "id": "ALD-001.1", + "checked": true, + "statement": "`pnpm install --frozen-lockfile` at the repo root succeeds.", + "itemId": "ALD-001", + "itemTitle": "pnpm workspaces monorepo bootstrap", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "ALD-001.2", + "checked": true, + "statement": "`pnpm run build` (project references) compiles with zero errors.", + "itemId": "ALD-001", + "itemTitle": "pnpm workspaces monorepo bootstrap", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "ALD-001.3", + "checked": true, + "statement": "A new package can be added under `packages/*` and is automatically picked up by the workspace without editing the root `package.json`.", + "itemId": "ALD-001", + "itemTitle": "pnpm workspaces monorepo bootstrap", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "ALD-002.1", + "checked": true, + "statement": "One exported type/schema exists per §11 subsection (§11.1–§11.10), named to match the section title.", + "itemId": "ALD-002", + "itemTitle": "Shared `@ald/types` schema package", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "ALD-002.2", + "checked": true, + "statement": "The package builds and is importable from any other workspace package.", + "itemId": "ALD-002", + "itemTitle": "Shared `@ald/types` schema package", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "ALD-002.3", + "checked": true, + "statement": "A schema-drift test fails if a §11 subsection type is removed without the corresponding source-doc section also changing (a lightweight manifest of expected export names is checked in CI once `ALD-078` exists, and locally before then).", + "itemId": "ALD-002", + "itemTitle": "Shared `@ald/types` schema package", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "ALD-003.1", + "checked": true, + "statement": "A documented list of all environment variables exists with defaults and types.", + "itemId": "ALD-003", + "itemTitle": "Environment, configuration, and secrets convention", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/config/__tests__/environment.test.ts", + "scripts/scan-secrets.mjs" + ] + }, + { + "id": "ALD-003.2", + "checked": true, + "statement": "Loading config with a required variable missing fails fast with a clear error, not a silent default.", + "itemId": "ALD-003", + "itemTitle": "Environment, configuration, and secrets convention", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/config/__tests__/environment.test.ts", + "scripts/scan-secrets.mjs" + ] + }, + { + "id": "ALD-003.3", + "checked": true, + "statement": "No secret-shaped value (private key, API token) appears in any committed file; a scan step verifies this.", + "itemId": "ALD-003", + "itemTitle": "Environment, configuration, and secrets convention", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/config/__tests__/environment.test.ts", + "scripts/scan-secrets.mjs" + ] + }, + { + "id": "ALD-004.1", + "checked": true, + "statement": "All three twin packs load without error when the DTSF runtime scans `twins/packs/`.", + "itemId": "ALD-004", + "itemTitle": "DTSF twin pack scaffolding (baby-a, baby-b, nursery)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-004.2", + "checked": true, + "statement": "Every registered route pattern in all three packs starts with `/`, not `/baby-a`, `/baby-b`, or `/nursery`.", + "itemId": "ALD-004", + "itemTitle": "DTSF twin pack scaffolding (baby-a, baby-b, nursery)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-004.3", + "checked": true, + "statement": "A request to `/baby-a/` reaches the handler with `req.params[0]` equal to the unprefixed path, confirmed by an integration test.", + "itemId": "ALD-004", + "itemTitle": "DTSF twin pack scaffolding (baby-a, baby-b, nursery)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-005.1", + "checked": true, + "statement": "Database is created with `journal_mode=WAL` confirmed via `PRAGMA journal_mode`.", + "itemId": "ALD-005", + "itemTitle": "SQLite schema migration and WAL mode", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/database.test.ts" + ] + }, + { + "id": "ALD-005.2", + "checked": true, + "statement": "Every table in §13.1 exists, and event/audit/experiment tables have triggers rejecting `UPDATE` and `DELETE` while versioned records append new rows.", + "itemId": "ALD-005", + "itemTitle": "SQLite schema migration and WAL mode", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/database.test.ts" + ] + }, + { + "id": "ALD-005.3", + "checked": true, + "statement": "Running the migration twice is idempotent, and an automated test proves privileged application code cannot bypass the append-only triggers.", + "itemId": "ALD-005", + "itemTitle": "SQLite schema migration and WAL mode", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/database.test.ts" + ] + }, + { + "id": "ALD-006.1", + "checked": true, + "statement": "Serializing the same logical event twice, in two different process runs, produces byte-identical output.", + "itemId": "ALD-006", + "itemTitle": "Canonical ledger event serializer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/canonical.test.ts" + ] + }, + { + "id": "ALD-006.2", + "checked": true, + "statement": "Key order in the serialized form is independent of the key insertion order of the input object.", + "itemId": "ALD-006", + "itemTitle": "Canonical ledger event serializer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/canonical.test.ts" + ] + }, + { + "id": "ALD-006.3", + "checked": true, + "statement": "A round-trip (serialize → deserialize) produces a deep-equal object to the input.", + "itemId": "ALD-006", + "itemTitle": "Canonical ledger event serializer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/canonical.test.ts" + ] + }, + { + "id": "ALD-007.1", + "checked": true, + "statement": "Every event type listed in §5 has a corresponding validator function.", + "itemId": "ALD-007", + "itemTitle": "Event type registry and validators", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/event-types.test.ts" + ] + }, + { + "id": "ALD-007.2", + "checked": true, + "statement": "Submitting an event with a missing required field is rejected before reaching the serializer.", + "itemId": "ALD-007", + "itemTitle": "Event type registry and validators", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/event-types.test.ts" + ] + }, + { + "id": "ALD-007.3", + "checked": true, + "statement": "Submitting an unknown event type is rejected with a clear error, not silently accepted.", + "itemId": "ALD-007", + "itemTitle": "Event type registry and validators", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/event-types.test.ts" + ] + }, + { + "id": "ALD-008.1", + "checked": true, + "statement": "Each stored event's previous hash matches the immediately preceding event in the same run and event domain, with sequence starting at `1`.", + "itemId": "ALD-008", + "itemTitle": "Hash chaining of ledger and channel events", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/snapshot.test.ts" + ] + }, + { + "id": "ALD-008.2", + "checked": true, + "statement": "Mutating, deleting, inserting, or reordering an event in any primary chain is detected by a chain-walk validator.", + "itemId": "ALD-008", + "itemTitle": "Hash chaining of ledger and channel events", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/snapshot.test.ts" + ] + }, + { + "id": "ALD-008.3", + "checked": true, + "statement": "Sender intention, channel event, receiver delivery receipt, and receiver interpretation carry the exact cross-hashes required by LEDGER §6 and SPECIFICATION §11.5.", + "itemId": "ALD-008", + "itemTitle": "Hash chaining of ledger and channel events", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/snapshot.test.ts" + ] + }, + { + "id": "ALD-009.1", + "checked": true, + "statement": "Every committed event and checkpoint has a signature verifiable by the public key registered for exactly its domain.", + "itemId": "ALD-009", + "itemTitle": "Per-run event and witness key provisioning", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/hashing/__tests__/keystore.test.ts" + ] + }, + { + "id": "ALD-009.2", + "checked": true, + "statement": "Cross-domain signing attempts fail, including Baby A attempting to sign Baby B or channel content.", + "itemId": "ALD-009", + "itemTitle": "Per-run event and witness key provisioning", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/hashing/__tests__/keystore.test.ts" + ] + }, + { + "id": "ALD-009.3", + "checked": true, + "statement": "Private keys are absent from SQLite, logs, model context, and evidence bundles; per-run rotation produces distinct public keys.", + "itemId": "ALD-009", + "itemTitle": "Per-run event and witness key provisioning", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/hashing/__tests__/keystore.test.ts" + ] + }, + { + "id": "ALD-010.1", + "checked": true, + "statement": "A signing or insert failure at any point results in zero sender-ledger and channel rows committed.", + "itemId": "ALD-010", + "itemTitle": "Evidence Writer and atomic turn transaction", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts" + ] + }, + { + "id": "ALD-010.2", + "checked": true, + "statement": "A successful `TurnCommitRequest` commits both signed rows atomically and returns their entry hashes before delivery.", + "itemId": "ALD-010", + "itemTitle": "Evidence Writer and atomic turn transaction", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts" + ] + }, + { + "id": "ALD-010.3", + "checked": true, + "statement": "Module boundaries and database permissions prevent every other component, including Gateway and Controller, from writing event tables directly.", + "itemId": "ALD-010", + "itemTitle": "Evidence Writer and atomic turn transaction", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts" + ] + }, + { + "id": "ALD-011.1", + "checked": true, + "statement": "Simulated crash during an in-flight `ALD-010` transaction leaves the database with either the pre-transaction or post-transaction state, never a partial one, on restart.", + "itemId": "ALD-011", + "itemTitle": "WAL durability and crash-safety tests", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/crash-safety.test.ts" + ] + }, + { + "id": "ALD-011.2", + "checked": true, + "statement": "The chain-walk validator from `ALD-008` reports zero integrity violations after each crash-recovery test run.", + "itemId": "ALD-011", + "itemTitle": "WAL durability and crash-safety tests", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/crash-safety.test.ts" + ] + }, + { + "id": "ALD-011.3", + "checked": true, + "statement": "The test suite runs at least 20 randomized crash-point trials in CI (once `ALD-078` exists) without a single torn-write failure.", + "itemId": "ALD-011", + "itemTitle": "WAL durability and crash-safety tests", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/crash-safety.test.ts" + ] + }, + { + "id": "ALD-012.1", + "checked": true, + "statement": "Given a fixed ordered set of event hashes, the builder produces a deterministic, reproducible root hash.", + "itemId": "ALD-012", + "itemTitle": "Ordered Merkle trees and consistency proofs", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts" + ] + }, + { + "id": "ALD-012.2", + "checked": true, + "statement": "An inclusion proof for any leaf verifies correctly against the root using only the proof and the leaf hash.", + "itemId": "ALD-012", + "itemTitle": "Ordered Merkle trees and consistency proofs", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts" + ] + }, + { + "id": "ALD-012.3", + "checked": true, + "statement": "Valid extension checkpoints produce a consistency proof, while reordering, deletion, insertion, or a non-prefix tree fails consistency verification.", + "itemId": "ALD-012", + "itemTitle": "Ordered Merkle trees and consistency proofs", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts" + ] + }, + { + "id": "ALD-013.1", + "checked": true, + "statement": "A generated manifest validates against the authoritative schema and includes every event tree present in the Evidence Store.", + "itemId": "ALD-013", + "itemTitle": "Checkpoint manifest generation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/checkpoint-service.test.ts" + ] + }, + { + "id": "ALD-013.2", + "checked": true, + "statement": "Each manifest references the immediately prior checkpoint hash and carries a valid Nursery witness signature.", + "itemId": "ALD-013", + "itemTitle": "Checkpoint manifest generation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/checkpoint-service.test.ts" + ] + }, + { + "id": "ALD-013.3", + "checked": true, + "statement": "Every tree size/root exactly matches `ALD-012`, and a missing or extra tree causes checkpoint generation to fail.", + "itemId": "ALD-013", + "itemTitle": "Checkpoint manifest generation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/checkpoint-service.test.ts" + ] + }, + { + "id": "ALD-014.1", + "checked": true, + "statement": "A checkpoint is generated automatically once the configured trigger threshold from §9 is reached.", + "itemId": "ALD-014", + "itemTitle": "Checkpoint frequency scheduler", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/scheduler.test.ts", + "packages/ops/__tests__/checkpoint-scheduler.test.ts" + ] + }, + { + "id": "ALD-014.2", + "checked": true, + "statement": "No two checkpoints overlap in event range.", + "itemId": "ALD-014", + "itemTitle": "Checkpoint frequency scheduler", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/scheduler.test.ts", + "packages/ops/__tests__/checkpoint-scheduler.test.ts" + ] + }, + { + "id": "ALD-014.3", + "checked": true, + "statement": "The scheduler is a background timer with the crash-protection convention (registered under the process's `uncaughtException`/`unhandledRejection` handlers) so a scheduling failure logs rather than crashes the server.", + "itemId": "ALD-014", + "itemTitle": "Checkpoint frequency scheduler", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/scheduler.test.ts", + "packages/ops/__tests__/checkpoint-scheduler.test.ts" + ] + }, + { + "id": "ALD-015.1", + "checked": true, + "statement": "The CLI runs against an exported bundle with no network access and no shared process state with the server.", + "itemId": "ALD-015", + "itemTitle": "Independent verifier CLI", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/verifier/__tests__/cli.test.ts" + ] + }, + { + "id": "ALD-015.2", + "checked": true, + "statement": "It accepts an unchanged local bundle and rejects every non-chain mutation case in LEDGER §17 with the correct machine-readable failure location.", + "itemId": "ALD-015", + "itemTitle": "Independent verifier CLI", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/verifier/__tests__/cli.test.ts" + ] + }, + { + "id": "ALD-015.3", + "checked": true, + "statement": "It distinguishes chain, signature, inclusion, consistency, checkpoint, fork/gap, and unanchored-tail results.", + "itemId": "ALD-015", + "itemTitle": "Independent verifier CLI", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/verifier/__tests__/cli.test.ts" + ] + }, + { + "id": "ALD-016.1", + "checked": true, + "statement": "The exported bundle contains every event, manifest, and signature needed for `ALD-015` to verify it with no other input.", + "itemId": "ALD-016", + "itemTitle": "Evidence bundle export", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/export.test.ts" + ] + }, + { + "id": "ALD-016.2", + "checked": true, + "statement": "Exporting the same run twice without intervening writes produces byte-identical bundles.", + "itemId": "ALD-016", + "itemTitle": "Evidence bundle export", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/export.test.ts" + ] + }, + { + "id": "ALD-016.3", + "checked": true, + "statement": "The bundle format is documented with a schema so a third party could write their own verifier.", + "itemId": "ALD-016", + "itemTitle": "Evidence bundle export", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/export.test.ts" + ] + }, + { + "id": "ALD-017.1", + "checked": true, + "statement": "Every verifier CLI run (`ALD-015`) produces a report conforming to the `ALD-002` schema for Verification Report.", + "itemId": "ALD-017", + "itemTitle": "Verification report schema and generator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/verifier/__tests__/cli.test.ts", + "packages/verifier/__tests__/verify-bundle.test.ts" + ] + }, + { + "id": "ALD-017.2", + "checked": true, + "statement": "A failing verification produces a report with machine-readable failure codes, not just free text.", + "itemId": "ALD-017", + "itemTitle": "Verification report schema and generator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/verifier/__tests__/cli.test.ts", + "packages/verifier/__tests__/verify-bundle.test.ts" + ] + }, + { + "id": "ALD-017.3", + "checked": true, + "statement": "Reports are timestamped and reference the exact bundle export they were generated from.", + "itemId": "ALD-017", + "itemTitle": "Verification report schema and generator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/verifier/__tests__/cli.test.ts", + "packages/verifier/__tests__/verify-bundle.test.ts" + ] + }, + { + "id": "ALD-018.1", + "checked": true, + "statement": "A stored Anchor Receipt validates against the `ALD-002` schema and always references an existing checkpoint manifest (`ALD-013`).", + "itemId": "ALD-018", + "itemTitle": "Anchor receipt schema and storage", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts" + ] + }, + { + "id": "ALD-018.2", + "checked": true, + "statement": "Only a checkpoint root hash and minimal metadata are ever stored as the on-chain payload field — no raw observation or model data.", + "itemId": "ALD-018", + "itemTitle": "Anchor receipt schema and storage", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts" + ] + }, + { + "id": "ALD-018.3", + "checked": true, + "statement": "Querying receipts by checkpoint ID returns at most one receipt per chain per checkpoint.", + "itemId": "ALD-018", + "itemTitle": "Anchor receipt schema and storage", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts" + ] + }, + { + "id": "ALD-019.1", + "checked": true, + "statement": "The anchoring key is stored separately from the event-signing key and neither can be derived from the other.", + "itemId": "ALD-019", + "itemTitle": "Anchoring signer key management", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts" + ] + }, + { + "id": "ALD-019.2", + "checked": true, + "statement": "A key-rotation procedure exists and is exercised by a test that anchors before and after rotation without breaking prior receipts' validity.", + "itemId": "ALD-019", + "itemTitle": "Anchoring signer key management", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts" + ] + }, + { + "id": "ALD-019.3", + "checked": true, + "statement": "The anchoring private key is never logged, telemetered, or included in any evidence bundle.", + "itemId": "ALD-019", + "itemTitle": "Anchoring signer key management", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts" + ] + }, + { + "id": "ALD-020.1", + "checked": false, + "statement": "A submitted checkpoint root is independently observable on a public Base Sepolia block explorer.", + "itemId": "ALD-020", + "itemTitle": "Base Sepolia anchoring client", + "disposition": "external-blocked", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts" + ] + }, + { + "id": "ALD-020.2", + "checked": true, + "statement": "The default configuration anchors to Base Sepolia with no additional opt-in required.", + "itemId": "ALD-020", + "itemTitle": "Base Sepolia anchoring client", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts" + ] + }, + { + "id": "ALD-020.3", + "checked": true, + "statement": "The submitted on-chain payload contains only the root hash and minimal required metadata, matching `ALD-018`'s privacy criterion.", + "itemId": "ALD-020", + "itemTitle": "Base Sepolia anchoring client", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts" + ] + }, + { + "id": "ALD-021.1", + "checked": true, + "statement": "A receipt is marked `confirmed` only after reaching the configured confirmation depth.", + "itemId": "ALD-021", + "itemTitle": "Anchor confirmation and retry/backoff", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/ops/__tests__/failure-policy.test.ts" + ] + }, + { + "id": "ALD-021.2", + "checked": true, + "statement": "Transient RPC failure retries without duplicate submission; wrong-chain, failed, or nonexistent transactions fail verification.", + "itemId": "ALD-021", + "itemTitle": "Anchor confirmation and retry/backoff", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/ops/__tests__/failure-policy.test.ts" + ] + }, + { + "id": "ALD-021.3", + "checked": true, + "statement": "The verifier reports any event tail after the final anchored checkpoint and independently reproduces the anchored checkpoint hash.", + "itemId": "ALD-021", + "itemTitle": "Anchor confirmation and retry/backoff", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/ops/__tests__/failure-policy.test.ts" + ] + }, + { + "id": "ALD-022.1", + "checked": true, + "statement": "With no explicit opt-in set, the system never submits any transaction to mainnet, confirmed by a test that asserts zero mainnet RPC calls under default config.", + "itemId": "ALD-022", + "itemTitle": "Mainnet anchoring policy switch", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts" + ] + }, + { + "id": "ALD-022.2", + "checked": false, + "statement": "Enabling the opt-in flag and providing mainnet-specific key/config anchors successfully to mainnet in a manual/staging test.", + "itemId": "ALD-022", + "itemTitle": "Mainnet anchoring policy switch", + "disposition": "external-blocked", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts" + ] + }, + { + "id": "ALD-022.3", + "checked": true, + "statement": "Switching the opt-in flag off again immediately reverts all anchoring to Base Sepolia with no code change required.", + "itemId": "ALD-022", + "itemTitle": "Mainnet anchoring policy switch", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts" + ] + }, + { + "id": "ALD-023.1", + "checked": true, + "statement": "Missing fields and incompatible track/learning-signal, oracle/experiment, or carrier-specific combinations are rejected with field-specific errors.", + "itemId": "ALD-023", + "itemTitle": "Run configuration schema and validation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/run-config.test.ts" + ] + }, + { + "id": "ALD-023.2", + "checked": true, + "statement": "Valid root and derived configs cover every model, deployment, communication, carrier, affect, and interaction mode; lineage fields are all-or-none.", + "itemId": "ALD-023", + "itemTitle": "Run configuration schema and validation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/run-config.test.ts" + ] + }, + { + "id": "ALD-023.3", + "checked": true, + "statement": "Validated canonical configs are persisted, hashable, and retrievable by run ID.", + "itemId": "ALD-023", + "itemTitle": "Run configuration schema and validation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/run-config.test.ts" + ] + }, + { + "id": "ALD-024.1", + "checked": true, + "statement": "Every transition listed in §7.2 is implemented and unit-tested.", + "itemId": "ALD-024", + "itemTitle": "Run state machine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/state-machine.test.ts" + ] + }, + { + "id": "ALD-024.2", + "checked": true, + "statement": "Every transition **not** listed in §7.2 is rejected with an explicit \"invalid transition\" error, verified by an exhaustive test over all state pairs.", + "itemId": "ALD-024", + "itemTitle": "Run state machine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/state-machine.test.ts" + ] + }, + { + "id": "ALD-024.3", + "checked": true, + "statement": "The current state of any run is queryable and matches the last successfully applied transition.", + "itemId": "ALD-024", + "itemTitle": "Run state machine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/state-machine.test.ts" + ] + }, + { + "id": "ALD-025.1", + "checked": true, + "statement": "Every phase in §8.1 executes in the documented order for a successful turn.", + "itemId": "ALD-025", + "itemTitle": "Turn phase orchestrator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/scratch-rl-run.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-025.2", + "checked": true, + "statement": "A turn exceeding the §8.3 timing budget is terminated and recorded as a timeout, not left hanging.", + "itemId": "ALD-025", + "itemTitle": "Turn phase orchestrator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/scratch-rl-run.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-025.3", + "checked": true, + "statement": "The commit phase always goes through the `ALD-010` atomic wrapper — no direct ledger writes bypass it.", + "itemId": "ALD-025", + "itemTitle": "Turn phase orchestrator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/scratch-rl-run.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-026.1", + "checked": true, + "statement": "Pausing a run mid-turn either lets the current turn's atomic commit finish or fully rolls it back — never a partial commit.", + "itemId": "ALD-026", + "itemTitle": "Pause/abort handling", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts" + ] + }, + { + "id": "ALD-026.2", + "checked": true, + "statement": "An `aborted-sealed` run is terminal and can never accept another turn or be reopened.", + "itemId": "ALD-026", + "itemTitle": "Pause/abort handling", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts" + ] + }, + { + "id": "ALD-026.3", + "checked": true, + "statement": "Pause/abort/resume operations are recorded through the audited intervention path and produce required checkpoints.", + "itemId": "ALD-026", + "itemTitle": "Pause/abort handling", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts" + ] + }, + { + "id": "ALD-027.1", + "checked": true, + "statement": "After a simulated crash mid-turn, restart reconstructs the run's state to exactly the last atomically committed turn, with no phantom in-progress turn.", + "itemId": "ALD-027", + "itemTitle": "Crash recovery and integrity-fork detection", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "ALD-027.2", + "checked": true, + "statement": "A mismatched duplicate sequence preserves both artifacts, transitions the run to `forked-invalid`, halts writes, and requires research-integrity review.", + "itemId": "ALD-027", + "itemTitle": "Crash recovery and integrity-fork detection", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "ALD-027.3", + "checked": true, + "statement": "Recovery appends an explicit recovery event at the next unused sequence and matches the state independently derived by `ALD-015`.", + "itemId": "ALD-027", + "itemTitle": "Crash recovery and integrity-fork detection", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "ALD-028.1", + "checked": true, + "statement": "A child run records `parentRunId`, `derivedFromCheckpointHash`, and both initial policy refs in config and its first initialization event.", + "itemId": "ALD-028", + "itemTitle": "Derived-run branching and lineage", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts" + ] + }, + { + "id": "ALD-028.2", + "checked": true, + "statement": "Child sequences restart at `1`, and writes never modify parent evidence or reopen a terminal parent.", + "itemId": "ALD-028", + "itemTitle": "Derived-run branching and lineage", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts" + ] + }, + { + "id": "ALD-028.3", + "checked": true, + "statement": "`ALD-016` exports lineage references and `ALD-015` verifies them against the immutable parent bundle.", + "itemId": "ALD-028", + "itemTitle": "Derived-run branching and lineage", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts" + ] + }, + { + "id": "ALD-029.1", + "checked": true, + "statement": "Every artifact passes through the Gateway; direct Baby-to-Baby routes fail in an instrumented integration test.", + "itemId": "ALD-029", + "itemTitle": "Symbol Gateway core router and validator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-029.2", + "checked": true, + "statement": "`normal`, `disabled`, `constant`, seeded `random`, seeded `shuffled`, and E03-only `oracle` conditions produce their exact §9.6 behavior with no code changes.", + "itemId": "ALD-029", + "itemTitle": "Symbol Gateway core router and validator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-029.3", + "checked": true, + "statement": "Every accepted, rejected, or control-substituted turn records the Baby-proposal hash when present and exact delivered-artifact hash through `ALD-035`.", + "itemId": "ALD-029", + "itemTitle": "Symbol Gateway core router and validator", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-030.1", + "checked": true, + "statement": "Emitting a token in the registered vocabulary is accepted and forwarded.", + "itemId": "ALD-030", + "itemTitle": "Fixed-token protocol", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/conformance.test.ts" + ] + }, + { + "id": "ALD-030.2", + "checked": true, + "statement": "Emitting any token, string, or byte sequence not in the registered vocabulary is rejected, not silently coerced to the nearest valid token.", + "itemId": "ALD-030", + "itemTitle": "Fixed-token protocol", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/conformance.test.ts" + ] + }, + { + "id": "ALD-030.3", + "checked": true, + "statement": "The vocabulary is configurable per run without a code change.", + "itemId": "ALD-030", + "itemTitle": "Fixed-token protocol", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/conformance.test.ts" + ] + }, + { + "id": "ALD-031.1", + "checked": true, + "statement": "Each alternate carrier accepts a valid bounded artifact and reproduces the same carrier-qualified `markHash`.", + "itemId": "ALD-031", + "itemTitle": "Alternate neutral carrier protocols", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts" + ] + }, + { + "id": "ALD-031.2", + "checked": true, + "statement": "Out-of-range glyph IDs, bitmap sizes, stroke values, tone bins, text/color fields, and semantic tags are rejected with specific reasons.", + "itemId": "ALD-031", + "itemTitle": "Alternate neutral carrier protocols", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts" + ] + }, + { + "id": "ALD-031.3", + "checked": true, + "statement": "Every carrier contributes accept/reject vectors to `ALD-036`, and exactly one carrier family is available in a run.", + "itemId": "ALD-031", + "itemTitle": "Alternate neutral carrier protocols", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts" + ] + }, + { + "id": "ALD-032.1", + "checked": true, + "statement": "The evaluator records mark-level leakage metrics and the exact analysis version in the run evidence bundle.", + "itemId": "ALD-032", + "itemTitle": "Alternate-carrier leakage evaluation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts" + ] + }, + { + "id": "ALD-032.2", + "checked": true, + "statement": "Pre-registered recognizable-glyph and unintended-feature probes produce explicit pass, fail, or inconclusive results without altering the original canvas artifact.", + "itemId": "ALD-032", + "itemTitle": "Alternate-carrier leakage evaluation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts" + ] + }, + { + "id": "ALD-032.3", + "checked": true, + "statement": "A failed leakage evaluation blocks an ungrounded-language claim while preserving the run as valid negative or integrity evidence.", + "itemId": "ALD-032", + "itemTitle": "Alternate-carrier leakage evaluation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts" + ] + }, + { + "id": "ALD-033.1", + "checked": true, + "statement": "Declared/permuted/opaque modes emit only A1-A6, while any other display or out-of-window submission is rejected.", + "itemId": "ALD-033", + "itemTitle": "Six-display affect protocol", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/ops/__tests__/metrics.test.ts" + ] + }, + { + "id": "ALD-033.2", + "checked": true, + "statement": "Derived mode disables `submit_affect`, records the private measurement, and applies the pre-registered Gateway mapping; emergent mode records a carrier Channel Event rather than an Affect Event.", + "itemId": "ALD-033", + "itemTitle": "Six-display affect protocol", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/ops/__tests__/metrics.test.ts" + ] + }, + { + "id": "ALD-033.3", + "checked": true, + "statement": "Affect schemas and every mode's accept/reject vectors extend `ALD-036`, including normalized timing/envelope behavior.", + "itemId": "ALD-033", + "itemTitle": "Six-display affect protocol", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/ops/__tests__/metrics.test.ts" + ] + }, + { + "id": "ALD-034.1", + "checked": true, + "statement": "A fixed-token violation produces the standard rejection shape and an append-only `channel.rejected` event with reason code and payload hash but no raw rejected content.", + "itemId": "ALD-034", + "itemTitle": "Channel violation detection and rejection behavior", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts" + ] + }, + { + "id": "ALD-034.2", + "checked": true, + "statement": "Five consecutive rejections by default trigger an automatic pause and `safety-trigger` audit entry.", + "itemId": "ALD-034", + "itemTitle": "Channel violation detection and rejection behavior", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts" + ] + }, + { + "id": "ALD-034.3", + "checked": true, + "statement": "A protocol-module contract test proves canvas and affect handlers can register later without changing the rejection event shape or pause policy.", + "itemId": "ALD-034", + "itemTitle": "Channel violation detection and rejection behavior", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts" + ] + }, + { + "id": "ALD-035.1", + "checked": true, + "statement": "Every Gateway proposal includes one required private intention draft and rejects Baby-supplied run/turn/sender/hash metadata.", + "itemId": "ALD-035", + "itemTitle": "Turn envelope, channel event, and ledger draft schemas", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/interpretation.test.ts" + ] + }, + { + "id": "ALD-035.2", + "checked": true, + "statement": "Every accepted/rejected event contains the sender-ledger binding, delivery receipt, previous channel hash, entry hash, and channel-writer signature required by §11.5.", + "itemId": "ALD-035", + "itemTitle": "Turn envelope, channel event, and ledger draft schemas", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/interpretation.test.ts" + ] + }, + { + "id": "ALD-035.3", + "checked": true, + "statement": "Interpretation drafts require the delivered channel hash, and all schema failures use the standard Gateway error shape.", + "itemId": "ALD-035", + "itemTitle": "Turn envelope, channel event, and ledger draft schemas", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/interpretation.test.ts" + ] + }, + { + "id": "ALD-036.1", + "checked": true, + "statement": "The MVP suite exercises fixed-token acceptance/rejection, all six communication controls, dual proposal/delivery hashing, consecutive-rejection pause, and schema failures.", + "itemId": "ALD-036", + "itemTitle": "Gateway/protocol conformance test suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conformance.test.ts" + ] + }, + { + "id": "ALD-036.2", + "checked": true, + "statement": "The suite runs against a mocked/stub learner, with no dependency on any specific `ALD-044`–047 adapter.", + "itemId": "ALD-036", + "itemTitle": "Gateway/protocol conformance test suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conformance.test.ts" + ] + }, + { + "id": "ALD-036.3", + "checked": true, + "statement": "A protocol registration test requires every enabled module to contribute accept/reject vectors; the consolidated suite is the gate referenced by EPIC-06 and `ALD-078`.", + "itemId": "ALD-036", + "itemTitle": "Gateway/protocol conformance test suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conformance.test.ts" + ] + }, + { + "id": "ALD-037.1", + "checked": true, + "statement": "A built Observation validates against the `ALD-002` schema and contains no field not listed in §11.2.", + "itemId": "ALD-037", + "itemTitle": "Observation schema and builder", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/observation.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts" + ] + }, + { + "id": "ALD-037.2", + "checked": true, + "statement": "Two observations built from identical underlying state are byte-identical after canonicalization (reusing `ALD-006`'s approach).", + "itemId": "ALD-037", + "itemTitle": "Observation schema and builder", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/observation.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts" + ] + }, + { + "id": "ALD-037.3", + "checked": true, + "statement": "The builder is the only code path producing Observations delivered to learners.", + "itemId": "ALD-037", + "itemTitle": "Observation schema and builder", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/observation.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts" + ] + }, + { + "id": "ALD-038.1", + "checked": true, + "statement": "Every prohibited field category listed in §10.1 is demonstrated blocked using a test observation deliberately constructed to contain it.", + "itemId": "ALD-038", + "itemTitle": "Observation hygiene filter", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-038.2", + "checked": true, + "statement": "The filter runs on every Observation before it reaches the Gateway/learner boundary, with no bypass path.", + "itemId": "ALD-038", + "itemTitle": "Observation hygiene filter", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-038.3", + "checked": true, + "statement": "A blocked field produces an audit-logged event (feeding `ALD-059`), not a silent drop.", + "itemId": "ALD-038", + "itemTitle": "Observation hygiene filter", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-039.1", + "checked": true, + "statement": "A bundle containing OCR-detected text, caption metadata, semantic filenames, or human-readable labels cannot be referenced by a run.", + "itemId": "ALD-039", + "itemTitle": "OCR detection and scenario-bundle quarantine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts" + ] + }, + { + "id": "ALD-039.2", + "checked": true, + "statement": "Text-free controls pass while pre-registered adversarial image/text fixtures are quarantined before any adapter receives them.", + "itemId": "ALD-039", + "itemTitle": "OCR detection and scenario-bundle quarantine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts" + ] + }, + { + "id": "ALD-039.3", + "checked": true, + "statement": "Quarantine events retain artifact hashes and reason codes without exposing raw injection text in Baby-visible or public logs.", + "itemId": "ALD-039", + "itemTitle": "OCR detection and scenario-bundle quarantine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts" + ] + }, + { + "id": "ALD-040.1", + "checked": true, + "statement": "Each side-channel category named in §10.3 has a corresponding mitigation implemented (e.g., constant-shape error responses, timing normalization where specified).", + "itemId": "ALD-040", + "itemTitle": "Side-channel elimination in transport layer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/observation-and-measurement.test.ts" + ] + }, + { + "id": "ALD-040.2", + "checked": true, + "statement": "A test harness measuring the relevant channel (e.g., response latency variance) confirms the mitigation is effective within the tolerance §10.3 implies.", + "itemId": "ALD-040", + "itemTitle": "Side-channel elimination in transport layer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/observation-and-measurement.test.ts" + ] + }, + { + "id": "ALD-040.3", + "checked": true, + "statement": "This item's test harness is reused (not duplicated) by the red-team suite in `ALD-067`.", + "itemId": "ALD-040", + "itemTitle": "Side-channel elimination in transport layer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/observation-and-measurement.test.ts" + ] + }, + { + "id": "ALD-041.1", + "checked": true, + "statement": "Two runs with the same seed and interaction mode produce byte-identical scenarios, private facts, utilities, and task sequences.", + "itemId": "ALD-041", + "itemTitle": "Deterministic scenario/task engine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/referential-engine.test.ts" + ] + }, + { + "id": "ALD-041.2", + "checked": true, + "statement": "Every §9.5 interaction profile produces its required utility relationship, including a provably empty zone of possible agreement for `no-agreement-control`.", + "itemId": "ALD-041", + "itemTitle": "Deterministic scenario/task engine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/referential-engine.test.ts" + ] + }, + { + "id": "ALD-041.3", + "checked": true, + "statement": "The engine's output feeds `ALD-037`'s Observation builder with no intermediate non-deterministic step.", + "itemId": "ALD-041", + "itemTitle": "Deterministic scenario/task engine", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/scenario/__tests__/referential-engine.test.ts" + ] + }, + { + "id": "ALD-042.1", + "checked": true, + "statement": "The interface implements every §6.2 method, including `receive(DeliveredChannelArtifact)` returning a `LedgerDraftEnvelope`.", + "itemId": "ALD-042", + "itemTitle": "Learner Adapter interface", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/no-learning.test.ts" + ] + }, + { + "id": "ALD-042.2", + "checked": true, + "statement": "The `no-learning` reference adapter is selectable, performs no policy update, and passes the contract-conformance test.", + "itemId": "ALD-042", + "itemTitle": "Learner Adapter interface", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/no-learning.test.ts" + ] + }, + { + "id": "ALD-042.3", + "checked": true, + "statement": "The interface is the only integration point the turn orchestrator (`ALD-025`) uses to reach a learner.", + "itemId": "ALD-042", + "itemTitle": "Learner Adapter interface", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/no-learning.test.ts" + ] + }, + { + "id": "ALD-043.1", + "checked": true, + "statement": "A contract containing a symbol-meaning example, sample exchange, or prohibited side-channel instruction fails CI and cannot be referenced by a run.", + "itemId": "ALD-043", + "itemTitle": "Learner contract versioning, lint, and tool-only enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/lint-learner-contracts.mjs" + ] + }, + { + "id": "ALD-043.2", + "checked": true, + "statement": "Referenced contract versions are immutable and their prompt-bundle hashes appear in run evidence.", + "itemId": "ALD-043", + "itemTitle": "Learner contract versioning, lint, and tool-only enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/lint-learner-contracts.mjs" + ] + }, + { + "id": "ALD-043.3", + "checked": true, + "statement": "The no-learning reference and all four adapter tracks are blocked and audited when attempting any state write or output outside the §6.3 tool surface.", + "itemId": "ALD-043", + "itemTitle": "Learner contract versioning, lint, and tool-only enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/lint-learner-contracts.mjs" + ] + }, + { + "id": "ALD-044.1", + "checked": true, + "statement": "The `frozen-llm` track is selectable via `ALD-023` run configuration and records the exact model and weight hashes.", + "itemId": "ALD-044", + "itemTitle": "Frozen-LLM adapter with local open-weight default", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts" + ] + }, + { + "id": "ALD-044.2", + "checked": true, + "statement": "A full turn (observation → proposal → Gateway validation → commit) completes end-to-end using this adapter.", + "itemId": "ALD-044", + "itemTitle": "Frozen-LLM adapter with local open-weight default", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts" + ] + }, + { + "id": "ALD-044.3", + "checked": true, + "statement": "The adapter exposes no weight-update path and passes the `ALD-036` Gateway conformance suite.", + "itemId": "ALD-044", + "itemTitle": "Frozen-LLM adapter with local open-weight default", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts" + ] + }, + { + "id": "ALD-045.1", + "checked": true, + "statement": "The track starts from randomly initialized parameters whose initial hash is recorded in the evidence bundle.", + "itemId": "ALD-045", + "itemTitle": "From-scratch RL learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "ALD-045.2", + "checked": true, + "statement": "A full turn and minimal reward-to-parameter-update cycle complete end-to-end in both pre-registered extrinsic-reward and intrinsic-motivation modes.", + "itemId": "ALD-045", + "itemTitle": "From-scratch RL learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "ALD-045.3", + "checked": true, + "statement": "Policy updates use only the Baby's private buffers and emit a verifiable policy checkpoint reference.", + "itemId": "ALD-045", + "itemTitle": "From-scratch RL learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "ALD-046.1", + "checked": true, + "statement": "The track is selectable, starts from recorded random initialization, and rejects any scalar reward supplied to its update path.", + "itemId": "ALD-046", + "itemTitle": "Self-supervised ungrounded learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "ALD-046.2", + "checked": true, + "statement": "A full turn and minimal predictive/contrastive update complete end-to-end.", + "itemId": "ALD-046", + "itemTitle": "Self-supervised ungrounded learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "ALD-046.3", + "checked": true, + "statement": "The evidence bundle records the loss definition and proves that outcome labels are not included in the self-supervised update batch.", + "itemId": "ALD-046", + "itemTitle": "Self-supervised ungrounded learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "ALD-047.1", + "checked": true, + "statement": "The track is selectable and records the provenance and hashes of every sensory, world-model, and communication component.", + "itemId": "ALD-047", + "itemTitle": "Hybrid learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/predictive-model.test.ts" + ] + }, + { + "id": "ALD-047.2", + "checked": true, + "statement": "A full turn and minimal policy update complete end-to-end through the shared interface.", + "itemId": "ALD-047", + "itemTitle": "Hybrid learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/predictive-model.test.ts" + ] + }, + { + "id": "ALD-047.3", + "checked": true, + "statement": "Any text-aligned frozen feature automatically weakens the run's claim classification; strict ungrounded classification requires `ALD-057` to pass.", + "itemId": "ALD-047", + "itemTitle": "Hybrid learner track", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/predictive-model.test.ts" + ] + }, + { + "id": "ALD-048.1", + "checked": true, + "statement": "Every route in §12.4, including Gateway-only `/deliver`, exists on both `baby-a` and `baby-b`, all unprefixed.", + "itemId": "ALD-048", + "itemTitle": "baby-a / baby-b twin pack implementation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-048.2", + "checked": true, + "statement": "Each route's behavior matches its documented purpose in §12.4 (verified by an integration test per route).", + "itemId": "ALD-048", + "itemTitle": "baby-a / baby-b twin pack implementation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-048.3", + "checked": true, + "statement": "A full run using these twin packs completes at least one turn end-to-end through the Gateway.", + "itemId": "ALD-048", + "itemTitle": "baby-a / baby-b twin pack implementation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-049.1", + "checked": true, + "statement": "Every route in §12.5 exists, unprefixed, and drives the correct `ALD-024` state transition.", + "itemId": "ALD-049", + "itemTitle": "Nursery controller twin pack", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-049.2", + "checked": true, + "statement": "Creating a run via nursery correctly provisions both `baby-a` and `baby-b` instances.", + "itemId": "ALD-049", + "itemTitle": "Nursery controller twin pack", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-049.3", + "checked": true, + "statement": "Pausing/aborting and derived-run creation use the same state/lineage services as direct internal calls, with no divergent logic path or reopening of terminal parents.", + "itemId": "ALD-049", + "itemTitle": "Nursery controller twin pack", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-050.1", + "checked": true, + "statement": "Every route in §12.6 exists, unprefixed, and returns data conforming to the `ALD-002` schemas involved.", + "itemId": "ALD-050", + "itemTitle": "Evidence and verification routes on nursery", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-050.2", + "checked": true, + "statement": "Triggering an export via this route produces a bundle identical to calling `ALD-016`'s export function directly.", + "itemId": "ALD-050", + "itemTitle": "Evidence and verification routes on nursery", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-050.3", + "checked": true, + "statement": "Unauthorized callers (per `ALD-051`) cannot reach these routes.", + "itemId": "ALD-050", + "itemTitle": "Evidence and verification routes on nursery", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-051.1", + "checked": true, + "statement": "Every role defined in §12.2 is enforced on every route that names a restriction.", + "itemId": "ALD-051", + "itemTitle": "Authorization roles and route guards", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-051.2", + "checked": true, + "statement": "Missing/invalid credentials return `401 UNAUTHENTICATED`; valid identities with insufficient roles return `403 FORBIDDEN`.", + "itemId": "ALD-051", + "itemTitle": "Authorization roles and route guards", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-051.3", + "checked": true, + "statement": "A test matrix of (role × route) confirms allow/deny matches §12.2 exactly.", + "itemId": "ALD-051", + "itemTitle": "Authorization roles and route guards", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-052.1", + "checked": true, + "statement": "Every success response across all routes matches the §12.3 success envelope.", + "itemId": "ALD-052", + "itemTitle": "Response and error shape standardization", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-052.2", + "checked": true, + "statement": "Every error response across all routes matches the §12.3 error envelope, including the ones from `ALD-034`'s channel violations.", + "itemId": "ALD-052", + "itemTitle": "Response and error shape standardization", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-052.3", + "checked": true, + "statement": "A lint/test rule fails the build if a new route is added without conforming to the envelope.", + "itemId": "ALD-052", + "itemTitle": "Response and error shape standardization", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-053.1", + "checked": true, + "statement": "A run created under Mode P and one under Mode R differ exactly along the dimensions listed in §5.3 — no undocumented behavioral difference.", + "itemId": "ALD-053", + "itemTitle": "Mode P / Mode R deployment switch", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-053.2", + "checked": true, + "statement": "The mode is immutable for the lifetime of a run once created (cannot be switched mid-run).", + "itemId": "ALD-053", + "itemTitle": "Mode P / Mode R deployment switch", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-053.3", + "checked": true, + "statement": "The active mode is recorded in the Run Configuration and visible in every exported evidence bundle.", + "itemId": "ALD-053", + "itemTitle": "Mode P / Mode R deployment switch", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-054.1", + "checked": true, + "statement": "Every claim statement listed in §5.4 is machine-checked against the run's actual recorded mode before being allowed to render/export.", + "itemId": "ALD-054", + "itemTitle": "Claim-boundary enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/claim-boundary.test.ts" + ] + }, + { + "id": "ALD-054.2", + "checked": true, + "statement": "A Mode P run attempting to surface a Mode-R-only claim label is blocked with a specific error, not silently downgraded.", + "itemId": "ALD-054", + "itemTitle": "Claim-boundary enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/claim-boundary.test.ts" + ] + }, + { + "id": "ALD-054.3", + "checked": true, + "statement": "The check is exercised by an automated test for every claim statement in §5.4, not spot-checked manually.", + "itemId": "ALD-054", + "itemTitle": "Claim-boundary enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/types/__tests__/claim-boundary.test.ts" + ] + }, + { + "id": "ALD-055.1", + "checked": true, + "statement": "In Mode R, `baby-a` and `baby-b` learner processes run in distinct OS processes/containers, verified by distinct process IDs / container IDs.", + "itemId": "ALD-055", + "itemTitle": "Separate-container isolation for Mode R", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-055.2", + "checked": true, + "statement": "No in-memory object reference is shared between the two learner containers (verified by an isolation test attempting cross-container object access and observing failure).", + "itemId": "ALD-055", + "itemTitle": "Separate-container isolation for Mode R", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-055.3", + "checked": true, + "statement": "Killing one learner container does not corrupt or crash the other, or the Gateway/evidence store.", + "itemId": "ALD-055", + "itemTitle": "Separate-container isolation for Mode R", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-056.1", + "checked": true, + "statement": "Each trainable learner's update step reads only from its own adapter's local buffers, never from the counterpart process.", + "itemId": "ALD-056", + "itemTitle": "Training isolation guarantees", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-056.2", + "checked": true, + "statement": "An isolation test that attempts to smuggle counterpart-agent internal state into a training update fails to do so, confirmed by the test.", + "itemId": "ALD-056", + "itemTitle": "Training isolation guarantees", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-056.3", + "checked": true, + "statement": "Training isolation is verified specifically under Mode R container separation (`ALD-055`), not just asserted for Mode P.", + "itemId": "ALD-056", + "itemTitle": "Training isolation guarantees", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-057.1", + "checked": true, + "statement": "Every test in §6.5 runs against `scratch-rl`, `self-supervised`, and strict `hybrid`; frozen-LLM and no-learning runs are explicitly classified rather than incorrectly presented as ungrounded.", + "itemId": "ALD-057", + "itemTitle": "Semantic-leakage test battery automation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts" + ] + }, + { + "id": "ALD-057.2", + "checked": true, + "statement": "Linear-probe evaluation uses the pre-registered 95% label-shuffled confidence-interval rule.", + "itemId": "ALD-057", + "itemTitle": "Semantic-leakage test battery automation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts" + ] + }, + { + "id": "ALD-057.3", + "checked": true, + "statement": "Battery results are attached to the run's evidence record so a reviewer can see leakage-test outcomes per run.", + "itemId": "ALD-057", + "itemTitle": "Semantic-leakage test battery automation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts" + ] + }, + { + "id": "ALD-058.1", + "checked": true, + "statement": "Every API request across all twin routes produces a telemetry record with the fields §14.1 requires.", + "itemId": "ALD-058", + "itemTitle": "Telemetry event pipeline", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "ALD-058.2", + "checked": true, + "statement": "Telemetry recording failures never block or fail the underlying request (verified by fault-injection test on the telemetry sink).", + "itemId": "ALD-058", + "itemTitle": "Telemetry event pipeline", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "ALD-058.3", + "checked": true, + "statement": "Telemetry data is queryable by run ID and by time range.", + "itemId": "ALD-058", + "itemTitle": "Telemetry event pipeline", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "ALD-059.1", + "checked": true, + "statement": "Every §14.2 human view/intervention and §14.5 safety trigger produces an append-only audit record with authenticated actor and machine-readable reason.", + "itemId": "ALD-059", + "itemTitle": "Audit, intervention, and safety-event logging", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/interventions.test.ts" + ] + }, + { + "id": "ALD-059.2", + "checked": true, + "statement": "Each intervention produces a signed checkpoint, and any unplanned intervention links to an append-only notebook deviation record.", + "itemId": "ALD-059", + "itemTitle": "Audit, intervention, and safety-event logging", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/interventions.test.ts" + ] + }, + { + "id": "ALD-059.3", + "checked": true, + "statement": "Audit and intervention logs plus checkpoint references are included in every evidence bundle, not only Mode R.", + "itemId": "ALD-059", + "itemTitle": "Audit, intervention, and safety-event logging", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/interventions.test.ts" + ] + }, + { + "id": "ALD-060.1", + "checked": true, + "statement": "A manual \"take snapshot now\" action produces a snapshot file set that a restore can consume.", + "itemId": "ALD-060", + "itemTitle": "Snapshot and restore mechanism", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/snapshot.test.ts" + ] + }, + { + "id": "ALD-060.2", + "checked": true, + "statement": "Restarting the server after a snapshot automatically restores to that snapshot's state (`autoRestore()`-equivalent behavior).", + "itemId": "ALD-060", + "itemTitle": "Snapshot and restore mechanism", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/snapshot.test.ts" + ] + }, + { + "id": "ALD-060.3", + "checked": true, + "statement": "A restored run's evidence-store state matches, byte-for-byte in the chain-walk sense (`ALD-008`), the state at the moment the snapshot was taken.", + "itemId": "ALD-060", + "itemTitle": "Snapshot and restore mechanism", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/snapshot.test.ts" + ] + }, + { + "id": "ALD-061.1", + "checked": true, + "statement": "Every failure mode named in §14.5 has an implemented, tested handling path.", + "itemId": "ALD-061", + "itemTitle": "Failure handling policy implementation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts" + ] + }, + { + "id": "ALD-061.2", + "checked": true, + "statement": "An unhandled rejection thrown from a background task (e.g., a failed anchor confirmation poll) is caught, logged, and does not crash the server process, confirmed by a fault-injection test.", + "itemId": "ALD-061", + "itemTitle": "Failure handling policy implementation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts" + ] + }, + { + "id": "ALD-061.3", + "checked": true, + "statement": "The failure-handling behavior for anchoring failures reuses `ALD-021`'s retry/backoff rather than a separate ad hoc mechanism.", + "itemId": "ALD-061", + "itemTitle": "Failure handling policy implementation", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts" + ] + }, + { + "id": "ALD-062.1", + "checked": true, + "statement": "Only eligible non-public, non-mainnet run-bundle payloads are purged; public or mainnet-anchored bundles are retained indefinitely.", + "itemId": "ALD-062", + "itemTitle": "Retention policy enforcement job", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/retention.test.ts" + ] + }, + { + "id": "ALD-062.2", + "checked": true, + "statement": "`run_metadata`, ledger/channel/audit/intervention rows, checkpoint manifests, and anchor receipts remain queryable after purge.", + "itemId": "ALD-062", + "itemTitle": "Retention policy enforcement job", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/retention.test.ts" + ] + }, + { + "id": "ALD-062.3", + "checked": true, + "statement": "The job's actions are themselves audit-logged (`ALD-059`).", + "itemId": "ALD-062", + "itemTitle": "Retention policy enforcement job", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/retention.test.ts" + ] + }, + { + "id": "ALD-063.1", + "checked": true, + "statement": "The console displays live run/pre-registration state, public transcript, telemetry, verification, checkpoint, and anchor data from authoritative routes.", + "itemId": "ALD-063", + "itemTitle": "Dashboard/research console MVP", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-063.2", + "checked": true, + "statement": "Baby A/B observations and audit ledgers render in clearly separated read-only panels, and operator controls are role-gated and audited.", + "itemId": "ALD-063", + "itemTitle": "Dashboard/research console MVP", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-063.3", + "checked": true, + "statement": "The console is a self-contained vanilla HTML/CSS/JS page with no frontend framework, build tool, or client-side side-channel route.", + "itemId": "ALD-063", + "itemTitle": "Dashboard/research console MVP", + "disposition": "provisional-mapped", + "assertionPaths": [ + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-064.1", + "checked": true, + "statement": "Every generated interpretation references source native events and is explicitly labeled external analysis.", + "itemId": "ALD-064", + "itemTitle": "Human audit-ledger Interpreter", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "ALD-064.2", + "checked": true, + "statement": "Baby identities cannot read audit-ledger entries, while authorized researchers and the verifier can.", + "itemId": "ALD-064", + "itemTitle": "Human audit-ledger Interpreter", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "ALD-064.3", + "checked": true, + "statement": "Audit entries are included in their checkpoint auxiliary tree and exported evidence without modifying native ledger events.", + "itemId": "ALD-064", + "itemTitle": "Human audit-ledger Interpreter", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "ALD-065.1", + "checked": true, + "statement": "Every reused component is listed with its permitted §16.2 mapping, and no Diplomacy game logic or side-channel route is imported.", + "itemId": "ALD-065", + "itemTitle": "Prohibited UX pattern review checklist", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-research-console.mjs" + ] + }, + { + "id": "ALD-065.2", + "checked": true, + "statement": "Any failing item blocks `ALD-063` from being marked `Done` until resolved.", + "itemId": "ALD-065", + "itemTitle": "Prohibited UX pattern review checklist", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-research-console.mjs" + ] + }, + { + "id": "ALD-065.3", + "checked": true, + "statement": "Every §16.3 prohibition has a pass/fail result, and the checklist reruns after material dashboard changes.", + "itemId": "ALD-065", + "itemTitle": "Prohibited UX pattern review checklist", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-research-console.mjs" + ] + }, + { + "id": "ALD-066.1", + "checked": true, + "statement": "Same-seed scenario replay reproduces scenario/observation hashes; wrong-seed replay fails automatically.", + "itemId": "ALD-066", + "itemTitle": "Automated replay fidelity and viewer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-066.2", + "checked": true, + "statement": "Deterministic adapters reproduce the §14.3 replay digest, while nondeterministic adapters are explicitly `not-applicable` and pass recorded-decision playback only.", + "itemId": "ALD-066", + "itemTitle": "Automated replay fidelity and viewer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-066.3", + "checked": true, + "statement": "The viewer displays machine results and remains read-only; it cannot alter evidence or override a failure.", + "itemId": "ALD-066", + "itemTitle": "Automated replay fidelity and viewer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "ALD-067.1", + "checked": true, + "statement": "The harness includes at least one active exploit attempt per side-channel category named in §10.3.", + "itemId": "ALD-067", + "itemTitle": "Side-channel red-team harness", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/side-channel.test.ts" + ] + }, + { + "id": "ALD-067.2", + "checked": true, + "statement": "Every Research-Grade exploit attempt fails to extract cross-agent information, confirmed by an automated pass/fail check, while Mode P remains the explicit comparison condition.", + "itemId": "ALD-067", + "itemTitle": "Side-channel red-team harness", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/side-channel.test.ts" + ] + }, + { + "id": "ALD-067.3", + "checked": true, + "statement": "The harness runs under both Mode P and Mode R (`ALD-053`) so `E01`'s isolation comparison is possible.", + "itemId": "ALD-067", + "itemTitle": "Side-channel red-team harness", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/side-channel.test.ts" + ] + }, + { + "id": "ALD-068.1", + "checked": true, + "statement": "The suite includes direct text, low-contrast/OCR-evasion fixtures, metadata labels, semantic filenames, and malformed-image cases.", + "itemId": "ALD-068", + "itemTitle": "Observation-text and quarantine-bypass red-team suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/observation-and-measurement.test.ts" + ] + }, + { + "id": "ALD-068.2", + "checked": true, + "statement": "Every positive fixture is quarantined and zero raw text reaches an adapter context; negative text-free controls remain loadable.", + "itemId": "ALD-068", + "itemTitle": "Observation-text and quarantine-bypass red-team suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/observation-and-measurement.test.ts" + ] + }, + { + "id": "ALD-068.3", + "checked": true, + "statement": "Results are exported as hashed evidence linked from the E02 Experiment Record, not misrepresented as the Experiment Record itself.", + "itemId": "ALD-068", + "itemTitle": "Observation-text and quarantine-bypass red-team suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/redteam/__tests__/observation-and-measurement.test.ts" + ] + }, + { + "id": "ALD-069.1", + "checked": true, + "statement": "The harness supports at least three roles in a single run: two communicating learners and one eavesdropper observer.", + "itemId": "ALD-069", + "itemTitle": "Ephemeral encoding and adversarial cryptography research harness", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/crypto-research/__tests__/harness.test.ts" + ] + }, + { + "id": "ALD-069.2", + "checked": true, + "statement": "The harness logs every encoding scheme change as a distinct, timestamped event so `E40` can measure encoding lifetime.", + "itemId": "ALD-069", + "itemTitle": "Ephemeral encoding and adversarial cryptography research harness", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/crypto-research/__tests__/harness.test.ts" + ] + }, + { + "id": "ALD-069.3", + "checked": true, + "statement": "The harness itself makes no claim about cryptographic security — it only provides the measurement/instrumentation `E40`'s research execution needs; a passing harness build is not a security claim.", + "itemId": "ALD-069", + "itemTitle": "Ephemeral encoding and adversarial cryptography research harness", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/crypto-research/__tests__/harness.test.ts" + ] + }, + { + "id": "ALD-070.1", + "checked": true, + "statement": "A written policy statement exists distinguishing \"research-harness encoding\" from \"production cryptographic signing,\" citing `ALD-009` and `ALD-019` as the only production mechanisms.", + "itemId": "ALD-070", + "itemTitle": "Cryptographic novelty-vs-security separation policy", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/crypto-research/__tests__/boundary.test.ts" + ] + }, + { + "id": "ALD-070.2", + "checked": true, + "statement": "A code-level check (e.g., module boundary or lint rule) prevents any `ALD-069`-harness-derived code from being imported into the `ALD-009`/`ALD-019` signing modules.", + "itemId": "ALD-070", + "itemTitle": "Cryptographic novelty-vs-security separation policy", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/crypto-research/__tests__/boundary.test.ts" + ] + }, + { + "id": "ALD-070.3", + "checked": true, + "statement": "This policy is included in the documentation set (`ALD-079`) and referenced by `E40`'s readiness gate (`ALD-077`).", + "itemId": "ALD-070", + "itemTitle": "Cryptographic novelty-vs-security separation policy", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/crypto-research/__tests__/boundary.test.ts" + ] + }, + { + "id": "ALD-071.1", + "checked": true, + "statement": "A confirmatory run cannot start without a bound experiment ID, protocol commit, complete repository-native or external registration, canonical pre-registration hash, and matching pre-run commitment receipt whose simulated/public class matches the run configuration.", + "itemId": "ALD-071", + "itemTitle": "Pre-registration binding and Experiment Record writer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-071.2", + "checked": true, + "statement": "Pre-registration creates Experiment Record version `1`; later disposition/evidence changes append higher versions without updating prior rows.", + "itemId": "ALD-071", + "itemTitle": "Pre-registration binding and Experiment Record writer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-071.3", + "checked": true, + "statement": "Every evidence bundle contains the latest record plus its version history and references resolvable by an independent reviewer.", + "itemId": "ALD-071", + "itemTitle": "Pre-registration binding and Experiment Record writer", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "ALD-072.1", + "checked": true, + "statement": "An intervention defined in a pre-registration reference (`ALD-071`) can be toggled on/off for a run via configuration, with no code change per intervention.", + "itemId": "ALD-072", + "itemTitle": "Intervention test suite and baseline/statistics scaffold", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "ALD-072.2", + "checked": true, + "statement": "The scaffold computes every baseline statistic named in §15.3 over a completed run's evidence.", + "itemId": "ALD-072", + "itemTitle": "Intervention test suite and baseline/statistics scaffold", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "ALD-072.3", + "checked": true, + "statement": "The scaffold's output is a data structure ready for a researcher's downstream analysis — it does not itself draw or store scientific conclusions.", + "itemId": "ALD-072", + "itemTitle": "Intervention test suite and baseline/statistics scaffold", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "ALD-073.1", + "checked": true, + "statement": "`E00` readiness: the verifier accepts an unchanged anchored bundle and rejects all 14 mutation/anchor cases in LEDGER §17, including wrong-chain anchors and unanchored tails.", + "itemId": "ALD-073", + "itemTitle": "Gate G1: Integrity and isolation readiness (E00–E03)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-073.2", + "checked": true, + "statement": "`E01`/`E02` readiness: `ALD-067`/`ALD-068` red-team suites are green.", + "itemId": "ALD-073", + "itemTitle": "Gate G1: Integrity and isolation readiness (E00–E03)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-073.3", + "checked": true, + "statement": "`E03` readiness: `ALD-029`/`ALD-036` run all six controls with dual-hash evidence, while `ALD-041`/`ALD-042`/`ALD-072` provide deterministic scenarios, no-learning behavior, confidence intervals, and effect sizes.", + "itemId": "ALD-073", + "itemTitle": "Gate G1: Integrity and isolation readiness (E00–E03)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-074.1", + "checked": true, + "statement": "`E10`/`E11`/`E12` readiness: `ALD-044`/`ALD-045`/`ALD-046` complete full turns; ungrounded tracks pass training isolation and semantic-leakage qualification.", + "itemId": "ALD-074", + "itemTitle": "Gate G2: Model-track and protocol readiness (E10–E16)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-074.2", + "checked": true, + "statement": "`E13` readiness: all `ALD-031` alternate carriers pass conformance and `ALD-032` leakage evaluation can compare all five notebook carrier conditions.", + "itemId": "ALD-074", + "itemTitle": "Gate G2: Model-track and protocol readiness (E10–E16)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-074.3", + "checked": true, + "statement": "`E14`/`E15`/`E16` readiness: `ALD-072` supports role reversal, held-out splits, and causal interventions, and `ALD-064` produces separately labeled human audit interpretations.", + "itemId": "ALD-074", + "itemTitle": "Gate G2: Model-track and protocol readiness (E10–E16)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-075.1", + "checked": true, + "statement": "`E20` readiness: `ALD-033` affect protocol independently passes conformance with all six displays exercised.", + "itemId": "ALD-075", + "itemTitle": "Gate G3: Affect and learning-comparison readiness (E20–E22)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/interventions/__tests__/readiness-gates.test.ts" + ] + }, + { + "id": "ALD-075.2", + "checked": true, + "statement": "`E21` readiness: no-learning (`ALD-042`), frozen-LLM (`ALD-044`), extrinsic/intrinsic scratch-RL (`ALD-045`), and self-supervised (`ALD-046`) modes run under the same scenario configuration.", + "itemId": "ALD-075", + "itemTitle": "Gate G3: Affect and learning-comparison readiness (E20–E22)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/interventions/__tests__/readiness-gates.test.ts" + ] + }, + { + "id": "ALD-075.3", + "checked": true, + "statement": "`E22` readiness: `ALD-072`'s scaffold supports a staged/curriculum sequence of interventions within a single run.", + "itemId": "ALD-075", + "itemTitle": "Gate G3: Affect and learning-comparison readiness (E20–E22)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/interventions/__tests__/readiness-gates.test.ts" + ] + }, + { + "id": "ALD-076.1", + "checked": true, + "statement": "`E30` readiness: `ALD-028` derived-run support can substitute a new learner adapter in the child run without altering the parent.", + "itemId": "ALD-076", + "itemTitle": "Gate G4: Transfer and negotiation-scenario readiness (E30–E32)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-076.2", + "checked": true, + "statement": "`E31` readiness: `ALD-072`'s baseline/statistics scaffold can compare metrics across a long sequence of turns/checkpoints for drift analysis.", + "itemId": "ALD-076", + "itemTitle": "Gate G4: Transfer and negotiation-scenario readiness (E30–E32)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-076.3", + "checked": true, + "statement": "`E32` readiness: `ALD-041` produces each pre-registered cooperative and negotiation utility condition from configuration without a code change.", + "itemId": "ALD-076", + "itemTitle": "Gate G4: Transfer and negotiation-scenario readiness (E30–E32)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-077.1", + "checked": true, + "statement": "`E40` readiness: `ALD-069` harness and `ALD-070` separation policy both pass their own acceptance criteria.", + "itemId": "ALD-077", + "itemTitle": "Gate G5: Cryptography-research and replication readiness (E40, E50)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/interventions/__tests__/readiness-gates.test.ts" + ] + }, + { + "id": "ALD-077.2", + "checked": true, + "statement": "`E50` readiness: `ALD-028` derived-run support and `ALD-072`'s scaffold together launch the same pre-registered configuration across independent seeds and aggregate baseline statistics.", + "itemId": "ALD-077", + "itemTitle": "Gate G5: Cryptography-research and replication readiness (E40, E50)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/interventions/__tests__/readiness-gates.test.ts" + ] + }, + { + "id": "ALD-077.3", + "checked": true, + "statement": "This gate's checklist cross-references the `EXPERIMENT-NOTEBOOK.md` [§12. Publication Checklist](EXPERIMENT-NOTEBOOK.md#12-publication-checklist) items that are software-verifiable, without asserting any of the checklist's research-judgment items are satisfied.", + "itemId": "ALD-077", + "itemTitle": "Gate G5: Cryptography-research and replication readiness (E40, E50)", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/interventions/__tests__/readiness-gates.test.ts" + ] + }, + { + "id": "ALD-078.1", + "checked": false, + "statement": "CI runs on every proposed change and blocks merge on any failing suite.", + "itemId": "ALD-078", + "itemTitle": "CI pipeline and consolidated test suite", + "disposition": "external-blocked", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "ALD-078.2", + "checked": true, + "statement": "The consolidated suite includes at least one test per `Done` item's acceptance criteria at the time CI is stood up.", + "itemId": "ALD-078", + "itemTitle": "CI pipeline and consolidated test suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "ALD-078.3", + "checked": true, + "statement": "CI run time and flakiness are tracked; a flaky test is quarantined with a tracked follow-up, not silently ignored.", + "itemId": "ALD-078", + "itemTitle": "CI pipeline and consolidated test suite", + "disposition": "provisional-mapped", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "ALD-079.1", + "checked": true, + "statement": "Every route implemented in EPIC-09 has a corresponding API reference entry with request/response shape.", + "itemId": "ALD-079", + "itemTitle": "Architecture/API/operations documentation set", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-api-docs.mjs", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-079.2", + "checked": true, + "statement": "The Mode R runbook lets an operator who has not read the source code stand up an isolated run following the documented steps alone.", + "itemId": "ALD-079", + "itemTitle": "Architecture/API/operations documentation set", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-api-docs.mjs", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-079.3", + "checked": false, + "statement": "The snapshot/restore runbook is validated by having someone other than the implementer follow it to perform a real restore.", + "itemId": "ALD-079", + "itemTitle": "Architecture/API/operations documentation set", + "disposition": "external-blocked", + "assertionPaths": [ + "scripts/check-api-docs.mjs", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "ALD-080.1", + "checked": true, + "statement": "A documented release process exists (version scheme, changelog convention, tagging) consistent with §1's normative-language conventions.", + "itemId": "ALD-080", + "itemTitle": "Release process and publication-checklist mapping", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-080.2", + "checked": true, + "statement": "Every software-verifiable item in the §12 publication checklist is mapped to at least one `ALD-XXX` ID or Gate (`ALD-073`–`077`).", + "itemId": "ALD-080", + "itemTitle": "Release process and publication-checklist mapping", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-080.3", + "checked": true, + "statement": "The mapping explicitly flags which §12 checklist items are research-judgment calls outside this backlog's scope (e.g., \"results support the stated hypothesis\"), rather than silently omitting them.", + "itemId": "ALD-080", + "itemTitle": "Release process and publication-checklist mapping", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "ALD-081.1", + "checked": true, + "statement": "A clean frozen-lockfile install followed by `pnpm audit --audit-level=high` exits zero.", + "itemId": "ALD-081", + "itemTitle": "Dependency-integrity enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "ALD-081.2", + "checked": true, + "statement": "Hosted consolidated CI retains the audit JSON even when another check fails.", + "itemId": "ALD-081", + "itemTitle": "Dependency-integrity enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "ALD-081.3", + "checked": true, + "statement": "Required native dependency install scripts are explicitly allowlisted by exact package version.", + "itemId": "ALD-081", + "itemTitle": "Dependency-integrity enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "ALD-082.1", + "checked": true, + "statement": "A fixed seed reproduces byte-identical 30,000-replicate design output.", + "itemId": "ALD-082", + "itemTitle": "Reproducible E03 design simulation and seed manifest", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03-design.test.ts" + ] + }, + { + "id": "ALD-082.2", + "checked": true, + "statement": "Every registered SD/sample-size row has a lower 95% Monte Carlo bound at or above the 90% power floor.", + "itemId": "ALD-082", + "itemTitle": "Reproducible E03 design simulation and seed manifest", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03-design.test.ts" + ] + }, + { + "id": "ALD-082.3", + "checked": true, + "statement": "The committed seed manifest deterministically derives separate scenario, random-control, and shuffled-control seeds without exposing realized confirmatory outcomes.", + "itemId": "ALD-082", + "itemTitle": "Reproducible E03 design simulation and seed manifest", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03-design.test.ts" + ] + }, + { + "id": "ALD-083.1", + "checked": true, + "statement": "The compiler output validates as a `PreRegistrationArtifact` and is byte-identical on repeat.", + "itemId": "ALD-083", + "itemTitle": "Canonical E03 registration compiler", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03-registration.test.ts" + ] + }, + { + "id": "ALD-083.2", + "checked": true, + "statement": "Run IDs, realized random seeds, execution bindings, and the pre-registration hash are excluded from the hashed parameter template.", + "itemId": "ALD-083", + "itemTitle": "Canonical E03 registration compiler", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03-registration.test.ts" + ] + }, + { + "id": "ALD-083.3", + "checked": true, + "statement": "Every primary and reserve condition configuration carries the same exact pre-registration hash.", + "itemId": "ALD-083", + "itemTitle": "Canonical E03 registration compiler", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03-registration.test.ts" + ] + }, + { + "id": "ALD-084.1", + "checked": true, + "statement": "Human and JSON outputs independently check Mode R, learning isolation, artifact class, hashes, immutable ancestral commits, seed count, complete registration, and confirmed matching pre-run commitment.", + "itemId": "ALD-084", + "itemTitle": "Fail-closed research preflight", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/research-preflight.test.ts" + ] + }, + { + "id": "ALD-084.2", + "checked": true, + "statement": "A missing repository-native/external registration, mismatched binding, or absent/incorrect commitment blocks confirmatory readiness.", + "itemId": "ALD-084", + "itemTitle": "Fail-closed research preflight", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/research-preflight.test.ts" + ] + }, + { + "id": "ALD-084.3", + "checked": true, + "statement": "A passing preflight explicitly does not claim public-chain verification or scientific validity.", + "itemId": "ALD-084", + "itemTitle": "Fail-closed research preflight", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/research-preflight.test.ts" + ] + }, + { + "id": "ALD-085.1", + "checked": true, + "statement": "The report binds the exact weight-file hash, inference-runtime archive hash, model identity, quantization, software commit, and seed hash.", + "itemId": "ALD-085", + "itemTitle": "Real open-weight frozen-model qualification", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-qualification.test.ts" + ] + }, + { + "id": "ALD-085.2", + "checked": true, + "statement": "At least two episodes exercise intention and interpretation in both Baby roles with tool-only conformance and no policy updates.", + "itemId": "ALD-085", + "itemTitle": "Real open-weight frozen-model qualification", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-qualification.test.ts" + ] + }, + { + "id": "ALD-085.3", + "checked": true, + "statement": "The committed report is labeled non-confirmatory and omits prompts, observations, raw output, candidate references, and private ledger content.", + "itemId": "ALD-085", + "itemTitle": "Real open-weight frozen-model qualification", + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/learners/__tests__/frozen-qualification.test.ts" + ] + }, + { + "id": "ALD-086.1", + "checked": true, + "statement": "The checker derives acceptance totals from backlog checkboxes rather than accepting a manually supplied number.", + "itemId": "ALD-086", + "itemTitle": "Project-status drift enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-project-status.mjs" + ] + }, + { + "id": "ALD-086.2", + "checked": true, + "statement": "Root package/lock versions and the public README/manuscript engineering snapshot must match exactly.", + "itemId": "ALD-086", + "itemTitle": "Project-status drift enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-project-status.mjs" + ] + }, + { + "id": "ALD-086.3", + "checked": true, + "statement": "Both local and hosted consolidated checks run the drift checker before build and tests.", + "itemId": "ALD-086", + "itemTitle": "Project-status drift enforcement", + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-project-status.mjs" + ] + } + ], + "normativeRequirements": [ + { + "id": "SPEC-MUST-L101", + "source": "SPECIFICATION.md:101", + "section": 2, + "statement": "The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** in this document are to be interpreted as described in RFC 2119. **MUST**/**MUST NOT** denote requirements whose violation makes a run's evidence or isolation claim invalid. **SHOULD**/**SHOULD NOT** denote strong defaults that a pre-registered protocol amendment may override, with the deviation recorded per EXPERIMENT-NOTEBOOK.md §3 and §9. **MAY** denotes an explicitly permitted implementation choice.", + "applicable": false, + "backlogItems": [ + "ALD-078" + ], + "disposition": "not-applicable-definition", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "SPEC-MUST-L102", + "source": "SPECIFICATION.md:102", + "section": 2, + "statement": "The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** in this document are to be interpreted as described in RFC 2119. **MUST**/**MUST NOT** denote requirements whose violation makes a run's evidence or isolation claim invalid. **SHOULD**/**SHOULD NOT** denote strong defaults that a pre-registered protocol amendment may override, with the deviation recorded per EXPERIMENT-NOTEBOOK.md §3 and §9. **MAY** denotes an explicitly permitted implementation choice.", + "applicable": false, + "backlogItems": [ + "ALD-078" + ], + "disposition": "not-applicable-definition", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "SPEC-MUST-L109", + "source": "SPECIFICATION.md:109", + "section": 2, + "statement": "A component \"conforms\" to this specification if it satisfies every MUST that applies to its role. A **run** conforms if every component it used conformed, and if the run's disposition (valid/invalid/aborted, per §7) was recorded honestly.", + "applicable": true, + "backlogItems": [ + "ALD-078" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + ".github/workflows/book-integrity.yml" + ] + }, + { + "id": "SPEC-MUST-L188", + "source": "SPECIFICATION.md:188", + "section": 4, + "statement": "1. **Baby A twin** and **Baby B twin** — host a `LearnerAdapter`, private observation buffer, private memory, and an append-only client to their own Ledger Writer. Neither twin has any API, tool, or state reference to the other twin (§4.2). 2. **Nursery Controller / BabySitter twin** — a single twin (resolves Q7) that runs the run/turn state machine, invokes the Scenario Engine and Symbol Gateway, drives dashboards, and performs read-only audit. It MUST NOT expose a route that lets one Baby read the other Baby's state, ledger, or observation. 3. **Symbol Gateway** — deterministic; the only network path between Baby A and Baby B. Owns validation, metering, atomic commit, and rejection logging for fixed-token, generative-carrier, and affect artifacts. 4. **Scenario Engine** — deterministic; generates scenario instances and held-out sets from `(seed, scenarioBundleConfig)`. Contains no learned model and no model-derived randomness so that scenario generation is independently reproducible from the seed alone. 5. **Model Adapters** — one `LearnerAdapter` process per Baby per track (§6). 6. **Event Signers** — isolated signers for Baby A ledger, Baby B ledger, channel, affect, and audit events. Each holds only its own per-run Ed25519 private key and signs only its own event domain. 7. **Evidence Writer and Store** — the single serialization and SQLite transaction owner. It assigns sequences, obtains signatures from the appropriate Event Signers, atomically inserts related events into SQLite WAL, and exports JSONL evidence (LEDGER-INTEGRITY-DESIGN.md §3). No other component writes event tables. 8. **Checkpoint Service** — builds ordered Merkle trees and signs checkpoint manifests with the Nursery witness key. 9. **Verifier** — a standalone CLI/library, run outside the production runtime, that only trusts the exported evidence bundle and independent chain data. 10. **Base Anchor Publisher** — holds the anchor wallet key; submits and confirms checkpoint-hash transactions. 11. **Dashboard / Research Console** — the DTSF per-twin dashboards plus a Nursery-wide console (§16). 12. **Audit Interpreter** — a delayed, read-only consumer of agent-native ledger state that submits clearly labeled generated interpretations through the private Evidence Writer API; it has no Baby- or Gateway-facing route (§13.6).", + "applicable": true, + "backlogItems": [ + "ALD-001", + "ALD-004", + "ALD-010", + "ALD-029" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/types/__tests__/schema-manifest.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L219", + "source": "SPECIFICATION.md:219", + "section": 4, + "statement": "| Boundary | Enforced by | What crosses | What MUST NOT cross |", + "applicable": false, + "backlogItems": [ + "ALD-001", + "ALD-004", + "ALD-010", + "ALD-029" + ], + "disposition": "not-applicable-definition", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/types/__tests__/schema-manifest.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L227", + "source": "SPECIFICATION.md:227", + "section": 4, + "statement": "| Nursery Controller ⇄ Symbol Gateway / Scenario Engine | Internal RPC | Run configuration, scenario requests, audit reads | Nursery Controller MUST NOT inject content into a Baby's proposal or override Gateway validation |", + "applicable": true, + "backlogItems": [ + "ALD-001", + "ALD-004", + "ALD-010", + "ALD-029" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/types/__tests__/schema-manifest.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L228", + "source": "SPECIFICATION.md:228", + "section": 4, + "statement": "| Nursery Controller ⇄ Evidence Store | Read/write per role (§12.2) | Run, ledger, channel, checkpoint records | Nursery Controller MUST NOT delete or rewrite committed rows (§13.1) |", + "applicable": true, + "backlogItems": [ + "ALD-001", + "ALD-004", + "ALD-010", + "ALD-029" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/types/__tests__/schema-manifest.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L230", + "source": "SPECIFICATION.md:230", + "section": 4, + "statement": "| Runtime ⇄ Verifier | None at run time | The exported evidence bundle only, after sealing | Any live runtime trust; the Verifier MUST be runnable with zero access to the production database or keys |", + "applicable": true, + "backlogItems": [ + "ALD-001", + "ALD-004", + "ALD-010", + "ALD-029" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/types/__tests__/schema-manifest.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L242", + "source": "SPECIFICATION.md:242", + "section": 4, + "statement": "| Component | MUST | MUST NOT |", + "applicable": false, + "backlogItems": [ + "ALD-001", + "ALD-004", + "ALD-010", + "ALD-029" + ], + "disposition": "not-applicable-definition", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/types/__tests__/schema-manifest.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L281", + "source": "SPECIFICATION.md:281", + "section": 5, + "statement": "**Claim boundary (MUST be stated verbatim in any Mode P report):** \"This run used Prototype Mode isolation. It demonstrates protocol, ledger, and orchestration correctness. It does not support a channel-isolation or side-channel-resistance claim, because both Babies executed in the same process.\"", + "applicable": true, + "backlogItems": [ + "ALD-053", + "ALD-054", + "ALD-055" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/types/__tests__/claim-boundary.test.ts", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "SPEC-MUST-L298", + "source": "SPECIFICATION.md:298", + "section": 5, + "statement": "**Claim boundary (MUST be stated verbatim in any Mode R report):** \"This run used Research-Grade Mode isolation under the threat model in §10.3. It supports a practical side-channel-resistance claim against the enumerated channels. It is not a formally verified isolation proof and does not rule out every conceivable physical or computational side channel (CONCEPT-IDEA.md §10).\"", + "applicable": true, + "backlogItems": [ + "ALD-053", + "ALD-054", + "ALD-055" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/types/__tests__/claim-boundary.test.ts", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "SPEC-MUST-L311", + "source": "SPECIFICATION.md:311", + "section": 5, + "statement": "| Turn timing normalization | SHOULD | MUST |", + "applicable": true, + "backlogItems": [ + "ALD-053", + "ALD-054", + "ALD-055" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/types/__tests__/claim-boundary.test.ts", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "SPEC-MUST-L317", + "source": "SPECIFICATION.md:317", + "section": 5, + "statement": "Every experiment record (§11.9) and every EXPERIMENT-NOTEBOOK.md run record MUST carry a `deploymentMode` field with value `prototype` or `research-grade`, and any publication MUST reproduce the matching claim-boundary sentence from §5.1 or §5.2 verbatim (resolves Q8).", + "applicable": true, + "backlogItems": [ + "ALD-053", + "ALD-054", + "ALD-055" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/types/__tests__/claim-boundary.test.ts", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "SPEC-MUST-L319", + "source": "SPECIFICATION.md:319", + "section": 5, + "statement": "Every experiment record (§11.9) and every EXPERIMENT-NOTEBOOK.md run record MUST carry a `deploymentMode` field with value `prototype` or `research-grade`, and any publication MUST reproduce the matching claim-boundary sentence from §5.1 or §5.2 verbatim (resolves Q8).", + "applicable": true, + "backlogItems": [ + "ALD-053", + "ALD-054", + "ALD-055" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/types/__tests__/claim-boundary.test.ts", + "scripts/run-mode-r-smoke.mjs" + ] + }, + { + "id": "SPEC-MUST-L328", + "source": "SPECIFICATION.md:328", + "section": 6, + "statement": "| `frozen-llm` | Frozen pretrained LLM | Small open-weight instruction model (3B-8B default, §6.7), weights frozen, adapts only via private memory/ledger | MUST NOT be described as first-language acquisition; studies new external protocol invention |", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L334", + "source": "SPECIFICATION.md:334", + "section": 6, + "statement": "**Default initial baseline (resolves Q1):** implementations MUST support all five track IDs behind the identical Nursery interface. The default first-implemented pair, matching CONCEPT-IDEA.md §20.9, is three parallel Baby pairs: `frozen-llm` (orchestration validation), `scratch-rl` (primary scientific baseline), and `self-supervised` (reward-free comparison). `hybrid` and `no-learning` MUST be implementable behind the same `LearnerAdapter` interface before E11/E12 begin, but MAY be scheduled after the initial three pairs are stable.", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L338", + "source": "SPECIFICATION.md:338", + "section": 6, + "statement": "**Default initial baseline (resolves Q1):** implementations MUST support all five track IDs behind the identical Nursery interface. The default first-implemented pair, matching CONCEPT-IDEA.md §20.9, is three parallel Baby pairs: `frozen-llm` (orchestration validation), `scratch-rl` (primary scientific baseline), and `self-supervised` (reward-free comparison). `hybrid` and `no-learning` MUST be implementable behind the same `LearnerAdapter` interface before E11/E12 begin, but MAY be scheduled after the initial three pairs are stable.", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L378", + "source": "SPECIFICATION.md:378", + "section": 6, + "statement": "`updatePolicy` MUST run inside the same process/container as the rest of that Baby's adapter in Mode R; it MUST NOT read the other Baby's replay buffer, gradients, or optimizer state (resolves the \"independent policy updates\" default and Q11's within-architecture requirement, because `extrinsic-reward MARL`, `intrinsic-motivation MARL`, `self-supervised`, and `no-learning` are all expressible as variants of the same adapter interface and, where feasible, the same recurrent backbone, differing only in the reward/update-rule fields of `UpdateBatch`).", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L379", + "source": "SPECIFICATION.md:379", + "section": 6, + "statement": "`updatePolicy` MUST run inside the same process/container as the rest of that Baby's adapter in Mode R; it MUST NOT read the other Baby's replay buffer, gradients, or optimizer state (resolves the \"independent policy updates\" default and Q11's within-architecture requirement, because `extrinsic-reward MARL`, `intrinsic-motivation MARL`, `self-supervised`, and `no-learning` are all expressible as variants of the same adapter interface and, where feasible, the same recurrent backbone, differing only in the reward/update-rule fields of `UpdateBatch`).", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L390", + "source": "SPECIFICATION.md:390", + "section": 6, + "statement": "A Baby twin exposes **no** general chat/response surface. Public-action tools use the discriminated `AgentActionProposal` variants below, and every public tool call MUST include its private intention `LedgerEventDraft` in the enclosing `TurnProposalEnvelope`. The approved action variants are:", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L404", + "source": "SPECIFICATION.md:404", + "section": 6, + "statement": "The Gateway MUST reject any accompanying free text even when a valid tool call is also present (CONCEPT-IDEA.md §20.4). Exactly one of `emit_symbols` or the selected alternate-carrier tool is available per run, selected by `carrierMode` (§9); never more than one carrier family. In `affectMode: \"derived\"`, `submit_affect` is unavailable: the Gateway calls `measureAffect()` after the pre-registered outcome event and performs the fixed mapping itself.", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L416", + "source": "SPECIFICATION.md:416", + "section": 6, + "statement": "- MUST NOT contain example symbol-meaning pairs, sample exchanges, or a suggested default vocabulary (CONCEPT-IDEA.md §20.3); - MUST state the tool-only constraint, the prohibition on constructing an alternate channel, and the instruction to preserve rather than overwrite contradictory evidence; - MUST be linted automatically for the banned patterns above before a run may reference it (a build/CI check, not only a human review step); - is immutable once referenced by a sealed run; a change requires a new version number, recorded as `promptBundleHash` in the run manifest (LEDGER-INTEGRITY- DESIGN.md §8) and as `learnerContractVersion` in the experiment record (§11.9).", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L418", + "source": "SPECIFICATION.md:418", + "section": 6, + "statement": "- MUST NOT contain example symbol-meaning pairs, sample exchanges, or a suggested default vocabulary (CONCEPT-IDEA.md §20.3); - MUST state the tool-only constraint, the prohibition on constructing an alternate channel, and the instruction to preserve rather than overwrite contradictory evidence; - MUST be linted automatically for the banned patterns above before a run may reference it (a build/CI check, not only a human review step); - is immutable once referenced by a sealed run; a change requires a new version number, recorded as `promptBundleHash` in the run manifest (LEDGER-INTEGRITY- DESIGN.md §8) and as `learnerContractVersion` in the experiment record (§11.9).", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L421", + "source": "SPECIFICATION.md:421", + "section": 6, + "statement": "- MUST NOT contain example symbol-meaning pairs, sample exchanges, or a suggested default vocabulary (CONCEPT-IDEA.md §20.3); - MUST state the tool-only constraint, the prohibition on constructing an alternate channel, and the instruction to preserve rather than overwrite contradictory evidence; - MUST be linted automatically for the banned patterns above before a run may reference it (a build/CI check, not only a human review step); - is immutable once referenced by a sealed run; a change requires a new version number, recorded as `promptBundleHash` in the run manifest (LEDGER-INTEGRITY- DESIGN.md §8) and as `learnerContractVersion` in the experiment record (§11.9).", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L432", + "source": "SPECIFICATION.md:432", + "section": 6, + "statement": "Before an adapter variant may be used in an \"initially ungrounded\" (`scratch-rl`, `self-supervised`, or strict `hybrid`) claim, it MUST pass:", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L452", + "source": "SPECIFICATION.md:452", + "section": 6, + "statement": "The learner-contract text in CONCEPT-IDEA.md §20.3 is the normative template for the `frozen-llm` track's system prompt. This specification does not repeat it in full; implementations MUST use that template verbatim as the v1 contract body, subject only to the linting rule in §6.4.", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L469", + "source": "SPECIFICATION.md:469", + "section": 6, + "statement": "| BabySitter narration | MAY use a larger reasoning model for audit summaries only; MUST NOT feed its output back into either Baby during a run (CONCEPT-IDEA.md §20.7) |", + "applicable": true, + "backlogItems": [ + "ALD-042", + "ALD-043", + "ALD-044", + "ALD-045", + "ALD-046", + "ALD-047", + "ALD-057" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/leakage/__tests__/semantic-leakage.test.ts", + "packages/learners/__tests__/frozen-llm-conformance.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/learners/__tests__/llm-client.test.ts", + "packages/learners/__tests__/no-learning.test.ts", + "packages/learners/__tests__/predictive-model.test.ts", + "packages/learners/__tests__/recurrent-adapters.test.ts", + "packages/learners/__tests__/recurrent-model.test.ts", + "packages/learners/__tests__/self-supervised.test.ts", + "packages/learners/__tests__/tabular-reinforce.test.ts", + "packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts", + "packages/orchestrator/__tests__/self-supervised-evidence.test.ts", + "packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts", + "scripts/lint-learner-contracts.mjs", + "scripts/run-mode-r-study.mjs" + ] + }, + { + "id": "SPEC-MUST-L508", + "source": "SPECIFICATION.md:508", + "section": 7, + "statement": "| `sealing` | export + anchor complete | `sealed` | Verifier MUST pass before disposition is marked `valid` in the notebook |", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L516", + "source": "SPECIFICATION.md:516", + "section": 7, + "statement": "- **Pause:** MAY be issued by a `researcher-operator` or by an automated safety trigger (§14.5). A pause MUST NOT discard an already-committed turn transaction; it MUST prevent a new turn from starting. A checkpoint MUST be produced at pause. - **Abort:** terminates the run early. It MUST still produce `run.sealed`, a final checkpoint, and attempt a final anchor (LEDGER-INTEGRITY-DESIGN.md §15). A run without a confirmed final anchor cannot be `valid`; it follows the `sealing-blocked` path above. Failure evidence is part of the research record and MUST NOT be deleted from the run index (EXPERIMENT-NOTEBOOK.md §3). - **Recovery:** on restart, the runtime MUST load the last valid entry/checkpoint hashes, verify the committed prefix, continue with the next sequence number, and create an explicit recovery event (`sequence` numbers are never reused or truncated). - **Fork:** if two entries claim the same Baby and sequence with different hashes, the run transitions to `forked-invalid`. Both conflicting artifacts MUST be preserved. The run's evidence is invalid for scientific interpretation until a research-integrity review is logged in EXPERIMENT-NOTEBOOK.md §10.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L517", + "source": "SPECIFICATION.md:517", + "section": 7, + "statement": "- **Pause:** MAY be issued by a `researcher-operator` or by an automated safety trigger (§14.5). A pause MUST NOT discard an already-committed turn transaction; it MUST prevent a new turn from starting. A checkpoint MUST be produced at pause. - **Abort:** terminates the run early. It MUST still produce `run.sealed`, a final checkpoint, and attempt a final anchor (LEDGER-INTEGRITY-DESIGN.md §15). A run without a confirmed final anchor cannot be `valid`; it follows the `sealing-blocked` path above. Failure evidence is part of the research record and MUST NOT be deleted from the run index (EXPERIMENT-NOTEBOOK.md §3). - **Recovery:** on restart, the runtime MUST load the last valid entry/checkpoint hashes, verify the committed prefix, continue with the next sequence number, and create an explicit recovery event (`sequence` numbers are never reused or truncated). - **Fork:** if two entries claim the same Baby and sequence with different hashes, the run transitions to `forked-invalid`. Both conflicting artifacts MUST be preserved. The run's evidence is invalid for scientific interpretation until a research-integrity review is logged in EXPERIMENT-NOTEBOOK.md §10.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L519", + "source": "SPECIFICATION.md:519", + "section": 7, + "statement": "- **Pause:** MAY be issued by a `researcher-operator` or by an automated safety trigger (§14.5). A pause MUST NOT discard an already-committed turn transaction; it MUST prevent a new turn from starting. A checkpoint MUST be produced at pause. - **Abort:** terminates the run early. It MUST still produce `run.sealed`, a final checkpoint, and attempt a final anchor (LEDGER-INTEGRITY-DESIGN.md §15). A run without a confirmed final anchor cannot be `valid`; it follows the `sealing-blocked` path above. Failure evidence is part of the research record and MUST NOT be deleted from the run index (EXPERIMENT-NOTEBOOK.md §3). - **Recovery:** on restart, the runtime MUST load the last valid entry/checkpoint hashes, verify the committed prefix, continue with the next sequence number, and create an explicit recovery event (`sequence` numbers are never reused or truncated). - **Fork:** if two entries claim the same Baby and sequence with different hashes, the run transitions to `forked-invalid`. Both conflicting artifacts MUST be preserved. The run's evidence is invalid for scientific interpretation until a research-integrity review is logged in EXPERIMENT-NOTEBOOK.md §10.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L523", + "source": "SPECIFICATION.md:523", + "section": 7, + "statement": "- **Pause:** MAY be issued by a `researcher-operator` or by an automated safety trigger (§14.5). A pause MUST NOT discard an already-committed turn transaction; it MUST prevent a new turn from starting. A checkpoint MUST be produced at pause. - **Abort:** terminates the run early. It MUST still produce `run.sealed`, a final checkpoint, and attempt a final anchor (LEDGER-INTEGRITY-DESIGN.md §15). A run without a confirmed final anchor cannot be `valid`; it follows the `sealing-blocked` path above. Failure evidence is part of the research record and MUST NOT be deleted from the run index (EXPERIMENT-NOTEBOOK.md §3). - **Recovery:** on restart, the runtime MUST load the last valid entry/checkpoint hashes, verify the committed prefix, continue with the next sequence number, and create an explicit recovery event (`sequence` numbers are never reused or truncated). - **Fork:** if two entries claim the same Baby and sequence with different hashes, the run transitions to `forked-invalid`. Both conflicting artifacts MUST be preserved. The run's evidence is invalid for scientific interpretation until a research-integrity review is logged in EXPERIMENT-NOTEBOOK.md §10.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L525", + "source": "SPECIFICATION.md:525", + "section": 7, + "statement": "- **Pause:** MAY be issued by a `researcher-operator` or by an automated safety trigger (§14.5). A pause MUST NOT discard an already-committed turn transaction; it MUST prevent a new turn from starting. A checkpoint MUST be produced at pause. - **Abort:** terminates the run early. It MUST still produce `run.sealed`, a final checkpoint, and attempt a final anchor (LEDGER-INTEGRITY-DESIGN.md §15). A run without a confirmed final anchor cannot be `valid`; it follows the `sealing-blocked` path above. Failure evidence is part of the research record and MUST NOT be deleted from the run index (EXPERIMENT-NOTEBOOK.md §3). - **Recovery:** on restart, the runtime MUST load the last valid entry/checkpoint hashes, verify the committed prefix, continue with the next sequence number, and create an explicit recovery event (`sequence` numbers are never reused or truncated). - **Fork:** if two entries claim the same Baby and sequence with different hashes, the run transitions to `forked-invalid`. Both conflicting artifacts MUST be preserved. The run's evidence is invalid for scientific interpretation until a research-integrity review is logged in EXPERIMENT-NOTEBOOK.md §10.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L530", + "source": "SPECIFICATION.md:530", + "section": 7, + "statement": "- **Pause:** MAY be issued by a `researcher-operator` or by an automated safety trigger (§14.5). A pause MUST NOT discard an already-committed turn transaction; it MUST prevent a new turn from starting. A checkpoint MUST be produced at pause. - **Abort:** terminates the run early. It MUST still produce `run.sealed`, a final checkpoint, and attempt a final anchor (LEDGER-INTEGRITY-DESIGN.md §15). A run without a confirmed final anchor cannot be `valid`; it follows the `sealing-blocked` path above. Failure evidence is part of the research record and MUST NOT be deleted from the run index (EXPERIMENT-NOTEBOOK.md §3). - **Recovery:** on restart, the runtime MUST load the last valid entry/checkpoint hashes, verify the committed prefix, continue with the next sequence number, and create an explicit recovery event (`sequence` numbers are never reused or truncated). - **Fork:** if two entries claim the same Baby and sequence with different hashes, the run transitions to `forked-invalid`. Both conflicting artifacts MUST be preserved. The run's evidence is invalid for scientific interpretation until a research-integrity review is logged in EXPERIMENT-NOTEBOOK.md §10.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L538", + "source": "SPECIFICATION.md:538", + "section": 7, + "statement": "A **derived run** is a new run initialized from an immutable parent checkpoint for partner replacement, longitudinal comparison, rollback controls, or replication. It is not a ledger fork and MUST NOT reuse the parent run's ID or event sequences.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L541", + "source": "SPECIFICATION.md:541", + "section": 7, + "statement": "- `parentRunId`, `derivedFromCheckpointHash`, and per-Baby `initialPolicyRef` values MUST be recorded in the child `RunConfig` and run manifest. - Each child event chain starts at sequence `1` with the documented all-zero previous hash. The child run's initialization event commits the parent checkpoint hash. - The parent evidence bundle is read-only and referenced by hash; no parent event, checkpoint, disposition, or anchor receipt may be changed. - A child may replace one learner or policy while preserving the other only when the complete replacement plan was pre-registered. - An `aborted-sealed` or `sealed` parent remains terminal. Starting a derived run does not resume or reopen it.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L552", + "source": "SPECIFICATION.md:552", + "section": 7, + "statement": "This lineage mechanism supports E30, E31, and E50. The word **fork** remains reserved for the integrity failure in §7.3; implementations and UI labels MUST use **derived run** or **branch** for this feature.", + "applicable": true, + "backlogItems": [ + "ALD-024", + "ALD-026", + "ALD-027", + "ALD-028" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/state-machine.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/derived-run-evidence.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/recovery.test.ts" + ] + }, + { + "id": "SPEC-MUST-L597", + "source": "SPECIFICATION.md:597", + "section": 8, + "statement": "The receiver submits its interpretation through a private `LedgerDraftEnvelope` to the Gateway, which authenticates the receiver identity and forwards it to the Evidence Writer. That event is not retroactively part of the sender transaction, but it MUST reference the delivered channel event hash. `ledgerLagTurns` (default `0`) fixes how many turns the receiver may defer its interpretation event; `0` means the interpretation event MUST be committed within the same turn cycle that delivered the message, before the receiver's own next proposal is accepted (resolves Q10).", + "applicable": true, + "backlogItems": [ + "ALD-010", + "ALD-025" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/scratch-rl-run.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "SPEC-MUST-L599", + "source": "SPECIFICATION.md:599", + "section": 8, + "statement": "The receiver submits its interpretation through a private `LedgerDraftEnvelope` to the Gateway, which authenticates the receiver identity and forwards it to the Evidence Writer. That event is not retroactively part of the sender transaction, but it MUST reference the delivered channel event hash. `ledgerLagTurns` (default `0`) fixes how many turns the receiver may defer its interpretation event; `0` means the interpretation event MUST be committed within the same turn cycle that delivered the message, before the receiver's own next proposal is accepted (resolves Q10).", + "applicable": true, + "backlogItems": [ + "ALD-010", + "ALD-025" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "packages/orchestrator/__tests__/adapter-failure.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/scratch-rl-run.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts" + ] + }, + { + "id": "SPEC-MUST-L646", + "source": "SPECIFICATION.md:646", + "section": 9, + "statement": "The unfamiliar glyph bundle MUST be generated and frozen before pre-registration, contain no Unicode text labels in Baby-visible data, and pass the OCR/glyph leakage audit. Bitmap and canvas carriers have no color or text field. Tone carriers use eight pitch bins and four duration bins; raw audio upload is prohibited.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L712", + "source": "SPECIFICATION.md:712", + "section": 9, + "statement": "1. The affect display occupies a distinct typed field (`AffectEvent`, §11.6) and can never appear inside a `symbols`/`strokes` payload. 2. An affect window opens only immediately after a Gateway-defined action/outcome event, never at a Baby-chosen arbitrary point. Window frequency (`affectWindowSchedule`) is fixed before the run. 3. Exactly one allowlisted `displayId` (`A1`-`A6`) is delivered per open window; no sequences, repetitions, combinations, or custom forms are accepted. 4. Delivery timing and envelope size are normalized identically to ordinary channel delivery (§10.3). 5. The receiving Baby cannot reply through the affect channel until the next Gateway-defined window. 6. The Gateway rejects and logs every non-allowlisted code point or malformed affect payload as `channel.rejected` with reason `affect-violation`. 7. Analysis (§15.3) MUST test mutual information between affect choice and referent/task state, controlling for the stated emotional context; any unexpected correlation is treated as suspected leakage and reported as such, never silently dropped.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L738", + "source": "SPECIFICATION.md:738", + "section": 9, + "statement": "The Scenario Engine MUST implement five deterministic, pre-registerable interaction profiles required by E32:", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L752", + "source": "SPECIFICATION.md:752", + "section": 9, + "statement": "- the complete utility matrices, reservation values, resource constraints, and zone of possible agreement MUST be generated deterministically from the scenario seed and committed in researcher-only ground truth before the run; - each Baby MUST receive only its own permitted observation and utility information; - the BabySitter MUST NOT reveal whether agreement is possible during a run; - public channel capacity MUST remain controlled independently of interaction mode; - agreement, individual utility, joint utility, informativeness, ambiguity, and pre-registered deception indicators MUST be recorded separately.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L754", + "source": "SPECIFICATION.md:754", + "section": 9, + "statement": "- the complete utility matrices, reservation values, resource constraints, and zone of possible agreement MUST be generated deterministically from the scenario seed and committed in researcher-only ground truth before the run; - each Baby MUST receive only its own permitted observation and utility information; - the BabySitter MUST NOT reveal whether agreement is possible during a run; - public channel capacity MUST remain controlled independently of interaction mode; - agreement, individual utility, joint utility, informativeness, ambiguity, and pre-registered deception indicators MUST be recorded separately.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L755", + "source": "SPECIFICATION.md:755", + "section": 9, + "statement": "- the complete utility matrices, reservation values, resource constraints, and zone of possible agreement MUST be generated deterministically from the scenario seed and committed in researcher-only ground truth before the run; - each Baby MUST receive only its own permitted observation and utility information; - the BabySitter MUST NOT reveal whether agreement is possible during a run; - public channel capacity MUST remain controlled independently of interaction mode; - agreement, individual utility, joint utility, informativeness, ambiguity, and pre-registered deception indicators MUST be recorded separately.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L756", + "source": "SPECIFICATION.md:756", + "section": 9, + "statement": "- the complete utility matrices, reservation values, resource constraints, and zone of possible agreement MUST be generated deterministically from the scenario seed and committed in researcher-only ground truth before the run; - each Baby MUST receive only its own permitted observation and utility information; - the BabySitter MUST NOT reveal whether agreement is possible during a run; - public channel capacity MUST remain controlled independently of interaction mode; - agreement, individual utility, joint utility, informativeness, ambiguity, and pre-registered deception indicators MUST be recorded separately.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L758", + "source": "SPECIFICATION.md:758", + "section": 9, + "statement": "- the complete utility matrices, reservation values, resource constraints, and zone of possible agreement MUST be generated deterministically from the scenario seed and committed in researcher-only ground truth before the run; - each Baby MUST receive only its own permitted observation and utility information; - the BabySitter MUST NOT reveal whether agreement is possible during a run; - public channel capacity MUST remain controlled independently of interaction mode; - agreement, individual utility, joint utility, informativeness, ambiguity, and pre-registered deception indicators MUST be recorded separately.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L761", + "source": "SPECIFICATION.md:761", + "section": 9, + "statement": "Interaction mode changes incentives, not the communication carrier. Every carrier mode in §9.1-§9.2 MUST remain technically usable with every interaction profile.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L778", + "source": "SPECIFICATION.md:778", + "section": 9, + "statement": "Control replacement occurs after proposal validation but before channel-event construction. Every Baby-originated condition MUST record both the Baby proposal hash and delivered artifact hash; the hashes may be equal under `normal`. `oracle` is available only to E03 control runs, MUST be generated deterministically from researcher-only ground truth, and MUST be labeled `oracle-control` in every report so it cannot be mistaken for learned communication.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L780", + "source": "SPECIFICATION.md:780", + "section": 9, + "statement": "Control replacement occurs after proposal validation but before channel-event construction. Every Baby-originated condition MUST record both the Baby proposal hash and delivered artifact hash; the hashes may be equal under `normal`. `oracle` is available only to E03 control runs, MUST be generated deterministically from researcher-only ground truth, and MUST be labeled `oracle-control` in every report so it cannot be mistaken for learned communication.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L781", + "source": "SPECIFICATION.md:781", + "section": 9, + "statement": "Control replacement occurs after proposal validation but before channel-event construction. Every Baby-originated condition MUST record both the Baby proposal hash and delivered artifact hash; the hashes may be equal under `normal`. `oracle` is available only to E03 control runs, MUST be generated deterministically from researcher-only ground truth, and MUST be labeled `oracle-control` in every report so it cannot be mistaken for learned communication.", + "applicable": true, + "backlogItems": [ + "ALD-029", + "ALD-030", + "ALD-031", + "ALD-032", + "ALD-033", + "ALD-034", + "ALD-035", + "ALD-036" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/affect-leakage.test.ts", + "packages/analysis/__tests__/carrier-leakage.test.ts", + "packages/analysis/__tests__/information.test.ts", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/affect.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conditions.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/gateway/__tests__/rejection.test.ts", + "packages/learners/__tests__/affect-policies.test.ts", + "packages/learners/__tests__/frozen-llm-gateway.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L788", + "source": "SPECIFICATION.md:788", + "section": 10, + "statement": "Human language MUST be removed from observation inputs, not only from channel outputs (resolves Q2 jointly with §18):", + "applicable": true, + "backlogItems": [ + "ALD-038", + "ALD-039", + "ALD-040", + "ALD-056", + "ALD-067", + "ALD-068" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts", + "packages/redteam/__tests__/observation-and-measurement.test.ts", + "packages/redteam/__tests__/side-channel.test.ts", + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-mode-r-smoke.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L801", + "source": "SPECIFICATION.md:801", + "section": 10, + "statement": "`observationEncoding` is a named experiment variable (`opaque-numeric` default, `pixel`, or `hybrid-features`), and the Scenario Engine's authoring pipeline MUST run an automated Observation Hygiene Filter (regex/dictionary scan against a maintained human-language token list, plus a schema check that rejects any string- typed field not on an explicit allowlist of opaque-identifier formats) before any scenario bundle may be referenced by a preregistered run.", + "applicable": true, + "backlogItems": [ + "ALD-038", + "ALD-039", + "ALD-040", + "ALD-056", + "ALD-067", + "ALD-068" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts", + "packages/redteam/__tests__/observation-and-measurement.test.ts", + "packages/redteam/__tests__/side-channel.test.ts", + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-mode-r-smoke.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L809", + "source": "SPECIFICATION.md:809", + "section": 10, + "statement": "Synthetic scenes MUST contain no text. Any future use of real images MUST pass through OCR detection and quarantine so that environmental text cannot instruct an agent or leak human vocabulary (CONCEPT-IDEA.md §20.5); a scenario bundle containing detected OCR text MUST fail the Observation Hygiene Filter and MUST NOT be loaded into a run.", + "applicable": true, + "backlogItems": [ + "ALD-038", + "ALD-039", + "ALD-040", + "ALD-056", + "ALD-067", + "ALD-068" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts", + "packages/redteam/__tests__/observation-and-measurement.test.ts", + "packages/redteam/__tests__/side-channel.test.ts", + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-mode-r-smoke.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L812", + "source": "SPECIFICATION.md:812", + "section": 10, + "statement": "Synthetic scenes MUST contain no text. Any future use of real images MUST pass through OCR detection and quarantine so that environmental text cannot instruct an agent or leak human vocabulary (CONCEPT-IDEA.md §20.5); a scenario bundle containing detected OCR text MUST fail the Observation Hygiene Filter and MUST NOT be loaded into a run.", + "applicable": true, + "backlogItems": [ + "ALD-038", + "ALD-039", + "ALD-040", + "ALD-056", + "ALD-067", + "ALD-068" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts", + "packages/redteam/__tests__/observation-and-measurement.test.ts", + "packages/redteam/__tests__/side-channel.test.ts", + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-mode-r-smoke.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L822", + "source": "SPECIFICATION.md:822", + "section": 10, + "statement": "- fixed turn schedule and response deadline (§8.3); - normalized message envelope size and error behavior (a rejected/failed turn and an accepted turn MUST produce externally indistinguishable timing/size profiles where technically feasible); - bounded carrier size (§9.1/§9.2 ceilings); - no arbitrary silence, variable retry count, or unconstrained message length; - no direct network, filesystem, clipboard, or process access from a Baby process/container beyond the Gateway RPC; - no model-generated identifiers reaching the other Baby; - periodic correlation audits between channel/affect choices and hidden task state, reported alongside every run that uses the affect channel or a generative carrier.", + "applicable": true, + "backlogItems": [ + "ALD-038", + "ALD-039", + "ALD-040", + "ALD-056", + "ALD-067", + "ALD-068" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts", + "packages/redteam/__tests__/observation-and-measurement.test.ts", + "packages/redteam/__tests__/side-channel.test.ts", + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-mode-r-smoke.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L837", + "source": "SPECIFICATION.md:837", + "section": 10, + "statement": "**In scope (claimed):** network route absence, timing/size normalization within the stated envelope, filesystem/process isolation, tool inventory audit. **Explicitly out of scope (not claimed, per CONCEPT-IDEA.md §10):** exotic hardware side channels (cache timing, power analysis), and any covert channel not enumerated above. This boundary MUST be restated in any Mode R publication.", + "applicable": true, + "backlogItems": [ + "ALD-038", + "ALD-039", + "ALD-040", + "ALD-056", + "ALD-067", + "ALD-068" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts", + "packages/redteam/__tests__/observation-and-measurement.test.ts", + "packages/redteam/__tests__/side-channel.test.ts", + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-mode-r-smoke.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L850", + "source": "SPECIFICATION.md:850", + "section": 10, + "statement": "A centralized-training variant MAY be implemented for engineering comparison but MUST be labeled `trainingIsolation: \"centralized\"` and reported as a weaker- isolation condition, never as the default.", + "applicable": true, + "backlogItems": [ + "ALD-038", + "ALD-039", + "ALD-040", + "ALD-056", + "ALD-067", + "ALD-068" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/gateway/__tests__/glyph-bundle.test.ts", + "packages/orchestrator/__tests__/scenario-quarantine.test.ts", + "packages/redteam/__tests__/observation-and-measurement.test.ts", + "packages/redteam/__tests__/side-channel.test.ts", + "packages/scenario/__tests__/hygiene.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-mode-r-smoke.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L974", + "source": "SPECIFICATION.md:974", + "section": 11, + "statement": "`runId`, `turn`, `sender`, sequences, timestamps, and hashes are absent from Baby-produced proposals. The Gateway derives them from the authenticated service identity and authoritative run state. Any payload containing a Baby-supplied trusted metadata field is rejected. `DeliveredChannelArtifact` is a Gateway-to-Baby message, not a Baby proposal; the receiver MUST echo its `channelEventHash` unchanged in the returned `LedgerDraftEnvelope`, and the Evidence Writer validates it against the recorded delivery receipt.", + "applicable": true, + "backlogItems": [ + "ALD-002", + "ALD-018", + "ALD-023", + "ALD-035", + "ALD-037" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/run-config.test.ts", + "packages/scenario/__tests__/observation.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1006", + "source": "SPECIFICATION.md:1006", + "section": 11, + "statement": "`agent-native-ledger` content MAY include embeddings/weights references, probability distributions, and prediction errors; `human-audit-ledger` content MUST use the two/three-column hypothesis/evidence shape from CONCEPT-IDEA.md §11.1 regardless of which layer produced it.", + "applicable": true, + "backlogItems": [ + "ALD-002", + "ALD-018", + "ALD-023", + "ALD-035", + "ALD-037" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/run-config.test.ts", + "packages/scenario/__tests__/observation.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1042", + "source": "SPECIFICATION.md:1042", + "section": 11, + "statement": "`channelEventHash` means `ChannelEvent.entryHash`, computed with the channel-event domain separator and verified by the Channel Event Signer. Rejected events use a hash of the rejected payload and MUST NOT retain the raw payload. Normal, constant, random, and shuffled accepted events MUST contain `babyProposalHash`, the sender ledger binding, and (unless disabled) a delivery receipt. `oracle` events use `origin: \"gateway-control\"` and may omit Baby proposal and ledger fields. `disabled` events have no delivery receipt. Rejected events omit delivery fields unless a separate rejection-intention event was atomically committed. For `disabled`, `publicArtifactHash` is the domain-separated hash of canonical `null`.", + "applicable": true, + "backlogItems": [ + "ALD-002", + "ALD-018", + "ALD-023", + "ALD-035", + "ALD-037" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/run-config.test.ts", + "packages/scenario/__tests__/observation.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1074", + "source": "SPECIFICATION.md:1074", + "section": 11, + "statement": "Normative shape is LEDGER-INTEGRITY-DESIGN.md §8, including its optional named `auxiliaryTrees` map for `affect`, `audit`, `turns`, and witness-committed unsigned `intervention` roots. Every run manifest MUST additionally be reachable from an `ExperimentRecord` (§11.9) via `checkpointManifestRef`.", + "applicable": true, + "backlogItems": [ + "ALD-002", + "ALD-018", + "ALD-023", + "ALD-035", + "ALD-037" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/publisher.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/gateway/__tests__/interpretation.test.ts", + "packages/learners/__tests__/frozen-llm.test.ts", + "packages/lifecycle/__tests__/derived-run.test.ts", + "packages/lifecycle/__tests__/run-config.test.ts", + "packages/scenario/__tests__/observation.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "packages/types/__tests__/schema-manifest.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1149", + "source": "SPECIFICATION.md:1149", + "section": 12, + "statement": "All routes follow the DTSF unprefixed convention: a twin's Express route is `/:twinName/*`, and the runtime strips the twin name before dispatch, so a handler registered as `/observe` is reachable externally as `/baby-a/observe`. Route patterns and this specification's OpenAPI-equivalent listings below MUST NOT include the twin-name prefix (matching the DTSF convention already used elsewhere in this environment).", + "applicable": true, + "backlogItems": [ + "ALD-004", + "ALD-010", + "ALD-048", + "ALD-049", + "ALD-050", + "ALD-051", + "ALD-052" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1229", + "source": "SPECIFICATION.md:1229", + "section": 12, + "statement": "The Evidence Writer MUST reject any commit request whose authenticated sender, authoritative run/turn state, and requested event domain do not agree. Event Signers MUST reject calls from every identity except `internal-evidence-writer`.", + "applicable": true, + "backlogItems": [ + "ALD-004", + "ALD-010", + "ALD-048", + "ALD-049", + "ALD-050", + "ALD-051", + "ALD-052" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1231", + "source": "SPECIFICATION.md:1231", + "section": 12, + "statement": "The Evidence Writer MUST reject any commit request whose authenticated sender, authoritative run/turn state, and requested event domain do not agree. Event Signers MUST reject calls from every identity except `internal-evidence-writer`.", + "applicable": true, + "backlogItems": [ + "ALD-004", + "ALD-010", + "ALD-048", + "ALD-049", + "ALD-050", + "ALD-051", + "ALD-052" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/evidence/__tests__/api.test.ts", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1248", + "source": "SPECIFICATION.md:1248", + "section": 13, + "statement": "All new tables MUST use WAL mode, serialize writes through the same single evidence-writer service, and reject `UPDATE`/`DELETE` via triggers, matching the existing tables' controls.", + "applicable": true, + "backlogItems": [ + "ALD-005", + "ALD-006", + "ALD-007", + "ALD-008", + "ALD-009", + "ALD-012", + "ALD-013", + "ALD-016", + "ALD-018", + "ALD-019", + "ALD-020", + "ALD-021", + "ALD-022", + "ALD-064" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts", + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts", + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/canonical.test.ts", + "packages/evidence/__tests__/database.test.ts", + "packages/evidence/__tests__/event-types.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/hashing/__tests__/keystore.test.ts", + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts", + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1284", + "source": "SPECIFICATION.md:1284", + "section": 13, + "statement": "Normative and unchanged from LEDGER-INTEGRITY-DESIGN.md: RFC 8785 canonical JSON; SHA-256 with domain-separated prefixes; Ed25519 writer/witness signatures; RFC 6962-style ordered Merkle trees with `sequence`-bound leaf hashes. This specification does not restate the hash/Merkle formulas; implementers MUST follow LEDGER-INTEGRITY-DESIGN.md §4, §7, and §8 exactly, including for the new `affect_events` and `audit_ledger_entries` tables (each gets its own hash chain and its own Merkle root, included in the checkpoint manifest as additional named trees alongside `babyA`/`babyB`/`channel`).", + "applicable": true, + "backlogItems": [ + "ALD-005", + "ALD-006", + "ALD-007", + "ALD-008", + "ALD-009", + "ALD-012", + "ALD-013", + "ALD-016", + "ALD-018", + "ALD-019", + "ALD-020", + "ALD-021", + "ALD-022", + "ALD-064" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts", + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts", + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/canonical.test.ts", + "packages/evidence/__tests__/database.test.ts", + "packages/evidence/__tests__/event-types.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/hashing/__tests__/keystore.test.ts", + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts", + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1293", + "source": "SPECIFICATION.md:1293", + "section": 13, + "statement": "The approved research profile is simulation-only. Every prospective campaign run MUST declare `anchorClass: \"simulated\"` and use the deterministic in-memory transport. Its Base-shaped network, transaction, block, and confirmation values are non-monetary test data and MUST NOT be described as public-chain publication, economic finality, or incurred cost. The exact `anchorClass` is bound into the run configuration, pending-submission record, anchor receipt, pre-registration binding, and verifier checks so a simulated receipt cannot be silently relabeled.", + "applicable": true, + "backlogItems": [ + "ALD-005", + "ALD-006", + "ALD-007", + "ALD-008", + "ALD-009", + "ALD-012", + "ALD-013", + "ALD-016", + "ALD-018", + "ALD-019", + "ALD-020", + "ALD-021", + "ALD-022", + "ALD-064" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts", + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts", + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/canonical.test.ts", + "packages/evidence/__tests__/database.test.ts", + "packages/evidence/__tests__/event-types.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/hashing/__tests__/keystore.test.ts", + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts", + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1295", + "source": "SPECIFICATION.md:1295", + "section": 13, + "statement": "The approved research profile is simulation-only. Every prospective campaign run MUST declare `anchorClass: \"simulated\"` and use the deterministic in-memory transport. Its Base-shaped network, transaction, block, and confirmation values are non-monetary test data and MUST NOT be described as public-chain publication, economic finality, or incurred cost. The exact `anchorClass` is bound into the run configuration, pending-submission record, anchor receipt, pre-registration binding, and verifier checks so a simulated receipt cannot be silently relabeled.", + "applicable": true, + "backlogItems": [ + "ALD-005", + "ALD-006", + "ALD-007", + "ALD-008", + "ALD-009", + "ALD-012", + "ALD-013", + "ALD-016", + "ALD-018", + "ALD-019", + "ALD-020", + "ALD-021", + "ALD-022", + "ALD-064" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts", + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts", + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/canonical.test.ts", + "packages/evidence/__tests__/database.test.ts", + "packages/evidence/__tests__/event-types.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/hashing/__tests__/keystore.test.ts", + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts", + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1301", + "source": "SPECIFICATION.md:1301", + "section": 13, + "statement": "Base Sepolia and Base mainnet remain optional transport capabilities inherited from LEDGER-INTEGRITY-DESIGN.md §10. Any real-chain operation MUST instead declare `anchorClass: \"public-chain\"`; only the 32-byte checkpoint hash and minimal routing metadata may be public. Public-chain operation is outside the currently approved research profile and requires a new prospective governance amendment. If amended, Sepolia waits for at least one confirmation and mainnet waits for the `safe` block tag or an equivalent provider-specific policy before reporting anchored-final.", + "applicable": true, + "backlogItems": [ + "ALD-005", + "ALD-006", + "ALD-007", + "ALD-008", + "ALD-009", + "ALD-012", + "ALD-013", + "ALD-016", + "ALD-018", + "ALD-019", + "ALD-020", + "ALD-021", + "ALD-022", + "ALD-064" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts", + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts", + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/canonical.test.ts", + "packages/evidence/__tests__/database.test.ts", + "packages/evidence/__tests__/event-types.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/hashing/__tests__/keystore.test.ts", + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts", + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1325", + "source": "SPECIFICATION.md:1325", + "section": 13, + "statement": "Only checkpoint hashes are ever anchored on-chain (§13.4). The `audit_ledger_entries` table (resolves Q25) is populated by a deterministic or BabySitter-generated Interpreter that reads `agent-native-ledger` content on a delayed, batched basis and writes to this strictly separate table; the architecture MUST NOT expose any Baby-reachable API for reading `audit_ledger_entries`, enforced by the same role model as §12.2 (only `researcher-viewer`/`researcher-operator`/`verifier-service` may read it). Every generated interpretation is labeled `source: \"generated-analysis\"` and MUST NOT be represented, in any report, as the Baby's own explanation. The reference implementation validates every `sourceEntryHash` against the named Baby's signed native stream, waits until at least one later turn is fully complete, validates the whole submitted batch before its first append, and creates an `analysis` checkpoint after the batch. The Interpreter has no adapter or Gateway dependency, so no generated interpretation can be delivered back to a Baby as observation, outcome, message, or ledger feedback.", + "applicable": true, + "backlogItems": [ + "ALD-005", + "ALD-006", + "ALD-007", + "ALD-008", + "ALD-009", + "ALD-012", + "ALD-013", + "ALD-016", + "ALD-018", + "ALD-019", + "ALD-020", + "ALD-021", + "ALD-022", + "ALD-064" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts", + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts", + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/canonical.test.ts", + "packages/evidence/__tests__/database.test.ts", + "packages/evidence/__tests__/event-types.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/hashing/__tests__/keystore.test.ts", + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts", + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1328", + "source": "SPECIFICATION.md:1328", + "section": 13, + "statement": "Only checkpoint hashes are ever anchored on-chain (§13.4). The `audit_ledger_entries` table (resolves Q25) is populated by a deterministic or BabySitter-generated Interpreter that reads `agent-native-ledger` content on a delayed, batched basis and writes to this strictly separate table; the architecture MUST NOT expose any Baby-reachable API for reading `audit_ledger_entries`, enforced by the same role model as §12.2 (only `researcher-viewer`/`researcher-operator`/`verifier-service` may read it). Every generated interpretation is labeled `source: \"generated-analysis\"` and MUST NOT be represented, in any report, as the Baby's own explanation. The reference implementation validates every `sourceEntryHash` against the named Baby's signed native stream, waits until at least one later turn is fully complete, validates the whole submitted batch before its first append, and creates an `analysis` checkpoint after the batch. The Interpreter has no adapter or Gateway dependency, so no generated interpretation can be delivered back to a Baby as observation, outcome, message, or ledger feedback.", + "applicable": true, + "backlogItems": [ + "ALD-005", + "ALD-006", + "ALD-007", + "ALD-008", + "ALD-009", + "ALD-012", + "ALD-013", + "ALD-016", + "ALD-018", + "ALD-019", + "ALD-020", + "ALD-021", + "ALD-022", + "ALD-064" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/anchor/__tests__/key-file.test.ts", + "packages/anchor/__tests__/publisher.test.ts", + "packages/anchor/__tests__/verify-anchor.test.ts", + "packages/anchor/__tests__/viem-transport.test.ts", + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/canonical.test.ts", + "packages/evidence/__tests__/database.test.ts", + "packages/evidence/__tests__/event-types.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/hashing/__tests__/keystore.test.ts", + "packages/merkle/__tests__/consistency.test.ts", + "packages/merkle/__tests__/inclusion.test.ts", + "packages/merkle/__tests__/tree.test.ts", + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/orchestrator/__tests__/audit-interpreter.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1351", + "source": "SPECIFICATION.md:1351", + "section": 14, + "statement": "Every human read is logged as a low-noise `audit.human_view` event (non-blocking, informational). Every human intervention (pause/resume/abort/ annotate) requires the `researcher-operator` role, writes to `intervention_log`, and MUST also produce a checkpoint (§7.2) and a corresponding EXPERIMENT-NOTEBOOK.md §9 deviation-log row if it was not part of the pre-registered protocol (resolves Q22).", + "applicable": true, + "backlogItems": [ + "ALD-058", + "ALD-059", + "ALD-060", + "ALD-061", + "ALD-062", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/retention.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1357", + "source": "SPECIFICATION.md:1357", + "section": 14, + "statement": "A run's evidence bundle MUST support two replay checks:", + "applicable": true, + "backlogItems": [ + "ALD-058", + "ALD-059", + "ALD-060", + "ALD-061", + "ALD-062", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/retention.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1375", + "source": "SPECIFICATION.md:1375", + "section": 14, + "statement": "For nondeterministic adapters that cannot reproduce execution under recorded seeds, the report MUST mark execution replay `not-applicable`, state why, and still pass scenario replay plus a recorded-decision playback check. It MUST NOT claim full execution reproducibility. This resolves the replay half of Q21.", + "applicable": true, + "backlogItems": [ + "ALD-058", + "ALD-059", + "ALD-060", + "ALD-061", + "ALD-062", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/retention.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1376", + "source": "SPECIFICATION.md:1376", + "section": 14, + "statement": "For nondeterministic adapters that cannot reproduce execution under recorded seeds, the report MUST mark execution replay `not-applicable`, state why, and still pass scenario replay plus a recorded-decision playback check. It MUST NOT claim full execution reproducibility. This resolves the replay half of Q21.", + "applicable": true, + "backlogItems": [ + "ALD-058", + "ALD-059", + "ALD-060", + "ALD-061", + "ALD-062", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/retention.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1385", + "source": "SPECIFICATION.md:1385", + "section": 14, + "statement": "Reuses the existing DTSF snapshot mechanism: automatic snapshots every 300 seconds (`DTSF_SNAPSHOT_INTERVAL_MS`, configurable), a final snapshot on graceful shutdown (SIGINT/SIGTERM), and `autoRestore()` on startup loading the latest snapshot. Ledger/channel/checkpoint state additionally has its own independent integrity chain (§13), so a DTSF snapshot restore MUST be followed by the recovery procedure in §7.3 (verify committed prefix before accepting new writes), not treated as a substitute for it.", + "applicable": true, + "backlogItems": [ + "ALD-058", + "ALD-059", + "ALD-060", + "ALD-061", + "ALD-062", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/retention.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1391", + "source": "SPECIFICATION.md:1391", + "section": 14, + "statement": "Automated safety triggers (any of which MUST cause a `pause`, not a silent continue):", + "applicable": true, + "backlogItems": [ + "ALD-058", + "ALD-059", + "ALD-060", + "ALD-061", + "ALD-062", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/ops/__tests__/failure-policy.test.ts", + "packages/ops/__tests__/metrics.test.ts", + "packages/ops/__tests__/retention.test.ts", + "packages/ops/__tests__/snapshot.test.ts", + "packages/ops/__tests__/telemetry.test.ts", + "packages/orchestrator/__tests__/interventions.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "twins/packs/__tests__/routes.test.ts", + "twins/packs/__tests__/telemetry.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1435", + "source": "SPECIFICATION.md:1435", + "section": 15, + "statement": "For a confirmatory or publication-facing run, the canonical pre-registration artifact MUST be committed unchanged in the repository before outcome collection. Its binding records the packet path, canonical hash, commit, and commit time; the registration commit MUST be ancestral to the execution commit, and preflight MUST load the artifact bytes from that commit and reproduce the registered hash. The same `preRegistrationHash` MUST be committed before the run enters `running`. Under the approved simulation-only profile, that receipt MUST declare `anchorClass: \"simulated\"`; the deterministic receipt proves exact local consumption and tamper detection. An external registration or archival timestamp MAY be added as an independently hosted enhancement but is not required for local synthetic execution. Repository-native registration does not prove an independent timestamp, third-party custody, decentralized persistence, or economic finality.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1437", + "source": "SPECIFICATION.md:1437", + "section": 15, + "statement": "For a confirmatory or publication-facing run, the canonical pre-registration artifact MUST be committed unchanged in the repository before outcome collection. Its binding records the packet path, canonical hash, commit, and commit time; the registration commit MUST be ancestral to the execution commit, and preflight MUST load the artifact bytes from that commit and reproduce the registered hash. The same `preRegistrationHash` MUST be committed before the run enters `running`. Under the approved simulation-only profile, that receipt MUST declare `anchorClass: \"simulated\"`; the deterministic receipt proves exact local consumption and tamper detection. An external registration or archival timestamp MAY be added as an independently hosted enhancement but is not required for local synthetic execution. Repository-native registration does not prove an independent timestamp, third-party custody, decentralized persistence, or economic finality.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1439", + "source": "SPECIFICATION.md:1439", + "section": 15, + "statement": "For a confirmatory or publication-facing run, the canonical pre-registration artifact MUST be committed unchanged in the repository before outcome collection. Its binding records the packet path, canonical hash, commit, and commit time; the registration commit MUST be ancestral to the execution commit, and preflight MUST load the artifact bytes from that commit and reproduce the registered hash. The same `preRegistrationHash` MUST be committed before the run enters `running`. Under the approved simulation-only profile, that receipt MUST declare `anchorClass: \"simulated\"`; the deterministic receipt proves exact local consumption and tamper detection. An external registration or archival timestamp MAY be added as an independently hosted enhancement but is not required for local synthetic execution. Repository-native registration does not prove an independent timestamp, third-party custody, decentralized persistence, or economic finality.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1440", + "source": "SPECIFICATION.md:1440", + "section": 15, + "statement": "For a confirmatory or publication-facing run, the canonical pre-registration artifact MUST be committed unchanged in the repository before outcome collection. Its binding records the packet path, canonical hash, commit, and commit time; the registration commit MUST be ancestral to the execution commit, and preflight MUST load the artifact bytes from that commit and reproduce the registered hash. The same `preRegistrationHash` MUST be committed before the run enters `running`. Under the approved simulation-only profile, that receipt MUST declare `anchorClass: \"simulated\"`; the deterministic receipt proves exact local consumption and tamper detection. An external registration or archival timestamp MAY be added as an independently hosted enhancement but is not required for local synthetic execution. Repository-native registration does not prove an independent timestamp, third-party custody, decentralized persistence, or economic finality.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1448", + "source": "SPECIFICATION.md:1448", + "section": 15, + "statement": "The run's `ExperimentRecord.disposition` and the matching EXPERIMENT-NOTEBOOK.md run record MUST agree; the Verifier's `VerificationReport` is authoritative for **integrity** (did the evidence chain hold), while the notebook is authoritative for **scientific interpretation status** (is the run valid/invalid/replicated). This resolves Q29.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1455", + "source": "SPECIFICATION.md:1455", + "section": 15, + "statement": "Ledger meanings MUST be validated behaviorally, not accepted from ledger prose alone (resolves Q14). The mandatory suite, run during `evaluating`:", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1466", + "source": "SPECIFICATION.md:1466", + "section": 15, + "statement": "Default descriptive readiness threshold: within each run, ledger-predicted direction matches observed behavior change in at least 70% of probed instances. This is not an inferential test. Confirmatory inference MUST account for probe clustering within run/seed using a hierarchical Bernoulli model or a pre-registered seed-level equivalent against the E03 chance baseline at alpha = 0.05.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1484", + "source": "SPECIFICATION.md:1484", + "section": 15, + "statement": "Any conclusion that performance is equivalent to chance or that leakage is absent MUST use a pre-registered equivalence/non-inferiority bound or Bayes-factor criterion with power or sensitivity analysis. A non-significant difference alone cannot satisfy a no-leakage or at-chance acceptance criterion.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1503", + "source": "SPECIFICATION.md:1503", + "section": 15, + "statement": "A nonce, salt, unique artifact hash, or low recovery rate against one Eve model MUST NOT change the third field. Learned encodings MUST NOT be imported into the production hashing, signing, anchoring, authentication, or key-management packages. This formalizes Q17 and the `cipherThreatModel` variable in §18.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1504", + "source": "SPECIFICATION.md:1504", + "section": 15, + "statement": "A nonce, salt, unique artifact hash, or low recovery rate against one Eve model MUST NOT change the third field. Learned encodings MUST NOT be imported into the production hashing, signing, anchoring, authentication, or key-management packages. This formalizes Q17 and the `cipherThreatModel` variable in §18.", + "applicable": true, + "backlogItems": [ + "ALD-041", + "ALD-071", + "ALD-072" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/analysis/__tests__/e03.test.ts", + "packages/orchestrator/__tests__/experiments/e03-controls.test.ts", + "packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts", + "packages/orchestrator/__tests__/experiments/production-runtime.test.ts", + "packages/orchestrator/__tests__/experiments/verifier-integration.test.ts", + "packages/orchestrator/__tests__/pre-registration.test.ts", + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "packages/orchestrator/__tests__/sealing-and-guards.test.ts", + "packages/scenario/__tests__/referential-engine.test.ts", + "scripts/run-qualification.mjs" + ] + }, + { + "id": "SPEC-MUST-L1531", + "source": "SPECIFICATION.md:1531", + "section": 16, + "statement": "- run configuration and pre-registration status; - live transcript view (public artifacts only, never a Baby's private ledger content exposed to the other Baby's view, even in the human console's own layout — the console MUST render Baby A's and Baby B's perspectives as clearly separated panels, never merged); - ledger convergence comparison (read-only, side-by-side, never fed back into a live run); - checkpoint/anchor status with links to the independent chain explorer; - pause/resume/abort controls gated to `researcher-operator`; - verification-report status (pass/fail, last-run timestamp).", + "applicable": true, + "backlogItems": [ + "ALD-063", + "ALD-065", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "scripts/check-research-console.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1557", + "source": "SPECIFICATION.md:1557", + "section": 16, + "statement": "Caucuses, coalition rooms, direct delegation-to-delegation side links, and any other secondary communication route from the Diplomacy Table MUST NOT be reintroduced (resolves Q20). The console MUST NOT provide any control that lets a human relay content from one Baby's panel into the other Baby's observation feed during an active run outside of an explicitly logged, pre-registered human-in-the-loop experimental condition.", + "applicable": true, + "backlogItems": [ + "ALD-063", + "ALD-065", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "scripts/check-research-console.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1558", + "source": "SPECIFICATION.md:1558", + "section": 16, + "statement": "Caucuses, coalition rooms, direct delegation-to-delegation side links, and any other secondary communication route from the Diplomacy Table MUST NOT be reintroduced (resolves Q20). The console MUST NOT provide any control that lets a human relay content from one Baby's panel into the other Baby's observation feed during an active run outside of an explicitly logged, pre-registered human-in-the-loop experimental condition.", + "applicable": true, + "backlogItems": [ + "ALD-063", + "ALD-065", + "ALD-066" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/orchestrator/__tests__/run-lifecycle.test.ts", + "scripts/check-research-console.mjs", + "twins/packs/__tests__/routes.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1569", + "source": "SPECIFICATION.md:1569", + "section": 17, + "statement": "- every applicable MUST in this document is satisfied and demonstrated by an automated test where feasible; - the Verifier (§11.10) passes on a synthetic evidence bundle and correctly rejects every mutation case enumerated in LEDGER-INTEGRITY-DESIGN.md §17; - the Replay Fidelity Test (§14.3) passes on at least one evaluation-phase run; - Mode P and Mode R claim-boundary sentences (§5.1/§5.2) are reproduced verbatim in generated reports; - the Intervention Test Suite (§15.2) is wired into the evaluation pipeline, even if a given experiment's results are still pending.", + "applicable": true, + "backlogItems": [ + "ALD-036", + "ALD-073", + "ALD-074", + "ALD-075", + "ALD-076", + "ALD-077", + "ALD-078" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + ".github/workflows/book-integrity.yml", + "packages/gateway/__tests__/affect-conformance.test.ts", + "packages/gateway/__tests__/carrier-registry.test.ts", + "packages/gateway/__tests__/carriers.test.ts", + "packages/gateway/__tests__/conformance.test.ts", + "packages/interventions/__tests__/readiness-gates.test.ts", + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "SPEC-MUST-L1669", + "source": "SPECIFICATION.md:1669", + "section": 19, + "statement": "| ADR-01 | Exact Base RPC provider(s) for submission and independent verification | Vendor/account/SLA choice outside this specification's authority | Any RPC provider satisfying: chain-ID confirmation, calldata/event retrieval, and block/finality-tag reporting; the Verifier MUST be provider-agnostic |", + "applicable": true, + "backlogItems": [ + "ALD-070" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/crypto-research/__tests__/boundary.test.ts" + ] + }, + { + "id": "SPEC-MUST-L1713", + "source": "SPECIFICATION.md:1713", + "section": 20, + "statement": "This specification is implementation-ready for Phase 0 (§17.3) as of the date of its commit. Amendments MUST follow the same protocol-amendment discipline as EXPERIMENT-NOTEBOOK.md §3: record the change, apply it to new runs, and never silently reinterpret an already-sealed run's evidence.", + "applicable": true, + "backlogItems": [ + "ALD-080" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "scripts/check-readiness-gates.mjs" + ] + }, + { + "id": "LEDGER-MUST-L290", + "source": "LEDGER-INTEGRITY-DESIGN.md:290", + "section": 8, + "statement": "`babyA`, `babyB`, and `channel` are mandatory trees. `auxiliaryTrees` is an ordered map of named, independently hash-chained event streams. The initial implementation uses `affect` when affect is enabled and `audit` when generated human-audit entries exist. A verifier MUST reject an unknown auxiliary tree unless its schema and writer public key appear in the run manifest, and MUST include every present auxiliary tree in checkpoint rebuilding and consistency proofs.", + "applicable": true, + "backlogItems": [ + "ALD-013", + "ALD-015", + "ALD-016" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/verifier/__tests__/cli.test.ts" + ] + }, + { + "id": "LEDGER-MUST-L291", + "source": "LEDGER-INTEGRITY-DESIGN.md:291", + "section": 8, + "statement": "`babyA`, `babyB`, and `channel` are mandatory trees. `auxiliaryTrees` is an ordered map of named, independently hash-chained event streams. The initial implementation uses `affect` when affect is enabled and `audit` when generated human-audit entries exist. A verifier MUST reject an unknown auxiliary tree unless its schema and writer public key appear in the run manifest, and MUST include every present auxiliary tree in checkpoint rebuilding and consistency proofs.", + "applicable": true, + "backlogItems": [ + "ALD-013", + "ALD-015", + "ALD-016" + ], + "disposition": "provisional-mapped", + "assertionPaths": [ + "packages/checkpoint/__tests__/checkpoint-service.test.ts", + "packages/evidence/__tests__/export.test.ts", + "packages/verifier/__tests__/cli.test.ts" + ] + } + ] +} diff --git a/docs/requirement-conformance-matrix.md b/docs/requirement-conformance-matrix.md new file mode 100644 index 0000000..f84a8c4 --- /dev/null +++ b/docs/requirement-conformance-matrix.md @@ -0,0 +1,388 @@ +# Requirement Conformance Matrix + +Generated from repository sources for baseline `559436f` / matrix v0.1.97. + +This is a mapping and provisional-disposition artifact. A mapped file or test +name is not by itself proof that the requirement is true. V04–V11 execution +receipts promote individual rows only after the named behavior is freshly checked. + +## Summary + +| Population | Total | Provisionally mapped | Open/external | Not applicable | +|---|---:|---:|---:|---:| +| Backlog acceptance criteria | 258 | 254 | 4 | 0 | +| MUST-bearing source lines | 96 | 92 | 0 | 4 | + +## Backlog acceptance criteria + +| Criterion | State | Provisional disposition | Assertion paths | Requirement | +|---|---|---|---|---| +| ALD-001.1 | checked | provisional-mapped | `packages/types/__tests__/schema-manifest.test.ts` | `pnpm install --frozen-lockfile` at the repo root succeeds. | +| ALD-001.2 | checked | provisional-mapped | `packages/types/__tests__/schema-manifest.test.ts` | `pnpm run build` (project references) compiles with zero errors. | +| ALD-001.3 | checked | provisional-mapped | `packages/types/__tests__/schema-manifest.test.ts` | A new package can be added under `packages/*` and is automatically picked up by the workspace without editing the root `package.json`. | +| ALD-002.1 | checked | provisional-mapped | `packages/types/__tests__/schema-manifest.test.ts` | One exported type/schema exists per §11 subsection (§11.1–§11.10), named to match the section title. | +| ALD-002.2 | checked | provisional-mapped | `packages/types/__tests__/schema-manifest.test.ts` | The package builds and is importable from any other workspace package. | +| ALD-002.3 | checked | provisional-mapped | `packages/types/__tests__/schema-manifest.test.ts` | A schema-drift test fails if a §11 subsection type is removed without the corresponding source-doc section also changing (a lightweight manifest of expected export names is checked in CI once `ALD-078` exists, and locally before then). | +| ALD-003.1 | checked | provisional-mapped | `packages/config/__tests__/environment.test.ts`
    `scripts/scan-secrets.mjs` | A documented list of all environment variables exists with defaults and types. | +| ALD-003.2 | checked | provisional-mapped | `packages/config/__tests__/environment.test.ts`
    `scripts/scan-secrets.mjs` | Loading config with a required variable missing fails fast with a clear error, not a silent default. | +| ALD-003.3 | checked | provisional-mapped | `packages/config/__tests__/environment.test.ts`
    `scripts/scan-secrets.mjs` | No secret-shaped value (private key, API token) appears in any committed file; a scan step verifies this. | +| ALD-004.1 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | All three twin packs load without error when the DTSF runtime scans `twins/packs/`. | +| ALD-004.2 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Every registered route pattern in all three packs starts with `/`, not `/baby-a`, `/baby-b`, or `/nursery`. | +| ALD-004.3 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | A request to `/baby-a/` reaches the handler with `req.params[0]` equal to the unprefixed path, confirmed by an integration test. | +| ALD-005.1 | checked | provisional-mapped | `packages/evidence/__tests__/database.test.ts` | Database is created with `journal_mode=WAL` confirmed via `PRAGMA journal_mode`. | +| ALD-005.2 | checked | provisional-mapped | `packages/evidence/__tests__/database.test.ts` | Every table in §13.1 exists, and event/audit/experiment tables have triggers rejecting `UPDATE` and `DELETE` while versioned records append new rows. | +| ALD-005.3 | checked | provisional-mapped | `packages/evidence/__tests__/database.test.ts` | Running the migration twice is idempotent, and an automated test proves privileged application code cannot bypass the append-only triggers. | +| ALD-006.1 | checked | provisional-mapped | `packages/evidence/__tests__/canonical.test.ts` | Serializing the same logical event twice, in two different process runs, produces byte-identical output. | +| ALD-006.2 | checked | provisional-mapped | `packages/evidence/__tests__/canonical.test.ts` | Key order in the serialized form is independent of the key insertion order of the input object. | +| ALD-006.3 | checked | provisional-mapped | `packages/evidence/__tests__/canonical.test.ts` | A round-trip (serialize → deserialize) produces a deep-equal object to the input. | +| ALD-007.1 | checked | provisional-mapped | `packages/evidence/__tests__/event-types.test.ts` | Every event type listed in §5 has a corresponding validator function. | +| ALD-007.2 | checked | provisional-mapped | `packages/evidence/__tests__/event-types.test.ts` | Submitting an event with a missing required field is rejected before reaching the serializer. | +| ALD-007.3 | checked | provisional-mapped | `packages/evidence/__tests__/event-types.test.ts` | Submitting an unknown event type is rejected with a clear error, not silently accepted. | +| ALD-008.1 | checked | provisional-mapped | `packages/ops/__tests__/snapshot.test.ts` | Each stored event's previous hash matches the immediately preceding event in the same run and event domain, with sequence starting at `1`. | +| ALD-008.2 | checked | provisional-mapped | `packages/ops/__tests__/snapshot.test.ts` | Mutating, deleting, inserting, or reordering an event in any primary chain is detected by a chain-walk validator. | +| ALD-008.3 | checked | provisional-mapped | `packages/ops/__tests__/snapshot.test.ts` | Sender intention, channel event, receiver delivery receipt, and receiver interpretation carry the exact cross-hashes required by LEDGER §6 and SPECIFICATION §11.5. | +| ALD-009.1 | checked | provisional-mapped | `packages/hashing/__tests__/keystore.test.ts` | Every committed event and checkpoint has a signature verifiable by the public key registered for exactly its domain. | +| ALD-009.2 | checked | provisional-mapped | `packages/hashing/__tests__/keystore.test.ts` | Cross-domain signing attempts fail, including Baby A attempting to sign Baby B or channel content. | +| ALD-009.3 | checked | provisional-mapped | `packages/hashing/__tests__/keystore.test.ts` | Private keys are absent from SQLite, logs, model context, and evidence bundles; per-run rotation produces distinct public keys. | +| ALD-010.1 | checked | provisional-mapped | `packages/evidence/__tests__/api.test.ts` | A signing or insert failure at any point results in zero sender-ledger and channel rows committed. | +| ALD-010.2 | checked | provisional-mapped | `packages/evidence/__tests__/api.test.ts` | A successful `TurnCommitRequest` commits both signed rows atomically and returns their entry hashes before delivery. | +| ALD-010.3 | checked | provisional-mapped | `packages/evidence/__tests__/api.test.ts` | Module boundaries and database permissions prevent every other component, including Gateway and Controller, from writing event tables directly. | +| ALD-011.1 | checked | provisional-mapped | `packages/evidence/__tests__/crash-safety.test.ts` | Simulated crash during an in-flight `ALD-010` transaction leaves the database with either the pre-transaction or post-transaction state, never a partial one, on restart. | +| ALD-011.2 | checked | provisional-mapped | `packages/evidence/__tests__/crash-safety.test.ts` | The chain-walk validator from `ALD-008` reports zero integrity violations after each crash-recovery test run. | +| ALD-011.3 | checked | provisional-mapped | `packages/evidence/__tests__/crash-safety.test.ts` | The test suite runs at least 20 randomized crash-point trials in CI (once `ALD-078` exists) without a single torn-write failure. | +| ALD-012.1 | checked | provisional-mapped | `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts` | Given a fixed ordered set of event hashes, the builder produces a deterministic, reproducible root hash. | +| ALD-012.2 | checked | provisional-mapped | `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts` | An inclusion proof for any leaf verifies correctly against the root using only the proof and the leaf hash. | +| ALD-012.3 | checked | provisional-mapped | `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts` | Valid extension checkpoints produce a consistency proof, while reordering, deletion, insertion, or a non-prefix tree fails consistency verification. | +| ALD-013.1 | checked | provisional-mapped | `packages/checkpoint/__tests__/checkpoint-service.test.ts` | A generated manifest validates against the authoritative schema and includes every event tree present in the Evidence Store. | +| ALD-013.2 | checked | provisional-mapped | `packages/checkpoint/__tests__/checkpoint-service.test.ts` | Each manifest references the immediately prior checkpoint hash and carries a valid Nursery witness signature. | +| ALD-013.3 | checked | provisional-mapped | `packages/checkpoint/__tests__/checkpoint-service.test.ts` | Every tree size/root exactly matches `ALD-012`, and a missing or extra tree causes checkpoint generation to fail. | +| ALD-014.1 | checked | provisional-mapped | `packages/checkpoint/__tests__/scheduler.test.ts`
    `packages/ops/__tests__/checkpoint-scheduler.test.ts` | A checkpoint is generated automatically once the configured trigger threshold from §9 is reached. | +| ALD-014.2 | checked | provisional-mapped | `packages/checkpoint/__tests__/scheduler.test.ts`
    `packages/ops/__tests__/checkpoint-scheduler.test.ts` | No two checkpoints overlap in event range. | +| ALD-014.3 | checked | provisional-mapped | `packages/checkpoint/__tests__/scheduler.test.ts`
    `packages/ops/__tests__/checkpoint-scheduler.test.ts` | The scheduler is a background timer with the crash-protection convention (registered under the process's `uncaughtException`/`unhandledRejection` handlers) so a scheduling failure logs rather than crashes the server. | +| ALD-015.1 | checked | provisional-mapped | `packages/verifier/__tests__/cli.test.ts` | The CLI runs against an exported bundle with no network access and no shared process state with the server. | +| ALD-015.2 | checked | provisional-mapped | `packages/verifier/__tests__/cli.test.ts` | It accepts an unchanged local bundle and rejects every non-chain mutation case in LEDGER §17 with the correct machine-readable failure location. | +| ALD-015.3 | checked | provisional-mapped | `packages/verifier/__tests__/cli.test.ts` | It distinguishes chain, signature, inclusion, consistency, checkpoint, fork/gap, and unanchored-tail results. | +| ALD-016.1 | checked | provisional-mapped | `packages/evidence/__tests__/export.test.ts` | The exported bundle contains every event, manifest, and signature needed for `ALD-015` to verify it with no other input. | +| ALD-016.2 | checked | provisional-mapped | `packages/evidence/__tests__/export.test.ts` | Exporting the same run twice without intervening writes produces byte-identical bundles. | +| ALD-016.3 | checked | provisional-mapped | `packages/evidence/__tests__/export.test.ts` | The bundle format is documented with a schema so a third party could write their own verifier. | +| ALD-017.1 | checked | provisional-mapped | `packages/verifier/__tests__/cli.test.ts`
    `packages/verifier/__tests__/verify-bundle.test.ts` | Every verifier CLI run (`ALD-015`) produces a report conforming to the `ALD-002` schema for Verification Report. | +| ALD-017.2 | checked | provisional-mapped | `packages/verifier/__tests__/cli.test.ts`
    `packages/verifier/__tests__/verify-bundle.test.ts` | A failing verification produces a report with machine-readable failure codes, not just free text. | +| ALD-017.3 | checked | provisional-mapped | `packages/verifier/__tests__/cli.test.ts`
    `packages/verifier/__tests__/verify-bundle.test.ts` | Reports are timestamped and reference the exact bundle export they were generated from. | +| ALD-018.1 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts` | A stored Anchor Receipt validates against the `ALD-002` schema and always references an existing checkpoint manifest (`ALD-013`). | +| ALD-018.2 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts` | Only a checkpoint root hash and minimal metadata are ever stored as the on-chain payload field — no raw observation or model data. | +| ALD-018.3 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts` | Querying receipts by checkpoint ID returns at most one receipt per chain per checkpoint. | +| ALD-019.1 | checked | provisional-mapped | `packages/anchor/__tests__/key-file.test.ts` | The anchoring key is stored separately from the event-signing key and neither can be derived from the other. | +| ALD-019.2 | checked | provisional-mapped | `packages/anchor/__tests__/key-file.test.ts` | A key-rotation procedure exists and is exercised by a test that anchors before and after rotation without breaking prior receipts' validity. | +| ALD-019.3 | checked | provisional-mapped | `packages/anchor/__tests__/key-file.test.ts` | The anchoring private key is never logged, telemetered, or included in any evidence bundle. | +| ALD-020.1 | open | external-blocked | `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts` | A submitted checkpoint root is independently observable on a public Base Sepolia block explorer. | +| ALD-020.2 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts` | The default configuration anchors to Base Sepolia with no additional opt-in required. | +| ALD-020.3 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts` | The submitted on-chain payload contains only the root hash and minimal required metadata, matching `ALD-018`'s privacy criterion. | +| ALD-021.1 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts` | A receipt is marked `confirmed` only after reaching the configured confirmation depth. | +| ALD-021.2 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts` | Transient RPC failure retries without duplicate submission; wrong-chain, failed, or nonexistent transactions fail verification. | +| ALD-021.3 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts` | The verifier reports any event tail after the final anchored checkpoint and independently reproduces the anchored checkpoint hash. | +| ALD-022.1 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts` | With no explicit opt-in set, the system never submits any transaction to mainnet, confirmed by a test that asserts zero mainnet RPC calls under default config. | +| ALD-022.2 | open | external-blocked | `packages/anchor/__tests__/publisher.test.ts` | Enabling the opt-in flag and providing mainnet-specific key/config anchors successfully to mainnet in a manual/staging test. | +| ALD-022.3 | checked | provisional-mapped | `packages/anchor/__tests__/publisher.test.ts` | Switching the opt-in flag off again immediately reverts all anchoring to Base Sepolia with no code change required. | +| ALD-023.1 | checked | provisional-mapped | `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/run-config.test.ts` | Missing fields and incompatible track/learning-signal, oracle/experiment, or carrier-specific combinations are rejected with field-specific errors. | +| ALD-023.2 | checked | provisional-mapped | `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/run-config.test.ts` | Valid root and derived configs cover every model, deployment, communication, carrier, affect, and interaction mode; lineage fields are all-or-none. | +| ALD-023.3 | checked | provisional-mapped | `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/run-config.test.ts` | Validated canonical configs are persisted, hashable, and retrievable by run ID. | +| ALD-024.1 | checked | provisional-mapped | `packages/lifecycle/__tests__/state-machine.test.ts` | Every transition listed in §7.2 is implemented and unit-tested. | +| ALD-024.2 | checked | provisional-mapped | `packages/lifecycle/__tests__/state-machine.test.ts` | Every transition **not** listed in §7.2 is rejected with an explicit "invalid transition" error, verified by an exhaustive test over all state pairs. | +| ALD-024.3 | checked | provisional-mapped | `packages/lifecycle/__tests__/state-machine.test.ts` | The current state of any run is queryable and matches the last successfully applied transition. | +| ALD-025.1 | checked | provisional-mapped | `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/scratch-rl-run.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | Every phase in §8.1 executes in the documented order for a successful turn. | +| ALD-025.2 | checked | provisional-mapped | `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/scratch-rl-run.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | A turn exceeding the §8.3 timing budget is terminated and recorded as a timeout, not left hanging. | +| ALD-025.3 | checked | provisional-mapped | `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/scratch-rl-run.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | The commit phase always goes through the `ALD-010` atomic wrapper — no direct ledger writes bypass it. | +| ALD-026.1 | checked | provisional-mapped | `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts` | Pausing a run mid-turn either lets the current turn's atomic commit finish or fully rolls it back — never a partial commit. | +| ALD-026.2 | checked | provisional-mapped | `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts` | An `aborted-sealed` run is terminal and can never accept another turn or be reopened. | +| ALD-026.3 | checked | provisional-mapped | `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts` | Pause/abort/resume operations are recorded through the audited intervention path and produce required checkpoints. | +| ALD-027.1 | checked | provisional-mapped | `packages/orchestrator/__tests__/recovery.test.ts` | After a simulated crash mid-turn, restart reconstructs the run's state to exactly the last atomically committed turn, with no phantom in-progress turn. | +| ALD-027.2 | checked | provisional-mapped | `packages/orchestrator/__tests__/recovery.test.ts` | A mismatched duplicate sequence preserves both artifacts, transitions the run to `forked-invalid`, halts writes, and requires research-integrity review. | +| ALD-027.3 | checked | provisional-mapped | `packages/orchestrator/__tests__/recovery.test.ts` | Recovery appends an explicit recovery event at the next unused sequence and matches the state independently derived by `ALD-015`. | +| ALD-028.1 | checked | provisional-mapped | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts` | A child run records `parentRunId`, `derivedFromCheckpointHash`, and both initial policy refs in config and its first initialization event. | +| ALD-028.2 | checked | provisional-mapped | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts` | Child sequences restart at `1`, and writes never modify parent evidence or reopen a terminal parent. | +| ALD-028.3 | checked | provisional-mapped | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts` | `ALD-016` exports lineage references and `ALD-015` verifies them against the immutable parent bundle. | +| ALD-029.1 | checked | provisional-mapped | `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `twins/packs/__tests__/routes.test.ts` | Every artifact passes through the Gateway; direct Baby-to-Baby routes fail in an instrumented integration test. | +| ALD-029.2 | checked | provisional-mapped | `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `twins/packs/__tests__/routes.test.ts` | `normal`, `disabled`, `constant`, seeded `random`, seeded `shuffled`, and E03-only `oracle` conditions produce their exact §9.6 behavior with no code changes. | +| ALD-029.3 | checked | provisional-mapped | `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `twins/packs/__tests__/routes.test.ts` | Every accepted, rejected, or control-substituted turn records the Baby-proposal hash when present and exact delivered-artifact hash through `ALD-035`. | +| ALD-030.1 | checked | provisional-mapped | `packages/gateway/__tests__/conformance.test.ts` | Emitting a token in the registered vocabulary is accepted and forwarded. | +| ALD-030.2 | checked | provisional-mapped | `packages/gateway/__tests__/conformance.test.ts` | Emitting any token, string, or byte sequence not in the registered vocabulary is rejected, not silently coerced to the nearest valid token. | +| ALD-030.3 | checked | provisional-mapped | `packages/gateway/__tests__/conformance.test.ts` | The vocabulary is configurable per run without a code change. | +| ALD-031.1 | checked | provisional-mapped | `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts` | Each alternate carrier accepts a valid bounded artifact and reproduces the same carrier-qualified `markHash`. | +| ALD-031.2 | checked | provisional-mapped | `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts` | Out-of-range glyph IDs, bitmap sizes, stroke values, tone bins, text/color fields, and semantic tags are rejected with specific reasons. | +| ALD-031.3 | checked | provisional-mapped | `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts` | Every carrier contributes accept/reject vectors to `ALD-036`, and exactly one carrier family is available in a run. | +| ALD-032.1 | checked | provisional-mapped | `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts` | The evaluator records mark-level leakage metrics and the exact analysis version in the run evidence bundle. | +| ALD-032.2 | checked | provisional-mapped | `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts` | Pre-registered recognizable-glyph and unintended-feature probes produce explicit pass, fail, or inconclusive results without altering the original canvas artifact. | +| ALD-032.3 | checked | provisional-mapped | `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts` | A failed leakage evaluation blocks an ungrounded-language claim while preserving the run as valid negative or integrity evidence. | +| ALD-033.1 | checked | provisional-mapped | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/ops/__tests__/metrics.test.ts` | Declared/permuted/opaque modes emit only A1-A6, while any other display or out-of-window submission is rejected. | +| ALD-033.2 | checked | provisional-mapped | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/ops/__tests__/metrics.test.ts` | Derived mode disables `submit_affect`, records the private measurement, and applies the pre-registered Gateway mapping; emergent mode records a carrier Channel Event rather than an Affect Event. | +| ALD-033.3 | checked | provisional-mapped | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/ops/__tests__/metrics.test.ts` | Affect schemas and every mode's accept/reject vectors extend `ALD-036`, including normalized timing/envelope behavior. | +| ALD-034.1 | checked | provisional-mapped | `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts` | A fixed-token violation produces the standard rejection shape and an append-only `channel.rejected` event with reason code and payload hash but no raw rejected content. | +| ALD-034.2 | checked | provisional-mapped | `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts` | Five consecutive rejections by default trigger an automatic pause and `safety-trigger` audit entry. | +| ALD-034.3 | checked | provisional-mapped | `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts` | A protocol-module contract test proves canvas and affect handlers can register later without changing the rejection event shape or pause policy. | +| ALD-035.1 | checked | provisional-mapped | `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts` | Every Gateway proposal includes one required private intention draft and rejects Baby-supplied run/turn/sender/hash metadata. | +| ALD-035.2 | checked | provisional-mapped | `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts` | Every accepted/rejected event contains the sender-ledger binding, delivery receipt, previous channel hash, entry hash, and channel-writer signature required by §11.5. | +| ALD-035.3 | checked | provisional-mapped | `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts` | Interpretation drafts require the delivered channel hash, and all schema failures use the standard Gateway error shape. | +| ALD-036.1 | checked | provisional-mapped | `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conformance.test.ts` | The MVP suite exercises fixed-token acceptance/rejection, all six communication controls, dual proposal/delivery hashing, consecutive-rejection pause, and schema failures. | +| ALD-036.2 | checked | provisional-mapped | `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conformance.test.ts` | The suite runs against a mocked/stub learner, with no dependency on any specific `ALD-044`–047 adapter. | +| ALD-036.3 | checked | provisional-mapped | `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conformance.test.ts` | A protocol registration test requires every enabled module to contribute accept/reject vectors; the consolidated suite is the gate referenced by EPIC-06 and `ALD-078`. | +| ALD-037.1 | checked | provisional-mapped | `packages/scenario/__tests__/observation.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts` | A built Observation validates against the `ALD-002` schema and contains no field not listed in §11.2. | +| ALD-037.2 | checked | provisional-mapped | `packages/scenario/__tests__/observation.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts` | Two observations built from identical underlying state are byte-identical after canonicalization (reusing `ALD-006`'s approach). | +| ALD-037.3 | checked | provisional-mapped | `packages/scenario/__tests__/observation.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts` | The builder is the only code path producing Observations delivered to learners. | +| ALD-038.1 | checked | provisional-mapped | `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `twins/packs/__tests__/routes.test.ts` | Every prohibited field category listed in §10.1 is demonstrated blocked using a test observation deliberately constructed to contain it. | +| ALD-038.2 | checked | provisional-mapped | `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `twins/packs/__tests__/routes.test.ts` | The filter runs on every Observation before it reaches the Gateway/learner boundary, with no bypass path. | +| ALD-038.3 | checked | provisional-mapped | `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `twins/packs/__tests__/routes.test.ts` | A blocked field produces an audit-logged event (feeding `ALD-059`), not a silent drop. | +| ALD-039.1 | checked | provisional-mapped | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts` | A bundle containing OCR-detected text, caption metadata, semantic filenames, or human-readable labels cannot be referenced by a run. | +| ALD-039.2 | checked | provisional-mapped | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts` | Text-free controls pass while pre-registered adversarial image/text fixtures are quarantined before any adapter receives them. | +| ALD-039.3 | checked | provisional-mapped | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts` | Quarantine events retain artifact hashes and reason codes without exposing raw injection text in Baby-visible or public logs. | +| ALD-040.1 | checked | provisional-mapped | `packages/redteam/__tests__/observation-and-measurement.test.ts` | Each side-channel category named in §10.3 has a corresponding mitigation implemented (e.g., constant-shape error responses, timing normalization where specified). | +| ALD-040.2 | checked | provisional-mapped | `packages/redteam/__tests__/observation-and-measurement.test.ts` | A test harness measuring the relevant channel (e.g., response latency variance) confirms the mitigation is effective within the tolerance §10.3 implies. | +| ALD-040.3 | checked | provisional-mapped | `packages/redteam/__tests__/observation-and-measurement.test.ts` | This item's test harness is reused (not duplicated) by the red-team suite in `ALD-067`. | +| ALD-041.1 | checked | provisional-mapped | `packages/scenario/__tests__/referential-engine.test.ts` | Two runs with the same seed and interaction mode produce byte-identical scenarios, private facts, utilities, and task sequences. | +| ALD-041.2 | checked | provisional-mapped | `packages/scenario/__tests__/referential-engine.test.ts` | Every §9.5 interaction profile produces its required utility relationship, including a provably empty zone of possible agreement for `no-agreement-control`. | +| ALD-041.3 | checked | provisional-mapped | `packages/scenario/__tests__/referential-engine.test.ts` | The engine's output feeds `ALD-037`'s Observation builder with no intermediate non-deterministic step. | +| ALD-042.1 | checked | provisional-mapped | `packages/learners/__tests__/no-learning.test.ts` | The interface implements every §6.2 method, including `receive(DeliveredChannelArtifact)` returning a `LedgerDraftEnvelope`. | +| ALD-042.2 | checked | provisional-mapped | `packages/learners/__tests__/no-learning.test.ts` | The `no-learning` reference adapter is selectable, performs no policy update, and passes the contract-conformance test. | +| ALD-042.3 | checked | provisional-mapped | `packages/learners/__tests__/no-learning.test.ts` | The interface is the only integration point the turn orchestrator (`ALD-025`) uses to reach a learner. | +| ALD-043.1 | checked | provisional-mapped | `scripts/lint-learner-contracts.mjs` | A contract containing a symbol-meaning example, sample exchange, or prohibited side-channel instruction fails CI and cannot be referenced by a run. | +| ALD-043.2 | checked | provisional-mapped | `scripts/lint-learner-contracts.mjs` | Referenced contract versions are immutable and their prompt-bundle hashes appear in run evidence. | +| ALD-043.3 | checked | provisional-mapped | `scripts/lint-learner-contracts.mjs` | The no-learning reference and all four adapter tracks are blocked and audited when attempting any state write or output outside the §6.3 tool surface. | +| ALD-044.1 | checked | provisional-mapped | `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts` | The `frozen-llm` track is selectable via `ALD-023` run configuration and records the exact model and weight hashes. | +| ALD-044.2 | checked | provisional-mapped | `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts` | A full turn (observation → proposal → Gateway validation → commit) completes end-to-end using this adapter. | +| ALD-044.3 | checked | provisional-mapped | `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts` | The adapter exposes no weight-update path and passes the `ALD-036` Gateway conformance suite. | +| ALD-045.1 | checked | provisional-mapped | `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `scripts/run-mode-r-study.mjs` | The track starts from randomly initialized parameters whose initial hash is recorded in the evidence bundle. | +| ALD-045.2 | checked | provisional-mapped | `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `scripts/run-mode-r-study.mjs` | A full turn and minimal reward-to-parameter-update cycle complete end-to-end in both pre-registered extrinsic-reward and intrinsic-motivation modes. | +| ALD-045.3 | checked | provisional-mapped | `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `scripts/run-mode-r-study.mjs` | Policy updates use only the Baby's private buffers and emit a verifiable policy checkpoint reference. | +| ALD-046.1 | checked | provisional-mapped | `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `scripts/run-mode-r-study.mjs` | The track is selectable, starts from recorded random initialization, and rejects any scalar reward supplied to its update path. | +| ALD-046.2 | checked | provisional-mapped | `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `scripts/run-mode-r-study.mjs` | A full turn and minimal predictive/contrastive update complete end-to-end. | +| ALD-046.3 | checked | provisional-mapped | `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `scripts/run-mode-r-study.mjs` | The evidence bundle records the loss definition and proves that outcome labels are not included in the self-supervised update batch. | +| ALD-047.1 | checked | provisional-mapped | `packages/learners/__tests__/predictive-model.test.ts` | The track is selectable and records the provenance and hashes of every sensory, world-model, and communication component. | +| ALD-047.2 | checked | provisional-mapped | `packages/learners/__tests__/predictive-model.test.ts` | A full turn and minimal policy update complete end-to-end through the shared interface. | +| ALD-047.3 | checked | provisional-mapped | `packages/learners/__tests__/predictive-model.test.ts` | Any text-aligned frozen feature automatically weakens the run's claim classification; strict ungrounded classification requires `ALD-057` to pass. | +| ALD-048.1 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Every route in §12.4, including Gateway-only `/deliver`, exists on both `baby-a` and `baby-b`, all unprefixed. | +| ALD-048.2 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Each route's behavior matches its documented purpose in §12.4 (verified by an integration test per route). | +| ALD-048.3 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | A full run using these twin packs completes at least one turn end-to-end through the Gateway. | +| ALD-049.1 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Every route in §12.5 exists, unprefixed, and drives the correct `ALD-024` state transition. | +| ALD-049.2 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Creating a run via nursery correctly provisions both `baby-a` and `baby-b` instances. | +| ALD-049.3 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Pausing/aborting and derived-run creation use the same state/lineage services as direct internal calls, with no divergent logic path or reopening of terminal parents. | +| ALD-050.1 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Every route in §12.6 exists, unprefixed, and returns data conforming to the `ALD-002` schemas involved. | +| ALD-050.2 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Triggering an export via this route produces a bundle identical to calling `ALD-016`'s export function directly. | +| ALD-050.3 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Unauthorized callers (per `ALD-051`) cannot reach these routes. | +| ALD-051.1 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Every role defined in §12.2 is enforced on every route that names a restriction. | +| ALD-051.2 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Missing/invalid credentials return `401 UNAUTHENTICATED`; valid identities with insufficient roles return `403 FORBIDDEN`. | +| ALD-051.3 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | A test matrix of (role × route) confirms allow/deny matches §12.2 exactly. | +| ALD-052.1 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Every success response across all routes matches the §12.3 success envelope. | +| ALD-052.2 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Every error response across all routes matches the §12.3 error envelope, including the ones from `ALD-034`'s channel violations. | +| ALD-052.3 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | A lint/test rule fails the build if a new route is added without conforming to the envelope. | +| ALD-053.1 | checked | provisional-mapped | `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | A run created under Mode P and one under Mode R differ exactly along the dimensions listed in §5.3 — no undocumented behavioral difference. | +| ALD-053.2 | checked | provisional-mapped | `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | The mode is immutable for the lifetime of a run once created (cannot be switched mid-run). | +| ALD-053.3 | checked | provisional-mapped | `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | The active mode is recorded in the Run Configuration and visible in every exported evidence bundle. | +| ALD-054.1 | checked | provisional-mapped | `packages/types/__tests__/claim-boundary.test.ts` | Every claim statement listed in §5.4 is machine-checked against the run's actual recorded mode before being allowed to render/export. | +| ALD-054.2 | checked | provisional-mapped | `packages/types/__tests__/claim-boundary.test.ts` | A Mode P run attempting to surface a Mode-R-only claim label is blocked with a specific error, not silently downgraded. | +| ALD-054.3 | checked | provisional-mapped | `packages/types/__tests__/claim-boundary.test.ts` | The check is exercised by an automated test for every claim statement in §5.4, not spot-checked manually. | +| ALD-055.1 | checked | provisional-mapped | `scripts/run-mode-r-smoke.mjs` | In Mode R, `baby-a` and `baby-b` learner processes run in distinct OS processes/containers, verified by distinct process IDs / container IDs. | +| ALD-055.2 | checked | provisional-mapped | `scripts/run-mode-r-smoke.mjs` | No in-memory object reference is shared between the two learner containers (verified by an isolation test attempting cross-container object access and observing failure). | +| ALD-055.3 | checked | provisional-mapped | `scripts/run-mode-r-smoke.mjs` | Killing one learner container does not corrupt or crash the other, or the Gateway/evidence store. | +| ALD-056.1 | checked | provisional-mapped | `scripts/run-mode-r-smoke.mjs` | Each trainable learner's update step reads only from its own adapter's local buffers, never from the counterpart process. | +| ALD-056.2 | checked | provisional-mapped | `scripts/run-mode-r-smoke.mjs` | An isolation test that attempts to smuggle counterpart-agent internal state into a training update fails to do so, confirmed by the test. | +| ALD-056.3 | checked | provisional-mapped | `scripts/run-mode-r-smoke.mjs` | Training isolation is verified specifically under Mode R container separation (`ALD-055`), not just asserted for Mode P. | +| ALD-057.1 | checked | provisional-mapped | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts` | Every test in §6.5 runs against `scratch-rl`, `self-supervised`, and strict `hybrid`; frozen-LLM and no-learning runs are explicitly classified rather than incorrectly presented as ungrounded. | +| ALD-057.2 | checked | provisional-mapped | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts` | Linear-probe evaluation uses the pre-registered 95% label-shuffled confidence-interval rule. | +| ALD-057.3 | checked | provisional-mapped | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts` | Battery results are attached to the run's evidence record so a reviewer can see leakage-test outcomes per run. | +| ALD-058.1 | checked | provisional-mapped | `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | Every API request across all twin routes produces a telemetry record with the fields §14.1 requires. | +| ALD-058.2 | checked | provisional-mapped | `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | Telemetry recording failures never block or fail the underlying request (verified by fault-injection test on the telemetry sink). | +| ALD-058.3 | checked | provisional-mapped | `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | Telemetry data is queryable by run ID and by time range. | +| ALD-059.1 | checked | provisional-mapped | `packages/orchestrator/__tests__/interventions.test.ts` | Every §14.2 human view/intervention and §14.5 safety trigger produces an append-only audit record with authenticated actor and machine-readable reason. | +| ALD-059.2 | checked | provisional-mapped | `packages/orchestrator/__tests__/interventions.test.ts` | Each intervention produces a signed checkpoint, and any unplanned intervention links to an append-only notebook deviation record. | +| ALD-059.3 | checked | provisional-mapped | `packages/orchestrator/__tests__/interventions.test.ts` | Audit and intervention logs plus checkpoint references are included in every evidence bundle, not only Mode R. | +| ALD-060.1 | checked | provisional-mapped | `packages/ops/__tests__/snapshot.test.ts` | A manual "take snapshot now" action produces a snapshot file set that a restore can consume. | +| ALD-060.2 | checked | provisional-mapped | `packages/ops/__tests__/snapshot.test.ts` | Restarting the server after a snapshot automatically restores to that snapshot's state (`autoRestore()`-equivalent behavior). | +| ALD-060.3 | checked | provisional-mapped | `packages/ops/__tests__/snapshot.test.ts` | A restored run's evidence-store state matches, byte-for-byte in the chain-walk sense (`ALD-008`), the state at the moment the snapshot was taken. | +| ALD-061.1 | checked | provisional-mapped | `packages/ops/__tests__/failure-policy.test.ts` | Every failure mode named in §14.5 has an implemented, tested handling path. | +| ALD-061.2 | checked | provisional-mapped | `packages/ops/__tests__/failure-policy.test.ts` | An unhandled rejection thrown from a background task (e.g., a failed anchor confirmation poll) is caught, logged, and does not crash the server process, confirmed by a fault-injection test. | +| ALD-061.3 | checked | provisional-mapped | `packages/ops/__tests__/failure-policy.test.ts` | The failure-handling behavior for anchoring failures reuses `ALD-021`'s retry/backoff rather than a separate ad hoc mechanism. | +| ALD-062.1 | checked | provisional-mapped | `packages/ops/__tests__/retention.test.ts` | Only eligible non-public, non-mainnet run-bundle payloads are purged; public or mainnet-anchored bundles are retained indefinitely. | +| ALD-062.2 | checked | provisional-mapped | `packages/ops/__tests__/retention.test.ts` | `run_metadata`, ledger/channel/audit/intervention rows, checkpoint manifests, and anchor receipts remain queryable after purge. | +| ALD-062.3 | checked | provisional-mapped | `packages/ops/__tests__/retention.test.ts` | The job's actions are themselves audit-logged (`ALD-059`). | +| ALD-063.1 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | The console displays live run/pre-registration state, public transcript, telemetry, verification, checkpoint, and anchor data from authoritative routes. | +| ALD-063.2 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | Baby A/B observations and audit ledgers render in clearly separated read-only panels, and operator controls are role-gated and audited. | +| ALD-063.3 | checked | provisional-mapped | `twins/packs/__tests__/routes.test.ts` | The console is a self-contained vanilla HTML/CSS/JS page with no frontend framework, build tool, or client-side side-channel route. | +| ALD-064.1 | checked | provisional-mapped | `packages/orchestrator/__tests__/audit-interpreter.test.ts` | Every generated interpretation references source native events and is explicitly labeled external analysis. | +| ALD-064.2 | checked | provisional-mapped | `packages/orchestrator/__tests__/audit-interpreter.test.ts` | Baby identities cannot read audit-ledger entries, while authorized researchers and the verifier can. | +| ALD-064.3 | checked | provisional-mapped | `packages/orchestrator/__tests__/audit-interpreter.test.ts` | Audit entries are included in their checkpoint auxiliary tree and exported evidence without modifying native ledger events. | +| ALD-065.1 | checked | provisional-mapped | `scripts/check-research-console.mjs` | Every reused component is listed with its permitted §16.2 mapping, and no Diplomacy game logic or side-channel route is imported. | +| ALD-065.2 | checked | provisional-mapped | `scripts/check-research-console.mjs` | Any failing item blocks `ALD-063` from being marked `Done` until resolved. | +| ALD-065.3 | checked | provisional-mapped | `scripts/check-research-console.mjs` | Every §16.3 prohibition has a pass/fail result, and the checklist reruns after material dashboard changes. | +| ALD-066.1 | checked | provisional-mapped | `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts` | Same-seed scenario replay reproduces scenario/observation hashes; wrong-seed replay fails automatically. | +| ALD-066.2 | checked | provisional-mapped | `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts` | Deterministic adapters reproduce the §14.3 replay digest, while nondeterministic adapters are explicitly `not-applicable` and pass recorded-decision playback only. | +| ALD-066.3 | checked | provisional-mapped | `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts` | The viewer displays machine results and remains read-only; it cannot alter evidence or override a failure. | +| ALD-067.1 | checked | provisional-mapped | `packages/redteam/__tests__/side-channel.test.ts` | The harness includes at least one active exploit attempt per side-channel category named in §10.3. | +| ALD-067.2 | checked | provisional-mapped | `packages/redteam/__tests__/side-channel.test.ts` | Every Research-Grade exploit attempt fails to extract cross-agent information, confirmed by an automated pass/fail check, while Mode P remains the explicit comparison condition. | +| ALD-067.3 | checked | provisional-mapped | `packages/redteam/__tests__/side-channel.test.ts` | The harness runs under both Mode P and Mode R (`ALD-053`) so `E01`'s isolation comparison is possible. | +| ALD-068.1 | checked | provisional-mapped | `packages/redteam/__tests__/observation-and-measurement.test.ts` | The suite includes direct text, low-contrast/OCR-evasion fixtures, metadata labels, semantic filenames, and malformed-image cases. | +| ALD-068.2 | checked | provisional-mapped | `packages/redteam/__tests__/observation-and-measurement.test.ts` | Every positive fixture is quarantined and zero raw text reaches an adapter context; negative text-free controls remain loadable. | +| ALD-068.3 | checked | provisional-mapped | `packages/redteam/__tests__/observation-and-measurement.test.ts` | Results are exported as hashed evidence linked from the E02 Experiment Record, not misrepresented as the Experiment Record itself. | +| ALD-069.1 | checked | provisional-mapped | `packages/crypto-research/__tests__/harness.test.ts` | The harness supports at least three roles in a single run: two communicating learners and one eavesdropper observer. | +| ALD-069.2 | checked | provisional-mapped | `packages/crypto-research/__tests__/harness.test.ts` | The harness logs every encoding scheme change as a distinct, timestamped event so `E40` can measure encoding lifetime. | +| ALD-069.3 | checked | provisional-mapped | `packages/crypto-research/__tests__/harness.test.ts` | The harness itself makes no claim about cryptographic security — it only provides the measurement/instrumentation `E40`'s research execution needs; a passing harness build is not a security claim. | +| ALD-070.1 | checked | provisional-mapped | `packages/crypto-research/__tests__/boundary.test.ts` | A written policy statement exists distinguishing "research-harness encoding" from "production cryptographic signing," citing `ALD-009` and `ALD-019` as the only production mechanisms. | +| ALD-070.2 | checked | provisional-mapped | `packages/crypto-research/__tests__/boundary.test.ts` | A code-level check (e.g., module boundary or lint rule) prevents any `ALD-069`-harness-derived code from being imported into the `ALD-009`/`ALD-019` signing modules. | +| ALD-070.3 | checked | provisional-mapped | `packages/crypto-research/__tests__/boundary.test.ts` | This policy is included in the documentation set (`ALD-079`) and referenced by `E40`'s readiness gate (`ALD-077`). | +| ALD-071.1 | checked | provisional-mapped | `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | A confirmatory run cannot start without a bound experiment ID, protocol commit, complete repository-native or external registration, canonical pre-registration hash, and matching pre-run commitment receipt whose simulated/public class matches the run configuration. | +| ALD-071.2 | checked | provisional-mapped | `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | Pre-registration creates Experiment Record version `1`; later disposition/evidence changes append higher versions without updating prior rows. | +| ALD-071.3 | checked | provisional-mapped | `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | Every evidence bundle contains the latest record plus its version history and references resolvable by an independent reviewer. | +| ALD-072.1 | checked | provisional-mapped | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `scripts/run-qualification.mjs` | An intervention defined in a pre-registration reference (`ALD-071`) can be toggled on/off for a run via configuration, with no code change per intervention. | +| ALD-072.2 | checked | provisional-mapped | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `scripts/run-qualification.mjs` | The scaffold computes every baseline statistic named in §15.3 over a completed run's evidence. | +| ALD-072.3 | checked | provisional-mapped | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `scripts/run-qualification.mjs` | The scaffold's output is a data structure ready for a researcher's downstream analysis — it does not itself draw or store scientific conclusions. | +| ALD-073.1 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E00` readiness: the verifier accepts an unchanged anchored bundle and rejects all 14 mutation/anchor cases in LEDGER §17, including wrong-chain anchors and unanchored tails. | +| ALD-073.2 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E01`/`E02` readiness: `ALD-067`/`ALD-068` red-team suites are green. | +| ALD-073.3 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E03` readiness: `ALD-029`/`ALD-036` run all six controls with dual-hash evidence, while `ALD-041`/`ALD-042`/`ALD-072` provide deterministic scenarios, no-learning behavior, confidence intervals, and effect sizes. | +| ALD-074.1 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E10`/`E11`/`E12` readiness: `ALD-044`/`ALD-045`/`ALD-046` complete full turns; ungrounded tracks pass training isolation and semantic-leakage qualification. | +| ALD-074.2 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E13` readiness: all `ALD-031` alternate carriers pass conformance and `ALD-032` leakage evaluation can compare all five notebook carrier conditions. | +| ALD-074.3 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E14`/`E15`/`E16` readiness: `ALD-072` supports role reversal, held-out splits, and causal interventions, and `ALD-064` produces separately labeled human audit interpretations. | +| ALD-075.1 | checked | provisional-mapped | `packages/interventions/__tests__/readiness-gates.test.ts` | `E20` readiness: `ALD-033` affect protocol independently passes conformance with all six displays exercised. | +| ALD-075.2 | checked | provisional-mapped | `packages/interventions/__tests__/readiness-gates.test.ts` | `E21` readiness: no-learning (`ALD-042`), frozen-LLM (`ALD-044`), extrinsic/intrinsic scratch-RL (`ALD-045`), and self-supervised (`ALD-046`) modes run under the same scenario configuration. | +| ALD-075.3 | checked | provisional-mapped | `packages/interventions/__tests__/readiness-gates.test.ts` | `E22` readiness: `ALD-072`'s scaffold supports a staged/curriculum sequence of interventions within a single run. | +| ALD-076.1 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E30` readiness: `ALD-028` derived-run support can substitute a new learner adapter in the child run without altering the parent. | +| ALD-076.2 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E31` readiness: `ALD-072`'s baseline/statistics scaffold can compare metrics across a long sequence of turns/checkpoints for drift analysis. | +| ALD-076.3 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | `E32` readiness: `ALD-041` produces each pre-registered cooperative and negotiation utility condition from configuration without a code change. | +| ALD-077.1 | checked | provisional-mapped | `packages/interventions/__tests__/readiness-gates.test.ts` | `E40` readiness: `ALD-069` harness and `ALD-070` separation policy both pass their own acceptance criteria. | +| ALD-077.2 | checked | provisional-mapped | `packages/interventions/__tests__/readiness-gates.test.ts` | `E50` readiness: `ALD-028` derived-run support and `ALD-072`'s scaffold together launch the same pre-registered configuration across independent seeds and aggregate baseline statistics. | +| ALD-077.3 | checked | provisional-mapped | `packages/interventions/__tests__/readiness-gates.test.ts` | This gate's checklist cross-references the `EXPERIMENT-NOTEBOOK.md` [§12. Publication Checklist](EXPERIMENT-NOTEBOOK.md#12-publication-checklist) items that are software-verifiable, without asserting any of the checklist's research-judgment items are satisfied. | +| ALD-078.1 | open | external-blocked | `.github/workflows/book-integrity.yml` | CI runs on every proposed change and blocks merge on any failing suite. | +| ALD-078.2 | checked | provisional-mapped | `.github/workflows/book-integrity.yml` | The consolidated suite includes at least one test per `Done` item's acceptance criteria at the time CI is stood up. | +| ALD-078.3 | checked | provisional-mapped | `.github/workflows/book-integrity.yml` | CI run time and flakiness are tracked; a flaky test is quarantined with a tracked follow-up, not silently ignored. | +| ALD-079.1 | checked | provisional-mapped | `scripts/check-api-docs.mjs`
    `scripts/run-mode-r-smoke.mjs` | Every route implemented in EPIC-09 has a corresponding API reference entry with request/response shape. | +| ALD-079.2 | checked | provisional-mapped | `scripts/check-api-docs.mjs`
    `scripts/run-mode-r-smoke.mjs` | The Mode R runbook lets an operator who has not read the source code stand up an isolated run following the documented steps alone. | +| ALD-079.3 | open | external-blocked | `scripts/check-api-docs.mjs`
    `scripts/run-mode-r-smoke.mjs` | The snapshot/restore runbook is validated by having someone other than the implementer follow it to perform a real restore. | +| ALD-080.1 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | A documented release process exists (version scheme, changelog convention, tagging) consistent with §1's normative-language conventions. | +| ALD-080.2 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | Every software-verifiable item in the §12 publication checklist is mapped to at least one `ALD-XXX` ID or Gate (`ALD-073`–`077`). | +| ALD-080.3 | checked | provisional-mapped | `scripts/check-readiness-gates.mjs` | The mapping explicitly flags which §12 checklist items are research-judgment calls outside this backlog's scope (e.g., "results support the stated hypothesis"), rather than silently omitting them. | +| ALD-081.1 | checked | provisional-mapped | `.github/workflows/book-integrity.yml` | A clean frozen-lockfile install followed by `pnpm audit --audit-level=high` exits zero. | +| ALD-081.2 | checked | provisional-mapped | `.github/workflows/book-integrity.yml` | Hosted consolidated CI retains the audit JSON even when another check fails. | +| ALD-081.3 | checked | provisional-mapped | `.github/workflows/book-integrity.yml` | Required native dependency install scripts are explicitly allowlisted by exact package version. | +| ALD-082.1 | checked | provisional-mapped | `packages/analysis/__tests__/e03-design.test.ts` | A fixed seed reproduces byte-identical 30,000-replicate design output. | +| ALD-082.2 | checked | provisional-mapped | `packages/analysis/__tests__/e03-design.test.ts` | Every registered SD/sample-size row has a lower 95% Monte Carlo bound at or above the 90% power floor. | +| ALD-082.3 | checked | provisional-mapped | `packages/analysis/__tests__/e03-design.test.ts` | The committed seed manifest deterministically derives separate scenario, random-control, and shuffled-control seeds without exposing realized confirmatory outcomes. | +| ALD-083.1 | checked | provisional-mapped | `packages/analysis/__tests__/e03-registration.test.ts` | The compiler output validates as a `PreRegistrationArtifact` and is byte-identical on repeat. | +| ALD-083.2 | checked | provisional-mapped | `packages/analysis/__tests__/e03-registration.test.ts` | Run IDs, realized random seeds, execution bindings, and the pre-registration hash are excluded from the hashed parameter template. | +| ALD-083.3 | checked | provisional-mapped | `packages/analysis/__tests__/e03-registration.test.ts` | Every primary and reserve condition configuration carries the same exact pre-registration hash. | +| ALD-084.1 | checked | provisional-mapped | `packages/ops/__tests__/research-preflight.test.ts` | Human and JSON outputs independently check Mode R, learning isolation, artifact class, hashes, immutable ancestral commits, seed count, complete registration, and confirmed matching pre-run commitment. | +| ALD-084.2 | checked | provisional-mapped | `packages/ops/__tests__/research-preflight.test.ts` | A missing repository-native/external registration, mismatched binding, or absent/incorrect commitment blocks confirmatory readiness. | +| ALD-084.3 | checked | provisional-mapped | `packages/ops/__tests__/research-preflight.test.ts` | A passing preflight explicitly does not claim public-chain verification or scientific validity. | +| ALD-085.1 | checked | provisional-mapped | `packages/learners/__tests__/frozen-qualification.test.ts` | The report binds the exact weight-file hash, inference-runtime archive hash, model identity, quantization, software commit, and seed hash. | +| ALD-085.2 | checked | provisional-mapped | `packages/learners/__tests__/frozen-qualification.test.ts` | At least two episodes exercise intention and interpretation in both Baby roles with tool-only conformance and no policy updates. | +| ALD-085.3 | checked | provisional-mapped | `packages/learners/__tests__/frozen-qualification.test.ts` | The committed report is labeled non-confirmatory and omits prompts, observations, raw output, candidate references, and private ledger content. | +| ALD-086.1 | checked | provisional-mapped | `scripts/check-project-status.mjs` | The checker derives acceptance totals from backlog checkboxes rather than accepting a manually supplied number. | +| ALD-086.2 | checked | provisional-mapped | `scripts/check-project-status.mjs` | Root package/lock versions and the public README/manuscript engineering snapshot must match exactly. | +| ALD-086.3 | checked | provisional-mapped | `scripts/check-project-status.mjs` | Both local and hosted consolidated checks run the drift checker before build and tests. | + +## Normative MUST inventory + +| Requirement | Source | Applicable | Provisional disposition | Backlog mapping | Assertion paths | +|---|---|---|---|---|---| +| SPEC-MUST-L101 | `SPECIFICATION.md:101` | no | not-applicable-definition | ALD-078 | `.github/workflows/book-integrity.yml` | +| SPEC-MUST-L102 | `SPECIFICATION.md:102` | no | not-applicable-definition | ALD-078 | `.github/workflows/book-integrity.yml` | +| SPEC-MUST-L109 | `SPECIFICATION.md:109` | yes | provisional-mapped | ALD-078 | `.github/workflows/book-integrity.yml` | +| SPEC-MUST-L188 | `SPECIFICATION.md:188` | yes | provisional-mapped | ALD-001, ALD-004, ALD-010, ALD-029 | `packages/evidence/__tests__/api.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L219 | `SPECIFICATION.md:219` | no | not-applicable-definition | ALD-001, ALD-004, ALD-010, ALD-029 | `packages/evidence/__tests__/api.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L227 | `SPECIFICATION.md:227` | yes | provisional-mapped | ALD-001, ALD-004, ALD-010, ALD-029 | `packages/evidence/__tests__/api.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L228 | `SPECIFICATION.md:228` | yes | provisional-mapped | ALD-001, ALD-004, ALD-010, ALD-029 | `packages/evidence/__tests__/api.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L230 | `SPECIFICATION.md:230` | yes | provisional-mapped | ALD-001, ALD-004, ALD-010, ALD-029 | `packages/evidence/__tests__/api.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L242 | `SPECIFICATION.md:242` | no | not-applicable-definition | ALD-001, ALD-004, ALD-010, ALD-029 | `packages/evidence/__tests__/api.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L281 | `SPECIFICATION.md:281` | yes | provisional-mapped | ALD-053, ALD-054, ALD-055 | `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/types/__tests__/claim-boundary.test.ts`
    `scripts/run-mode-r-smoke.mjs` | +| SPEC-MUST-L298 | `SPECIFICATION.md:298` | yes | provisional-mapped | ALD-053, ALD-054, ALD-055 | `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/types/__tests__/claim-boundary.test.ts`
    `scripts/run-mode-r-smoke.mjs` | +| SPEC-MUST-L311 | `SPECIFICATION.md:311` | yes | provisional-mapped | ALD-053, ALD-054, ALD-055 | `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/types/__tests__/claim-boundary.test.ts`
    `scripts/run-mode-r-smoke.mjs` | +| SPEC-MUST-L317 | `SPECIFICATION.md:317` | yes | provisional-mapped | ALD-053, ALD-054, ALD-055 | `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/types/__tests__/claim-boundary.test.ts`
    `scripts/run-mode-r-smoke.mjs` | +| SPEC-MUST-L319 | `SPECIFICATION.md:319` | yes | provisional-mapped | ALD-053, ALD-054, ALD-055 | `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/types/__tests__/claim-boundary.test.ts`
    `scripts/run-mode-r-smoke.mjs` | +| SPEC-MUST-L328 | `SPECIFICATION.md:328` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L334 | `SPECIFICATION.md:334` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L338 | `SPECIFICATION.md:338` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L378 | `SPECIFICATION.md:378` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L379 | `SPECIFICATION.md:379` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L390 | `SPECIFICATION.md:390` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L404 | `SPECIFICATION.md:404` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L416 | `SPECIFICATION.md:416` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L418 | `SPECIFICATION.md:418` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L421 | `SPECIFICATION.md:421` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L432 | `SPECIFICATION.md:432` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L452 | `SPECIFICATION.md:452` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L469 | `SPECIFICATION.md:469` | yes | provisional-mapped | ALD-042, ALD-043, ALD-044, ALD-045, ALD-046, ALD-047, ALD-057 | `packages/leakage/__tests__/semantic-leakage.test.ts`
    `packages/learners/__tests__/frozen-llm-conformance.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/learners/__tests__/llm-client.test.ts`
    `packages/learners/__tests__/no-learning.test.ts`
    `packages/learners/__tests__/predictive-model.test.ts`
    `packages/learners/__tests__/recurrent-adapters.test.ts`
    `packages/learners/__tests__/recurrent-model.test.ts`
    `packages/learners/__tests__/self-supervised.test.ts`
    `packages/learners/__tests__/tabular-reinforce.test.ts`
    `packages/orchestrator/__tests__/frozen-provenance-evidence.test.ts`
    `packages/orchestrator/__tests__/self-supervised-evidence.test.ts`
    `packages/orchestrator/__tests__/semantic-leakage-evidence.test.ts`
    `scripts/lint-learner-contracts.mjs`
    `scripts/run-mode-r-study.mjs` | +| SPEC-MUST-L508 | `SPECIFICATION.md:508` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L516 | `SPECIFICATION.md:516` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L517 | `SPECIFICATION.md:517` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L519 | `SPECIFICATION.md:519` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L523 | `SPECIFICATION.md:523` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L525 | `SPECIFICATION.md:525` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L530 | `SPECIFICATION.md:530` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L538 | `SPECIFICATION.md:538` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L541 | `SPECIFICATION.md:541` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L552 | `SPECIFICATION.md:552` | yes | provisional-mapped | ALD-024, ALD-026, ALD-027, ALD-028 | `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/state-machine.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/derived-run-evidence.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/recovery.test.ts` | +| SPEC-MUST-L597 | `SPECIFICATION.md:597` | yes | provisional-mapped | ALD-010, ALD-025 | `packages/evidence/__tests__/api.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/scratch-rl-run.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | +| SPEC-MUST-L599 | `SPECIFICATION.md:599` | yes | provisional-mapped | ALD-010, ALD-025 | `packages/evidence/__tests__/api.test.ts`
    `packages/orchestrator/__tests__/adapter-failure.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/scratch-rl-run.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts` | +| SPEC-MUST-L646 | `SPECIFICATION.md:646` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L712 | `SPECIFICATION.md:712` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L738 | `SPECIFICATION.md:738` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L752 | `SPECIFICATION.md:752` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L754 | `SPECIFICATION.md:754` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L755 | `SPECIFICATION.md:755` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L756 | `SPECIFICATION.md:756` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L758 | `SPECIFICATION.md:758` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L761 | `SPECIFICATION.md:761` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L778 | `SPECIFICATION.md:778` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L780 | `SPECIFICATION.md:780` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L781 | `SPECIFICATION.md:781` | yes | provisional-mapped | ALD-029, ALD-030, ALD-031, ALD-032, ALD-033, ALD-034, ALD-035, ALD-036 | `packages/analysis/__tests__/affect-leakage.test.ts`
    `packages/analysis/__tests__/carrier-leakage.test.ts`
    `packages/analysis/__tests__/information.test.ts`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/affect.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conditions.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/gateway/__tests__/rejection.test.ts`
    `packages/learners/__tests__/affect-policies.test.ts`
    `packages/learners/__tests__/frozen-llm-gateway.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/orchestrator/__tests__/carrier-leakage-evidence.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L788 | `SPECIFICATION.md:788` | yes | provisional-mapped | ALD-038, ALD-039, ALD-040, ALD-056, ALD-067, ALD-068 | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts`
    `packages/redteam/__tests__/observation-and-measurement.test.ts`
    `packages/redteam/__tests__/side-channel.test.ts`
    `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-mode-r-smoke.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L801 | `SPECIFICATION.md:801` | yes | provisional-mapped | ALD-038, ALD-039, ALD-040, ALD-056, ALD-067, ALD-068 | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts`
    `packages/redteam/__tests__/observation-and-measurement.test.ts`
    `packages/redteam/__tests__/side-channel.test.ts`
    `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-mode-r-smoke.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L809 | `SPECIFICATION.md:809` | yes | provisional-mapped | ALD-038, ALD-039, ALD-040, ALD-056, ALD-067, ALD-068 | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts`
    `packages/redteam/__tests__/observation-and-measurement.test.ts`
    `packages/redteam/__tests__/side-channel.test.ts`
    `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-mode-r-smoke.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L812 | `SPECIFICATION.md:812` | yes | provisional-mapped | ALD-038, ALD-039, ALD-040, ALD-056, ALD-067, ALD-068 | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts`
    `packages/redteam/__tests__/observation-and-measurement.test.ts`
    `packages/redteam/__tests__/side-channel.test.ts`
    `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-mode-r-smoke.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L822 | `SPECIFICATION.md:822` | yes | provisional-mapped | ALD-038, ALD-039, ALD-040, ALD-056, ALD-067, ALD-068 | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts`
    `packages/redteam/__tests__/observation-and-measurement.test.ts`
    `packages/redteam/__tests__/side-channel.test.ts`
    `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-mode-r-smoke.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L837 | `SPECIFICATION.md:837` | yes | provisional-mapped | ALD-038, ALD-039, ALD-040, ALD-056, ALD-067, ALD-068 | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts`
    `packages/redteam/__tests__/observation-and-measurement.test.ts`
    `packages/redteam/__tests__/side-channel.test.ts`
    `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-mode-r-smoke.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L850 | `SPECIFICATION.md:850` | yes | provisional-mapped | ALD-038, ALD-039, ALD-040, ALD-056, ALD-067, ALD-068 | `packages/gateway/__tests__/glyph-bundle.test.ts`
    `packages/orchestrator/__tests__/scenario-quarantine.test.ts`
    `packages/redteam/__tests__/observation-and-measurement.test.ts`
    `packages/redteam/__tests__/side-channel.test.ts`
    `packages/scenario/__tests__/hygiene.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-mode-r-smoke.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L974 | `SPECIFICATION.md:974` | yes | provisional-mapped | ALD-002, ALD-018, ALD-023, ALD-035, ALD-037 | `packages/anchor/__tests__/publisher.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/run-config.test.ts`
    `packages/scenario/__tests__/observation.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts` | +| SPEC-MUST-L1006 | `SPECIFICATION.md:1006` | yes | provisional-mapped | ALD-002, ALD-018, ALD-023, ALD-035, ALD-037 | `packages/anchor/__tests__/publisher.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/run-config.test.ts`
    `packages/scenario/__tests__/observation.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts` | +| SPEC-MUST-L1042 | `SPECIFICATION.md:1042` | yes | provisional-mapped | ALD-002, ALD-018, ALD-023, ALD-035, ALD-037 | `packages/anchor/__tests__/publisher.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/run-config.test.ts`
    `packages/scenario/__tests__/observation.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts` | +| SPEC-MUST-L1074 | `SPECIFICATION.md:1074` | yes | provisional-mapped | ALD-002, ALD-018, ALD-023, ALD-035, ALD-037 | `packages/anchor/__tests__/publisher.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/gateway/__tests__/interpretation.test.ts`
    `packages/learners/__tests__/frozen-llm.test.ts`
    `packages/lifecycle/__tests__/derived-run.test.ts`
    `packages/lifecycle/__tests__/run-config.test.ts`
    `packages/scenario/__tests__/observation.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `packages/types/__tests__/schema-manifest.test.ts` | +| SPEC-MUST-L1149 | `SPECIFICATION.md:1149` | yes | provisional-mapped | ALD-004, ALD-010, ALD-048, ALD-049, ALD-050, ALD-051, ALD-052 | `packages/evidence/__tests__/api.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L1229 | `SPECIFICATION.md:1229` | yes | provisional-mapped | ALD-004, ALD-010, ALD-048, ALD-049, ALD-050, ALD-051, ALD-052 | `packages/evidence/__tests__/api.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L1231 | `SPECIFICATION.md:1231` | yes | provisional-mapped | ALD-004, ALD-010, ALD-048, ALD-049, ALD-050, ALD-051, ALD-052 | `packages/evidence/__tests__/api.test.ts`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L1248 | `SPECIFICATION.md:1248` | yes | provisional-mapped | ALD-005, ALD-006, ALD-007, ALD-008, ALD-009, ALD-012, ALD-013, ALD-016, ALD-018, ALD-019, ALD-020, ALD-021, ALD-022, ALD-064 | `packages/anchor/__tests__/key-file.test.ts`
    `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts`
    `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/canonical.test.ts`
    `packages/evidence/__tests__/database.test.ts`
    `packages/evidence/__tests__/event-types.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/hashing/__tests__/keystore.test.ts`
    `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/orchestrator/__tests__/audit-interpreter.test.ts` | +| SPEC-MUST-L1284 | `SPECIFICATION.md:1284` | yes | provisional-mapped | ALD-005, ALD-006, ALD-007, ALD-008, ALD-009, ALD-012, ALD-013, ALD-016, ALD-018, ALD-019, ALD-020, ALD-021, ALD-022, ALD-064 | `packages/anchor/__tests__/key-file.test.ts`
    `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts`
    `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/canonical.test.ts`
    `packages/evidence/__tests__/database.test.ts`
    `packages/evidence/__tests__/event-types.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/hashing/__tests__/keystore.test.ts`
    `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/orchestrator/__tests__/audit-interpreter.test.ts` | +| SPEC-MUST-L1293 | `SPECIFICATION.md:1293` | yes | provisional-mapped | ALD-005, ALD-006, ALD-007, ALD-008, ALD-009, ALD-012, ALD-013, ALD-016, ALD-018, ALD-019, ALD-020, ALD-021, ALD-022, ALD-064 | `packages/anchor/__tests__/key-file.test.ts`
    `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts`
    `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/canonical.test.ts`
    `packages/evidence/__tests__/database.test.ts`
    `packages/evidence/__tests__/event-types.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/hashing/__tests__/keystore.test.ts`
    `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/orchestrator/__tests__/audit-interpreter.test.ts` | +| SPEC-MUST-L1295 | `SPECIFICATION.md:1295` | yes | provisional-mapped | ALD-005, ALD-006, ALD-007, ALD-008, ALD-009, ALD-012, ALD-013, ALD-016, ALD-018, ALD-019, ALD-020, ALD-021, ALD-022, ALD-064 | `packages/anchor/__tests__/key-file.test.ts`
    `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts`
    `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/canonical.test.ts`
    `packages/evidence/__tests__/database.test.ts`
    `packages/evidence/__tests__/event-types.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/hashing/__tests__/keystore.test.ts`
    `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/orchestrator/__tests__/audit-interpreter.test.ts` | +| SPEC-MUST-L1301 | `SPECIFICATION.md:1301` | yes | provisional-mapped | ALD-005, ALD-006, ALD-007, ALD-008, ALD-009, ALD-012, ALD-013, ALD-016, ALD-018, ALD-019, ALD-020, ALD-021, ALD-022, ALD-064 | `packages/anchor/__tests__/key-file.test.ts`
    `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts`
    `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/canonical.test.ts`
    `packages/evidence/__tests__/database.test.ts`
    `packages/evidence/__tests__/event-types.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/hashing/__tests__/keystore.test.ts`
    `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/orchestrator/__tests__/audit-interpreter.test.ts` | +| SPEC-MUST-L1325 | `SPECIFICATION.md:1325` | yes | provisional-mapped | ALD-005, ALD-006, ALD-007, ALD-008, ALD-009, ALD-012, ALD-013, ALD-016, ALD-018, ALD-019, ALD-020, ALD-021, ALD-022, ALD-064 | `packages/anchor/__tests__/key-file.test.ts`
    `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts`
    `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/canonical.test.ts`
    `packages/evidence/__tests__/database.test.ts`
    `packages/evidence/__tests__/event-types.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/hashing/__tests__/keystore.test.ts`
    `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/orchestrator/__tests__/audit-interpreter.test.ts` | +| SPEC-MUST-L1328 | `SPECIFICATION.md:1328` | yes | provisional-mapped | ALD-005, ALD-006, ALD-007, ALD-008, ALD-009, ALD-012, ALD-013, ALD-016, ALD-018, ALD-019, ALD-020, ALD-021, ALD-022, ALD-064 | `packages/anchor/__tests__/key-file.test.ts`
    `packages/anchor/__tests__/publisher.test.ts`
    `packages/anchor/__tests__/verify-anchor.test.ts`
    `packages/anchor/__tests__/viem-transport.test.ts`
    `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/canonical.test.ts`
    `packages/evidence/__tests__/database.test.ts`
    `packages/evidence/__tests__/event-types.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/hashing/__tests__/keystore.test.ts`
    `packages/merkle/__tests__/consistency.test.ts`
    `packages/merkle/__tests__/inclusion.test.ts`
    `packages/merkle/__tests__/tree.test.ts`
    `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/orchestrator/__tests__/audit-interpreter.test.ts` | +| SPEC-MUST-L1351 | `SPECIFICATION.md:1351` | yes | provisional-mapped | ALD-058, ALD-059, ALD-060, ALD-061, ALD-062, ALD-066 | `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/retention.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | +| SPEC-MUST-L1357 | `SPECIFICATION.md:1357` | yes | provisional-mapped | ALD-058, ALD-059, ALD-060, ALD-061, ALD-062, ALD-066 | `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/retention.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | +| SPEC-MUST-L1375 | `SPECIFICATION.md:1375` | yes | provisional-mapped | ALD-058, ALD-059, ALD-060, ALD-061, ALD-062, ALD-066 | `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/retention.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | +| SPEC-MUST-L1376 | `SPECIFICATION.md:1376` | yes | provisional-mapped | ALD-058, ALD-059, ALD-060, ALD-061, ALD-062, ALD-066 | `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/retention.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | +| SPEC-MUST-L1385 | `SPECIFICATION.md:1385` | yes | provisional-mapped | ALD-058, ALD-059, ALD-060, ALD-061, ALD-062, ALD-066 | `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/retention.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | +| SPEC-MUST-L1391 | `SPECIFICATION.md:1391` | yes | provisional-mapped | ALD-058, ALD-059, ALD-060, ALD-061, ALD-062, ALD-066 | `packages/ops/__tests__/failure-policy.test.ts`
    `packages/ops/__tests__/metrics.test.ts`
    `packages/ops/__tests__/retention.test.ts`
    `packages/ops/__tests__/snapshot.test.ts`
    `packages/ops/__tests__/telemetry.test.ts`
    `packages/orchestrator/__tests__/interventions.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `twins/packs/__tests__/routes.test.ts`
    `twins/packs/__tests__/telemetry.test.ts` | +| SPEC-MUST-L1435 | `SPECIFICATION.md:1435` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1437 | `SPECIFICATION.md:1437` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1439 | `SPECIFICATION.md:1439` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1440 | `SPECIFICATION.md:1440` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1448 | `SPECIFICATION.md:1448` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1455 | `SPECIFICATION.md:1455` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1466 | `SPECIFICATION.md:1466` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1484 | `SPECIFICATION.md:1484` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1503 | `SPECIFICATION.md:1503` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1504 | `SPECIFICATION.md:1504` | yes | provisional-mapped | ALD-041, ALD-071, ALD-072 | `packages/analysis/__tests__/e03.test.ts`
    `packages/orchestrator/__tests__/experiments/e03-controls.test.ts`
    `packages/orchestrator/__tests__/experiments/e11-naming-game.test.ts`
    `packages/orchestrator/__tests__/experiments/production-runtime.test.ts`
    `packages/orchestrator/__tests__/experiments/verifier-integration.test.ts`
    `packages/orchestrator/__tests__/pre-registration.test.ts`
    `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `packages/orchestrator/__tests__/sealing-and-guards.test.ts`
    `packages/scenario/__tests__/referential-engine.test.ts`
    `scripts/run-qualification.mjs` | +| SPEC-MUST-L1531 | `SPECIFICATION.md:1531` | yes | provisional-mapped | ALD-063, ALD-065, ALD-066 | `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `scripts/check-research-console.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L1557 | `SPECIFICATION.md:1557` | yes | provisional-mapped | ALD-063, ALD-065, ALD-066 | `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `scripts/check-research-console.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L1558 | `SPECIFICATION.md:1558` | yes | provisional-mapped | ALD-063, ALD-065, ALD-066 | `packages/orchestrator/__tests__/run-lifecycle.test.ts`
    `scripts/check-research-console.mjs`
    `twins/packs/__tests__/routes.test.ts` | +| SPEC-MUST-L1569 | `SPECIFICATION.md:1569` | yes | provisional-mapped | ALD-036, ALD-073, ALD-074, ALD-075, ALD-076, ALD-077, ALD-078 | `.github/workflows/book-integrity.yml`
    `packages/gateway/__tests__/affect-conformance.test.ts`
    `packages/gateway/__tests__/carrier-registry.test.ts`
    `packages/gateway/__tests__/carriers.test.ts`
    `packages/gateway/__tests__/conformance.test.ts`
    `packages/interventions/__tests__/readiness-gates.test.ts`
    `scripts/check-readiness-gates.mjs` | +| SPEC-MUST-L1669 | `SPECIFICATION.md:1669` | yes | provisional-mapped | ALD-070 | `packages/crypto-research/__tests__/boundary.test.ts` | +| SPEC-MUST-L1713 | `SPECIFICATION.md:1713` | yes | provisional-mapped | ALD-080 | `scripts/check-readiness-gates.mjs` | +| LEDGER-MUST-L290 | `LEDGER-INTEGRITY-DESIGN.md:290` | yes | provisional-mapped | ALD-013, ALD-015, ALD-016 | `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/verifier/__tests__/cli.test.ts` | +| LEDGER-MUST-L291 | `LEDGER-INTEGRITY-DESIGN.md:291` | yes | provisional-mapped | ALD-013, ALD-015, ALD-016 | `packages/checkpoint/__tests__/checkpoint-service.test.ts`
    `packages/evidence/__tests__/export.test.ts`
    `packages/verifier/__tests__/cli.test.ts` | + +## Interpretation + +- `provisional-mapped` means a concrete executable surface and required receipt are identified; behavioral truth still awaits the relevant V04–V11 challenge. +- `external-blocked` means the checked state cannot be completed from local execution without authentic authority or independent evidence. +- `open-unmapped` is a conformance defect and causes the generator to fail for a checked criterion or applicable normative line. +- `not-applicable-definition` covers normative-language definitions and table headers that contain the word MUST but impose no independently executable behavior. + +Exact statements, test selectors, and required commands are retained in the companion JSON. + diff --git a/docs/research-console-ux-checklist.json b/docs/research-console-ux-checklist.json new file mode 100644 index 0000000..7b4c986 --- /dev/null +++ b/docs/research-console-ux-checklist.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "specification": "SPECIFICATION.md §16.2-§16.3", + "reviewedAssetHash": "sha256:42c298f29264cdd99516bc47103e63e22fa37189be94d16c7303dea93d741f57", + "reusedComponents": [ + { "concept": "Delegation seat", "mapping": "Separate Baby A and Baby B panels", "result": "pass" }, + { "concept": "Table / convener", "mapping": "Nursery run-state strip and operator controls", "result": "pass" }, + { "concept": "Operator view", "mapping": "Viewer/operator credential selector", "result": "pass" }, + { "concept": "Delegation perspective", "mapping": "Role-separated observation and audit-ledger panels", "result": "pass" }, + { "concept": "Round and tick", "mapping": "Current run turn", "result": "pass" }, + { "concept": "Transcript", "mapping": "Append-only public transcript panel", "result": "pass" }, + { "concept": "Tactic detection", "mapping": "Audit and rejection evidence", "result": "pass" }, + { "concept": "Recorded run and debrief", "mapping": "Read-only replay and verification results", "result": "pass" } + ], + "prohibitions": [ + { "name": "caucus rooms", "result": "pass" }, + { "name": "coalition rooms", "result": "pass" }, + { "name": "direct Baby-to-Baby links", "result": "pass" }, + { "name": "human relay into an observation feed", "result": "pass" }, + { "name": "verification or replay override", "result": "pass" }, + { "name": "Mode R claims on Mode P evidence", "result": "pass" } + ], + "importsDiplomacyGameLogic": false, + "clientSideBabyRoutes": false, + "blocksConsoleReleaseOnFailure": true +} diff --git a/docs/research-protocol-cards.md b/docs/research-protocol-cards.md new file mode 100644 index 0000000..4abd093 --- /dev/null +++ b/docs/research-protocol-cards.md @@ -0,0 +1,139 @@ +# Research Protocol Cards + +Version: 1 +Frozen for D03: 2026-09-11 UTC +Canonical machine-readable cards: `protocols/research-protocol-cards.v1.json` + +These cards freeze the research questions, estimands, variable roles, and +confirmatory/secondary/exploratory separation. D04–D08 own the remaining scenario, +statistical, leakage, resource, and registration parameters. No card authorizes data +collection until its dependencies and external gates pass. + +## Contribution boundary + +The candidate contribution is the incremental predictive and audit value of +outcome-blind contemporaneous private semantic ledgers beyond transcript, +task-outcome, policy-state, ordinary-log, and signed-log comparators in controlled +emergent-communication studies. This is a question, not a result. The project does +not claim priority, human developmental equivalence, semantic truth from signatures, +zero leakage outside a registered bound, learned-cipher security, or causal +cross-architecture superiority. + +The independent unit is an independently seeded training run. Episodes, turns, +messages, affect windows, and intervention cases are repeated observations nested in +a run. Analyses that treat those repeated observations as independent are invalid. + +## Nonredundant ledger hypotheses + +| Hypothesis | Distinct question | Estimand | +|---|---|---| +| H2: causal effect | Does a ledger-consistent message intervention cause the predicted receiver behavior? | Within-case change in receiver probability assigned to the ledger-predicted action, ledger-consistent substitution minus seed-matched shuffled valid message, holding receiver observation and pre-intervention policy state fixed | +| H4: incremental prediction | Does the pre-outcome ledger predict held-out intervention responses better than information already available without that ledger? | Paired improvement in a preregistered proper prediction score over the strongest eligible transcript-only, task-history, policy-state, random, and majority baseline | + +H2 can succeed while H4 fails if the intervention is behaviorally effective but the +ledger adds no information beyond policy state or transcripts. H4 can succeed only +on untouched intervention cases using predictions committed before outcomes. The +same statistic cannot be reported for both hypotheses. + +## Family-wise testing graph + +E00–E03 are validity gates, not statistical hypotheses and not sources of reusable +alpha. The confirmatory family has nine members: H1, H2, H3, H4, H5, H6a, H6b, H7, +and H8. One preregistered p-value is produced per member, then Holm correction is +applied globally at family-wise alpha 0.05. A multi-component directional hypothesis +uses the maximum component p-value and requires every component direction and +practical threshold. Missing or invalid prerequisite evidence yields `not-tested`, +never a favorable p-value. + +```mermaid +flowchart TD + Q0[E00 integrity] --> Q1[E01 isolation] + Q0 --> Q2[E02 leakage] + Q1 --> Q3[E03 chance controls] + Q2 --> Q3 + Q3 --> H1[H1 · E11 communication] + Q3 --> B0[E10/E12 benchmarks] + H1 --> H5[H5 · E13 carrier formation] + H5 --> S14[E14 repair design] + S14 --> H3[H3 · E15 composition] + H3 --> H2[H2 · E16 causal effect] + H3 --> H4[H4 · E16 ledger increment] + H2 --> H6[H6a/H6b · E20 affect] + H4 --> H6 + H2 --> B1[E21/E22 mechanisms] + H4 --> B1 + H6 --> H7[H7 · E30 transfer] + B1 --> H7 + H7 --> S31[E31 drift] + S31 --> H8[H8 · E32 incentives] + H8 --> X40[E40 exploratory encoding] + X40 --> R50[E50 replication] +``` + +Arrows represent validity/design dependencies, not alpha recycling and not a demand +that every upstream effect be positive before a downstream null result may be +reported. If H1 fails, later form or structure outcomes cannot be interpreted as +useful communication, even if their descriptive analyses are complete. + +## Protocol-card index + +| ID | Class | Primary question / outcome | Locked estimand | Later design owner | +|---|---|---|---|---| +| E00 | Qualification | Evidence integrity / verifier disposition | Mutation-class detection and unchanged-bundle acceptance | D06 | +| E01 | Qualification | Side-route isolation / prohibited receiver delivery | Delivery count and class-specific detector sensitivity | D06 | +| E02 | Qualification | Observation leakage / held-out target prediction | Probe advantage over chance and upper bound | D04, D06 | +| E03 | Qualification | Chance controls / seed success | Five equivalence decisions, oracle adequacy, paired separation | D05 | +| E10 | Secondary benchmark | Frozen-model external protocol / held-out success | Descriptive success and intervention contrasts | D04, D07 | +| E11 | Confirmatory H1 | Recurrent RL communication / held-out success | Normal minus four seed-paired trained-policy controls | D04, D05, D07 | +| E12 | Secondary benchmark | Reward-free protocol / prediction and task success | Within-architecture loss ablation; cross-track descriptive difference | D04, D07 | +| E13 | Confirmatory H5 | Blank-carrier forms / stability and convergence | Stable-form minimum plus blank-versus-token time ratio | D04, D05 | +| E14 | Secondary | Dialogue repair / successful bounded repair | Paired repair probability and restricted mean turns | D04, D05 | +| E15 | Confirmatory H3 | Composition / untouched-combination success | 32-symbol/4-token minus 128-symbol/8-token seed effect | D04, D05 | +| E16 | Confirmatory H2/H4 | Causal listening and ledger validity | Causal action-probability change; held-out proper-score increment | D05, D06 | +| E20 | Confirmatory H6a/H6b | Affect utility and leakage / repair time and excess CMI | Repair contrast; one-sided 0.02-bit leakage upper bound | D05, D06 | +| E21 | Secondary benchmark | Learning mechanisms / success, listening, ledger prediction | Within-track ablations; cross-architecture descriptive differences | D04, D07 | +| E22 | Secondary | Plasticity/curriculum / acquisition and stability | Schedule contrasts under equal exposure and update budgets | D04, D05, D07 | +| E30 | Confirmatory H7 | Partner transfer / replacement degradation | Fixed-dyad minus eight-partner degradation | D04, D05, D07 | +| E31 | Secondary | Longitudinal stability / drift and replay | Within-seed trajectory with between-seed variation and selection accounting | D04, D05 | +| E32 | Confirmatory H8 | Incentives / informativeness and ambiguity | Aligned-to-conflicting paired two-direction conjunction | D04, D05, D06 | +| E40 | Exploratory | Ephemeral encoding / recovery and utility | Threat-model-specific descriptive recovery differences | D04, D06, D07 | +| E50 | Replication | Reproducibility / frozen finding-specific rule | New-seed replication estimate for each designated primary finding | D07, D08 | + +## Outcome hierarchy by experiment + +Qualification outcomes determine whether later evidence is valid. Confirmatory +outcomes enter the global nine-member family. Secondary outcomes receive effect +sizes and uncertainty but no confirmatory language. Exploratory outcomes are labeled +and use false-discovery-rate summaries only within clearly declared exploratory +families. + +| ID | Confirmatory | Secondary | Exploratory | +|---|---|---|---| +| E00 | None; deterministic acceptance | Verification latency, bytes, simulated-commitment overhead | None | +| E01 | None; deterministic acceptance | Rejection timing and audit completeness | Unanticipated route taxonomy | +| E02 | None; validity bound | Post-restore stability and field attribution | Nonlinear probe sensitivity | +| E03 | None; qualification decisions | Tail and invalid-run sensitivity | None | +| E10 | None | Success, compliance, intervention effects, convention time | Form structure | +| E11 | H1 | Sample efficiency, utilization, variance, signaling | Form clusters | +| E12 | None | Prediction, success, listening, reuse | Representation geometry | +| E13 | H5 | Success, reuse, modification, acquisition, generalization, capacity | Form families and transformation motifs | +| E14 | None | Repair probability/time, symmetry, transfer, cost | Unprompted repair constructions | +| E15 | H3 | Seen success, reuse, topology, part/order effects | Additional bandwidth levels | +| E16 | H2 and H4 | Signaling, other interventions, calibration, explanation gap | Ledger disagreement before repair | +| E20 | H6a and H6b | Convergence, success, covert-channel detector power | Mapping-specific trajectories | +| E21 | None | Mechanism and ablation comparisons | Trajectory clustering | +| E22 | None | Acquisition, stability, generalization, drift | Nonlinear phase transitions | +| E30 | H7 | Other partner types, recovery, ledger-assisted adaptation | Partner-specific form clusters | +| E31 | None | Drift, replay, revisions, entropy, derived rollback | Change points | +| E32 | H8 | Agreement, utilities, operational deception indicators | Incentive-linked compression and drift | +| E40 | None | Recovery, utility, integrity, instance novelty | Encoding dynamics | +| E50 | Frozen replication targets | Reproduction and heterogeneity | Operator/deployment sensitivity | + +## D03 completion check + +The JSON is the canonical source and must contain exactly the 19 notebook experiment +IDs and nine confirmatory hypotheses. `pnpm audit:protocol-cards` verifies coverage, +dependency references, class labels, required fields, H2/H4 nonidentity, family +membership, and synchronization with the experiment notebook. Changing a frozen +question, estimand, outcome class, or family rule requires a dated protocol amendment; +after outcome access it cannot be relabeled confirmatory. diff --git a/docs/scenario-splits-and-model-comparisons.md b/docs/scenario-splits-and-model-comparisons.md new file mode 100644 index 0000000..0a90c81 --- /dev/null +++ b/docs/scenario-splits-and-model-comparisons.md @@ -0,0 +1,70 @@ +# Scenario Splits and Model Comparisons + +Version: 1 +Frozen for D04: 2026-09-11 UTC +Canonical manifest: `protocols/scenario-split-and-model-comparison.v1.json` + +## Split decision + +The numeric referential generator uses two attributes with four values each, for 16 +semantic type codes. Type code `4a + b` represents attribute pair `(a, b)`. The four +diagonal combinations `[0, 5, 10, 15]` form the untouched compositional test set. +Every attribute value occurs in the 12-code seen pool and once in the held-out set, +so success cannot depend on an attribute value that was itself absent from training. + +| Split | Target support | Candidate support | Learning | Permitted use | +|---|---|---|---|---| +| Train | 12 non-diagonal codes | 12 non-diagonal codes | Enabled | Parameter updates only | +| Validation | Same 12 seen codes, independent split PRNG domain | 12 non-diagonal codes | Disabled | Tuning, stopping-rule application, and baseline selection | +| Held-out test | Four diagonal codes | All codes; held-out target is present | Disabled | One locked confirmatory evaluation after tuning freezes | +| Evaluation | All codes | All codes | Disabled | Engineering and qualification only; never the confirmatory test | + +The prior engine excluded held-out codes only as training targets. It could expose an +unseen combination as a distractor in a learner's training observation. That is a +real compositional-test leak. The D04 implementation restricts every training and +validation candidate to the seen pool and adds a separate validation PRNG domain. + +Train and validation deliberately share semantic type support because validation is +an in-distribution tuning split. They may not share exact `scenarioRef` or `stateHash` +instances. Held-out semantic targets may not appear anywhere in train/validation, +including distractors. Asset-backed bundles additionally require normalized-byte and +semantic-identifier deduplication; perceptual near-duplicate thresholds must be +registered when such a bundle is introduced and are not invented for the current +numeric generator. + +## Matched comparisons + +| Comparison | Interpretation | Required matching | Deliberate difference | +|---|---|---|---| +| E11/E12 recurrent mechanism | Within-architecture causal | Independent 4,049-parameter per-role GRUs, initialization namespaces, scenarios, carrier/capacity, episodes, turns, update opportunities, evaluation and interventions | Learning signal and its necessary loss | +| E15 bandwidth | Within-architecture causal | Recurrent architecture, initialization, scenarios, exposure, updates, evaluation, compute ceiling | 32-symbol/4-token versus 128-symbol/8-token channel | +| E13/E14 carrier | Within-architecture causal | Recurrent learner, eight selectable slots, 3-bit effective one-mark capacity, scenarios, updates, evaluation | Physical token/glyph/bitmap/stroke/tone grammar | +| E20 affect | Within-architecture causal | Frozen policies, scenarios, windows, cardinality, timing, repair opportunities | Registered affect mapping | +| E30 partner training | Within-architecture causal for primary contrast | Total episodes, total updates, scenarios, evaluation, adaptation | Fixed dyad versus eight-partner round robin | +| E32 incentive | Within-architecture causal | Frozen cooperative start, scenarios, channel, information boundary, budget | Utilities, reservations, private-information condition | +| Frozen model or different architecture versus recurrent model | Cross-architecture descriptive | Scenario/channel/evaluation accounting where supported | Pretraining, scale, tokenizer, memory, runtime, and optimization | + +Budget matching is multidimensional. Equal wall-clock duration is not a substitute +for equal training episodes, environment turns, optimizer updates, examples per +update, evaluation episodes, intervention opportunities, effective channel capacity, +and trainable parameters where architectures are shared. Measured compute time and +peak memory are reported, not silently used as outcome-dependent stopping rules. + +E30's eight-partner arm receives the same total interaction and update budget as the +fixed dyad, so each training partner receives one eighth of the exposure. Both total +exposure and per-partner exposure must be reported; calling the arms “matched” without +both quantities would conceal the central tradeoff. + +## Executable checks + +`pnpm audit:scenario-design` checks the manifest partitions, diagonal balance, +comparison classifications, required budget dimensions, and 2,000 generated episodes +per split. It fails if a held-out type enters any training/validation candidate list, +if a held-out target enters those splits, if exact state/reference hashes overlap, or +if any held-out type is missing from the held-out target sample. Focused scenario +tests independently cover the same runtime behavior. + +D04 is complete for the numeric generator and declared model comparisons. It does +not set seed counts, power, resource ceilings, or asset near-duplicate thresholds; +those are explicit D05/D07 or future asset-bundle registration parameters. + diff --git a/docs/seed-and-resource-allocation.md b/docs/seed-and-resource-allocation.md new file mode 100644 index 0000000..3e0485a --- /dev/null +++ b/docs/seed-and-resource-allocation.md @@ -0,0 +1,70 @@ +# Seed and Resource Allocation + +Status: D07 allocation policy frozen; no pilot or study outcome + +The machine-readable source is +`protocols/seed-and-resource-allocation.v1.json`; the generated audit receipt is +`reports/research/seed-resource-ledger.json`. + +## Disjoint seed domains + +Software qualification, blinded pilot, confirmatory, and replication each use a +different domain in a NUL-separated SHA-256 derivation. Within a stage, experiment, +and slot, paired conditions share the scenario seed but have different learner, +Gateway, and analysis seeds. The two learner roles also have different seeds. The +audit exhaustively derived 27,190 planned seed values and found no collision. + +Software-qualification seeds cannot estimate effects or variances. Pilot seeds can +estimate feasibility and the variance used to choose N, but cannot enter a +confirmatory estimate. Replication seeds have a separate domain and cannot overlap +either. An invalid primary run can use only the next ordered reserve, and both remain +in the index. + +Each experiment receives at most five development iterations and five qualification +seeds per condition per iteration. Development uses training and validation data; +held-out test data is accessed once. A blinded pilot may select the primary and +ten-percent reserve counts. It may not tune architecture, outcomes, practical +margins, directions, splits, or exclusions. + +## Sample-size decision + +The global family contains nine members: H1-H8 with H2 and H4 separate. The shared +candidate grid is 25, 50, 75, 100, 125, 150, 200, and 300 independent seeds. For +each member, the frozen raw-scale practical margin and blinded-pilot upper variance +bound feed a 30,000-repetition simulation of the complete statistic, any composite +rule, missingness, and global Holm correction. The smallest shared N whose lower 95% +Monte Carlo all-required-decisions power bound is at least 0.90 is selected before +unblinding. The present N=100 is a resource-planning value based on the already +validated standardized-effect-0.40 family case, not a final sample-size finding. + +If no candidate through N=300 qualifies, the margin cannot be widened. The affected +study remains unregistered until a prospective redesign or a larger approved +resource envelope exists. + +## Measured resource model + +A fresh five-carrier software qualification executed 260 turns in 37.58 seconds at +about one CPU, peaked at 264,896 KiB RSS, and wrote 25,532,466 bytes in 15,695 files. +The observed planning rates are 98,201.79 bytes per turn and 7.516 wall seconds per +short run. This measurement includes build startup, local fake-chain work, and +verification; it does not estimate frozen-model latency or any prohibited public-chain latency or +distributed throughput. + +The maximum materialized pools contain 9,171 bundles and 9,358,100 turns, including +2,750 provisional replication bundles. Linear projection at the measured recurrent +rate is 855.9 GiB uncompressed and 375.7 single-core hours. These are upper-pool +planning figures, not instructions to execute unused seed suffixes. Selected E03 and +confirmatory prefixes will reduce them, while real frozen-model and external-service +costs can increase them. + +The current local authorization ceiling is 72 CPU-hours, 25 GiB working storage, +6 GiB peak process memory, one frozen-model process, and zero external spend. It is +adequate for software qualification and bounded, explicitly non-confirmatory pilots. +It is not adequate for the full campaign. D08 must bind exact selected prefixes, +measured per-experiment costs, an execution host/storage plan, governance, +registration, and anchoring before confirmatory execution. + +Run `pnpm run build:seed-resource-ledger` only when intentionally updating the +protocol. The consolidated gate uses `pnpm run audit:seed-resource` and fails on a +stale ledger, a collision, a missing experiment, an accounting mismatch, or any +local external-spend allowance. diff --git a/docs/simulation-funded-research-profile.md b/docs/simulation-funded-research-profile.md new file mode 100644 index 0000000..d771de5 --- /dev/null +++ b/docs/simulation-funded-research-profile.md @@ -0,0 +1,51 @@ +# Simulation-Funded Research Profile + +Status: approved prospective project profile +Decision: `ALD-GOV-2026-09-12-01` +Machine-readable policy: `protocols/research-governance-and-funding.v1.json` + +The research campaign uses no real currency, token, faucet allocation, paid RPC, +or public-chain transaction. It executes real software and records real observed +outputs over synthetic scenarios, but it uses a deterministic in-memory chain and +non-monetary test units for the anchoring path. + +## What may be simulated + +- account balance, fee, block, confirmation-depth, and transaction identifiers; +- scenario inputs, agent observations, communication artifacts, and fault cases; +- chain outages, reverts, retry behavior, wrong-chain receipts, and tampering. + +Every such value is labeled simulation evidence. The deterministic transport must +produce its identifiers from committed inputs, and the verifier must recompute the +evidence bindings and reject the registered mutations. + +## What may not be invented + +Outcome observations, effect sizes, uncertainty intervals, p-values, timing, +resource use, failure rates, and verifier dispositions must come from actual +executions. Missing executions remain missing data. Simulated funds do not permit +fabricated scientific results. + +## Integrity and registration + +Repository-native prospective registration is required for confirmatory work. Before +collection, the complete canonical packet is committed unchanged to Git and its +registration commit must be ancestral to the execution commit. Preflight loads the +packet bytes from that commit and reproduces the registered hash. The same hash is +then committed through the deterministic simulation transport and recorded with +`anchorClass: "simulated"`. This proves exact local consumption and makes later +changes detectable. It does not provide an independent timestamp, third-party +custody, public persistence, economic finality, or third-party chain availability. +External registration or archival timestamping remains an optional enhancement. + +Public-chain support remains an optional software capability and may be tested with +mocks. It is outside the approved research profile. Activating it requires a new +prospective amendment with an explicit nonzero spending and custody decision. + +## Data and retention boundary + +The approved scope is synthetic data only, with no human participants, personal +data, production secrets, or human-coded outcomes. Eligible research evidence is +retained indefinitely. Development bulk payloads may be purged after 30 days while +metadata, manifests, receipts, and audit logs remain. Any later human-data or +human-coding proposal requires a new decision before collection. diff --git a/docs/snapshot-restore-runbook.md b/docs/snapshot-restore-runbook.md new file mode 100644 index 0000000..569906f --- /dev/null +++ b/docs/snapshot-restore-runbook.md @@ -0,0 +1,53 @@ +# Snapshot and restore runbook + +This runbook covers the authoritative runtime snapshot in `@ald/ops`, not the +Research Console's lightweight `/session/snapshot` run-list cursor. The +evidence database remains authoritative; a snapshot restores policies and +cursors, then invokes normal integrity recovery against that same store. + +## Preparation + +1. Preserve the evidence SQLite file, bundle root, and snapshot directory as + one recovery unit. Do not copy only the JSON snapshot to an empty host. +2. Record the running software commit and the configured + `DTSF_SNAPSHOT_INTERVAL_MS` (default 300000 ms). +3. Pause external run stepping while taking a manual recovery snapshot. +4. Call `takeSnapshot(runtime, snapshotDirectory, { clock, softwareCommit })` + through the operator service. Record its returned `path` and + `snapshot.digest`. +5. Confirm `readSnapshotFile(path)` succeeds before declaring the snapshot + usable. + +## Restore + +1. Stop the old runtime cleanly. Keep the original database and bundle root + read-only until validation completes. +2. Start a new runtime instance over the preserved evidence database with the + same signer access, learner contracts, scenario bundles, and bundle root. +3. Before accepting traffic, call + `autoRestore(() => runtime, { directory: snapshotDirectory, bundleRoot })`. +4. Require `restored === true` and `ok === true`. For every run require no + `error`, `turnMatches === true`, both `policyMatches` values true, and + `prefix.ok === true`. +5. Inspect every `prefix.streams` entry. `prefixIntact` and `chainWalkOk` must + both be true; any violation is an integrity fork and the runtime must stay + quarantined. +6. Confirm recovery appended the expected recovery event and checkpoint after + the immutable snapshot prefix. Resume traffic only after those checks. + +## Failure handling + +- `no-snapshot`: confirm the directory mount and configured path; do not start + a fresh run under the missing run ID. +- `snapshot-digest-mismatch` or `snapshot-invalid`: quarantine the file and + select a known earlier snapshot. Never edit a snapshot to make it parse. +- `recover-failed`, a policy mismatch, or a chain-walk violation: keep the + run quarantined and preserve the database, snapshot, logs, and bundle for + investigation. Do not truncate, rewrite, or reset the evidence store. + +## Validation record + +The restoring operator records the snapshot path/digest, software commit, +database checksum, start/end time, per-run `AutoRestoreResult`, and their +identity. ALD-079 remains externally evidence-gated until an operator other +than the implementer follows this runbook and attaches that completed record. diff --git a/docs/statistical-validation-and-power.md b/docs/statistical-validation-and-power.md new file mode 100644 index 0000000..ac9b83d --- /dev/null +++ b/docs/statistical-validation-and-power.md @@ -0,0 +1,110 @@ +# Statistical Validation and Power Design + +Version: 1 +Frozen for D05: 2026-09-11 UTC +Canonical protocol: `protocols/statistical-analysis-and-power.v1.json` + +## Evidence boundary + +This is outcome-blind design evidence. It validates calculations and prospective +operating characteristics; it contains no agent experiment result. The independent +implementation is base R 4.6.1 installed through Homebrew. Its 66-row receipt is +`reports/research/statistical-validation.tsv`. `pnpm audit:statistics` compares 28 +production values with the frozen R references and enforces every declared simulation +finding. `pnpm audit:statistics:r` regenerates the receipt in a temporary directory +with R and requires byte identity. + +## Numerical agreement + +The production implementation agrees within `1e-10` with independent values for +normal and Student-t quantiles/CDFs, one-sample and Welch t tests, both TOST components, +Holm adjustment, a Wilson interval, an exact binomial tail, type-7 quantiles, +log-beta, plug-in and Miller-Madow entropy/mutual information, and an overdispersed +beta-binomial fit. +These checks exercise the special functions underneath the higher-level analyses, +not merely their final booleans. + +## Coverage, boundaries, and clustering + +| Stress case | Estimate | 95% Monte Carlo interval | Decision | +|---|---:|---:|---| +| Wilson coverage, binomial `p=0.25`, 200 episodes | 0.9592 | [0.9569, 0.9613] | Accept as descriptive interval | +| Seed-t coverage, 25 beta-binomial seeds, latent SD 0.05 | 0.9523 | [0.9498, 0.9547] | Accept as primary seed-level interval | +| Percentile-bootstrap coverage, same case, 999 resamples | 0.9335 | [0.9217, 0.9436] | Sensitivity only; upper bound is below nominal 0.95 | +| Pooled-episode Type I under seed clustering | 0.3081 | [0.3029, 0.3133] | Reject for inference | +| Seed-level Type I under the same clustering | 0.0406 | [0.0384, 0.0429] | Accept | + +At the lower and upper TOST boundaries, false-equivalence point estimates ranged +from 0.0071 to 0.0109 across the four seed counts at per-test alpha 0.01. The result +supports the implemented seed-level TOST rule and does not license episode-level +pseudoreplication. + +## E03 amendment and full-rule power + +The former E03 rule rejected a condition when more than 5% of observed seed rates +were at least 0.35. That count was a useful diagnostic but not a calibrated test. A +bounded beta-binomial simulation found only 0.0722 probability of satisfying the cap +in the lowest-variance row and zero observed passes in 10,000 repetitions of each +higher-variance row. Its complete-rule power was therefore at most 0.0705. + +The rule is amended before registration: + +- the seed/run remains the independent unit; +- five control TOST p-values receive Holm correction at family-wise alpha 0.05; +- oracle adequacy uses a one-sided seed-level t lower bound above 0.90; +- five paired oracle-minus-control tests above 0.60 receive Holm correction, with + conservative 99% Bonferroni lower bounds reported; +- bootstrap intervals remain sensitivity outputs; +- every non-oracle seed at or above 0.35 triggers case-level leakage review, while + the number of triggers is not itself a statistical rejection; +- evidence verification and a clean leakage disposition remain mandatory external + validity gates. + +| Latent seed SD | Seeds/condition | Full numeric-rule power | 95% Monte Carlo interval | +|---:|---:|---:|---:| +| 0.05 | 25 | 0.9318 | [0.9267, 0.9366] | +| 0.10 | 75 | 0.9308 | [0.9257, 0.9356] | +| 0.15 | 155 | 0.9163 | [0.9107, 0.9216] | +| 0.20 | 300 | 0.9552 | [0.9510, 0.9591] | + +All lower bounds exceed 0.90. The beta-binomial generator samples a bounded latent +seed probability and then 200 binary episodes, so it does not rely on impossible +normal rates outside `[0,1]`. + +## Invalid runs + +Ordered reserves handle prespecified validity failures; they never replace an +unfavorable valid outcome. With N=75 and eight reserves per condition, the modeled +probability that all six conditions avoid reserve exhaustion is 0.9999 at a 2% +invalid probability, 0.9277 at 5%, and 0.0871 at 10%. Thus a measured invalid rate +near 10% blocks the current allocation rather than inviting unregistered extension. + +The required invalid-as-failure sensitivity is intentionally severe. Replacing 5% +of N=75 observations with zero drove simulated full-rule power to zero because it +also degrades the oracle. It must be reported alongside complete-case analysis, but +it is not the primary estimand and cannot be used to relabel invalid runs as valid. + +## Nine-member confirmatory family + +Global Holm materially changes sample-size reasoning. Under nine independent +one-sided standardized tests, N=75 provides only 0.6752 probability that all nine +reject when the true standardized effect is 0.40. N=100 raises that estimate to +0.9068 with lower 95% Monte Carlo bound 0.9035. Conversely, even N=150 reaches only +0.8017 for effect 0.30. + +These are sensitivity values, not universal seed counts. D07 must convert each +frozen raw-scale practical margin and pilot variance into a member-specific effect, +simulate its complete test including composite outcomes and missingness, and select +the largest resulting count whose lower Monte Carlo bound is at least 0.90. Neither +the ten-seed floor nor the historical 75-seed convention is evidence of power. + +## Reproduction + +```sh +PATH=/home/linuxbrew/.linuxbrew/bin:$PATH pnpm audit:statistics +PATH=/home/linuxbrew/.linuxbrew/bin:$PATH pnpm audit:statistics:r +``` + +D05 is complete at the method-validation level. It does not create final +hypothesis-specific seed allocations; those depend on disjoint pilot variances, +frozen raw-scale practical margins, and resource accounting in D07. diff --git a/docs/test-reliability-policy.md b/docs/test-reliability-policy.md new file mode 100644 index 0000000..23288ea --- /dev/null +++ b/docs/test-reliability-policy.md @@ -0,0 +1,34 @@ +# CI and test reliability policy + +Every pull request and every push to `main` runs the consolidated integrity +workflow. The required checks are the `consolidated-suite` job and the real +Docker `mode-r` job; repository branch protection must require both job names. +The first job runs a blocking high/critical dependency audit, all lints, +readiness and acceptance-coverage checks, the TypeScript build, all Vitest +tests, and the secret scanner. Its retained artifacts include the complete pnpm +audit JSON alongside the JUnit and runtime reports. The project explicitly +approves only the pinned `better-sqlite3`, `esbuild`, and source-pinned +`read-as-book` lifecycle builds; new or upgraded lifecycle scripts require +review before installation. The Mode R job +runs the separate-container isolation and training suite. The 20 randomized +SIGKILL crash points in `packages/evidence/__tests__/crash-safety.test.ts` are +therefore part of every proposed-change gate. + +Both jobs upload `/usr/bin/time -v` output for each run. The consolidated job +also uploads JUnit output from Vitest. Artifacts are retained for 30 days so +runtime regressions and recurring failures can be compared between runs. + +Tests are never retried or silently ignored. A flaky test blocks merge until +it is fixed or explicitly quarantined. Quarantine requires all of the +following in the same change: + +1. a tracked issue linked from the test; +2. a named owner and removal condition; +3. movement into a visibly named, non-required quarantine job rather than an + inline skip in the consolidated suite; and +4. an update to the acceptance-coverage check so no `Done` criterion loses + its required blocking coverage. + +There is currently no quarantine job and no quarantined test. Any future job +must preserve failure history and must not be accepted as evidence for an +experiment-readiness gate. diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 5c028b7..0000000 --- a/package-lock.json +++ /dev/null @@ -1,4582 +0,0 @@ -{ - "name": "agentic-language-development", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agentic-language-development", - "version": "0.1.0", - "workspaces": [ - "packages/*", - "twins/packs/*" - ], - "dependencies": { - "read-as-book": "https://codeload.github.com/Ethical-Tech-CoLab/read-as-book/tar.gz/d1d1ec6193867c13637636fc03e538c27d95261c" - }, - "devDependencies": { - "@napi-rs/canvas": "^1.0.8", - "@types/node": "^20.11.0", - "esbuild": "^0.28.2", - "eslint": "^9.0.0", - "marked": "^18.0.11", - "pdfjs-dist": "^6.2.108", - "sharp": "^0.35.4", - "tsx": "^4.21.0", - "typescript": "^5.4.0", - "typescript-eslint": "^8.0.0", - "vitest": "^4.1.11", - "yaml": "^2.8.1" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/@ald/config": { - "resolved": "packages/config", - "link": true - }, - "node_modules/@ald/evidence": { - "resolved": "packages/evidence", - "link": true - }, - "node_modules/@ald/twin-baby-a": { - "resolved": "twins/packs/baby-a", - "link": true - }, - "node_modules/@ald/twin-baby-b": { - "resolved": "twins/packs/baby-b", - "link": true - }, - "node_modules/@ald/twin-nursery": { - "resolved": "twins/packs/nursery", - "link": true - }, - "node_modules/@ald/types": { - "resolved": "packages/types", - "link": true - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha1-hCV647BTHrKuwf+iPXBwDaAHupU=", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha1-v24QMDvPLnxoaXX6Uvk37Cco2Lw=", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha1-LYTs5qTiaE2SvibuE9QnV9gxw4E=", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha1-DGJGvI0sTRcqrC2z+xGQ1yvWVQQ=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha1-/DjU1jWNjcHPU/CfdYn+Q262SAE=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha1-+Dr+6sHX2sAcei/QErPkUaBZH8w=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha1-UQFHwFWnlViNu+FP1rG4rQovMN4=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha1-CTuSAOzwsRW6Tl4kinSFycX4vV4=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha1-C+Irbfkl0hPoQeqHEjr134Cw+vc=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha1-vrEq1yuE9y0oSIzBuO6ffrFB11M=", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha1-G9vGUc2pupmVxT7ZxxzqplCUdi0=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha1-uB+dVVKbRcIGpGoTghSxqmh5aWs=", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha1-WYZnJBoEyZt27W75QKxQA4xBn5g=", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha1-HFHrnOqQP1PZe1rzsYQdtw9Vlso=", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha1-Y91h8XzrMagSJ/QT/qyKcbwsUfI=", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha1-N2Owj95c8lqx+suOd1Lt/kX7/Cc=", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha1-GhN/8pOoKQbrMXY4W9fo4OXPt8s=", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha1-Jos2IRwUbKVPj+EsV4qNbviXlIU=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha1-Ilca2VHWK7aszILY0frVyMGsC6E=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha1-QvzFcpfrCgyj9fxHUpH0waP3wN4=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha1-nrMq8QSsPaz07coB9ZZmSqsMc+8=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha1-/r7SQC1giCJekfIPtM4lIq0KTv0=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha1-hWQcPUZkKL+8zqXyHCaDZmP+9c4=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha1-pzb52JYkgQRfxMPlT1R58iyHD7Q=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha1-7lq0D60YYgG2UqM/il6xSenkJTI=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha1-xA0optmaEn2mcR8q/XSxHLY7Bqc=", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha1-shr/uATMFnwTPZX0WzodwTI7moc=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha1-iRG9crLDZApUNgngQAuMTS5+fLY=", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha1-DNcv6FUOPC6uFWqWpN3c0cisWAA=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha1-vM32Fbz3tujbgw7AuNIcmiXeWXs=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha1-8p4iBXrVMWzyODbO6aNMgf/8t+Y=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha1-G9AGzut+LlWyt3OrMY0wDhpmrto=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha1-dyJYIEE9lhdQnak0IZCiAZ54dhw=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha1-0iv9azp9jh8sCy8ubeERtT7G4T4=", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha1-by+8/3VQDSKdU14KlJrhNHLIR4c=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha1-biEmoTR+hqTe34cG7Gf/jhB+u60=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha1-l3nj/Zt+4zVxpXQ1z0M1oXlKbLI=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha1-qCcsoDsqz0kmcCIrIyC2xCG/3mA=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha1-j4AMzME/T4zTEW4tnAqUk52j4+0=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha1-8qCfYgEjkLK/8/xvskjd7IwJoJA=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha1-r1smkaIrRL6EewyoFkHF+2rQFyw=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha1-wrnS43TuYsWG062+qHGZsdenpro=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha1-sMLC+mYa33Xv/WtJZEl82AAQu50=", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha1-vBCyYt4vyACIAT9fmYKY0ciQn78=", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha1-dsSf8E+z+dhGsNBXWEG37lm+7Gg=", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha1-ulX9YDxdHQGhyxT/tNlwvGztgOE=", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha1-CKbPT7TujUX5lASladyqayk5HZc=", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha1-FGHm+zEKhps8E1BFibjdXAZiTag=", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha1-cQMpQbn8v468PBtilJknuETLrWI=", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha1-y5OKOXG5o2MpvJlCfx5bcuJmui4=", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha1-gkbS3sFSNP0cxUzFXcuhrrwmxBQ=", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha1-Wf9mPCqmtBiFc1fNd8pMyuzpWHY=", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha1-vBdFH4DZ8xZjs+kGFJ8Vmg7LHKg=", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha1-x9H4O8HP4iLNnckn88UTeql5HnY=", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha1-MD3+Ko5tH9J56tVZ9NLM/yVF4qI=", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha1-N1D3CtRq2H5KCO6NNyq5p+F+K2w=", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha1-3o2g2i7lUiZ8rncVlzMqhjN45Ds=", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha1-wvU7+RP6G0Z2KxksUnorNkeiEag=", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha1-xA18X4ziFsNSJMWO3O+OZEWTUlY=", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha1-AyyiBucTsRee/g4Y1SUYjGcndzA=", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha1-GdrHUBoVyHSLx6ESGfyjkeQkttc=", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha1-fR1BTUpTjgcEumt2zmVH5g9iEc4=", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha1-lTTJOAeCL0KC0HmjIvrMbbU9UPY=", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha1-9IjnoCkZkb/2Q874K1r9PP1w/PI=", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha1-gfcfP8DYRH3Dr2yzeOJOdY/1QiU=", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha1-d9LoiS06OCXw3JWrAs62E8shMoY=", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha1-QQG0Tj+5fW8ac8KpxhQtSR0aqDE=", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha1-jUdVvSbV/zT7iceQe9icCApdyfU=", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha1-C2e9Cx8BEjqYKvzGug3AC4d7igo=", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha1-aRKwDSxjHA0Vzhp6tXzWV/Ko+Lo=", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/canvas": { - "version": "1.0.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas/-/canvas-1.0.8.tgz", - "integrity": "sha1-aVF6v7KmGsLNaxVjz8rj/eEe+pc=", - "devOptional": true, - "license": "MIT", - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "1.0.8", - "@napi-rs/canvas-darwin-arm64": "1.0.8", - "@napi-rs/canvas-darwin-x64": "1.0.8", - "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", - "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", - "@napi-rs/canvas-linux-arm64-musl": "1.0.8", - "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", - "@napi-rs/canvas-linux-x64-gnu": "1.0.8", - "@napi-rs/canvas-linux-x64-musl": "1.0.8", - "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", - "@napi-rs/canvas-win32-x64-msvc": "1.0.8" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "1.0.8", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz", - "integrity": "sha1-dFWsHaM5rxz0fHupq3iOSIptih8=", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "1.0.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz", - "integrity": "sha1-ON6cQ0Tt6CdWz1DS7tztsUZdXVA=", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "1.0.8", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz", - "integrity": "sha1-E1z3jY0x2fKntoh/yg1tommpHoA=", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "1.0.8", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz", - "integrity": "sha1-AocuPDN5LRU+HAyBWCpXvk1jdR0=", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "1.0.8", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz", - "integrity": "sha1-QTNJo7vNb4sYRWaf97BD1gQjqsg=", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "1.0.8", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz", - "integrity": "sha1-Ts2xZaSPmrgat0O5ZjxXpGFUEgs=", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "1.0.8", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz", - "integrity": "sha1-RsutsPQ42mlJTPpK2EqsVZl8hUk=", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "1.0.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz", - "integrity": "sha1-p5rXmhJmtrSDTMJwgfMiLKMNLc0=", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "1.0.8", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz", - "integrity": "sha1-4nqCOlJ1UR3yZh0F1F+1qSEdCXQ=", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "1.0.8", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz", - "integrity": "sha1-dRAqJgiPgWivNZordoO51/VK/II=", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "1.0.8", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz", - "integrity": "sha1-HMzY0rBf35Erek/bVANEYMAttSI=", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha1-1Xolkau/H25QmBsH7iSrJpUw2Ho=", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.5", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", - "integrity": "sha1-FluAkQ3nzTP3ctW3sEWyWay3Qgw=", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", - "integrity": "sha1-XtdNS4+lbGjrGuuB0NIHqFtt4Fw=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", - "integrity": "sha1-byfHBg5YygMGH6fVD53ECbw3f+E=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", - "integrity": "sha1-dKuJfRNO3kBy/cYQjwAZPmFn7ig=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.5", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", - "integrity": "sha1-fTN/muS3OWdOGTh0cRirBnbI74o=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", - "integrity": "sha1-qBldwQka3g+RKwYT72UAlB5WFfQ=", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", - "integrity": "sha1-QPu7lwcrGsqj4Oj+l3T+Uk6GC7o=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", - "integrity": "sha1-PP6LD3wT3inazpqfxsAwgRZBdqs=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", - "integrity": "sha1-O9iQ2Wrin5NxiqFC7QmC8u4peK0=", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", - "integrity": "sha1-CVmg1a8idB1Xh5GOJ673DchgKAQ=", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", - "integrity": "sha1-e6qU6CYyisb0V9nhq6z/oDU+jSI=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.5", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", - "integrity": "sha1-XzdZfq8OIjE9Hj1L59G+H9MykE4=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", - "integrity": "sha1-0JZWevP3OMvmqoWKsKAarp81fvQ=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.5", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", - "integrity": "sha1-1TuRGqTmtUe3icB3R/q6sqyMI30=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", - "integrity": "sha1-e70Iz9pqmN6bdWRyx3KjbrtyKbw=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha1-4/zuCT+7XOdl4a0Ij/TeKIn2+b4=", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha1-p5tV26+GBIEvUtFAssmrQbwVC7g=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/better-sqlite3": { - "version": "9.6.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", - "integrity": "sha1-0U0RNePsoI1O6QR3eQtOh5rSZ2E=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha1-jpzZ4cNYH6azQaWu1ViOsoW+C0o=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha1-M0MRlx06BxIefrkbaEpgXn7qnL0=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha1-zz8Oh2177hWpOrkluCv1cKOQSiQ=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha1-WWoXRyM2lNUPatinhp/Lb1bPWEE=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-20.19.43.tgz", - "integrity": "sha1-/Oz1gLpCoNtVz0BMNyyXlzw3bJc=", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha1-Uvnw5H1adXHEM25pv+6lgVCe8s8=", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha1-aleq70yQ3yesNZCHXSno8RmIyI4=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha1-AVgCLsmSfgr81YqMwq1X4B2JL1w=", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha1-FVLbAHypIGocbHrPSeIQvReoxW8=", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha1-TUwtoJVg0Q3X2UfLotKdFNJa8W0=", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha1-9Fo+umuRMvtHFB7APOLydfHqmR0=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha1-lr7RBSdVWd87zwRJtzpkFNNcWc4=", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha1-So0AzB+rpcFP6rxg+Ft6MmUvNLY=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha1-EWw6R8BhGcWgUOiFGGHWSX3WS8I=", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha1-v7EGYv7tgZaixi58aOF3IMJ0F5o=", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha1-fHJDiAm1+lur9UGZofHCgaaYT88=", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha1-/ZVrvgt3JB6fFaxdzLHGOAYJaO8=", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha1-PkeKPWnTMKH8UMEnRswu4HMsz80=", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha1-YB1Ar5rPgqKNoihvPtr8abupAX8=", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.67.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha1-njyUiWl4JNLUzjqK0SYo+R6fWb4=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha1-X1gNH5zbujFNvyOy2RH46yOHj18=", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha1-jikGNhvF36JxdXqFiugGQxGPy7Q=", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.11", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha1-iyjrgkB3HW6pcOM76utBOEtRho4=", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha1-v7rZjI1sPx+03xIFatVpgh/3fyE=", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha1-30YesWWSSjFVmG3eaOEzYPU/PUw=", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha1-Ct1FyulTr+2ciPmOL2/JFkVYwyo=", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha1-myekKTuCeUKyI1Ob+rG9n36toxs=", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha1-T68BstbTJr/u2XrqH1IiC19MGUA=", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha1-ftW7VZCLOy8bxVxq8WU7rafweTc=", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha1-B+mCx0YmFnqnoklcU4F4ktcTlJI=", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha1-JG9Q88p4oyQPbJl+ipvR6sSeSzg=", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha1-9kGhlrM1aQsQcL8AtudZP+wZC/c=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha1-GxtEAWClv3rUC2UPCVljSBkDkwo=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha1-BnhG76v3ZxlX/IqejfO+OcbMC4Q=", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha1-EDU8npRTNLwFEabZCzj7x8nFBN8=", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bl/-/bl-4.1.0.tgz", - "integrity": "sha1-RRU1JkGCvsL7vIOmKrmM8R2fezo=", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha1-umLnwTEzBTWCGXFghRqPZI6Z7tA=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha1-s2MKvYlDQy9Us/BRkjjjPNffL3M=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/canonicalize": { - "version": "4.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/canonicalize/-/canonicalize-4.0.0.tgz", - "integrity": "sha1-sTuOJrkEOlbLdRwnvujgwiA7ucw=", - "license": "Apache-2.0", - "bin": { - "canonicalize": "bin/canonicalize.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chai/-/chai-6.2.2.tgz", - "integrity": "sha1-rkG1LJrKh3NFBTYnF/MlX6zaNg4=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha1-qsTit3NKdAhnrrFr8CqtVWoeegE=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha1-b8nXtC0ypYNZYzdmbn0ICE2izGs=", - "license": "ISC" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha1-S1YPZJ/E6RjdCrdc9JYei8iC2Co=", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", - "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha1-yjh2Et234QS9FthaqwDV7PCcZvw=", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha1-xPp8lUBKF6nD6Mp+FTcxK3NjMKw=", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha1-pvLc5hL63S7x9Rm3NVHxfoUZmDE=", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha1-c0TXEd6kDgt0q8LtSXeHQ8ztsIw=", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha1-MR+k9AFowZdcUFR3xRsjI01BrVU=", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha1-D0O9G62VW3LSTiJh46vllXzPCBY=", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha1-FLqDpdNz49MR5a/KKc9b+tllvzQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha1-Kk48iw91MZbvrpQ8j/qocw/Go/o=", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha1-iOZGogf61hQ2/6OetQUUcgBlXII=", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha1-TP6mD+fdCtjoFuHtAmwdUlG1EsE=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/espree/-/espree-10.4.0.tgz", - "integrity": "sha1-1U9JSdRikAWh+haNk3w/8ffiqDc=", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha1-CNBI8mHw3e21uulfRoCUY9nJSW0=", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha1-eteWTWeauyi+5yzsY3WLHF0smSE=", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha1-LupSkHAvJquP5TcDcP+GyWXSESM=", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha1-Z8PlSexAKkh7T8GT0ZU6UkdSNA0=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha1-dNLrTeC42hKTcRkQ1Qd1ubcQ72Q=", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha1-bhSz/O4POmNA7LV9LokYaSBSpHw=", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha1-JO338MxppE0AhWe6RZSrlvPDo9Y=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU=", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha1-h0v2nG9ATCtdmcSBNBOZ/VWJJjM=", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha1-d4e93PETG/+5JjbGlFe7wO3W2B8=", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha1-VTp7hEb/b2hDWcRF8eN6BdrMM90=", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha1-TJKBnstwg1YeT0okCoa+UZj1Nvw=", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha1-Ds45/LFO4BL0sEEL0z3ZwfAREnw=", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha1-ruyipQYwPwzuYcWebJ8qiNLyn8Y=", - "dev": true, - "license": "ISC" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha1-a+Dem+mYzhavivwkSXue6bfM2a0=", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4=", - "license": "MIT" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha1-bSN9mQg5UMeSkPJMdkKj3poo+eM=", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/globals/-/globals-14.0.0.tgz", - "integrity": "sha1-iY10E8Kbq89rr+Vvyt3thYrack4=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha1-jrehCmP/8l0VpXsAFYbRd9Gw01I=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha1-PNQOcp82Q/2HywTlC/DrcivFlvU=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha1-nOy1ZQPAraHydB271lRuSxO1fM8=", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ini/-/ini-1.3.8.tgz", - "integrity": "sha1-op2kJbSIBvNHZ6Tvzjlyaa8oQyw=", - "license": "ISC" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha1-ZPYeQsu7LuwgcanawLKLoeZdUIQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true, - "license": "ISC" - }, - "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha1-ASFsAB1n9I4s1WDXCMevIQkKOEg=", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha1-kziAKjDTtmBfvgYT4JQAjKjAWhM=", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha1-afaofZUTq4u4/mO9sJecRI5oRmA=", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha1-qHmpnilFL5QkOfKkBeOvizHU3pM=", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/levn/-/levn-0.4.1.tgz", - "integrity": "sha1-rkViwAdHO5MqYgDUAyaN0v/8at4=", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha1-wIhn1xp5OFxuGQIU/XL+8+X5Xws=", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha1-mmhB+IrlD8g1ApA4krQa9BvCuQc=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha1-wPLDHAv9GfpN0/GOlXofGhUgl9Y=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha1-ywcFllrLU4xmg5Sc5pJfs833w2E=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha1-djU4gosmurJoDa2vzITueLDrUCs=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha1-aGLjF2ozGu297B7TUrTX0N0HhN4=", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha1-xqOi7RUUHa9r3CYokw+OOb30c6o=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha1-f6EzSXH8goRfmCffbvigsgkUusY=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha1-i5J4YuqMK7xoMaRlCSRLUNmTblU=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha1-DFJbsHff2UQEwFnP5C2teX6Wrq8=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha1-hQ7hED2smJz6tQ46wi0aaeOU5j0=", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha1-40OuFS7tNgncbhGUnRo785oclG8=", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha1-VTIeswn+u8WcSAHZMackUqaB0oY=", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha1-VYqlO0O2YeGSWgr9+japoQhf5Xo=", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha1-VnY+wJoPqAkd8nh5/ZTRkHjADZE=", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/marked": { - "version": "18.0.11", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/marked/-/marked-18.0.11.tgz", - "integrity": "sha1-D1DOXN8lssB/Z9N4ciC2SlhFzOE=", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha1-LR1Zr5wbEpgVrMwsRqAipc4fo8k=", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha1-waRk52kzAuCCoHXO4MBXdBrEdyw=", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha1-+hDJEVzG2IZb4iG6R+6b7XhgERM=", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", - "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha1-9mot4Rmf/eD88hyKXxMQaxwIGRM=", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha1-E8IsAYf8/MzhRhhEE2NypH3cAn4=", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true, - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha1-AHGB7Q0bVq6WcOpsCE0r+DU4QF8=", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/obug/-/obug-2.1.4.tgz", - "integrity": "sha1-kJDYpUilIlF5FdKqaq6QcZesbPg=", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha1-fqHBpdkddk+yghOciP4R4YKjpzQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha1-4drMvnjQ0TiMoYxk/qOOPlfjcGs=", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha1-g8gxXGeFAF470CGDlBHJ4RDm2DQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/page-flip": { - "version": "2.0.7", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/page-flip/-/page-flip-2.0.7.tgz", - "integrity": "sha1-tur52seP0dzut8tNDi7rIDWR6h4=", - "license": "MIT" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha1-aR0nCeeMefrjoVZiJFLQB2LKqqI=", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha1-UTvb4tO5XXdi6METfvoZXGxhtbM=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha1-PsvsVUIWhbcKnahyss/z4cvtFxY=", - "dev": true, - "license": "MIT" - }, - "node_modules/pdfjs-dist": { - "version": "6.2.108", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", - "integrity": "sha1-Hgzg9LOgNPlT274jNKsB+93w6zA=", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=22.13.0 || >=24" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^1.0.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha1-UepXoX2G9gX4EDlZX7xA7QalX6s=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha1-bnUTV4DH4Q3zQzvyJmxVLTXIxiA=", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.17", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha1-1jCrrSsUdEPyCiEpF76uaLgJLuw=", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha1-3rxkidem5rDnYRiIzsiAM30xY5Y=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pump/-/pump-3.0.4.tgz", - "integrity": "sha1-HzE0MFJ/qLkFYi69Iv4UROdXqzw=", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha1-AnQi4vrsCyXhVJw+G9gwm5EztuU=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rc/-/rc-1.2.8.tgz", - "integrity": "sha1-zZJL9SAKB1uDwYjNa54hG3/A0+0=", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-as-book": { - "version": "0.1.0", - "resolved": "https://codeload.github.com/Ethical-Tech-CoLab/read-as-book/tar.gz/d1d1ec6193867c13637636fc03e538c27d95261c", - "integrity": "sha512-P+Ehla5SB3mp6Ydoh8ZOrlW1DIJwJn4tAshtWrJACqu+9FDPJ+sDKZ9Pt9Zjml8pD5guFuVaTf9qOFGbcnr5wQ==", - "license": "MIT", - "dependencies": { - "page-flip": "^2.0.7" - }, - "bin": { - "read-as-book-pages": "bin/render-pages.mjs" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@napi-rs/canvas": ">=0.1", - "pdfjs-dist": ">=4", - "react": ">=18", - "sharp": ">=0.33" - }, - "peerDependenciesMeta": { - "@napi-rs/canvas": { - "optional": true - }, - "pdfjs-dist": { - "optional": true - }, - "react": { - "optional": true - }, - "sharp": { - "optional": true - } - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha1-VqmzbqllwAxak+8x6xEaDxEFaWc=", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha1-SrzYUq0y3Xuqv+m0DgCjbbXzkuY=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/rolldown": { - "version": "1.2.5", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rolldown/-/rolldown-1.2.5.tgz", - "integrity": "sha1-H1BKfQUmCnaeYX2VBBC7sFFJjFA=", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.146.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.5", - "@rolldown/binding-android-arm64": "1.2.5", - "@rolldown/binding-darwin-arm64": "1.2.5", - "@rolldown/binding-darwin-x64": "1.2.5", - "@rolldown/binding-freebsd-x64": "1.2.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", - "@rolldown/binding-linux-arm64-gnu": "1.2.5", - "@rolldown/binding-linux-arm64-musl": "1.2.5", - "@rolldown/binding-linux-ppc64-gnu": "1.2.5", - "@rolldown/binding-linux-s390x-gnu": "1.2.5", - "@rolldown/binding-linux-x64-gnu": "1.2.5", - "@rolldown/binding-linux-x64-musl": "1.2.5", - "@rolldown/binding-openharmony-arm64": "1.2.5", - "@rolldown/binding-win32-arm64-msvc": "1.2.5", - "@rolldown/binding-win32-x64-msvc": "1.2.5" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-7.8.5.tgz", - "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha1-Nh3zspWdrrOAVBKJlgRWxr4Pkt4=", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha1-MudscLeXJOO7Vny51UPrhYzPrzA=", - "dev": true, - "license": "ISC" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha1-9Gl2CCujXCJj8cirXt/ibEHJVS8=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha1-SjnbVJKHyXnTUhEvoD/Zn9a8NUM=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha1-Gsig2Ug4SNFpXkGLbQMaPDzmjjs=", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha1-jr4OxgSFZoq0ciezEvQlTN+AydM=", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha1-QvEUWUpGzxqOMLCoT1bHjD7awh4=", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha1-MfEoGzgyYwQ0gxwxDAHMzajL4AY=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha1-G33NyzK4E4gBs+R4umpRyqiWSNo=", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha1-M+nClBPc4MWK2n/3fbTlowr//nA=", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha1-rK2EwoQTawYNw/qmRHSqmuvXcoc=", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha1-EDyfi6bXI3pHq23R3P93JRhjQms=", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha1-qswdux1Ok+atjdZJROCfmtFHpHQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha1-wBaDh9PY1wtrPCwJNt5f7nOM6iA=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha1-Ss1KFV4ic0mQpe0f6el/ETvLN8E=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", - "license": "0BSD", - "optional": true - }, - "node_modules/tsx": { - "version": "4.23.12", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tsx/-/tsx-4.23.12.tgz", - "integrity": "sha1-OkkZWRzZueAAEbdeWWyKuNsjwJw=", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha1-B7ggO/pwVsBlcFDjzNLDdzC6uPE=", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha1-HpLeCe4P8tlswISPXp80Xqkw2WM=", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", - "dev": true, - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha1-mxpSWVIlhZ5V9mnZKPiMbFfyp34=", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "license": "MIT" - }, - "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-8.2.2.tgz", - "integrity": "sha1-OZrvrTZWFFFFvhENE3oH6lu1UBQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha1-FlPBUhrpF/lg2bIYd3l8R9/YvyE=", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", - "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha1-o/aalxB/SUs83Dvd3Yg6fWXOvwQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha1-0sRcbdT7zmIaZvE2y+Mor9BBCzQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha1-eCdK/ZNZih391hMN9qVm3vy/mqQ=", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha1-ApTrPe4FAo0x7hpfosVWpqrxChs=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod/-/zod-4.4.3.tgz", - "integrity": "sha1-toDxcohdGLvr8hqDTqJeVaG781Y=", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "packages/config": { - "name": "@ald/config", - "version": "0.1.0" - }, - "packages/evidence": { - "name": "@ald/evidence", - "version": "0.1.0", - "dependencies": { - "@ald/config": "*", - "@ald/types": "*", - "better-sqlite3": "^12.4.1", - "canonicalize": "^4.0.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^9.6.0" - } - }, - "packages/types": { - "name": "@ald/types", - "version": "0.1.0", - "dependencies": { - "zod": "^4.4.3" - } - }, - "twins/packs/baby-a": { - "name": "@ald/twin-baby-a", - "version": "0.1.0", - "dependencies": { - "@ald/types": "*" - } - }, - "twins/packs/baby-b": { - "name": "@ald/twin-baby-b", - "version": "0.1.0", - "dependencies": { - "@ald/types": "*" - } - }, - "twins/packs/nursery": { - "name": "@ald/twin-nursery", - "version": "0.1.0", - "dependencies": { - "@ald/types": "*" - } - } - } -} diff --git a/package.json b/package.json index ac4b678..07d9e1d 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,108 @@ { "name": "agentic-language-development", - "version": "0.1.0", + "version": "0.1.97", "private": true, "description": "Research infrastructure for grounded, auditable emergent agent communication", "type": "module", - "workspaces": [ - "packages/*", - "twins/packs/*" - ], "scripts": { "build": "tsc --build", "clean": "tsc --build --clean", "test": "vitest run", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=artifacts/vitest-junit.xml", + "test:mode-r": "node scripts/run-mode-r-smoke.mjs", + "test:mode-r-study": "node scripts/run-mode-r-study.mjs", "test:watch": "vitest", + "design:e03": "pnpm run build && node scripts/run-e03-design-simulation.mjs", + "registration:e03": "pnpm run build && node scripts/build-e03-registration.mjs", + "preflight:research": "pnpm run build && node scripts/check-research-preflight.mjs", + "qualify:frozen-model": "pnpm run build && node scripts/run-frozen-model-qualification.mjs", + "qualify:generative-carriers": "pnpm run build && node scripts/run-generative-carrier-qualification.mjs", + "qualify:study-controls": "pnpm run build && node scripts/run-study-control-qualification.mjs", "lint": "eslint packages twins", "book:research": "node scripts/build-research-book.mjs", + "build:conformance-matrix": "node scripts/build-conformance-matrix.mjs", "scan:secrets": "node scripts/scan-secrets.mjs", - "check": "npm run lint && npm run build && npm test && npm run scan:secrets" + "audit:dependencies": "pnpm audit --audit-level=high", + "audit:sources": "node scripts/check-source-register.mjs", + "audit:protocol-cards": "node scripts/check-protocol-cards.mjs", + "audit:scenario-design": "tsx scripts/check-scenario-design.ts", + "audit:statistics": "tsx scripts/check-statistical-validation.ts", + "audit:statistics:r": "tsx scripts/check-statistical-validation.ts --live-r", + "audit:causal-leakage": "tsx scripts/check-causal-leakage-design.ts", + "audit:causal-leakage:r": "tsx scripts/check-causal-leakage-design.ts --live-r", + "qualify:causal-prediction": "tsx scripts/check-causal-prediction-qualification.ts --write", + "audit:causal-prediction": "tsx scripts/check-causal-prediction-qualification.ts", + "qualify:causal-prediction-runtime": "tsx scripts/check-causal-prediction-runtime-qualification.ts --write --live-evidence", + "audit:causal-prediction-runtime": "tsx scripts/check-causal-prediction-runtime-qualification.ts", + "qualify:carrier-perception": "tsx scripts/check-carrier-perception-qualification.ts --write", + "audit:carrier-perception": "tsx scripts/check-carrier-perception-qualification.ts", + "qualify:carrier-side-features": "tsx scripts/check-carrier-side-feature-qualification.ts --write --live-evidence", + "audit:carrier-side-features": "tsx scripts/check-carrier-side-feature-qualification.ts", + "build:registration-readiness": "tsx scripts/build-registration-packet-readiness.ts --write", + "build:registration-e00": "tsx scripts/build-e00-registration.ts --write", + "build:registration-e01": "tsx scripts/build-e01-registration.ts --write", + "activate:registration-e00": "tsx scripts/activate-e00-registration.ts --write", + "activate:registration-e01": "tsx scripts/activate-e01-registration.ts --write", + "audit:registration-e00": "tsx scripts/activate-e00-registration.ts", + "audit:registration-e01": "tsx scripts/build-e01-registration.ts && tsx scripts/activate-e01-registration.ts", + "audit:qualification-e00": "tsx scripts/check-e00-qualification.ts", + "audit:qualification-e01": "tsx scripts/check-e01-qualification.ts", + "audit:registration-readiness": "tsx scripts/build-registration-packet-readiness.ts", + "build:seed-resource-ledger": "tsx scripts/check-seed-resource-allocation.ts --write", + "audit:seed-resource": "tsx scripts/check-seed-resource-allocation.ts", + "audit:campaign-readiness": "tsx scripts/check-campaign-readiness.ts", + "audit:research-governance": "tsx scripts/check-research-governance.ts", + "audit:external-prerequisites": "tsx scripts/check-external-prerequisite-readiness.ts", + "build:data-claim-manifest": "tsx scripts/build-data-claim-manifest.ts --write", + "audit:data-claims": "tsx scripts/build-data-claim-manifest.ts", + "build:manuscript-readiness": "tsx scripts/check-manuscript-readiness.ts --write", + "audit:manuscript-readiness": "tsx scripts/check-manuscript-readiness.ts", + "audit:data-claims:live": "tsx scripts/build-data-claim-manifest.ts --live-evidence", + "benchmark:audit-cost": "tsx scripts/run-audit-cost-benchmark.ts --write", + "audit:audit-cost": "tsx scripts/run-audit-cost-benchmark.ts", + "check": "pnpm run lint && pnpm run lint:contracts && pnpm run lint:crypto-boundary && pnpm run lint:research-console && pnpm run lint:readiness && pnpm run lint:acceptance-coverage && pnpm run lint:conformance-matrix && pnpm run lint:project-status && pnpm run lint:native-builds && pnpm run lint:api-docs && pnpm run build && pnpm run audit:sources && pnpm run audit:protocol-cards && pnpm run audit:scenario-design && pnpm run audit:statistics && pnpm run audit:causal-leakage && pnpm run audit:causal-prediction && pnpm run audit:causal-prediction-runtime && pnpm run audit:carrier-perception && pnpm run audit:carrier-side-features && pnpm run audit:registration-e00 && pnpm run audit:registration-e01 && pnpm run audit:registration-readiness && pnpm run audit:qualification-e00 && pnpm run audit:qualification-e01 && pnpm run audit:seed-resource && pnpm run audit:research-governance && pnpm run audit:campaign-readiness && pnpm run audit:external-prerequisites && pnpm run audit:data-claims && pnpm run audit:manuscript-readiness && pnpm run audit:audit-cost && pnpm run test:integrity-auditor && pnpm test && pnpm run scan:secrets && pnpm run audit:dependencies", + "check:ci": "pnpm run lint && pnpm run lint:contracts && pnpm run lint:crypto-boundary && pnpm run lint:research-console && pnpm run lint:readiness && pnpm run lint:acceptance-coverage && pnpm run lint:conformance-matrix && pnpm run lint:project-status && pnpm run lint:native-builds && pnpm run lint:api-docs && pnpm run build && pnpm run audit:sources && pnpm run audit:protocol-cards && pnpm run audit:scenario-design && pnpm run audit:statistics && pnpm run audit:causal-leakage && pnpm run audit:causal-prediction && pnpm run audit:causal-prediction-runtime && pnpm run audit:carrier-perception && pnpm run audit:carrier-side-features && pnpm run audit:registration-e00 && pnpm run audit:registration-e01 && pnpm run audit:registration-readiness && pnpm run audit:qualification-e00 && pnpm run audit:qualification-e01 && pnpm run audit:seed-resource && pnpm run audit:research-governance && pnpm run audit:campaign-readiness && pnpm run audit:external-prerequisites && pnpm run audit:data-claims && pnpm run audit:manuscript-readiness && pnpm run audit:audit-cost && pnpm run test:integrity-auditor && pnpm run test:ci && pnpm run scan:secrets", + "lint:contracts": "node scripts/lint-learner-contracts.mjs", + "lint:crypto-boundary": "node scripts/lint-crypto-boundary.mjs", + "lint:research-console": "node scripts/check-research-console.mjs", + "lint:readiness": "node scripts/check-readiness-gates.mjs", + "lint:acceptance-coverage": "node scripts/check-acceptance-coverage.mjs", + "lint:conformance-matrix": "node scripts/build-conformance-matrix.mjs --check", + "lint:project-status": "node scripts/check-project-status.mjs", + "lint:native-builds": "node scripts/check-native-builds.mjs", + "lint:api-docs": "node scripts/check-api-docs.mjs", + "test:integrity-auditor": "CARGO_TARGET_DIR=.artifacts/cargo-target cargo test --locked --manifest-path tools/integrity-auditor/Cargo.toml && CARGO_TARGET_DIR=.artifacts/cargo-target cargo clippy --locked --manifest-path tools/integrity-auditor/Cargo.toml -- -D warnings", + "challenge:integrity": "pnpm run build && CARGO_TARGET_DIR=.artifacts/cargo-target cargo build --locked --release --manifest-path tools/integrity-auditor/Cargo.toml && tsx scripts/run-integrity-challenge.ts", + "qualify:e00": "pnpm run audit:registration-e00 && pnpm run challenge:integrity", + "qualify:e01": "pnpm run audit:registration-e01 && pnpm run build && tsx scripts/run-e01-qualification.ts" }, "devDependencies": { - "@napi-rs/canvas": "^1.0.8", - "@types/node": "^20.11.0", - "esbuild": "^0.28.2", - "eslint": "^9.0.0", - "marked": "^18.0.11", - "pdfjs-dist": "^6.2.108", - "sharp": "^0.35.4", - "tsx": "^4.21.0", - "typescript": "^5.4.0", - "typescript-eslint": "^8.0.0", - "vitest": "^4.1.11", - "yaml": "^2.8.1" + "@ald/analysis": "workspace:*", + "@ald/anchor": "workspace:*", + "@ald/gateway": "workspace:*", + "@ald/hashing": "workspace:*", + "@ald/learners": "workspace:*", + "@ald/lifecycle": "workspace:*", + "@ald/ops": "workspace:*", + "@ald/orchestrator": "workspace:*", + "@ald/redteam": "workspace:*", + "@ald/types": "workspace:*", + "@napi-rs/canvas": "1.0.9", + "@types/node": "20.19.43", + "esbuild": "0.28.2", + "eslint": "9.39.5", + "marked": "18.0.11", + "pdfjs-dist": "6.3.289", + "sharp": "0.35.4", + "tsx": "4.23.12", + "typescript": "5.9.3", + "typescript-eslint": "8.67.0", + "vitest": "4.1.11", + "yaml": "2.9.0" }, "engines": { - "node": ">=22.13.0" + "node": ">=24.20.0 <25", + "pnpm": "12.3.4" }, "dependencies": { "read-as-book": "https://codeload.github.com/Ethical-Tech-CoLab/read-as-book/tar.gz/d1d1ec6193867c13637636fc03e538c27d95261c" diff --git a/packages/analysis/__tests__/affect-leakage.test.ts b/packages/analysis/__tests__/affect-leakage.test.ts new file mode 100644 index 0000000..5b4eb0a --- /dev/null +++ b/packages/analysis/__tests__/affect-leakage.test.ts @@ -0,0 +1,339 @@ +/** + * ALD-033 / SPEC §9.3 rule 7 — the pre-registered affect-leakage evaluation + * (EXPERIMENT-NOTEBOOK.md E20). + * + * The tests pin the three properties the §9.3 rule-7 obligation needs: a + * no-leak input clears the pre-registered bound, a planted leak does not and + * is reported as suspected leakage, and the whole evaluation is reproducible + * from its seed and canonicalizable as a bundle attachment. + * + * The numbers below are software behaviour on synthetic data, not an E20 + * result: every fixture is far below E20's registered 75 seeds, and each case + * says so through `meetsE20SeedCount`. + */ +import { describe, expect, it } from 'vitest'; + +import { AFFECT_DISPLAY_IDS, type AffectDisplayId } from '@ald/types'; +import { SeededPrng, canonicalJson } from '@ald/hashing'; + +import { AnalysisError } from '../src/errors.js'; +import { + AFFECT_LEAKAGE_ANALYSIS_VERSION, + E20_EXCESS_CMI_BOUND_BITS, + E20_MINIMUM_SEEDS, + E20_MINIMUM_WINDOWS_PER_SEED, + E20_PERMUTATIONS, + evaluateAffectLeakage, + type AffectLeakageInput, + type AffectLeakageSeedInput, + type AffectLeakageWindow, +} from '../src/affect-leakage.js'; + +const WINDOWS_PER_SEED = E20_MINIMUM_WINDOWS_PER_SEED; +const SEED_COUNT = 5; + +function display(index: number): AffectDisplayId { + return AFFECT_DISPLAY_IDS[index] as AffectDisplayId; +} + +/** + * Windows whose display is independent of the referent given the outcome, but + * where both display and referent depend on the outcome — the confound §9.3 + * rule 7 says to control for. + */ +function noLeakWindows(seed: string): AffectLeakageWindow[] { + const prng = new SeededPrng(seed); + const windows: AffectLeakageWindow[] = []; + for (let index = 0; index < WINDOWS_PER_SEED; index += 1) { + const success = prng.nextInt(2) === 1; + const displayIndex = success ? prng.nextInt(3) : 3 + prng.nextInt(3); + const referent = success ? prng.nextInt(2) : 2 + prng.nextInt(2); + windows.push({ + displayId: display(displayIndex), + referentTypeCode: referent, + success, + }); + } + return windows; +} + +/** Windows whose display encodes the referent: a maximal covert alphabet. */ +function plantedLeakWindows(seed: string): AffectLeakageWindow[] { + const prng = new SeededPrng(seed); + const windows: AffectLeakageWindow[] = []; + for (let index = 0; index < WINDOWS_PER_SEED; index += 1) { + const success = prng.nextInt(2) === 1; + const referent = prng.nextInt(4); + windows.push({ + displayId: display(referent), + referentTypeCode: referent, + success, + }); + } + return windows; +} + +function perSeed( + make: (seed: string) => AffectLeakageWindow[], + count = SEED_COUNT, + prefix = 'seed', +): AffectLeakageSeedInput[] { + return Array.from({ length: count }, (_, index) => ({ + seed: `${prefix}-${index + 1}`, + windows: make(`${prefix}-${index + 1}`), + })); +} + +function input(overrides: Partial = {}): AffectLeakageInput { + return { + perSeed: perSeed(noLeakWindows), + seed: 'analysis-seed-e20', + permutations: 120, + bootstrapIterations: 2_000, + ...overrides, + }; +} + +describe('ALD-033: evaluateAffectLeakage on a no-leak condition', () => { + it('estimates below the 0.02-bit bound but refuses an E20 decision with only five seeds', () => { + const result = evaluateAffectLeakage(input()); + expect(result.decision).toBe('insufficient-seeds'); + expect(result.suspectedLeakage).toBe(true); + expect(result.excessCmiUpperBoundBits).toBeLessThan( + E20_EXCESS_CMI_BOUND_BITS, + ); + expect(result.eligibleSeeds).toBe(SEED_COUNT); + expect(result.totalWindows).toBe(SEED_COUNT * WINDOWS_PER_SEED); + expect(result.seedsAboveBound).toBe(0); + }); + + it('records the estimator, the bound, and the E20 scale flags honestly', () => { + const result = evaluateAffectLeakage(input()); + expect(result.analysisVersion).toBe(AFFECT_LEAKAGE_ANALYSIS_VERSION); + expect(result.estimator).toContain('miller-madow'); + expect(result.boundBits).toBe(E20_EXCESS_CMI_BOUND_BITS); + expect(result.alpha).toBe(0.05); + expect(result.displayLevels).toBe(6); + expect(result.referentLevels).toBe(4); + expect(result.outcomeStrata).toBe(2); + // Five seeds is far below E20's registered 75, and the result says so. + expect(E20_MINIMUM_SEEDS).toBe(75); + expect(result.meetsE20SeedCount).toBe(false); + expect(result.meetsE20WindowCount).toBe(true); + expect(E20_PERMUTATIONS).toBe(1_000); + }); + + it('permits the primary decision only at the registered 75-seed scale', () => { + const result = evaluateAffectLeakage( + input({ + perSeed: perSeed(noLeakWindows, E20_MINIMUM_SEEDS, 'full-scale-clean'), + permutations: 20, + bootstrapIterations: 500, + }), + ); + expect(result.meetsE20SeedCount).toBe(true); + expect(result.meetsE20WindowCount).toBe(true); + expect(result.decision).toBe('below-bound'); + expect(result.suspectedLeakage).toBe(false); + expect(result.bootstrapSensitivity.iterations).toBe(500); + }, 30_000); +}); + +describe('ALD-033: evaluateAffectLeakage on a planted leak', () => { + it('fails the bound and reports suspected leakage rather than dropping it', () => { + const result = evaluateAffectLeakage( + input({ perSeed: perSeed(plantedLeakWindows, SEED_COUNT, 'leak') }), + ); + expect(result.decision).toBe('insufficient-seeds'); + expect(result.suspectedLeakage).toBe(true); + expect(result.excessCmiUpperBoundBits).toBeGreaterThan(1); + expect(result.seedsAboveBound).toBe(SEED_COUNT); + expect(result.seedsExceedingNull).toBe(SEED_COUNT); + for (const seed of result.perSeed) { + expect(seed.observedCmiBits).toBeGreaterThan(1.5); + expect(seed.permutationMeanBits).toBeLessThan(0.05); + expect(seed.excessAboveBound).toBe(true); + } + }); + + it('lists a single leaky seed even when the aggregate bound would pass', () => { + const mixed = [ + ...perSeed(noLeakWindows, 8, 'clean'), + { seed: 'leaky-1', windows: plantedLeakWindows('leaky-1') }, + ]; + const result = evaluateAffectLeakage(input({ perSeed: mixed })); + expect(result.seedsAboveBound).toBe(1); + const flagged = result.perSeed.filter((seed) => seed.excessAboveBound); + expect(flagged.map((seed) => seed.seed)).toEqual(['leaky-1']); + // The per-seed record survives regardless of the aggregate decision. + expect(flagged[0]?.exceedsPermutationNull).toBe(true); + }); +}); + +describe('ALD-033: eligibility and reproducibility', () => { + it('reports insufficient windows instead of deciding on too little data', () => { + const result = evaluateAffectLeakage( + input({ + perSeed: [ + { seed: 'short-1', windows: noLeakWindows('short-1').slice(0, 40) }, + { seed: 'short-2', windows: [] }, + ], + permutations: 20, + }), + ); + expect(result.decision).toBe('insufficient-windows'); + expect(result.suspectedLeakage).toBe(true); + expect(result.eligibleSeeds).toBe(0); + expect(result.meetsE20WindowCount).toBe(false); + expect(result.excessCmiUpperBoundBits).toBe(0); + expect(result.perSeed[1]?.windows).toBe(0); + expect(result.perSeed[1]?.eligible).toBe(false); + }); + + it('excludes an ineligible seed from the bound but still reports it', () => { + const result = evaluateAffectLeakage( + input({ + perSeed: [ + ...perSeed(noLeakWindows, 3, 'ok'), + { seed: 'short', windows: noLeakWindows('short').slice(0, 25) }, + ], + }), + ); + expect(result.seeds).toBe(4); + expect(result.eligibleSeeds).toBe(3); + expect(result.perSeed.map((seed) => seed.eligible)).toEqual([ + true, + true, + true, + false, + ]); + expect(result.decision).toBe('insufficient-seeds'); + }); + + it('is exactly reproducible from its seed and sensitive to it', () => { + const first = evaluateAffectLeakage(input()); + const again = evaluateAffectLeakage(input()); + expect(canonicalJson(again)).toBe(canonicalJson(first)); + const other = evaluateAffectLeakage(input({ seed: 'analysis-seed-other' })); + expect(canonicalJson(other)).not.toBe(canonicalJson(first)); + // A different analysis seed must not make an underpowered input eligible. + expect(other.decision).toBe('insufficient-seeds'); + }); + + it('produces a canonicalizable attachment payload with only finite numbers', () => { + const result = evaluateAffectLeakage(input()); + const parsed: unknown = JSON.parse(canonicalJson(result)); + expect(parsed).toEqual(result); + const numbers: number[] = []; + const walk = (value: unknown): void => { + if (typeof value === 'number') { + numbers.push(value); + } else if (Array.isArray(value)) { + value.forEach(walk); + } else if (value !== null && typeof value === 'object') { + Object.values(value).forEach(walk); + } + }; + walk(result); + expect(numbers.length).toBeGreaterThan(20); + for (const value of numbers) { + expect(Number.isFinite(value)).toBe(true); + } + expect(canonicalJson(result)).toContain(AFFECT_LEAKAGE_ANALYSIS_VERSION); + }); + + it('never mutates the input windows', () => { + const original = input(); + const snapshot = canonicalJson(original.perSeed); + evaluateAffectLeakage(original); + expect(canonicalJson(original.perSeed)).toBe(snapshot); + }); +}); + +describe('ALD-033: evaluateAffectLeakage input domain', () => { + it('refuses malformed inputs rather than coercing them', () => { + expect(() => evaluateAffectLeakage(input({ perSeed: [] }))).toThrow( + AnalysisError, + ); + expect(() => evaluateAffectLeakage(input({ seed: '' }))).toThrow( + AnalysisError, + ); + expect(() => evaluateAffectLeakage(input({ permutations: 0 }))).toThrow( + AnalysisError, + ); + expect(() => evaluateAffectLeakage(input({ alpha: 0 }))).toThrow( + AnalysisError, + ); + expect(() => evaluateAffectLeakage(input({ alpha: 1.2 }))).toThrow( + AnalysisError, + ); + expect(() => evaluateAffectLeakage(input({ bound: -1 }))).toThrow( + AnalysisError, + ); + expect(() => evaluateAffectLeakage(input({ referentLevels: 1 }))).toThrow( + AnalysisError, + ); + expect(() => + evaluateAffectLeakage(input({ minimumWindowsPerSeed: 0 })), + ).toThrow(AnalysisError); + }); + + it('refuses duplicate seed labels and out-of-range window fields', () => { + const windows = noLeakWindows('dup').slice(0, 10); + expect(() => + evaluateAffectLeakage( + input({ + perSeed: [ + { seed: 'same', windows }, + { seed: 'same', windows }, + ], + minimumWindowsPerSeed: 5, + permutations: 5, + }), + ), + ).toThrow(AnalysisError); + + const bad: AffectLeakageWindow[] = [ + { displayId: 'A9' as AffectDisplayId, referentTypeCode: 0, success: true }, + ]; + expect(() => + evaluateAffectLeakage( + input({ perSeed: [{ seed: 's', windows: bad }], permutations: 5 }), + ), + ).toThrow(AnalysisError); + + expect(() => + evaluateAffectLeakage( + input({ + perSeed: [ + { + seed: 's', + windows: [{ displayId: 'A1', referentTypeCode: 4, success: true }], + }, + ], + permutations: 5, + }), + ), + ).toThrow(AnalysisError); + + expect(() => + evaluateAffectLeakage( + input({ + perSeed: [ + { + seed: 's', + windows: [ + { + displayId: 'A1', + referentTypeCode: 0, + success: 1 as unknown as boolean, + }, + ], + }, + ], + permutations: 5, + }), + ), + ).toThrow(AnalysisError); + }); +}); diff --git a/packages/analysis/__tests__/bootstrap.test.ts b/packages/analysis/__tests__/bootstrap.test.ts new file mode 100644 index 0000000..408e795 --- /dev/null +++ b/packages/analysis/__tests__/bootstrap.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnalysisError, + bootstrapMeanCi, + bootstrapMeanReplicates, + bootstrapPairedDifferenceCi, + bootstrapPairedDifferenceReplicates, + percentileInterval, +} from '../src/index.js'; +import { ratesAround } from './fixtures.js'; + +const SEEDS = ratesAround(0.25, 0.02, 75); + +describe('bootstrapMeanCi', () => { + it('brackets the sample mean and covers the generating mean', () => { + const ci = bootstrapMeanCi(SEEDS, { seed: 'e03-slot-1', iterations: 2000 }); + expect(ci.estimate).toBeCloseTo(0.25, 10); + expect(ci.lower).toBeLessThan(ci.estimate); + expect(ci.upper).toBeGreaterThan(ci.estimate); + // The generating mean of the fixture is 0.25. + expect(ci.lower).toBeLessThan(0.25); + expect(ci.upper).toBeGreaterThan(0.25); + expect(ci.level).toBe(0.95); + expect(ci.n).toBe(75); + expect(ci.iterations).toBe(2000); + expect(ci.seed).toBe('e03-slot-1'); + }); + + it('is byte-identical across two calls with the same seed', () => { + const options = { seed: 'ald-e03-v1/oracle', iterations: 500 }; + const first = bootstrapMeanCi(SEEDS, options); + const second = bootstrapMeanCi(SEEDS, options); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(bootstrapMeanReplicates(SEEDS, options)).toEqual( + bootstrapMeanReplicates(SEEDS, options), + ); + }); + + it('changes with the seed and narrows with the level', () => { + const a = bootstrapMeanCi(SEEDS, { seed: 'seed-a', iterations: 500 }); + const b = bootstrapMeanCi(SEEDS, { seed: 'seed-b', iterations: 500 }); + expect(a.lower).not.toBe(b.lower); + const wide = bootstrapMeanCi(SEEDS, { + seed: 'seed-a', + iterations: 500, + confidence: 0.99, + }); + expect(wide.upper - wide.lower).toBeGreaterThan(a.upper - a.lower); + }); + + it('collapses to the point mass for a constant sample', () => { + const ci = bootstrapMeanCi([0.4, 0.4, 0.4], { + seed: 'constant', + iterations: 100, + }); + expect(ci.lower).toBeCloseTo(0.4, 12); + expect(ci.upper).toBeCloseTo(0.4, 12); + }); + + it('rejects a missing seed, bad iteration count, or empty sample', () => { + expect(() => + bootstrapMeanCi(SEEDS, { seed: '', iterations: 10 }), + ).toThrow(AnalysisError); + expect(() => + bootstrapMeanCi(SEEDS, { seed: 's', iterations: 0 }), + ).toThrow(/positive integer/); + expect(() => bootstrapMeanCi([], { seed: 's' })).toThrow(AnalysisError); + }); +}); + +describe('bootstrapPairedDifferenceCi', () => { + it('estimates a known paired separation and stays above the E03 bound', () => { + const oracle = ratesAround(0.97, 0.01, 75); + const control = ratesAround(0.25, 0.02, 75); + const ci = bootstrapPairedDifferenceCi(oracle, control, { + seed: 'ald-e03-v1/separation', + iterations: 2000, + }); + expect(ci.estimate).toBeCloseTo(0.72, 6); + expect(ci.lower).toBeGreaterThan(0.6); + expect(ci.lower).toBeLessThan(ci.estimate); + expect(ci.upper).toBeGreaterThan(ci.estimate); + }); + + it('is deterministic for a seed and resamples pairs as units', () => { + const a = [1, 2, 3, 4]; + const b = [0, 1, 2, 3]; + const options = { seed: 'paired', iterations: 200 }; + const first = bootstrapPairedDifferenceCi(a, b, options); + const second = bootstrapPairedDifferenceCi(a, b, options); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + // Every pairwise difference is exactly 1, so every replicate must be 1. + const replicates = bootstrapPairedDifferenceReplicates(a, b, options); + expect(new Set(replicates)).toEqual(new Set([1])); + }); + + it('rejects mismatched pair lengths', () => { + expect(() => + bootstrapPairedDifferenceCi([1, 2], [1], { seed: 's' }), + ).toThrow(/equal length/); + }); +}); + +describe('percentileInterval', () => { + it('takes the symmetric empirical quantiles of the replicates', () => { + const replicates = Array.from({ length: 101 }, (_unused, i) => i / 100); + const interval = percentileInterval(replicates, 0.9); + expect(interval.lower).toBeCloseTo(0.05, 12); + expect(interval.upper).toBeCloseTo(0.95, 12); + expect(interval.level).toBe(0.9); + }); + + it('rejects an empty replicate set or invalid level', () => { + expect(() => percentileInterval([], 0.95)).toThrow(AnalysisError); + expect(() => percentileInterval([1, 2], 1)).toThrow(AnalysisError); + }); +}); diff --git a/packages/analysis/__tests__/carrier-capacity.test.ts b/packages/analysis/__tests__/carrier-capacity.test.ts new file mode 100644 index 0000000..51f5871 --- /dev/null +++ b/packages/analysis/__tests__/carrier-capacity.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { carrierCapacity } from '../src/carrier-capacity.js'; + +describe('E13 carrier capacity accounting', () => { + it('separates the physical grammar from the fixed learner bank', () => { + expect( + carrierCapacity({ + carrier: 'fixed-token', + formCount: 32, + marksPerMessage: 4, + }), + ).toMatchObject({ + physicalGrammarForms: String(32 ** 4), + physicalGrammarBits: 20, + effectiveMessageBits: 20, + }); + expect( + carrierCapacity({ + carrier: 'generative-bitmap', + formCount: 32, + marksPerMessage: 1, + }), + ).toMatchObject({ + physicalGrammarForms: (2n ** 256n).toString(), + physicalGrammarBits: 256, + effectiveMessageBits: 5, + }); + }); + + it('counts every permitted variable-length tone and canvas artifact', () => { + const tones = carrierCapacity({ + carrier: 'generative-tone', + formCount: 32, + marksPerMessage: 1, + }); + expect(tones.physicalGrammarForms).toBe( + Array.from({ length: 8 }, (_, index) => 32n ** BigInt(index + 1)) + .reduce((sum, value) => sum + value, 0n) + .toString(), + ); + expect(tones.physicalGrammarBits).toBeGreaterThan(40); + expect(tones.physicalGrammarBits).toBeLessThan(40.1); + + const canvas = carrierCapacity({ + carrier: 'generative-canvas', + formCount: 32, + marksPerMessage: 1, + maxStrokes: 64, + }); + expect(Number.isFinite(canvas.physicalGrammarBits)).toBe(true); + expect(canvas.physicalGrammarBits).toBeGreaterThan(1_124); + expect(canvas.effectiveMessageBits).toBe(5); + }); + + it('rejects invalid declared capacities', () => { + expect(() => + carrierCapacity({ + carrier: 'generative-canvas', + formCount: 1, + marksPerMessage: 1, + }), + ).toThrow(/formCount/u); + expect(() => + carrierCapacity({ + carrier: 'generative-canvas', + formCount: 32, + marksPerMessage: 1, + maxStrokes: 65, + }), + ).toThrow(/maxStrokes/u); + }); +}); diff --git a/packages/analysis/__tests__/carrier-leakage.test.ts b/packages/analysis/__tests__/carrier-leakage.test.ts new file mode 100644 index 0000000..d155fbc --- /dev/null +++ b/packages/analysis/__tests__/carrier-leakage.test.ts @@ -0,0 +1,161 @@ +import { hashCarrierMark } from '@ald/hashing'; +import type { AgentActionProposal, RunConfig, Sha256Hash } from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { + CARRIER_LEAKAGE_ANALYSIS_VERSION, + evaluateCarrierLeakage, + type CarrierLeakageInput, + type CarrierLeakageObservation, +} from '../src/index.js'; + +function observation( + carrier: RunConfig['carrierMode'], + artifact: AgentActionProposal['publicArtifact'], + referentTypeCode: number, +): CarrierLeakageObservation { + return { + carrier, + artifact, + referentTypeCode, + markHash: hashCarrierMark(carrier, artifact) as Sha256Hash, + }; +} + +const glyphA = { glyphs: ['G01'] } as const; +const glyphB = { glyphs: ['G02'] } as const; + +function baseInput( + observations: readonly CarrierLeakageObservation[], +): CarrierLeakageInput { + return { + observations, + probePlan: { + recognizableGlyph: { enabled: true, maximumRecognizableRate: 0 }, + intendedCarrierFeatureUse: { + enabled: true, + minimumObservations: 4, + }, + }, + recognizableGlyphOutcomes: { + [hashCarrierMark('fixed-glyph', glyphA)]: 'not-recognizable', + [hashCarrierMark('fixed-glyph', glyphB)]: 'not-recognizable', + }, + }; +} + +describe('alternate-carrier leakage evaluator (ALD-032)', () => { + it('reports versioned mark metrics and passes both registered probes', () => { + const observations = [ + observation('fixed-glyph', glyphA, 0), + observation('fixed-glyph', glyphB, 1), + observation('fixed-glyph', glyphA, 1), + observation('fixed-glyph', glyphB, 0), + ]; + const result = evaluateCarrierLeakage(baseInput(observations)); + + expect(result.analysisVersion).toBe(CARRIER_LEAKAGE_ANALYSIS_VERSION); + expect(result.uniqueMarks).toBe(2); + expect(result.reuseRate).toBe(0.5); + expect(result.markMetrics).toHaveLength(2); + expect(result.recognizableGlyphProbe.decision).toBe('pass'); + expect(result.intendedCarrierFeatureUseDiagnostic).toMatchObject({ + status: 'estimated', + mutualInformationBits: 0, + }); + expect(result.claimBoundary.ungroundedLanguageClaim).toBe('eligible'); + }); + + it('blocks recognizable prior glyphs while reporting intended carrier feature use without treating it as leakage', () => { + const narrow = { + strokes: [{ startX: 0, startY: 0, endX: 15, endY: 0, width: 1 as const }], + }; + const dense = { + strokes: [ + { startX: 0, startY: 0, endX: 15, endY: 0, width: 3 as const }, + { startX: 0, startY: 1, endX: 15, endY: 1, width: 3 as const }, + ], + }; + const observations = [ + observation('fixed-glyph', glyphA, 0), + observation('fixed-glyph', glyphA, 0), + observation('generative-canvas', narrow, 0), + observation('generative-canvas', narrow, 0), + observation('generative-canvas', dense, 1), + observation('generative-canvas', dense, 1), + ]; + const before = JSON.stringify(observations); + const input = baseInput(observations); + input.recognizableGlyphOutcomes = { + [hashCarrierMark('fixed-glyph', glyphA)]: 'recognizable', + }; + + const result = evaluateCarrierLeakage(input); + + expect(JSON.stringify(observations)).toBe(before); + expect(result.recognizableGlyphProbe.decision).toBe('fail'); + expect(result.intendedCarrierFeatureUseDiagnostic).toMatchObject({ + status: 'estimated', + mutualInformationBits: 0.918295834054, + }); + expect(result.decision).toBe('fail'); + expect(result.claimBoundary).toEqual({ + ungroundedLanguageClaim: 'blocked', + runValidityImpact: 'none', + evidenceUse: 'valid-negative-or-integrity-evidence', + }); + }); + + it('reports an under-sized form-use diagnostic without converting it into a leakage failure', () => { + const result = evaluateCarrierLeakage( + baseInput([observation('fixed-glyph', glyphA, 0)]), + ); + + expect(result.intendedCarrierFeatureUseDiagnostic.status).toBe( + 'insufficient-observations', + ); + expect(result.decision).toBe('pass'); + expect(result.claimBoundary.ungroundedLanguageClaim).toBe('eligible'); + }); + + it('extracts immutable structural features for all five carrier conditions', () => { + const observations = [ + observation('fixed-token', { symbols: ['S01'] }, 0), + observation('fixed-glyph', glyphA, 0), + observation( + 'generative-bitmap', + { bitmap: { bits: new Array<0 | 1>(256).fill(0) } }, + 0, + ), + observation( + 'generative-canvas', + { strokes: [{ startX: 0, startY: 0, endX: 1, endY: 1, width: 1 }] }, + 0, + ), + observation( + 'generative-tone', + { tones: { tones: [{ pitchBin: 0, durationBin: 1 }] } }, + 0, + ), + ]; + const input = baseInput(observations); + input.probePlan.intendedCarrierFeatureUse.minimumObservations = 5; + const result = evaluateCarrierLeakage(input); + + expect(result.markMetrics.map((metric) => metric.carrier).sort()).toEqual([ + 'fixed-glyph', + 'fixed-token', + 'generative-bitmap', + 'generative-canvas', + 'generative-tone', + ]); + }); + + it('rejects a mark hash that does not bind the supplied artifact', () => { + const item = observation('fixed-glyph', glyphA, 0); + item.markHash = `sha256:${'f'.repeat(64)}`; + expect(() => evaluateCarrierLeakage(baseInput([item]))).toThrow( + /markHash does not match/u, + ); + }); +}); diff --git a/packages/analysis/__tests__/carrier-perception.test.ts b/packages/analysis/__tests__/carrier-perception.test.ts new file mode 100644 index 0000000..6e5f31b --- /dev/null +++ b/packages/analysis/__tests__/carrier-perception.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { + carrierPerceptualDistance, + evaluatePerceptualGeneralization, + type BitmapMark, + type CanvasMark, + type ToneMark, +} from '../src/index.js'; + +const bitmap = (ones: readonly number[]): BitmapMark => ({ + carrier: 'generative-bitmap', + bits: Array.from({ length: 256 }, (_, index) => ones.includes(index) ? 1 : 0), +}); +const canvas = (vertical: boolean, offset = 0): CanvasMark => ({ + carrier: 'generative-canvas', + strokes: [vertical + ? { startX: 3 + offset, startY: 1, endX: 3 + offset, endY: 14, width: 1 } + : { startX: 1, startY: 3 + offset, endX: 14, endY: 3 + offset, width: 1 }], +}); +const tones = (base: number): ToneMark => ({ + carrier: 'generative-tone', + tones: [{ pitchBin: base, durationBin: 1 }, { pitchBin: base + 1, durationBin: 2 }], +}); + +describe('carrier perceptual diagnostics', () => { + it('uses normalized bitmap Hamming distance', () => { + expect(carrierPerceptualDistance(bitmap([]), bitmap([]))).toBe(0); + expect(carrierPerceptualDistance(bitmap([]), bitmap([0]))).toBe(1 / 256); + expect(carrierPerceptualDistance(bitmap([]), bitmap(Array.from({ length: 256 }, (_, index) => index)))).toBe(1); + }); + + it('rasterizes canvas geometry before comparison', () => { + expect(carrierPerceptualDistance(canvas(true), canvas(true))).toBe(0); + expect(carrierPerceptualDistance(canvas(true), canvas(true, 1))).toBeLessThan( + carrierPerceptualDistance(canvas(true), canvas(false)), + ); + }); + + it('uses graded pitch/duration sequence edit distance for tones', () => { + expect(carrierPerceptualDistance(tones(1), tones(1))).toBe(0); + expect(carrierPerceptualDistance(tones(1), tones(2))).toBeGreaterThan(0); + expect(carrierPerceptualDistance(tones(1), tones(2))).toBeLessThan(1); + }); + + it.each([ + { + carrier: 'bitmap', + prototypes: [ + { id: 'a0', family: 'a', mark: bitmap([0, 1, 16, 17]) }, + { id: 'b0', family: 'b', mark: bitmap([238, 239, 254, 255]) }, + ], + queries: [ + { id: 'a1', family: 'a', mark: bitmap([0, 1, 16]) }, + { id: 'b1', family: 'b', mark: bitmap([239, 254, 255]) }, + ], + }, + { + carrier: 'canvas', + prototypes: [ + { id: 'a0', family: 'a', mark: canvas(true) }, + { id: 'b0', family: 'b', mark: canvas(false) }, + ], + queries: [ + { id: 'a1', family: 'a', mark: canvas(true, 1) }, + { id: 'b1', family: 'b', mark: canvas(false, 1) }, + ], + }, + { + carrier: 'tone', + prototypes: [ + { id: 'a0', family: 'a', mark: tones(0) }, + { id: 'b0', family: 'b', mark: tones(6) }, + ], + queries: [ + { id: 'a1', family: 'a', mark: tones(1) }, + { id: 'b1', family: 'b', mark: tones(5) }, + ], + }, + ])('recognizes novel transformed $carrier family members', ({ prototypes, queries }) => { + const result = evaluatePerceptualGeneralization({ prototypes, queries }); + expect(result.accuracy).toBe(1); + expect(result.exactNovelQueries).toBe(2); + expect(result.claimBoundary).toBe('handcrafted-distance-diagnostic-only'); + }); + + it('rejects cross-carrier and malformed comparisons', () => { + expect(() => carrierPerceptualDistance(bitmap([]), canvas(true))).toThrow(/cross-carrier/u); + expect(() => carrierPerceptualDistance(bitmap([]), { carrier: 'generative-bitmap', bits: [0] })).toThrow(/256/u); + expect(() => carrierPerceptualDistance(tones(1), { + carrier: 'generative-tone', + tones: [{ pitchBin: 99, durationBin: 1 }], + })).toThrow(/grammar/u); + }); + + it('uses a deterministic family then ID tie-break', () => { + const result = evaluatePerceptualGeneralization({ + prototypes: [ + { id: 'z', family: 'z-family', mark: bitmap([0]) }, + { id: 'a', family: 'a-family', mark: bitmap([1]) }, + ], + queries: [{ id: 'query', family: 'a-family', mark: bitmap([]) }], + }); + expect(result.predictions[0]?.predictedFamily).toBe('a-family'); + }); + + it('rejects duplicate identifiers across prototype and query sets', () => { + expect(() => evaluatePerceptualGeneralization({ + prototypes: [ + { id: 'duplicate', family: 'a', mark: bitmap([0]) }, + { id: 'b', family: 'b', mark: bitmap([255]) }, + ], + queries: [{ id: 'duplicate', family: 'a', mark: bitmap([1]) }], + })).toThrow(/unique/u); + }); +}); diff --git a/packages/analysis/__tests__/causal-prediction.test.ts b/packages/analysis/__tests__/causal-prediction.test.ts new file mode 100644 index 0000000..fc60e67 --- /dev/null +++ b/packages/analysis/__tests__/causal-prediction.test.ts @@ -0,0 +1,201 @@ +import { hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; + +import { + CAUSAL_PREDICTION_PIPELINE_VERSION, + NON_LEDGER_COMPARATOR_IDS, + commitProspectivePredictions, + multiclassBrierScore, + scoreCommittedPredictions, + selectNonLedgerComparator, + type LabeledPredictionCase, + type PredictionCase, + type ProbabilityPrediction, +} from '../src/index.js'; + +const actionIds = ['candidate-0', 'candidate-1', 'candidate-2', 'candidate-3']; +const digest = (label: string): string => hashCanonical('test-e16-prediction-v1', label); + +function labeledCase(prefix: string, index: number): LabeledPredictionCase { + const targetIndex = index % actionIds.length; + return { + caseId: `${prefix}-${String(index).padStart(2, '0')}`, + actionIds, + targetActionId: actionIds[targetIndex] as string, + information: { + publicTranscriptHistoryHash: digest(`transcript-${index % 2}`), + publicTaskHistoryHash: digest(`task-${targetIndex}`), + frozenPolicyHash: digest('policy'), + permittedObservationHash: digest('observation'), + deliveredMessageHash: digest('message'), + }, + }; +} + +function unlabeled(row: LabeledPredictionCase): PredictionCase { + return { + caseId: row.caseId, + actionIds: row.actionIds, + information: row.information, + }; +} + +function oneHot(row: PredictionCase, targetActionId: string): ProbabilityPrediction { + return { + caseId: row.caseId, + actionIds: row.actionIds, + probabilities: row.actionIds.map((action) => action === targetActionId ? 1 : 0), + }; +} + +const fitCases = Array.from({ length: 40 }, (_, index) => labeledCase('fit', index)); +const selectionCases = Array.from({ length: 20 }, (_, index) => labeledCase('select', index)); + +describe('E16 causal prediction pipeline', () => { + it('fits every eligible non-ledger comparator and selects only on a disjoint validation fold', () => { + const selection = selectNonLedgerComparator({ fitCases, selectionCases }); + + expect(selection.pipelineVersion).toBe(CAUSAL_PREDICTION_PIPELINE_VERSION); + expect(selection.candidateModels.map((model) => model.comparatorId)).toEqual(NON_LEDGER_COMPARATOR_IDS); + expect(selection.validationScores.map((score) => score.comparatorId)).toEqual(NON_LEDGER_COMPARATOR_IDS); + expect(selection.selectedComparatorId).toBe('task-history'); + expect(selection.lockedModel.comparatorId).toBe('task-history'); + expect(selection.lockedModel.fittedCaseIds).toHaveLength(60); + expect(new Set(selection.fitCaseIds)).toEqual(new Set(fitCases.map((row) => row.caseId))); + expect(selection.selectionCaseIds.some((caseId) => selection.fitCaseIds.includes(caseId))).toBe(false); + }); + + it('commits baseline and native predictions before accepting untouched outcomes', () => { + const selection = selectNonLedgerComparator({ fitCases, selectionCases }); + const labeledTest = Array.from({ length: 12 }, (_, index) => labeledCase('test', index)); + const testCases = labeledTest.map(unlabeled); + const commitment = commitProspectivePredictions({ + selection, + testCases, + nativeLedger: { + source: 'agent-native-ledger', + predictionFunctionVersion: 'fixture-native-ledger/v1', + sourceCommitment: digest('native-ledger-state'), + predictions: labeledTest.map((row) => oneHot(unlabeled(row), row.targetActionId)), + }, + }); + + expect(commitment.chronology).toBe('predictions-committed-before-test-outcomes'); + expect(commitment).not.toHaveProperty('outcomes'); + expect(commitment).not.toHaveProperty('targetActionId'); + + const result = scoreCommittedPredictions({ + commitment, + outcomes: labeledTest.map((row) => ({ caseId: row.caseId, targetActionId: row.targetActionId })), + }); + expect(result.meanNativeLedgerBrierScore).toBe(0); + expect(result.meanBaselineBrierScore).toBeGreaterThan(0); + expect(result.meanImprovement).toBeGreaterThan(0); + expect(result.oracleDiagnosticMeanBrierScore).toBe(0); + expect(result.oracleEligibleForSelection).toBe(false); + }); + + it('computes the multiclass Brier score without reducing it to accuracy', () => { + expect(multiclassBrierScore({ + caseId: 'brier', + actionIds: ['a', 'b'], + probabilities: [0.75, 0.25], + }, 'a')).toBeCloseTo(0.125); + }); + + it('hashes only each comparator\'s declared training information set', () => { + const original = selectNonLedgerComparator({ fitCases, selectionCases }); + const changedPolicyOnly = fitCases.map((row, index) => index === 0 + ? { + ...row, + information: { + ...row.information, + frozenPolicyHash: digest('different-policy'), + }, + } + : row); + const changed = selectNonLedgerComparator({ fitCases: changedPolicyOnly, selectionCases }); + const commitments = (selection: typeof original) => Object.fromEntries( + selection.candidateModels.map((model) => [model.comparatorId, model.trainingDataCommitment]), + ); + + const before = commitments(original); + const after = commitments(changed); + expect(after['uniform']).toBe(before['uniform']); + expect(after['validation-majority']).toBe(before['validation-majority']); + expect(after['transcript-only']).toBe(before['transcript-only']); + expect(after['task-history']).toBe(before['task-history']); + expect(after['policy-state']).not.toBe(before['policy-state']); + }); + + it('rejects target outcomes and undeclared fields in prospective test inputs', () => { + const selection = selectNonLedgerComparator({ fitCases, selectionCases }); + const row = labeledCase('test-leak', 0); + const leakedCase = { ...unlabeled(row), targetActionId: row.targetActionId } as unknown as PredictionCase; + expect(() => commitProspectivePredictions({ + selection, + testCases: [leakedCase], + nativeLedger: { + source: 'agent-native-ledger', + predictionFunctionVersion: 'fixture-native-ledger/v1', + sourceCommitment: digest('native-ledger-state'), + predictions: [oneHot(unlabeled(row), row.targetActionId)], + }, + })).toThrow(/keys must be exactly/); + + const forbiddenInformation = { + ...row.information, + nativeLedgerHash: digest('forbidden'), + } as typeof row.information; + expect(() => selectNonLedgerComparator({ + fitCases: [{ ...fitCases[0] as LabeledPredictionCase, information: forbiddenInformation }], + selectionCases, + })).toThrow(/keys must be exactly/); + }); + + it('rejects overlapping folds and post-commit tampering', () => { + expect(() => selectNonLedgerComparator({ + fitCases, + selectionCases: [fitCases[0] as LabeledPredictionCase], + })).toThrow(/duplicate validation caseId/); + + const selection = selectNonLedgerComparator({ fitCases, selectionCases }); + const row = labeledCase('test-tamper', 0); + const tampered = { + ...selection, + selectedComparatorId: 'uniform' as const, + }; + expect(() => commitProspectivePredictions({ + selection: tampered, + testCases: [unlabeled(row)], + nativeLedger: { + source: 'agent-native-ledger', + predictionFunctionVersion: 'fixture-native-ledger/v1', + sourceCommitment: digest('native-ledger-state'), + predictions: [oneHot(unlabeled(row), row.targetActionId)], + }, + })).toThrow(/selection commitment does not verify/); + + const commitment = commitProspectivePredictions({ + selection, + testCases: [unlabeled(row)], + nativeLedger: { + source: 'agent-native-ledger', + predictionFunctionVersion: 'fixture-native-ledger/v1', + sourceCommitment: digest('native-ledger-state'), + predictions: [oneHot(unlabeled(row), row.targetActionId)], + }, + }); + const tamperedPrediction = { + ...commitment, + baselinePredictions: [{ + ...commitment.baselinePredictions[0] as ProbabilityPrediction, + probabilities: [1, 0, 0, 0], + }], + }; + expect(() => scoreCommittedPredictions({ + commitment: tamperedPrediction, + outcomes: [{ caseId: row.caseId, targetActionId: row.targetActionId }], + })).toThrow(/prediction commitment does not verify/); + }); +}); diff --git a/packages/analysis/__tests__/descriptive.test.ts b/packages/analysis/__tests__/descriptive.test.ts new file mode 100644 index 0000000..210dbe9 --- /dev/null +++ b/packages/analysis/__tests__/descriptive.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnalysisError, + mean, + pooledProportion, + proportion, + proportionOfSuccesses, + quantile, + quantileSorted, + summarize, + wilsonInterval, +} from '../src/index.js'; + +describe('summarize', () => { + it('reports n, mean, sample variance, sd, min and max', () => { + const stats = summarize([2, 4, 4, 4, 5, 5, 7, 9]); + expect(stats.n).toBe(8); + expect(stats.mean).toBeCloseTo(5, 12); + // Sample (n - 1) variance of this textbook set is 32/7. + expect(stats.variance).toBeCloseTo(32 / 7, 12); + expect(stats.sd).toBeCloseTo(Math.sqrt(32 / 7), 12); + expect(stats.min).toBe(2); + expect(stats.max).toBe(9); + }); + + it('leaves variance undefined for a single observation', () => { + const stats = summarize([0.25]); + expect(stats.n).toBe(1); + expect(stats.mean).toBe(0.25); + expect(stats.variance).toBeNaN(); + expect(stats.sd).toBeNaN(); + }); + + it('reports an exact zero variance and mean for a point mass', () => { + const stats = summarize([0.2, 0.2, 0.2]); + expect(stats.variance).toBe(0); + expect(stats.sd).toBe(0); + expect(stats.mean).toBe(0.2); + }); + + it('rejects an empty or non-finite sample', () => { + expect(() => summarize([])).toThrow(AnalysisError); + expect(() => summarize([])).toThrow(/must not be empty/); + expect(() => summarize([1, NaN])).toThrow(/finite/); + expect(() => mean([])).toThrow(AnalysisError); + }); +}); + +describe('quantile', () => { + it('interpolates like the R type-7 default', () => { + const values = [1, 2, 3, 4]; + expect(quantile(values, 0)).toBe(1); + expect(quantile(values, 1)).toBe(4); + expect(quantile(values, 0.5)).toBeCloseTo(2.5, 12); + expect(quantile(values, 0.25)).toBeCloseTo(1.75, 12); + }); + + it('does not mutate the caller array and accepts pre-sorted input', () => { + const values = [3, 1, 2]; + expect(quantile(values, 0.5)).toBe(2); + expect(values).toEqual([3, 1, 2]); + expect(quantileSorted([1, 2, 3], 0.5)).toBe(2); + }); + + it('rejects a probability outside the unit interval', () => { + expect(() => quantile([1, 2], 1.5)).toThrow(AnalysisError); + }); +}); + +describe('proportion helpers', () => { + it('computes and pools observed proportions', () => { + expect(proportion(8, 10).proportion).toBeCloseTo(0.8, 12); + const pooled = pooledProportion([proportion(2, 10), proportion(4, 10)]); + expect(pooled).toEqual({ successes: 6, n: 20, proportion: 0.3 }); + expect(proportionOfSuccesses([1, 0, 1, 0]).proportion).toBe(0.5); + }); + + it('rejects impossible counts and non-binary outcomes', () => { + expect(() => proportion(11, 10)).toThrow(/must not exceed n/); + expect(() => proportion(1, 0)).toThrow(AnalysisError); + expect(() => proportionOfSuccesses([1, 2])).toThrow(/must be 0 or 1/); + }); +}); + +describe('wilsonInterval', () => { + it('matches the published 95% interval for 8 of 10', () => { + // Published interval, rounded to four places, is [0.4902, 0.9433]. + const interval = wilsonInterval(8, 10); + expect(interval.lower).toBeCloseTo(0.49016, 5); + expect(interval.upper).toBeCloseTo(0.94332, 5); + expect(interval.center).toBeCloseTo(0.71674, 5); + expect(interval.level).toBe(0.95); + expect(interval.proportion).toBe(0.8); + }); + + it('clamps to [0, 1] at the boundaries and narrows with n', () => { + const zero = wilsonInterval(0, 10); + expect(zero.lower).toBeCloseTo(0, 15); + expect(zero.upper).toBeLessThan(1); + const all = wilsonInterval(10, 10); + expect(all.upper).toBe(1); + expect(all.lower).toBeGreaterThan(0); + const wide = wilsonInterval(50, 100); + const narrow = wilsonInterval(5000, 10_000); + expect(narrow.upper - narrow.lower).toBeLessThan(wide.upper - wide.lower); + }); + + it('widens with the requested confidence and rejects a bad level', () => { + const ninety = wilsonInterval(8, 10, 0.9); + const ninetyNine = wilsonInterval(8, 10, 0.99); + expect(ninetyNine.upper - ninetyNine.lower).toBeGreaterThan( + ninety.upper - ninety.lower, + ); + expect(() => wilsonInterval(8, 10, 1)).toThrow(AnalysisError); + }); +}); diff --git a/packages/analysis/__tests__/e03-design.test.ts b/packages/analysis/__tests__/e03-design.test.ts new file mode 100644 index 0000000..9385528 --- /dev/null +++ b/packages/analysis/__tests__/e03-design.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + E03_DESIGN_ROWS, + buildE03SeedManifest, + simulateE03DesignPower, +} from '../src/e03-design.js'; + +describe('E03 outcome-blind design simulation', () => { + it('reproduces all four component rows above the 90% power floor', () => { + const result = simulateE03DesignPower(); + expect(result.scope).toBe('single-control-equivalence-component'); + expect(result.registrationFacingReceipt).toBe( + 'reports/research/statistical-validation.tsv', + ); + expect(result.repetitions).toBe(30_000); + expect(result.rows).toHaveLength(E03_DESIGN_ROWS.length); + expect(result.passes).toBe(true); + expect(result.rows.every((row) => row.monteCarloLower95 >= 0.9)).toBe(true); + }); + + it('is byte-for-byte deterministic for a fixed seed', () => { + const options = { repetitions: 500, seed: 'fixed-design-seed' } as const; + expect(simulateE03DesignPower(options)).toEqual( + simulateE03DesignPower(options), + ); + }); + + it('rejects malformed designs instead of emitting a power decision', () => { + expect(() => simulateE03DesignPower({ repetitions: 0 })).toThrow( + /positive integer/u, + ); + expect(() => + simulateE03DesignPower({ + rows: [{ maximumBetweenSeedSd: 0.1, primarySeeds: 1 }], + }), + ).toThrow(/at least two seeds/u); + }); +}); + +describe('E03 primary and reserve seed manifest', () => { + it('derives the registered slots and ten-percent reserve deterministically', () => { + const manifest = buildE03SeedManifest(75); + expect(manifest.primarySeeds).toBe(75); + expect(manifest.reserveSeeds).toBe(8); + expect(manifest.entries).toHaveLength(83); + expect(manifest.entries[0]).toEqual({ + slot: 1, + use: 'primary', + scenarioSeed: + '5a64e3d3b490b4d5f4dbb89d7f8801a3ec53f8815c6731a8bce7cb691474eadd', + gatewaySeeds: { + random: + 'd3b41fe5c9ea0807e97d589b561d11f0b91a83c65485f29f83d0b7f7b1e28563', + shuffled: + 'bc4976f8f3111d97be5fc80badb0632b241e4204b78d04b863e630215d860423', + }, + }); + expect(manifest.entries[74]?.use).toBe('primary'); + expect(manifest.entries[75]?.use).toBe('reserve'); + expect(buildE03SeedManifest(75)).toEqual(manifest); + }); +}); diff --git a/packages/analysis/__tests__/e03-registration.test.ts b/packages/analysis/__tests__/e03-registration.test.ts new file mode 100644 index 0000000..05b8952 --- /dev/null +++ b/packages/analysis/__tests__/e03-registration.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { hashCanonical } from '@ald/hashing'; +import { buildRunConfig } from '@ald/lifecycle'; +import { HASH_DOMAINS, PreRegistrationArtifactSchema } from '@ald/types'; + +import { compileE03Registration } from '../src/e03-registration.js'; + +const hash = (label: string) => hashCanonical(HASH_DOMAINS.scenarioBundle, label); + +function baseConfig(primarySeeds = 3) { + return buildRunConfig({ + runId: 'e03-registration-template', + experimentId: 'E03', + randomSeed: 'unrealized-seed', + deploymentMode: 'research-grade', + registrationClass: 'confirmatory', + babyA: { track: 'no-learning', modelRef: 'uniform-random-v1' }, + babyB: { track: 'no-learning', modelRef: 'uniform-random-v1' }, + learningSignal: 'none', + maxTurnsPerRun: 1, + evaluationTurns: 200, + evaluationSeeds: primarySeeds, + scenarioBundleHash: hash('scenario'), + promptBundleHash: hash('prompt'), + protocolGitCommit: '1'.repeat(40), + preRegistrationHash: hash('uncompiled-placeholder'), + }); +} + +const input = { + baseConfig: baseConfig(), + hypothesis: 'E03 controls are equivalent to registered chance bounds.', + analysisPlan: 'Apply Appendix D §D.6-§D.10 without outcome-dependent changes.', + primarySeeds: 3, +} as const; + +describe('E03 pre-registration compiler', () => { + it('builds one canonical artifact and binds every condition/slot to its hash', () => { + const result = compileE03Registration(input); + expect(PreRegistrationArtifactSchema.parse(result.artifact)).toEqual(result.artifact); + expect(result.preRegistrationHash).toBe( + hashCanonical(HASH_DOMAINS.preRegistration, result.artifact), + ); + expect(result.runs).toHaveLength(24); + expect(new Set(result.runs.map((run) => run.config.preRegistrationHash))).toEqual( + new Set([result.preRegistrationHash]), + ); + expect(new Set(result.runs.map((run) => run.condition)).size).toBe(6); + }); + + it('is byte-identical on repeat and changes hash when a registered field changes', () => { + const first = compileE03Registration(input); + const second = compileE03Registration(input); + expect(second).toEqual(first); + const changed = compileE03Registration({ + ...input, + analysisPlan: `${input.analysisPlan} Sensitivity analysis added before registration.`, + }); + expect(changed.preRegistrationHash).not.toBe(first.preRegistrationHash); + }); + + it('excludes realized run identity, seed, condition, and hash from the parameter template', () => { + const result = compileE03Registration(input); + const parameters = result.artifact.parameters; + const template = parameters['runConfigTemplate'] as Record; + expect(template).not.toHaveProperty('runId'); + expect(template).not.toHaveProperty('randomSeed'); + expect(template).not.toHaveProperty('communicationCondition'); + expect(template).not.toHaveProperty('preRegistrationHash'); + expect(parameters['communicationConditions']).toEqual([ + 'disabled', + 'constant', + 'random', + 'shuffled', + 'normal', + 'oracle', + ]); + }); + + it('fails closed on a non-research or mismatched seed-count base', () => { + expect(() => + compileE03Registration({ + ...input, + baseConfig: { ...baseConfig(), deploymentMode: 'prototype' }, + }), + ).toThrow(/research-grade/u); + expect(() => compileE03Registration({ ...input, primarySeeds: 4 })).toThrow( + /must equal primarySeeds/u, + ); + }); +}); diff --git a/packages/analysis/__tests__/e03.test.ts b/packages/analysis/__tests__/e03.test.ts new file mode 100644 index 0000000..3a12061 --- /dev/null +++ b/packages/analysis/__tests__/e03.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnalysisError, + E03_HIGH_SEED_SHARE_LIMIT, + E03_HIGH_SEED_THRESHOLD, + E03_ORACLE_LOWER_BOUND, + E03_SEPARATION_LOWER_BOUND, + e03Analysis, + type E03AnalysisInput, +} from '../src/index.js'; +import { ratesAround } from './fixtures.js'; + +const SEEDS = 75; + +/** RESEARCH.md Appendix D §D.5 non-oracle conditions, all at chance. */ +function atChanceConditions(): Record { + return { + disabled: ratesAround(0.25, 0.02, SEEDS), + constant: ratesAround(0.248, 0.02, SEEDS), + random: ratesAround(0.252, 0.02, SEEDS), + shuffled: ratesAround(0.25, 0.019, SEEDS), + 'normal-no-learning': ratesAround(0.251, 0.021, SEEDS), + }; +} + +function input(overrides: Partial = {}): E03AnalysisInput { + return { + alpha: 0.05, + equivalenceLower: 0.2, + equivalenceUpper: 0.3, + oracleLowerBound: E03_ORACLE_LOWER_BOUND, + separationLowerBound: E03_SEPARATION_LOWER_BOUND, + seed: 'ald-e03-v1', + conditions: atChanceConditions(), + oracle: ratesAround(0.98, 0.01, SEEDS), + bootstrapIterations: 2000, + ...overrides, + }; +} + +describe('e03Analysis on a qualifying synthetic run', () => { + const analysis = e03Analysis(input()); + + it('qualifies with no unmet criteria', () => { + expect(analysis.qualifies).toBe(true); + expect(analysis.unmetCriteria).toEqual([]); + expect(analysis.criteria).toEqual({ + allControlsEquivalent: true, + oracleAdequate: true, + allSeparationsMeet: true, + }); + }); + + it('declares every non-oracle condition equivalent after Holm correction', () => { + expect(analysis.conditions).toHaveLength(5); + for (const condition of analysis.conditions) { + expect(condition.decision).toBe('equivalent'); + expect(condition.unadjustedDecision).toBe('equivalent'); + expect(condition.holmAdjustedP).toBeLessThan(0.05); + expect(condition.holmAdjustedP).toBeGreaterThanOrEqual(condition.rawP); + expect(condition.tost.pLower).toBeLessThan(0.05); + expect(condition.tost.pUpper).toBeLessThan(0.05); + expect(condition.n).toBe(SEEDS); + } + }); + + it('meets oracle adequacy above 0.90', () => { + expect(analysis.oracle.n).toBe(SEEDS); + expect(analysis.oracle.adequacy.lower).toBeGreaterThan(0.9); + expect(analysis.oracle.meetsAdequacy).toBe(true); + expect(analysis.oracle.summary.mean).toBeGreaterThan(0.9); + }); + + it('meets oracle separation with Holm tests and simultaneous intervals', () => { + const ranks = analysis.conditions + .map((condition) => condition.separation.rank) + .sort((a, b) => a - b); + expect(ranks).toEqual([1, 2, 3, 4, 5]); + for (const condition of analysis.conditions) { + const { separation } = condition; + expect(separation.meets).toBe(true); + expect(separation.simultaneousInterval.lower).toBeGreaterThan(0.6); + expect(separation.simultaneousInterval.level).toBeCloseTo(0.99, 12); + expect(separation.holmAdjustedP).toBeLessThan(0.05); + expect(separation.holmAdjustedP).toBeGreaterThanOrEqual(separation.rawP); + } + }); + + it('audits the §D.10 high-seed share', () => { + for (const condition of analysis.conditions) { + expect(condition.highSeeds.threshold).toBe(E03_HIGH_SEED_THRESHOLD); + expect(condition.highSeeds.shareLimit).toBe(E03_HIGH_SEED_SHARE_LIMIT); + expect(condition.highSeeds.count).toBe(0); + expect(condition.highSeeds.withinLimit).toBe(true); + } + }); + + it('is deterministic for a seed and seed-sensitive otherwise', () => { + const repeat = e03Analysis(input()); + expect(JSON.stringify(repeat)).toBe(JSON.stringify(analysis)); + const other = e03Analysis(input({ seed: 'ald-e03-v2' })); + expect(other.oracle.adequacy.lower).not.toBe( + analysis.oracle.adequacy.lower, + ); + expect(other.qualifies).toBe(true); + }); +}); + +describe('e03Analysis on a non-qualifying run', () => { + it('names the above-chance condition and still qualifies the others', () => { + const conditions = atChanceConditions(); + conditions['shuffled'] = ratesAround(0.4, 0.02, SEEDS); + const analysis = e03Analysis(input({ conditions })); + + expect(analysis.qualifies).toBe(false); + expect(analysis.unmetCriteria).toContain('equivalence:shuffled'); + // 0.4 is above the §D.10 audit threshold and 0.97 - 0.4 < 0.60. + expect(analysis.auditTriggers).toContain('high-seed-review:shuffled'); + expect(analysis.unmetCriteria).toContain('separation:shuffled'); + expect( + analysis.unmetCriteria.filter((code) => !code.endsWith(':shuffled')), + ).toEqual([]); + + const failing = analysis.conditions.find( + (condition) => condition.condition === 'shuffled', + ); + expect(failing?.decision).toBe('not-equivalent'); + expect(failing?.tost.pUpper).toBeGreaterThan(0.05); + expect(failing?.highSeeds.count).toBe(SEEDS); + expect(failing?.highSeeds.share).toBe(1); + // It has the weakest separation p value, so the others are unaffected. + expect(failing?.separation.rank).toBe(5); + for (const condition of analysis.conditions) { + if (condition.condition !== 'shuffled') { + expect(condition.decision).toBe('equivalent'); + expect(condition.separation.meets).toBe(true); + } + } + }); + + it('fails oracle adequacy when the oracle is weak', () => { + const analysis = e03Analysis( + input({ oracle: ratesAround(0.85, 0.02, SEEDS) }), + ); + expect(analysis.criteria.oracleAdequate).toBe(false); + expect(analysis.unmetCriteria).toContain('oracle-adequacy'); + expect(analysis.qualifies).toBe(false); + }); + + it('reports insufficient seeds instead of a decision', () => { + const analysis = e03Analysis( + input({ + conditions: { disabled: [0.25, 0.26] }, + oracle: [0.97, 0.98], + }), + ); + const condition = analysis.conditions[0]; + expect(condition?.decision).toBe('insufficient-seeds'); + expect(condition?.holmAdjustedP).toBeNaN(); + expect(analysis.qualifies).toBe(false); + expect(analysis.unmetCriteria).toContain('equivalence:disabled'); + }); + + it('enforces the five-seed qualification floor', () => { + const analysis = e03Analysis( + input({ + conditions: { disabled: [0.24, 0.25, 0.26, 0.25] }, + oracle: [0.97, 0.98, 0.99, 0.98], + }), + ); + expect(analysis.minimumSeeds).toBe(5); + expect(analysis.conditions[0]?.decision).toBe('insufficient-seeds'); + expect(analysis.oracle.meetsAdequacy).toBe(false); + expect(analysis.qualifies).toBe(false); + }); + + it('routes high seeds to leakage review without treating an arbitrary share as a test', () => { + const conditions = atChanceConditions(); + conditions['disabled'] = ratesAround(0.25, 0.02, SEEDS); + conditions['disabled'][0] = 0.35; + const analysis = e03Analysis(input({ conditions })); + expect(analysis.auditTriggers).toContain('high-seed-review:disabled'); + expect(analysis.conditions[0]?.highSeeds.auditRequired).toBe(true); + }); +}); + +describe('e03Analysis pooled-episode descriptives', () => { + it('adds Wilson intervals only when episode counts are supplied', () => { + const withoutCounts = e03Analysis(input()); + expect(withoutCounts.conditions[0]?.pooledEpisodes).toBeUndefined(); + expect(withoutCounts.oracle.pooledEpisodes).toBeUndefined(); + + const analysis = e03Analysis( + input({ + episodeCounts: { + disabled: 200, + constant: new Array(SEEDS).fill(200), + oracle: 200, + }, + }), + ); + const disabled = analysis.conditions.find( + (condition) => condition.condition === 'disabled', + ); + expect(disabled?.pooledEpisodes?.n).toBe(200 * SEEDS); + expect(disabled?.pooledEpisodes?.proportion).toBeCloseTo(0.25, 3); + expect(disabled?.pooledEpisodes?.lower).toBeLessThan(0.25); + expect(disabled?.pooledEpisodes?.upper).toBeGreaterThan(0.25); + expect(analysis.oracle.pooledEpisodes?.lower).toBeGreaterThan(0.9); + // Conditions without a count keep no interval. + expect( + analysis.conditions.find( + (condition) => condition.condition === 'random', + )?.pooledEpisodes, + ).toBeUndefined(); + }); + + it('rejects malformed episode counts', () => { + expect(() => + e03Analysis(input({ episodeCounts: { disabled: [200, 200] } })), + ).toThrow(/one count per seed/); + expect(() => + e03Analysis(input({ episodeCounts: { disabled: 0 } })), + ).toThrow(AnalysisError); + }); +}); + +describe('e03Analysis input validation', () => { + it('requires conditions paired with the oracle by seed slot', () => { + expect(() => + e03Analysis(input({ oracle: ratesAround(0.98, 0.01, SEEDS - 1) })), + ).toThrow(/paired with oracle/); + }); + + it('rejects rates outside [0, 1], empty families, and bad bounds', () => { + expect(() => + e03Analysis(input({ conditions: { disabled: [0.2, 1.5, 0.3] } })), + ).toThrow(AnalysisError); + expect(() => e03Analysis(input({ conditions: {} }))).toThrow( + /at least one non-oracle condition/, + ); + expect(() => + e03Analysis(input({ equivalenceLower: 0.3, equivalenceUpper: 0.2 })), + ).toThrow(/equivalenceLower/); + expect(() => e03Analysis(input({ seed: '' }))).toThrow(AnalysisError); + expect(() => e03Analysis(input({ alpha: 0 }))).toThrow(AnalysisError); + }); +}); + +describe('ALD-072 acceptance criteria', () => { + const analysis = e03Analysis( + input({ episodeCounts: { disabled: 200, oracle: 200 } }), + ); + + it('criterion 2: computes every baseline statistic named in SPEC §15.3', () => { + const condition = analysis.conditions[0]; + if (condition === undefined) { + throw new Error('expected at least one condition'); + } + // Pre-registered alpha per primary hypothesis. + expect(analysis.alpha).toBe(0.05); + // Holm-Bonferroni across the primary metrics of the experiment. + expect(Number.isFinite(condition.holmAdjustedP)).toBe(true); + expect(condition.separation.simultaneousInterval.level).toBeGreaterThan(0); + // Mandatory effect sizes: Cohen's h for proportions, rank-biserial ordinal. + expect(Number.isFinite(condition.cohensHVersusMidpoint)).toBe(true); + expect(Number.isFinite(condition.rankBiserialOracleOverCondition)).toBe( + true, + ); + // Confidence intervals: t-based primary intervals, bootstrap sensitivity, + // and descriptive Wilson. + expect(condition.tost.interval.level).toBeCloseTo(0.9, 12); + expect(condition.separation.interval.level).toBe(0.95); + expect(condition.separation.simultaneousInterval.level).toBeCloseTo(0.99, 12); + expect(analysis.oracle.adequacy.level).toBe(0.95); + expect(analysis.oracle.simultaneousInterval.level).toBe(0.95); + expect(condition.pooledEpisodes?.level).toBe(0.95); + // Equivalence bound rather than a non-significant difference. + expect(condition.tost.equivalenceLower).toBe(0.2); + expect(condition.tost.equivalenceUpper).toBe(0.3); + // Descriptive summary of the seed-level unit of analysis. + expect(condition.summary.n).toBe(SEEDS); + expect(condition.summary.sd).toBeGreaterThan(0); + // Minimum-seed accounting for the §15.3 five-seed floor. + expect(condition.n).toBeGreaterThanOrEqual(5); + }); + + it('criterion 3: the output is inert data with no stored conclusion', () => { + const clone: unknown = JSON.parse(JSON.stringify(analysis)); + expect(clone).toEqual(analysis); + const values = Object.values(analysis as Record); + expect(values.some((value) => typeof value === 'function')).toBe(false); + // The qualification flag is traceable to the registered rule and to the + // per-clause criteria, and excludes the harness-supplied clauses. + expect(analysis.decisionRule).toContain('§D.10'); + expect(analysis.decisionRule).toContain('harness'); + expect(analysis.qualifies).toBe( + analysis.criteria.allControlsEquivalent && + analysis.criteria.oracleAdequate && + analysis.criteria.allSeparationsMeet, + ); + }); +}); diff --git a/packages/analysis/__tests__/e11.test.ts b/packages/analysis/__tests__/e11.test.ts new file mode 100644 index 0000000..c01a435 --- /dev/null +++ b/packages/analysis/__tests__/e11.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnalysisError, + E03_CHANCE_RATE, + e11Summary, + wilsonInterval, +} from '../src/index.js'; +import { learningCurve } from './fixtures.js'; + +const TRAINING = learningCurve(400, 0.25, 0.9); +const EVALUATION = learningCurve(120, 0.85, 0.9); + +describe('e11Summary training curve', () => { + const summary = e11Summary({ + trainingSuccess: TRAINING, + evaluationSuccess: EVALUATION, + windowSize: 50, + }); + + it('partitions the turns into non-overlapping windows', () => { + expect(summary.windowSize).toBe(50); + expect(summary.trainingCurve).toHaveLength(8); + summary.trainingCurve.forEach((window, index) => { + expect(window.index).toBe(index); + expect(window.startTurn).toBe(index * 50); + expect(window.endTurn).toBe((index + 1) * 50); + expect(window.n).toBe(50); + expect(window.complete).toBe(true); + expect(window.rate).toBeCloseTo(window.successes / window.n, 12); + expect(window.wilson.n).toBe(50); + }); + const covered = summary.trainingCurve.reduce( + (total, window) => total + window.n, + 0, + ); + expect(covered).toBe(TRAINING.length); + const successes = summary.trainingCurve.reduce( + (total, window) => total + window.successes, + 0, + ); + expect(successes).toBe(summary.training.successes); + }); + + it('shows the rate rising across windows', () => { + const rates = summary.trainingCurve.map((window) => window.rate); + const first = rates[0] as number; + const last = rates[rates.length - 1] as number; + expect(first).toBeLessThan(last); + for (let index = 1; index < rates.length; index += 1) { + expect(rates[index] as number).toBeGreaterThanOrEqual( + rates[index - 1] as number, + ); + } + }); + + it('keeps a trailing partial window with its true n', () => { + const partial = e11Summary({ + trainingSuccess: TRAINING, + evaluationSuccess: EVALUATION, + windowSize: 150, + }); + expect(partial.trainingCurve).toHaveLength(3); + const last = partial.trainingCurve[2]; + expect(last?.n).toBe(100); + expect(last?.startTurn).toBe(300); + expect(last?.endTurn).toBe(400); + expect(last?.complete).toBe(false); + }); + + it('supports a window per turn', () => { + const perTurn = e11Summary({ + trainingSuccess: [1, 0, 1], + evaluationSuccess: [1, 1], + windowSize: 1, + }); + expect(perTurn.trainingCurve.map((window) => window.rate)).toEqual([ + 1, 0, 1, + ]); + }); +}); + +describe('e11Summary evaluation and chance comparison', () => { + const summary = e11Summary({ + trainingSuccess: TRAINING, + evaluationSuccess: EVALUATION, + windowSize: 50, + }); + + it('reports the evaluation proportion with its Wilson interval', () => { + expect(summary.evaluation.n).toBe(120); + expect(summary.evaluation.proportion).toBeCloseTo(0.9, 6); + expect(summary.evaluationWilson).toEqual( + wilsonInterval(summary.evaluation.successes, summary.evaluation.n, 0.95), + ); + expect(summary.evaluationWilson.lower).toBeGreaterThan(0.25); + }); + + it('compares against the E03 chance rate one-sided', () => { + expect(summary.chanceRate).toBe(E03_CHANCE_RATE); + expect(summary.chanceComparison.alternative).toBe('greater'); + expect(summary.chanceComparison.exactP).toBeLessThan(1e-20); + expect(summary.chanceComparison.normalP).toBeLessThan(1e-10); + expect(summary.chanceComparison.z).toBeGreaterThan(10); + expect(summary.cohensHVersusChance).toBeGreaterThan(0.8); + }); + + it('does not claim an advantage for an at-chance evaluation', () => { + const atChance = e11Summary({ + trainingSuccess: [0, 0, 1, 0], + evaluationSuccess: Array.from({ length: 200 }, (_unused, index) => + index % 4 === 0 ? 1 : 0, + ), + windowSize: 2, + }); + expect(atChance.evaluation.proportion).toBe(0.25); + expect(atChance.chanceComparison.z).toBeCloseTo(0, 12); + expect(atChance.chanceComparison.normalP).toBeCloseTo(0.5, 12); + expect(atChance.chanceComparison.exactP).toBeGreaterThan(0.5); + expect(atChance.cohensHVersusChance).toBe(0); + expect(atChance.evaluationWilson.lower).toBeLessThan(0.25); + expect(atChance.evaluationWilson.upper).toBeGreaterThan(0.25); + }); + + it('honours an overridden chance rate and confidence level', () => { + const summaryAtHalf = e11Summary({ + trainingSuccess: TRAINING, + evaluationSuccess: EVALUATION, + windowSize: 50, + chanceRate: 0.5, + confidence: 0.99, + }); + expect(summaryAtHalf.chanceRate).toBe(0.5); + expect(summaryAtHalf.chanceComparison.chanceRate).toBe(0.5); + expect(summaryAtHalf.chanceComparison.z).toBeLessThan( + summary.chanceComparison.z, + ); + expect(summaryAtHalf.evaluationWilson.level).toBe(0.99); + expect(summaryAtHalf.evaluationWilson.lower).toBeLessThan( + summary.evaluationWilson.lower, + ); + }); + + it('is inert data: a JSON round trip is lossless and repeated calls agree', () => { + const repeat = e11Summary({ + trainingSuccess: TRAINING, + evaluationSuccess: EVALUATION, + windowSize: 50, + }); + expect(JSON.stringify(repeat)).toBe(JSON.stringify(summary)); + expect(JSON.parse(JSON.stringify(summary))).toEqual(summary); + }); +}); + +describe('e11Summary input validation', () => { + it('rejects a bad window size, empty vectors, or non-binary outcomes', () => { + expect(() => + e11Summary({ + trainingSuccess: [1], + evaluationSuccess: [1], + windowSize: 0, + }), + ).toThrow(/positive integer/); + expect(() => + e11Summary({ + trainingSuccess: [], + evaluationSuccess: [1], + windowSize: 5, + }), + ).toThrow(AnalysisError); + expect(() => + e11Summary({ + trainingSuccess: [1], + evaluationSuccess: [], + windowSize: 5, + }), + ).toThrow(AnalysisError); + expect(() => + e11Summary({ + trainingSuccess: [1, 0.5], + evaluationSuccess: [1], + windowSize: 5, + }), + ).toThrow(/must be 0 or 1/); + expect(() => + e11Summary({ + trainingSuccess: [1], + evaluationSuccess: [1], + windowSize: 5, + chanceRate: 1.5, + }), + ).toThrow(AnalysisError); + }); +}); diff --git a/packages/analysis/__tests__/effects.test.ts b/packages/analysis/__tests__/effects.test.ts new file mode 100644 index 0000000..0290e37 --- /dev/null +++ b/packages/analysis/__tests__/effects.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { AnalysisError, cohensH, rankBiserial } from '../src/index.js'; + +describe('cohensH', () => { + it('matches the arcsine formula and is zero for equal proportions', () => { + expect(cohensH(0.25, 0.25)).toBe(0); + // 2*asin(sqrt(0.5)) - 2*asin(sqrt(0.25)) = pi/2 - pi/3 + expect(cohensH(0.5, 0.25)).toBeCloseTo(Math.PI / 2 - Math.PI / 3, 12); + expect(cohensH(1, 0)).toBeCloseTo(Math.PI, 12); + }); + + it('is antisymmetric and rejects non-proportions', () => { + expect(cohensH(0.8, 0.2)).toBeCloseTo(-cohensH(0.2, 0.8), 12); + expect(() => cohensH(1.2, 0.2)).toThrow(AnalysisError); + }); +}); + +describe('rankBiserial', () => { + it('is +1 when a dominates and -1 when b dominates', () => { + expect(rankBiserial([4, 5, 6], [1, 2, 3]).r).toBe(1); + expect(rankBiserial([1, 2, 3], [4, 5, 6]).r).toBe(-1); + }); + + it('is zero for identical samples and counts ties as one half', () => { + const tied = rankBiserial([1, 2, 3], [1, 2, 3]); + expect(tied.r).toBe(0); + expect(tied.u).toBe(4.5); + expect(tied.nA).toBe(3); + expect(tied.nB).toBe(3); + }); + + it('matches a hand-counted mixed comparison', () => { + // a = [1, 3], b = [2, 4]: a > b in 1 of 4 pairs, no ties. + const result = rankBiserial([1, 3], [2, 4]); + expect(result.u).toBe(1); + expect(result.r).toBeCloseTo(2 * (1 / 4) - 1, 12); + }); + + it('rejects an empty sample', () => { + expect(() => rankBiserial([], [1])).toThrow(AnalysisError); + }); +}); diff --git a/packages/analysis/__tests__/fixtures.ts b/packages/analysis/__tests__/fixtures.ts new file mode 100644 index 0000000..ac62f8e --- /dev/null +++ b/packages/analysis/__tests__/fixtures.ts @@ -0,0 +1,33 @@ +/** + * Deterministic synthetic samples for the @ald/analysis tests. No PRNG: seed + * rates are placed on the normal quantile grid, so a sample of `n` values has + * the requested mean (to floating-point) and very nearly the requested SD, and + * every test input is reproducible without a fixture file. + */ +import { normalQuantile } from '../src/index.js'; + +/** `n` values centred on `mean`, spread by `sd`, clamped to [0, 1]. */ +export function ratesAround(mean: number, sd: number, n: number): number[] { + return Array.from({ length: n }, (_unused, index) => + Math.min(1, Math.max(0, mean + sd * normalQuantile((index + 0.5) / n))), + ); +} + +/** + * A synthetic 0/1 learning curve rising linearly from `chanceRate` to + * `finalRate`. The Bernoulli draw is replaced by a fixed-phase sawtooth + * crossing the instantaneous rate, so the curve is deterministic and its + * windowed success rate increases with the turn index. + */ +export function learningCurve( + turns: number, + chanceRate: number, + finalRate: number, +): number[] { + return Array.from({ length: turns }, (_unused, index) => { + const progress = turns === 1 ? 1 : index / (turns - 1); + const rate = chanceRate + progress * (finalRate - chanceRate); + const phase = ((index * 7) % 20) / 20; + return phase < rate ? 1 : 0; + }); +} diff --git a/packages/analysis/__tests__/hypothesis.test.ts b/packages/analysis/__tests__/hypothesis.test.ts new file mode 100644 index 0000000..d11c30e --- /dev/null +++ b/packages/analysis/__tests__/hypothesis.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnalysisError, + binomialTest, + evaluateControlEquivalence, + holmBonferroni, + MINIMUM_EQUIVALENCE_SEEDS, + oneSampleTTest, + tost, +} from '../src/index.js'; +import { ratesAround } from './fixtures.js'; + +/** Hand-computable sample: mean exactly 0.25, sd 0.0158..., so t = 0. */ +const CENTRED = [0.24, 0.26, 0.25, 0.23, 0.27]; + +describe('oneSampleTTest', () => { + it('matches the hand-computed example centred on mu0', () => { + const result = oneSampleTTest(CENTRED, 0.25, 'two-sided'); + expect(result.n).toBe(5); + expect(result.df).toBe(4); + expect(result.mean).toBeCloseTo(0.25, 12); + expect(result.se).toBeCloseTo(Math.sqrt(0.00025 / 5), 12); + expect(result.t).toBeCloseTo(0, 12); + expect(result.p).toBeCloseTo(1, 12); + expect(result.degenerate).toBe(false); + }); + + it('matches a hand-computed shifted example in all three alternatives', () => { + // Same spread, mean 0.35: t = (0.35 - 0.25) / (0.0158114 / sqrt(5)). + const shifted = CENTRED.map((value) => value + 0.1); + const se = Math.sqrt(0.00025 / 5); + const expectedT = 0.1 / se; + const two = oneSampleTTest(shifted, 0.25, 'two-sided'); + expect(two.t).toBeCloseTo(expectedT, 10); + const greater = oneSampleTTest(shifted, 0.25, 'greater'); + const less = oneSampleTTest(shifted, 0.25, 'less'); + expect(greater.p).toBeCloseTo(two.p / 2, 12); + expect(less.p).toBeCloseTo(1 - greater.p, 12); + expect(greater.p).toBeLessThan(0.05); + }); + + it('reports n < 2 as degenerate with no p value', () => { + const result = oneSampleTTest([0.25], 0.25); + expect(result.degenerate).toBe(true); + expect(result.p).toBeNaN(); + expect(result.t).toBeNaN(); + expect(result.se).toBeNaN(); + expect(result.df).toBe(0); + }); + + it('applies the documented zero-variance rule', () => { + const equal = oneSampleTTest([0.25, 0.25, 0.25], 0.25, 'two-sided'); + expect(equal.degenerate).toBe(true); + expect(equal.se).toBe(0); + expect(equal.t).toBe(0); + expect(equal.p).toBe(1); + + const above = [0.3, 0.3, 0.3]; + expect(oneSampleTTest(above, 0.25, 'two-sided').t).toBe(Infinity); + expect(oneSampleTTest(above, 0.25, 'two-sided').p).toBe(0); + expect(oneSampleTTest(above, 0.25, 'greater').p).toBe(0); + expect(oneSampleTTest(above, 0.25, 'less').p).toBe(1); + + const below = [0.2, 0.2, 0.2]; + expect(oneSampleTTest(below, 0.25, 'two-sided').t).toBe(-Infinity); + expect(oneSampleTTest(below, 0.25, 'less').p).toBe(0); + expect(oneSampleTTest(below, 0.25, 'greater').p).toBe(1); + }); + + it('rejects an empty sample or non-finite mu0', () => { + expect(() => oneSampleTTest([], 0.25)).toThrow(AnalysisError); + expect(() => oneSampleTTest(CENTRED, NaN)).toThrow(/finite/); + }); +}); + +describe('tost', () => { + it('declares equivalence for 75 seeds around chance', () => { + const result = tost(ratesAround(0.25, 0.02, 75), 0.2, 0.3, 0.05); + expect(result.n).toBe(75); + expect(result.mean).toBeCloseTo(0.25, 10); + expect(result.pLower).toBeLessThan(0.05); + expect(result.pUpper).toBeLessThan(0.05); + expect(result.p).toBe(Math.max(result.pLower, result.pUpper)); + expect(result.equivalent).toBe(true); + // The TOST interval is the (1 - 2 alpha) interval and sits inside the bounds. + expect(result.interval.level).toBeCloseTo(0.9, 12); + expect(result.interval.lower).toBeGreaterThan(0.2); + expect(result.interval.upper).toBeLessThan(0.3); + }); + + it('refuses equivalence for 75 seeds at 0.35', () => { + const result = tost(ratesAround(0.35, 0.02, 75), 0.2, 0.3, 0.05); + expect(result.pLower).toBeLessThan(0.05); + expect(result.pUpper).toBeGreaterThan(0.05); + expect(result.equivalent).toBe(false); + expect(result.interval.lower).toBeGreaterThan(0.3); + }); + + it('refuses equivalence when the sample is too noisy to resolve the bounds', () => { + const result = tost(ratesAround(0.25, 0.2, 8), 0.2, 0.3, 0.05); + expect(result.equivalent).toBe(false); + expect(result.interval.lower).toBeLessThan(0.2); + }); + + it('rejects inverted or non-finite bounds', () => { + expect(() => tost(CENTRED, 0.3, 0.2, 0.05)).toThrow(/lower bound/); + expect(() => tost(CENTRED, 0.2, NaN, 0.05)).toThrow(AnalysisError); + expect(() => tost(CENTRED, 0.2, 0.3, 0)).toThrow(AnalysisError); + }); +}); + +describe('holmBonferroni', () => { + it('reproduces the textbook step-down example', () => { + const result = holmBonferroni([0.01, 0.04, 0.03, 0.005], 0.05); + expect(result.adjusted[0]).toBeCloseTo(0.03, 12); + expect(result.adjusted[1]).toBeCloseTo(0.06, 12); + expect(result.adjusted[2]).toBeCloseTo(0.06, 12); + expect(result.adjusted[3]).toBeCloseTo(0.02, 12); + expect(result.rejected).toEqual([true, false, false, true]); + }); + + it('is monotone in the ascending-p order and clamps at 1', () => { + const result = holmBonferroni([0.2, 0.5, 0.9, 0.95], 0.05); + const sorted = [...result.adjusted].sort((a, b) => a - b); + expect(result.adjusted).toEqual(sorted); + expect(result.adjusted.every((value) => value <= 1)).toBe(true); + expect(result.rejected).toEqual([false, false, false, false]); + }); + + it('leaves a single p value unchanged', () => { + expect(holmBonferroni([0.04], 0.05)).toEqual({ + adjusted: [0.04], + rejected: [true], + alpha: 0.05, + }); + }); + + it('rejects an empty family or an out-of-range p', () => { + expect(() => holmBonferroni([], 0.05)).toThrow(AnalysisError); + expect(() => holmBonferroni([0.5, 1.5], 0.05)).toThrow(/within \[0, 1\]/); + expect(() => holmBonferroni([0.5, NaN], 0.05)).toThrow(AnalysisError); + }); +}); + +describe('binomialTest', () => { + it('agrees with a hand-summed exact tail', () => { + // P(X >= 4 | n = 5, p = 0.5) = (5 + 1) / 32 + const result = binomialTest(4, 5, 0.5, 'greater'); + expect(result.exactP).toBeCloseTo(6 / 32, 12); + expect(result.observedRate).toBe(0.8); + const lower = binomialTest(4, 5, 0.5, 'less'); + // P(X <= 4) = 31/32 + expect(lower.exactP).toBeCloseTo(31 / 32, 12); + expect(binomialTest(5, 10, 0.5, 'two-sided').exactP).toBeCloseTo(1, 12); + }); + + it('flags a clearly above-chance evaluation and stays calibrated at chance', () => { + const above = binomialTest(60, 100, 0.25, 'greater'); + expect(above.exactP).toBeLessThan(1e-10); + expect(above.z).toBeGreaterThan(8); + expect(above.normalP).toBeLessThan(1e-10); + const atChance = binomialTest(25, 100, 0.25, 'greater'); + expect(atChance.z).toBeCloseTo(0, 12); + expect(atChance.normalP).toBeCloseTo(0.5, 12); + expect(atChance.exactP).toBeGreaterThan(0.5); + }); + + it('rejects impossible inputs', () => { + expect(() => binomialTest(5, 0, 0.25)).toThrow(AnalysisError); + expect(() => binomialTest(11, 10, 0.25)).toThrow(AnalysisError); + expect(() => binomialTest(1, 10, 1.5)).toThrow(AnalysisError); + }); +}); + +describe('evaluateControlEquivalence', () => { + it('maps a well-powered control onto the equivalent decision', () => { + const result = evaluateControlEquivalence({ + seedSuccessRates: ratesAround(0.25, 0.02, 75), + lower: 0.2, + upper: 0.3, + alpha: 0.05, + }); + expect(result.decision).toBe('equivalent'); + expect(result.tost.equivalent).toBe(true); + expect(result.minimumSeeds).toBe(MINIMUM_EQUIVALENCE_SEEDS); + }); + + it('maps an above-bound control onto not-equivalent', () => { + expect( + evaluateControlEquivalence({ + seedSuccessRates: ratesAround(0.35, 0.02, 75), + lower: 0.2, + upper: 0.3, + alpha: 0.05, + }).decision, + ).toBe('not-equivalent'); + }); + + it('never calls too few seeds not-equivalent', () => { + const result = evaluateControlEquivalence({ + seedSuccessRates: [0.25, 0.26], + lower: 0.2, + upper: 0.3, + alpha: 0.05, + }); + expect(result.decision).toBe('insufficient-seeds'); + }); +}); diff --git a/packages/analysis/__tests__/information.test.ts b/packages/analysis/__tests__/information.test.ts new file mode 100644 index 0000000..5d24b54 --- /dev/null +++ b/packages/analysis/__tests__/information.test.ts @@ -0,0 +1,311 @@ +/** + * ALD-033 — the discrete estimators E20 registers (SPECIFICATION.md §9.3 + * rule 7, §15.3; EXPERIMENT-NOTEBOOK.md E20). + * + * Known-answer vectors first (a uniform distribution's entropy, a perfectly + * dependent table's mutual information), then the two properties the E20 + * decision rule actually rests on: the within-outcome permutation null is + * calibrated on independent data, and every number is reproducible from the + * seed. + */ +import { describe, expect, it } from 'vitest'; + +import { SeededPrng } from '@ald/hashing'; + +import { AnalysisError } from '../src/errors.js'; +import { + conditionalMutualInformationBits, + millerMadowConditionalMutualInformationBits, + millerMadowEntropyBits, + millerMadowMutualInformationBits, + mutualInformationBits, + observedSupport, + permutationNullWithinStrata, + replicateQuantile, + seedBootstrapUpperBound, + shannonEntropyBits, + stratifiedJointCounts, + type StratifiedObservation, +} from '../src/information.js'; + +const LEVELS = { xLevels: 6, yLevels: 4, strata: 2 }; + +describe('ALD-033: shannonEntropyBits known answers', () => { + it('matches the closed form for uniform and degenerate distributions', () => { + expect(shannonEntropyBits([1, 1])).toBeCloseTo(1, 12); + expect(shannonEntropyBits([25, 25, 25, 25])).toBeCloseTo(2, 12); + expect(shannonEntropyBits([1, 1, 1, 1, 1, 1, 1, 1])).toBeCloseTo(3, 12); + expect(shannonEntropyBits([10, 0, 0, 0])).toBeCloseTo(0, 12); + expect(shannonEntropyBits([0, 0])).toBe(0); + // H(1/4, 3/4) = 0.8112781244591328 bits. + expect(shannonEntropyBits([1, 3])).toBeCloseTo(0.8112781244591328, 12); + }); + + it('reports the observed support and rejects malformed counts', () => { + expect(observedSupport([3, 0, 1, 0])).toBe(2); + expect(() => shannonEntropyBits([])).toThrow(AnalysisError); + expect(() => shannonEntropyBits([1, -1])).toThrow(AnalysisError); + expect(() => shannonEntropyBits([1, 1.5])).toThrow(AnalysisError); + }); +}); + +describe('ALD-033: Miller-Madow correction', () => { + it('adds exactly (K - 1) / (2 N ln 2) bits', () => { + const counts = [10, 20, 30, 0]; + const n = 60; + const support = 3; + expect(millerMadowEntropyBits(counts)).toBeCloseTo( + shannonEntropyBits(counts) + (support - 1) / (2 * n * Math.LN2), + 12, + ); + expect(millerMadowEntropyBits([5])).toBeCloseTo(0, 12); + expect(millerMadowEntropyBits([0, 0])).toBe(0); + }); + + it('corrects each of the three entropies of a mutual information separately', () => { + const joint = [ + [50, 0], + [0, 50], + ]; + const expected = + millerMadowEntropyBits([50, 50]) + + millerMadowEntropyBits([50, 50]) - + millerMadowEntropyBits([50, 0, 0, 50]); + expect(millerMadowMutualInformationBits(joint)).toBeCloseTo(expected, 12); + }); +}); + +describe('ALD-033: mutual and conditional mutual information known answers', () => { + it('is zero on an exactly independent table and one bit on a perfectly dependent one', () => { + expect( + mutualInformationBits([ + [25, 25], + [25, 25], + ]), + ).toBeCloseTo(0, 12); + expect( + mutualInformationBits([ + [50, 0], + [0, 50], + ]), + ).toBeCloseTo(1, 12); + expect( + mutualInformationBits([ + [10, 10, 10, 10], + [10, 10, 10, 10], + ]), + ).toBeCloseTo(0, 12); + }); + + it('weights strata by size for the conditional estimate', () => { + const dependent = [ + [50, 0], + [0, 50], + ]; + const independent = [ + [25, 25], + [25, 25], + ]; + expect(conditionalMutualInformationBits([dependent, dependent])).toBeCloseTo( + 1, + 12, + ); + expect( + conditionalMutualInformationBits([dependent, independent]), + ).toBeCloseTo(0.5, 12); + // A stratum with no observations contributes nothing at all. + expect( + conditionalMutualInformationBits([ + dependent, + [ + [0, 0], + [0, 0], + ], + ]), + ).toBeCloseTo(1, 12); + }); + + it('rejects ragged or empty tables', () => { + expect(() => mutualInformationBits([])).toThrow(AnalysisError); + expect(() => mutualInformationBits([[]])).toThrow(AnalysisError); + expect(() => mutualInformationBits([[1, 2], [3]])).toThrow(AnalysisError); + expect(() => millerMadowConditionalMutualInformationBits([])).toThrow( + AnalysisError, + ); + }); +}); + +describe('ALD-033: stratifiedJointCounts', () => { + it('tallies observations into one table per stratum', () => { + const observations: StratifiedObservation[] = [ + { x: 0, y: 0, stratum: 0 }, + { x: 0, y: 0, stratum: 0 }, + { x: 5, y: 3, stratum: 1 }, + ]; + const tables = stratifiedJointCounts(observations, LEVELS); + expect(tables).toHaveLength(2); + expect(tables[0]?.[0]?.[0]).toBe(2); + expect(tables[1]?.[5]?.[3]).toBe(1); + expect(tables[1]?.[0]?.[0]).toBe(0); + }); + + it('refuses an out-of-range level rather than dropping the observation', () => { + for (const bad of [ + { x: 6, y: 0, stratum: 0 }, + { x: 0, y: 4, stratum: 0 }, + { x: 0, y: 0, stratum: 2 }, + { x: -1, y: 0, stratum: 0 }, + { x: 0.5, y: 0, stratum: 0 }, + ]) { + expect(() => stratifiedJointCounts([bad], LEVELS)).toThrow(AnalysisError); + } + expect(() => + stratifiedJointCounts([], { xLevels: 0, yLevels: 4, strata: 2 }), + ).toThrow(AnalysisError); + }); +}); + +// --------------------------------------------------------------------------- +// Permutation null: calibration and reproducibility +// --------------------------------------------------------------------------- + +/** + * Independent affect and referent, but *both* correlated with the outcome — + * the case §9.3 rule 7 says to control for. A within-outcome permutation null + * must absorb that shared dependence, leaving an excess near zero. + */ +function outcomeConfoundedSample( + seed: string, + count: number, +): StratifiedObservation[] { + const prng = new SeededPrng(seed); + const observations: StratifiedObservation[] = []; + for (let index = 0; index < count; index += 1) { + const stratum = prng.nextInt(2); + const x = stratum === 1 ? prng.nextInt(3) : 3 + prng.nextInt(3); + const y = stratum === 1 ? prng.nextInt(2) : 2 + prng.nextInt(2); + observations.push({ x, y, stratum }); + } + return observations; +} + +describe('ALD-033: within-outcome permutation null', () => { + it('is calibrated on data where affect and referent are independent given the outcome', () => { + const observations = outcomeConfoundedSample('null-calibration', 3000); + const observed = millerMadowConditionalMutualInformationBits( + stratifiedJointCounts(observations, LEVELS), + ); + const nullResult = permutationNullWithinStrata(observations, LEVELS, { + permutations: 200, + seed: 'null-calibration/permutation', + }); + // The excess over the null mean is what E20 tests; under independence it + // sits far below the 0.02-bit bound. + expect(Math.abs(observed - nullResult.mean)).toBeLessThan(0.01); + expect(nullResult.replicates).toHaveLength(200); + }); + + it('separates a planted dependence from the null', () => { + const prng = new SeededPrng('planted'); + const observations: StratifiedObservation[] = []; + for (let index = 0; index < 2000; index += 1) { + const stratum = prng.nextInt(2); + const y = prng.nextInt(4); + // The display is a deterministic function of the referent: maximal leak. + observations.push({ x: y, y, stratum }); + } + const observed = millerMadowConditionalMutualInformationBits( + stratifiedJointCounts(observations, LEVELS), + ); + const nullResult = permutationNullWithinStrata(observations, LEVELS, { + permutations: 100, + seed: 'planted/permutation', + }); + expect(observed).toBeGreaterThan(1.9); + expect(nullResult.mean).toBeLessThan(0.05); + expect(observed - nullResult.mean).toBeGreaterThan(1.5); + }); + + it('is reproducible from the seed and sensitive to it', () => { + const observations = outcomeConfoundedSample('repro', 400); + const options = { permutations: 25, seed: 'stream-a' }; + const first = permutationNullWithinStrata(observations, LEVELS, options); + const again = permutationNullWithinStrata(observations, LEVELS, options); + expect(again.replicates).toEqual(first.replicates); + const other = permutationNullWithinStrata(observations, LEVELS, { + permutations: 25, + seed: 'stream-b', + }); + expect(other.replicates).not.toEqual(first.replicates); + }); + + it('refuses an unseeded or degenerate permutation request', () => { + const observations = outcomeConfoundedSample('domain', 10); + expect(() => + permutationNullWithinStrata(observations, LEVELS, { + permutations: 0, + seed: 'x', + }), + ).toThrow(AnalysisError); + expect(() => + permutationNullWithinStrata(observations, LEVELS, { + permutations: 5, + seed: '', + }), + ).toThrow(AnalysisError); + }); +}); + +describe('ALD-033: seedBootstrapUpperBound', () => { + it('returns a one-sided upper bound above the sample mean', () => { + const values = [0.001, -0.002, 0.004, 0.0, -0.001, 0.002, 0.003, -0.003]; + const bound = seedBootstrapUpperBound(values, { + seed: 'bootstrap-seed', + iterations: 2000, + level: 0.95, + }); + expect(bound.estimate).toBeCloseTo( + values.reduce((sum, value) => sum + value, 0) / values.length, + 12, + ); + expect(bound.upperBound).toBeGreaterThan(bound.estimate); + expect(bound.n).toBe(values.length); + expect(bound.level).toBe(0.95); + }); + + it('collapses to the value itself on a constant sample', () => { + const bound = seedBootstrapUpperBound([0.5, 0.5, 0.5], { + seed: 'constant', + iterations: 100, + }); + expect(bound.estimate).toBeCloseTo(0.5, 12); + expect(bound.upperBound).toBeCloseTo(0.5, 12); + }); + + it('is reproducible from the seed and rejects bad options', () => { + const values = [0.1, 0.2, 0.3]; + const options = { seed: 'repro', iterations: 500 }; + expect(seedBootstrapUpperBound(values, options)).toEqual( + seedBootstrapUpperBound(values, options), + ); + expect(() => seedBootstrapUpperBound([], options)).toThrow(AnalysisError); + expect(() => + seedBootstrapUpperBound(values, { seed: '', iterations: 10 }), + ).toThrow(AnalysisError); + expect(() => + seedBootstrapUpperBound(values, { seed: 's', iterations: 0 }), + ).toThrow(AnalysisError); + expect(() => + seedBootstrapUpperBound(values, { seed: 's', level: 1 }), + ).toThrow(AnalysisError); + }); + + it('reports replicate quantiles', () => { + const replicates = [5, 1, 4, 2, 3]; + expect(replicateQuantile(replicates, 0)).toBe(1); + expect(replicateQuantile(replicates, 1)).toBe(5); + expect(replicateQuantile(replicates, 0.5)).toBe(3); + expect(() => replicateQuantile(replicates, 1.5)).toThrow(AnalysisError); + expect(() => replicateQuantile([], 0.5)).toThrow(AnalysisError); + }); +}); diff --git a/packages/analysis/__tests__/intervention-analysis.test.ts b/packages/analysis/__tests__/intervention-analysis.test.ts new file mode 100644 index 0000000..e28f2fe --- /dev/null +++ b/packages/analysis/__tests__/intervention-analysis.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { + attributeHammingDistance, + betaBinomialAgreement, + checkHeldOutSplitIntegrity, + evaluateCheckpointDrift, + evaluateComposition, + messageEditDistance, + seedLevelAgreement, + symbolUsageDivergenceBits, + type CompositionEpisode, +} from '../src/index.js'; + +describe('composition and held-out evaluation', () => { + const episodes: CompositionEpisode[] = [ + { + split: 'train', + typeCode: 0, + attributes: [0, 0], + message: ['a', 'a'], + success: true, + seed: 's1', + }, + { + split: 'train', + typeCode: 1, + attributes: [0, 1], + message: ['a', 'b'], + success: true, + seed: 's2', + }, + { + split: 'held-out', + typeCode: 2, + attributes: [1, 0], + message: ['b', 'a'], + success: false, + seed: 's1', + }, + { + split: 'held-out', + typeCode: 3, + attributes: [1, 1], + message: ['b', 'b'], + success: true, + seed: 's2', + }, + ]; + + it('computes the registered distances and split-integrity result', () => { + expect(attributeHammingDistance([0, 1, 2], [0, 2, 3])).toBe(2); + expect(messageEditDistance(['a', 'b'], ['a', 'c', 'b'])).toBe(1); + expect( + checkHeldOutSplitIntegrity({ episodes, heldOutTypeCodes: [2, 3] }), + ).toMatchObject({ intact: true, violations: [] }); + }); + + it('reports separate deterministic metrics without a composition verdict', () => { + const input = { + episodes, + heldOutTypeCodes: [2, 3], + seed: 'composition-test', + permutations: 50, + orderProbes: [ + { baselineSuccess: true, reorderedSuccess: false }, + { baselineSuccess: true, reorderedSuccess: true }, + ], + } as const; + const result = evaluateComposition(input); + + expect(result.seen.summary.proportion).toBe(1); + expect(result.heldOut.summary.proportion).toBe(0.5); + expect(result.splitIntegrity.intact).toBe(true); + expect(result.topographic.degenerate).toBe(false); + expect(result.symbolReuse).toMatchObject({ + distinctForms: 2, + distinctMessages: 4, + formsSharedAcrossMessages: 2, + }); + expect(result.orderSensitivity?.difference).toBe(0.5); + expect(evaluateComposition(input)).toEqual(result); + expect(result).not.toHaveProperty('isCompositional'); + }); +}); + +describe('checkpoint drift', () => { + it('uses bounded Jensen-Shannon divergence for identical and disjoint usage', () => { + expect(symbolUsageDivergenceBits([4, 0], [8, 0])).toBeCloseTo(0); + expect(symbolUsageDivergenceBits([4, 0], [0, 8])).toBeCloseTo(1); + }); + + it('separates within-regime drift from a declared distribution shift', () => { + const result = evaluateCheckpointDrift({ + distributionShiftAt: [15], + checkpoints: [ + { + checkpointSequence: 0, + turn: 0, + success: { successes: 5, n: 10 }, + vocabularySize: 2, + meaningChanges: 0, + messageEntropyBits: 1, + symbolUsage: [10, 0], + }, + { + checkpointSequence: 1, + turn: 10, + success: { successes: 6, n: 10 }, + vocabularySize: 2, + meaningChanges: 1, + messageEntropyBits: 1, + symbolUsage: [20, 0], + }, + { + checkpointSequence: 2, + turn: 20, + success: { successes: 4, n: 10 }, + vocabularySize: 3, + meaningChanges: 4, + messageEntropyBits: 0.8, + symbolUsage: [0, 20], + }, + ], + }); + + expect(result.pairs.map((pair) => pair.driftScoreBits)).toEqual([0, 1]); + expect(result.pairs.map((pair) => pair.spansDistributionShift)).toEqual([ + false, true, + ]); + expect(result.stabilityIntervals.map((interval) => interval.checkpoints)).toEqual([ + 2, 1, + ]); + expect(result.regimeSeparation).toMatchObject({ + meanDifferenceBits: 1, + decision: 'separated', + }); + }); +}); + +describe('seed-clustered intervention inference', () => { + const seeds = [ + { seed: 's1', agreements: 8, probes: 10 }, + { seed: 's2', agreements: 9, probes: 10 }, + { seed: 's3', agreements: 7, probes: 10 }, + { seed: 's4', agreements: 8, probes: 10 }, + { seed: 's5', agreements: 9, probes: 10 }, + ]; + + it('treats seeds, rather than pooled probes, as the inferential units', () => { + const result = seedLevelAgreement({ + seeds, + chanceRate: 0.25, + minimumSeeds: 5, + bootstrap: { seed: 'seed-level', iterations: 100 }, + }); + + expect(result.seedCount).toBe(5); + expect(result.seedMean).toBeCloseTo(0.82); + expect(result.meetsSeedMinimum).toBe(true); + expect(result.decision).toBe('above-chance'); + expect(result.seedMeanBootstrap?.iterations).toBe(100); + }); + + it('fits and bootstraps the hierarchical Bernoulli model deterministically', () => { + const input = { + seeds, + chanceRate: 0.25, + minimumSeeds: 5, + bootstrap: { seed: 'beta-binomial', iterations: 25 }, + } as const; + const result = betaBinomialAgreement(input); + + expect(result.mu).toBeGreaterThan(0.7); + expect(result.totalProbes).toBe(50); + expect(result.totalAgreements).toBe(41); + expect(result.bootstrap?.iterations).toBe(25); + expect(betaBinomialAgreement(input)).toEqual(result); + }); +}); diff --git a/packages/analysis/__tests__/registration-packet.test.ts b/packages/analysis/__tests__/registration-packet.test.ts new file mode 100644 index 0000000..4196fa2 --- /dev/null +++ b/packages/analysis/__tests__/registration-packet.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { + REGISTRATION_BINDING_KEYS, + compileRegistrationPacket, + type CompileRegistrationPacketInput, +} from '../src/index.js'; + +function input(): CompileRegistrationPacketInput { + return { + experimentId: 'E16', + registrationClass: 'confirmatory', + bindings: { + protocolCard: { version: 1, sha256: 'a'.repeat(64) }, + runConfigurations: [{ condition: 'normal', sha256: 'b'.repeat(64) }], + practicalMargins: { h2Probability: 0.05, h4Brier: 0.02 }, + analysisVersions: ['e16-causal-prediction-pipeline/v1'], + modelAssets: [{ role: 'receiver', sha256: 'c'.repeat(64) }], + selectedSeedPrefix: { stage: 'confirmatory', primary: ['seed-001'], reserve: ['seed-002'] }, + executionHost: { manifestSha256: 'd'.repeat(64), topology: 'mode-r' }, + scenarioBundle: { sha256: 'e'.repeat(64), splits: ['train', 'validation', 'test'] }, + exclusionRules: ['integrity failure', 'configuration mismatch'], + stoppingRules: { outcomeDependent: false, maximumTurns: 1000 }, + evidenceAndAnchorPolicy: { chainId: 8453, confirmations: 20, verifier: 'ald-verify' }, + }, + }; +} + +describe('canonical registration packet', () => { + it('binds every required operational field in fixed order', () => { + const result = compileRegistrationPacket(input()); + expect(result.artifact.bindings.map((binding) => binding.key)).toEqual(REGISTRATION_BINDING_KEYS); + expect(result.artifact.bindings.every((binding) => binding.sha256.startsWith('sha256:'))).toBe(true); + expect(JSON.parse(result.canonicalArtifact)).toEqual(result.artifact); + expect(result.preRegistrationHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(result.claimBoundary).toBe('draft-until-repository-registered-and-pre-run-committed'); + }); + + it('is deterministic and changes when any bound content changes', () => { + const first = compileRegistrationPacket(input()); + expect(compileRegistrationPacket(input())).toEqual(first); + const changed = input(); + changed.bindings.practicalMargins = { h2Probability: 0.06, h4Brier: 0.02 }; + expect(compileRegistrationPacket(changed).preRegistrationHash).not.toBe(first.preRegistrationHash); + }); + + it('rejects missing, extra, empty, and placeholder bindings', () => { + const missing = input(); + delete (missing.bindings as Partial).executionHost; + expect(() => compileRegistrationPacket(missing)).toThrow(/must contain exactly/u); + + const extra = input() as CompileRegistrationPacketInput & { bindings: Record }; + extra.bindings['outcomes'] = ['forbidden']; + expect(() => compileRegistrationPacket(extra)).toThrow(/must contain exactly/u); + + const empty = input(); + empty.bindings.analysisVersions = []; + expect(() => compileRegistrationPacket(empty)).toThrow(/empty array/u); + + const placeholder = input(); + placeholder.bindings.executionHost = { manifestSha256: 'TBD' }; + expect(() => compileRegistrationPacket(placeholder)).toThrow(/placeholder/u); + }); + + it('rejects invalid experiment identity and non-finite numeric fields', () => { + expect(() => compileRegistrationPacket({ ...input(), experimentId: 'experiment-16' })).toThrow(/form E00/u); + const invalidNumber = input(); + invalidNumber.bindings.stoppingRules = { maximumTurns: Number.NaN }; + expect(() => compileRegistrationPacket(invalidNumber)).toThrow(/finite/u); + }); +}); diff --git a/packages/analysis/__tests__/special.test.ts b/packages/analysis/__tests__/special.test.ts new file mode 100644 index 0000000..92f34a5 --- /dev/null +++ b/packages/analysis/__tests__/special.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnalysisError, + binomialLogPmf, + logBeta, + logBinomialCoefficient, + logGamma, + normalCdf, + normalPdf, + normalQuantile, + regularizedIncompleteBeta, + regularizedLowerGamma, + regularizedUpperGamma, + studentTCdf, + studentTQuantile, +} from '../src/index.js'; + +describe('logGamma', () => { + it('matches closed-form values', () => { + expect(logGamma(0.5)).toBeCloseTo(Math.log(Math.sqrt(Math.PI)), 12); + expect(logGamma(1)).toBeCloseTo(0, 12); + expect(logGamma(2)).toBeCloseTo(0, 12); + // log(5!) = log(120) + expect(logGamma(6)).toBeCloseTo(Math.log(120), 10); + }); + + it('rejects a non-positive argument', () => { + expect(() => logGamma(0)).toThrow(AnalysisError); + expect(() => logGamma(-1)).toThrow(/x > 0/); + }); +}); + +describe('logBeta and logBinomialCoefficient', () => { + it('matches closed-form values', () => { + // B(2, 3) = 1/12 + expect(Math.exp(logBeta(2, 3))).toBeCloseTo(1 / 12, 12); + expect(Math.exp(logBinomialCoefficient(10, 3))).toBeCloseTo(120, 8); + expect(logBinomialCoefficient(5, 0)).toBeCloseTo(0, 12); + }); + + it('rejects k outside 0..n', () => { + expect(() => logBinomialCoefficient(5, 6)).toThrow(AnalysisError); + expect(() => logBinomialCoefficient(5, -1)).toThrow(AnalysisError); + }); +}); + +describe('regularizedIncompleteBeta', () => { + it('reproduces the exact value I_0.5(2, 3) = 11/16', () => { + expect(regularizedIncompleteBeta(0.5, 2, 3)).toBeCloseTo(0.6875, 12); + }); + + it('is a CDF: 0 at 0, 1 at 1, and symmetric under the argument swap', () => { + expect(regularizedIncompleteBeta(0, 2, 3)).toBe(0); + expect(regularizedIncompleteBeta(1, 2, 3)).toBe(1); + // I_x(a, b) = 1 - I_{1-x}(b, a) on both sides of the continued-fraction swap + for (const x of [0.05, 0.25, 0.5, 0.75, 0.95]) { + expect(regularizedIncompleteBeta(x, 3.5, 0.5)).toBeCloseTo( + 1 - regularizedIncompleteBeta(1 - x, 0.5, 3.5), + 12, + ); + } + }); + + it('rejects out-of-domain arguments', () => { + expect(() => regularizedIncompleteBeta(1.5, 2, 3)).toThrow(AnalysisError); + expect(() => regularizedIncompleteBeta(0.5, 0, 3)).toThrow(AnalysisError); + }); +}); + +describe('incomplete gamma', () => { + it('splits unity between the lower and upper branches', () => { + for (const x of [0.1, 0.5, 1, 2, 5, 20]) { + expect( + regularizedLowerGamma(0.5, x) + regularizedUpperGamma(0.5, x), + ).toBeCloseTo(1, 12); + } + }); +}); + +describe('normalCdf and normalQuantile', () => { + it('is exactly 0.5 at zero and symmetric', () => { + expect(normalCdf(0)).toBe(0.5); + expect(normalCdf(-1.5)).toBeCloseTo(1 - normalCdf(1.5), 14); + }); + + it('matches the standard 95% two-sided critical value', () => { + expect(normalQuantile(0.975)).toBeCloseTo(1.959964, 6); + expect(normalQuantile(0.025)).toBeCloseTo(-1.959964, 6); + expect(normalCdf(1.959963984540054)).toBeCloseTo(0.975, 12); + }); + + it('round-trips across the tails to double precision', () => { + for (const p of [1e-12, 1e-6, 0.01, 0.2, 0.5, 0.8, 0.99, 1 - 1e-9]) { + expect(normalCdf(normalQuantile(p))).toBeCloseTo(p, 12); + } + }); + + it('returns the infinite quantiles at the boundary and rejects outside', () => { + expect(normalQuantile(0)).toBe(-Infinity); + expect(normalQuantile(1)).toBe(Infinity); + expect(() => normalQuantile(1.1)).toThrow(AnalysisError); + expect(normalPdf(0)).toBeCloseTo(1 / Math.sqrt(2 * Math.PI), 14); + }); +}); + +describe('studentTCdf', () => { + it('matches the t(10) 97.5% point', () => { + expect(studentTCdf(2.228139, 10)).toBeCloseTo(0.975, 4); + }); + + it('is exactly 0.5 at t = 0 for every df', () => { + for (const df of [1, 2, 4, 10, 74, 1000]) { + expect(studentTCdf(0, df)).toBe(0.5); + } + }); + + it('is symmetric and approaches the normal for large df', () => { + expect(studentTCdf(-1.7, 12)).toBeCloseTo(1 - studentTCdf(1.7, 12), 12); + expect(studentTCdf(1.96, 1_000_000)).toBeCloseTo(normalCdf(1.96), 5); + }); + + it('rejects df <= 0', () => { + expect(() => studentTCdf(1, 0)).toThrow(AnalysisError); + }); +}); + +describe('studentTQuantile', () => { + it('matches the textbook t(10) 97.5% critical value', () => { + expect(studentTQuantile(0.975, 10)).toBeCloseTo(2.228139, 4); + }); + + it('is exactly zero at the median and mirrors below it', () => { + expect(studentTQuantile(0.5, 7)).toBe(0); + expect(studentTQuantile(0.05, 7)).toBeCloseTo( + -studentTQuantile(0.95, 7), + 12, + ); + }); + + it('inverts studentTCdf to 1e-10 across df', () => { + for (const df of [1, 3, 10, 74, 300]) { + for (const p of [0.01, 0.25, 0.6, 0.9, 0.999]) { + const recovered = studentTCdf(studentTQuantile(p, df), df); + expect(Math.abs(recovered - p)).toBeLessThanOrEqual(1e-10); + } + } + }); +}); + +describe('binomialLogPmf', () => { + it('matches a hand-computed pmf and sums to one', () => { + // C(4, 2) * 0.5^4 = 6/16 + expect(Math.exp(binomialLogPmf(2, 4, 0.5))).toBeCloseTo(0.375, 12); + let total = 0; + for (let k = 0; k <= 20; k += 1) { + total += Math.exp(binomialLogPmf(k, 20, 0.25)); + } + expect(total).toBeCloseTo(1, 12); + }); + + it('handles the degenerate success probabilities', () => { + expect(binomialLogPmf(0, 5, 0)).toBe(0); + expect(binomialLogPmf(1, 5, 0)).toBe(-Infinity); + expect(binomialLogPmf(5, 5, 1)).toBe(0); + expect(binomialLogPmf(4, 5, 1)).toBe(-Infinity); + }); + + it('rejects impossible counts', () => { + expect(() => binomialLogPmf(6, 5, 0.5)).toThrow(AnalysisError); + expect(() => binomialLogPmf(1.5, 5, 0.5)).toThrow(AnalysisError); + }); +}); diff --git a/packages/analysis/package.json b/packages/analysis/package.json new file mode 100644 index 0000000..eeebe91 --- /dev/null +++ b/packages/analysis/package.json @@ -0,0 +1,22 @@ +{ + "name": "@ald/analysis", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/lifecycle": "*" + } +} diff --git a/packages/analysis/src/affect-leakage.ts b/packages/analysis/src/affect-leakage.ts new file mode 100644 index 0000000..55e95d9 --- /dev/null +++ b/packages/analysis/src/affect-leakage.ts @@ -0,0 +1,412 @@ +/** + * The pre-registered affect-channel leakage evaluation (SPECIFICATION.md + * §9.3 rule 7 and §15.3; EXPERIMENT-NOTEBOOK.md E20; ALD-033). + * + * SPEC §9.3 rule 7 is the requirement: + * + * > Analysis (§15.3) MUST test mutual information between affect choice and + * > referent/task state, controlling for the stated emotional context; any + * > unexpected correlation is treated as suspected leakage and reported as + * > such, never silently dropped. + * + * E20 fixes the estimator and the decision rule: + * + * > Estimate conditional mutual information between affect and four-way + * > referent after stratifying by binary success/failure outcome, using a + * > Miller-Madow bias-corrected discrete estimator and within-outcome + * > permutation null. For each seed, subtract the mean of 1,000 within-outcome + * > permutations from the observed Miller-Madow estimate; test whether the + * > seed-level one-sided 95% Student-t upper bound on this excess CMI is below + * > 0.02 bits. Retain the percentile seed bootstrap as sensitivity analysis. + * + * {@link evaluateAffectLeakage} implements exactly that and returns a + * canonical object ready to be written as a bundle attachment of kind + * `affect-leakage` (docs/evidence-bundle-format.md §10). It does not write the + * attachment, does not touch the Evidence Store, and does not interpret the + * result: `decision` is the mechanical outcome of the pre-registered rule + * applied to the numbers, and `suspectedLeakage` is the §9.3 rule 7 reporting + * obligation — never a claim that a run did or did not leak. + * + * Honesty constraints encoded here: + * + * - a seed with too few eligible windows is reported as ineligible and + * excluded from the bound, never quietly padded or included; + * - `meetsE20SeedCount` / `meetsE20WindowCount` say whether the input reaches + * E20's registered scale (75 seeds, 1,000 windows per seed). A Mode P + * readiness run over a handful of windows will report `false` for both, and + * the decision must not be presented as an E20 result; + * - per-seed excesses above the bound are always listed, even when the + * aggregate bound passes, so an "unexpected correlation" cannot vanish into + * an aggregate. + */ +import { AFFECT_DISPLAY_IDS, type AffectDisplayId } from '@ald/types'; + +import { AnalysisError, assertLevel, assertProbability } from './errors.js'; +import { + millerMadowConditionalMutualInformationBits, + permutationNullWithinStrata, + replicateQuantile, + seedBootstrapUpperBound, + stratifiedJointCounts, + type StratifiedObservation, +} from './information.js'; +import { studentTQuantile } from './special.js'; + +/** Version stamped on the attachment; bump on any estimator change. */ +export const AFFECT_LEAKAGE_ANALYSIS_VERSION = 'affect-leakage-v2'; + +/** The estimator E20 registers, named in the output for reproducibility. */ +export const AFFECT_LEAKAGE_ESTIMATOR = + 'miller-madow-conditional-mutual-information-stratified-by-outcome'; + +/** E20: the excess-CMI bound in bits. */ +export const E20_EXCESS_CMI_BOUND_BITS = 0.02; +/** E20: within-outcome permutations per seed. */ +export const E20_PERMUTATIONS = 1_000; +/** E20: eligible affect windows required per seed. */ +export const E20_MINIMUM_WINDOWS_PER_SEED = 1_000; +/** E20: independent seeds required per enabled affect condition. */ +export const E20_MINIMUM_SEEDS = 75; +/** E20: the referent is four-way. */ +export const E20_REFERENT_LEVELS = 4; +/** SPEC §9.3: the display allowlist has six members. */ +export const AFFECT_DISPLAY_LEVELS = AFFECT_DISPLAY_IDS.length; +/** SPEC §15.3 α. */ +export const DEFAULT_ALPHA = 0.05; +/** Strata: binary success/failure outcome. */ +const OUTCOME_STRATA = 2; +/** Decimals every reported number is rounded to, for canonical stability. */ +const ANALYSIS_DECIMALS = 12; + +/** One eligible affect window, as recorded by the Gateway and the runtime. */ +export interface AffectLeakageWindow { + /** The sender's chosen display (`AffectEvent.displayId`). */ + displayId: AffectDisplayId; + /** Zero-based referent type code for the turn the window followed. */ + referentTypeCode: number; + /** The turn's binary outcome; the stratifying variable. */ + success: boolean; +} + +export interface AffectLeakageSeedInput { + /** Run seed label; only used for reporting and stream derivation. */ + seed: string; + windows: readonly AffectLeakageWindow[]; +} + +export interface AffectLeakageInput { + perSeed: readonly AffectLeakageSeedInput[]; + /** Analysis seed; every permutation and bootstrap stream derives from it. */ + seed: string; + /** Default {@link E20_PERMUTATIONS}. */ + permutations?: number; + /** Default {@link E20_EXCESS_CMI_BOUND_BITS}. */ + bound?: number; + /** Default {@link DEFAULT_ALPHA}. */ + alpha?: number; + /** Default {@link E20_REFERENT_LEVELS}. */ + referentLevels?: number; + /** Default {@link E20_MINIMUM_WINDOWS_PER_SEED}. */ + minimumWindowsPerSeed?: number; + /** Bootstrap resamples over seeds; default 10,000. */ + bootstrapIterations?: number; +} + +export interface AffectLeakageSeedResult { + seed: string; + windows: number; + /** False when `windows < minimumWindowsPerSeed`; excluded from the bound. */ + eligible: boolean; + /** Miller-Madow CMI of the observed data, in bits. */ + observedCmiBits: number; + /** Mean of the within-outcome permutation null, in bits. */ + permutationMeanBits: number; + /** `observed − null mean`: E20's excess CMI, in bits. */ + excessCmiBits: number; + /** The `1 − α` quantile of the null replicates, in bits. */ + permutationUpperQuantileBits: number; + /** True when the observed CMI exceeds that null quantile. */ + exceedsPermutationNull: boolean; + /** True when this seed's excess alone exceeds the bound. */ + excessAboveBound: boolean; +} + +export type AffectLeakageDecision = + | 'below-bound' + | 'not-below-bound' + | 'insufficient-seeds' + | 'insufficient-windows'; + +export interface AffectLeakageResult { + analysisVersion: typeof AFFECT_LEAKAGE_ANALYSIS_VERSION; + estimator: typeof AFFECT_LEAKAGE_ESTIMATOR; + displayLevels: number; + referentLevels: number; + outcomeStrata: number; + permutations: number; + boundBits: number; + alpha: number; + minimumWindowsPerSeed: number; + seed: string; + bootstrapIterations: number; + seeds: number; + eligibleSeeds: number; + totalWindows: number; + perSeed: AffectLeakageSeedResult[]; + /** Mean excess CMI over eligible seeds, in bits; 0 when none are eligible. */ + meanExcessCmiBits: number; + /** One-sided `1 − α` upper bound on the mean excess, in bits. */ + excessCmiUpperBoundBits: number; + /** Percentile seed-bootstrap sensitivity estimate; never the primary gate. */ + bootstrapSensitivity: { + estimate: number; + upperBoundBits: number; + iterations: number; + }; + decision: AffectLeakageDecision; + /** + * SPEC §9.3 rule 7: set whenever the pre-registered rule did not clear the + * bound. It is a reporting obligation, not a finding. + */ + suspectedLeakage: boolean; + /** Seeds whose own excess exceeded the bound, always reported. */ + seedsAboveBound: number; + /** Seeds whose observed CMI exceeded their own null `1 − α` quantile. */ + seedsExceedingNull: number; + /** Whether the input reaches E20's registered seed count. */ + meetsE20SeedCount: boolean; + /** Whether every eligible seed reaches E20's registered window count. */ + meetsE20WindowCount: boolean; +} + +function round(value: number): number { + if (!Number.isFinite(value)) { + throw new AnalysisError('domain', 'estimator produced a non-finite value'); + } + const factor = 10 ** ANALYSIS_DECIMALS; + return Math.round(value * factor) / factor; +} + +function displayIndex(displayId: AffectDisplayId, where: string): number { + const index = (AFFECT_DISPLAY_IDS as readonly string[]).indexOf(displayId); + if (index < 0) { + throw new AnalysisError( + 'domain', + `${where}.displayId must be one of the six allowlisted displays`, + ); + } + return index; +} + +function toObservations( + windows: readonly AffectLeakageWindow[], + referentLevels: number, + seed: string, +): StratifiedObservation[] { + return windows.map((window, index) => { + const where = `perSeed[${seed}].windows[${index}]`; + const referent = window.referentTypeCode; + if ( + !Number.isInteger(referent) || + referent < 0 || + referent >= referentLevels + ) { + throw new AnalysisError( + 'domain', + `${where}.referentTypeCode must be an integer within [0, ${referentLevels - 1}]`, + ); + } + if (typeof window.success !== 'boolean') { + throw new AnalysisError('domain', `${where}.success must be a boolean`); + } + return { + x: displayIndex(window.displayId, where), + y: referent, + stratum: window.success ? 1 : 0, + }; + }); +} + +/** + * Runs the E20 estimator over per-seed affect windows. + * + * Determinism: every permutation stream is + * `seed → 'affect-leakage' → 'permutation' → ` and the bootstrap + * stream is `seed → 'affect-leakage' → 'bootstrap'`, so two calls with the + * same input and seed return byte-identical canonical JSON. + * + * The input is never modified, and no window is ever excluded except by the + * declared `minimumWindowsPerSeed` eligibility rule. + */ +export function evaluateAffectLeakage( + input: AffectLeakageInput, +): AffectLeakageResult { + if (typeof input.seed !== 'string' || input.seed.length === 0) { + throw new AnalysisError('domain', 'seed must be a non-empty string'); + } + if (input.perSeed.length === 0) { + throw new AnalysisError('empty-sample', 'perSeed must not be empty'); + } + const permutations = input.permutations ?? E20_PERMUTATIONS; + const bound = input.bound ?? E20_EXCESS_CMI_BOUND_BITS; + const alpha = input.alpha ?? DEFAULT_ALPHA; + const referentLevels = input.referentLevels ?? E20_REFERENT_LEVELS; + const minimumWindowsPerSeed = + input.minimumWindowsPerSeed ?? E20_MINIMUM_WINDOWS_PER_SEED; + const bootstrapIterations = input.bootstrapIterations ?? 10_000; + + if (!Number.isInteger(permutations) || permutations < 1) { + throw new AnalysisError('domain', 'permutations must be a positive integer'); + } + if (!Number.isFinite(bound) || bound < 0) { + throw new AnalysisError('domain', 'bound must be a non-negative number'); + } + assertProbability(alpha, 'alpha'); + assertLevel(1 - alpha, 'one-sided level'); + if (!Number.isInteger(referentLevels) || referentLevels < 2) { + throw new AnalysisError( + 'domain', + 'referentLevels must be an integer of at least 2', + ); + } + if (!Number.isInteger(minimumWindowsPerSeed) || minimumWindowsPerSeed < 1) { + throw new AnalysisError( + 'domain', + 'minimumWindowsPerSeed must be a positive integer', + ); + } + if (new Set(input.perSeed.map((entry) => entry.seed)).size !== input.perSeed.length) { + throw new AnalysisError('domain', 'perSeed seed labels must be unique'); + } + + const levels = { + xLevels: AFFECT_DISPLAY_LEVELS, + yLevels: referentLevels, + strata: OUTCOME_STRATA, + }; + const permutationRoot = `${input.seed}/affect-leakage/permutation`; + + const perSeed: AffectLeakageSeedResult[] = []; + let totalWindows = 0; + + for (const entry of input.perSeed) { + if (typeof entry.seed !== 'string' || entry.seed.length === 0) { + throw new AnalysisError('domain', 'perSeed[].seed must be a non-empty string'); + } + const observations = toObservations(entry.windows, referentLevels, entry.seed); + totalWindows += observations.length; + const eligible = observations.length >= minimumWindowsPerSeed; + + if (observations.length === 0) { + perSeed.push({ + seed: entry.seed, + windows: 0, + eligible: false, + observedCmiBits: 0, + permutationMeanBits: 0, + excessCmiBits: 0, + permutationUpperQuantileBits: 0, + exceedsPermutationNull: false, + excessAboveBound: false, + }); + continue; + } + + const observed = millerMadowConditionalMutualInformationBits( + stratifiedJointCounts(observations, levels), + ); + const nullResult = permutationNullWithinStrata(observations, levels, { + permutations, + seed: `${permutationRoot}/${entry.seed}`, + }); + const excess = observed - nullResult.mean; + const upperQuantile = replicateQuantile(nullResult.replicates, 1 - alpha); + + perSeed.push({ + seed: entry.seed, + windows: observations.length, + eligible, + observedCmiBits: round(observed), + permutationMeanBits: round(nullResult.mean), + excessCmiBits: round(excess), + permutationUpperQuantileBits: round(upperQuantile), + exceedsPermutationNull: observed > upperQuantile, + excessAboveBound: excess > bound, + }); + } + + const eligible = perSeed.filter((result) => result.eligible); + const excesses = eligible.map((result) => result.excessCmiBits); + + let meanExcess = 0; + let upperBound = 0; + let bootstrapEstimate = 0; + let bootstrapUpperBound = 0; + let decision: AffectLeakageDecision = 'insufficient-windows'; + if (excesses.length > 0) { + const bootstrap = seedBootstrapUpperBound(excesses, { + seed: `${input.seed}/affect-leakage/bootstrap`, + iterations: bootstrapIterations, + level: 1 - alpha, + }); + bootstrapEstimate = round(bootstrap.estimate); + bootstrapUpperBound = round(bootstrap.upperBound); + meanExcess = bootstrapEstimate; + if (excesses.length >= 2) { + const squared = excesses.reduce( + (sum, value) => sum + (value - meanExcess) ** 2, + 0, + ); + const standardDeviation = Math.sqrt(squared / (excesses.length - 1)); + upperBound = round( + meanExcess + + studentTQuantile(1 - alpha, excesses.length - 1) * + (standardDeviation / Math.sqrt(excesses.length)), + ); + } else { + upperBound = meanExcess; + } + decision = + eligible.length < E20_MINIMUM_SEEDS + ? 'insufficient-seeds' + : upperBound < bound + ? 'below-bound' + : 'not-below-bound'; + } + + return { + analysisVersion: AFFECT_LEAKAGE_ANALYSIS_VERSION, + estimator: AFFECT_LEAKAGE_ESTIMATOR, + displayLevels: AFFECT_DISPLAY_LEVELS, + referentLevels, + outcomeStrata: OUTCOME_STRATA, + permutations, + boundBits: bound, + alpha, + minimumWindowsPerSeed, + seed: input.seed, + bootstrapIterations, + seeds: perSeed.length, + eligibleSeeds: eligible.length, + totalWindows, + perSeed, + meanExcessCmiBits: meanExcess, + excessCmiUpperBoundBits: upperBound, + bootstrapSensitivity: { + estimate: bootstrapEstimate, + upperBoundBits: bootstrapUpperBound, + iterations: bootstrapIterations, + }, + decision, + suspectedLeakage: decision !== 'below-bound', + seedsAboveBound: perSeed.filter((result) => result.excessAboveBound).length, + seedsExceedingNull: perSeed.filter((result) => result.exceedsPermutationNull) + .length, + meetsE20SeedCount: eligible.length >= E20_MINIMUM_SEEDS, + meetsE20WindowCount: + eligible.length > 0 && + eligible.every( + (result) => result.windows >= E20_MINIMUM_WINDOWS_PER_SEED, + ), + }; +} diff --git a/packages/analysis/src/bootstrap.ts b/packages/analysis/src/bootstrap.ts new file mode 100644 index 0000000..b134a2f --- /dev/null +++ b/packages/analysis/src/bootstrap.ts @@ -0,0 +1,167 @@ +/** + * Percentile bootstrap over seed-level outcomes (RESEARCH.md Appendix D §D.6 + * item 2 "Oracle adequacy": lower bound of the two-sided 95% bootstrap + * interval for mean seed-level oracle success; item 3 "Oracle separation": + * paired seed-level oracle-minus-control differences). + * + * Randomness comes only from `SeededPrng` (@ald/hashing), so a bootstrap is a + * pure function of `(values, seed, iterations, confidence)` and replays + * exactly from the registered seed manifest (SPECIFICATION.md §14.3, + * Appendix D §D.4). + */ +import { SeededPrng } from '@ald/hashing'; + +import { + AnalysisError, + assertLevel, + assertSample, +} from './errors.js'; +import { + mean as sampleMean, + quantileSorted, + type ConfidenceInterval, +} from './descriptive.js'; + +export interface BootstrapOptions { + /** Required: the toolkit never draws unseeded randomness. */ + readonly seed: string; + /** Resample count; Appendix D-scale analyses use the 10000 default. */ + readonly iterations?: number; + /** Two-sided coverage of the returned interval. */ + readonly confidence?: number; +} + +export interface BootstrapCi extends ConfidenceInterval { + /** Observed statistic on the original sample (not the replicate mean). */ + readonly estimate: number; + readonly n: number; + readonly iterations: number; + readonly seed: string; +} + +const DEFAULT_ITERATIONS = 10_000; +const DEFAULT_CONFIDENCE = 0.95; + +interface ResolvedOptions { + readonly seed: string; + readonly iterations: number; + readonly confidence: number; +} + +function resolveOptions(options: BootstrapOptions): ResolvedOptions { + const iterations = options.iterations ?? DEFAULT_ITERATIONS; + const confidence = options.confidence ?? DEFAULT_CONFIDENCE; + if (typeof options.seed !== 'string' || options.seed.length === 0) { + throw new AnalysisError('domain', 'seed must be a non-empty string'); + } + if (!Number.isInteger(iterations) || iterations < 1) { + throw new AnalysisError('domain', 'iterations must be a positive integer'); + } + assertLevel(confidence, 'confidence'); + return { seed: options.seed, iterations, confidence }; +} + +/** + * Bootstrap replicate means, in draw order. Resampling draws `n` indices with + * replacement per replicate from a single `SeededPrng` stream, so the whole + * sequence is fixed by the seed. + */ +export function bootstrapMeanReplicates( + values: readonly number[], + options: BootstrapOptions, +): number[] { + assertSample(values, 'values'); + const resolved = resolveOptions(options); + const prng = new SeededPrng(resolved.seed); + const n = values.length; + const replicates = new Array(resolved.iterations); + for (let replicate = 0; replicate < resolved.iterations; replicate += 1) { + let total = 0; + for (let draw = 0; draw < n; draw += 1) { + total += values[prng.nextInt(n)] as number; + } + replicates[replicate] = total / n; + } + return replicates; +} + +/** + * Bootstrap replicate means of the paired differences `a[i] - b[i]`. Pairs are + * resampled as units, which is what a paired seed-level contrast requires + * (Appendix D §D.6 item 3: the same scenario seed is used in every condition, + * so oracle and control outcomes are paired by seed slot). + */ +export function bootstrapPairedDifferenceReplicates( + a: readonly number[], + b: readonly number[], + options: BootstrapOptions, +): number[] { + assertSample(a, 'a'); + assertSample(b, 'b'); + if (a.length !== b.length) { + throw new AnalysisError( + 'length-mismatch', + 'paired samples must have equal length', + ); + } + const differences = a.map((value, index) => value - (b[index] as number)); + return bootstrapMeanReplicates(differences, options); +} + +/** + * Percentile interval from replicates: the `(1 - level) / 2` and + * `1 - (1 - level) / 2` empirical quantiles (type-7 interpolation). + */ +export function percentileInterval( + replicates: readonly number[], + level: number, +): ConfidenceInterval { + assertSample(replicates, 'replicates'); + assertLevel(level, 'level'); + const sorted = [...replicates].sort((left, right) => left - right); + const tail = (1 - level) / 2; + return { + lower: quantileSorted(sorted, tail), + upper: quantileSorted(sorted, 1 - tail), + level, + }; +} + +/** Percentile bootstrap confidence interval for the mean of `values`. */ +export function bootstrapMeanCi( + values: readonly number[], + options: BootstrapOptions, +): BootstrapCi { + const resolved = resolveOptions(options); + const replicates = bootstrapMeanReplicates(values, options); + const interval = percentileInterval(replicates, resolved.confidence); + return { + ...interval, + estimate: sampleMean(values), + n: values.length, + iterations: resolved.iterations, + seed: resolved.seed, + }; +} + +/** + * Percentile bootstrap confidence interval for the mean paired difference + * `a - b` (Appendix D §D.6 "Oracle separation"). + */ +export function bootstrapPairedDifferenceCi( + a: readonly number[], + b: readonly number[], + options: BootstrapOptions, +): BootstrapCi { + const resolved = resolveOptions(options); + const replicates = bootstrapPairedDifferenceReplicates(a, b, options); + const interval = percentileInterval(replicates, resolved.confidence); + const differences = a.map((value, index) => value - (b[index] as number)); + return { + ...interval, + estimate: sampleMean(differences), + n: differences.length, + iterations: resolved.iterations, + seed: resolved.seed, + }; +} diff --git a/packages/analysis/src/carrier-capacity.ts b/packages/analysis/src/carrier-capacity.ts new file mode 100644 index 0000000..7b100b9 --- /dev/null +++ b/packages/analysis/src/carrier-capacity.ts @@ -0,0 +1,110 @@ +import type { RunConfig } from '@ald/types'; + +import { AnalysisError } from './errors.js'; + +const BITMAP_FORMS = 2n ** 256n; +const CANVAS_STROKE_FORMS = 16n ** 4n * 3n; +const TONE_FORMS = 8n * 4n; + +export interface CarrierCapacityInput { + carrier: RunConfig['carrierMode']; + formCount: number; + marksPerMessage: number; + maxStrokes?: number; +} + +export interface CarrierCapacity { + carrier: RunConfig['carrierMode']; + /** Exact count of artifacts admitted by the declared physical grammar. */ + physicalGrammarForms: string; + /** log2 of `physicalGrammarForms`, rounded to 12 decimal places. */ + physicalGrammarBits: number; + /** Fixed learner action-bank capacity; it does not expand after acquisition. */ + effectiveFormCount: number; + /** Maximum information in one bank-index message under a uniform code. */ + effectiveMessageBits: number; + marksPerMessage: number; +} + +function sumPowers(base: bigint, maximumExponent: number): bigint { + let total = 0n; + let term = 1n; + for (let exponent = 1; exponent <= maximumExponent; exponent += 1) { + term *= base; + total += term; + } + return total; +} + +/** Stable log2 for positive BigInts, including the 64-stroke canvas grammar. */ +function log2BigInt(value: bigint): number { + const bits = value.toString(2); + const precision = Math.min(53, bits.length); + const leading = Number.parseInt(bits.slice(0, precision), 2); + return bits.length - precision + Math.log2(leading); +} + +function rounded(value: number): number { + return Math.round(value * 1_000_000_000_000) / 1_000_000_000_000; +} + +/** + * Capacity accounting for E13. + * + * `physicalGrammarBits` describes everything the Gateway grammar could carry; + * `effectiveMessageBits` describes only the learner's fixed action bank. The + * two are intentionally separate: acquiring or modifying a form replaces a + * slot and cannot silently increase model capacity. + */ +export function carrierCapacity(input: CarrierCapacityInput): CarrierCapacity { + if (!Number.isInteger(input.formCount) || input.formCount < 2) { + throw new AnalysisError('domain', 'formCount must be an integer of at least 2'); + } + if (!Number.isInteger(input.marksPerMessage) || input.marksPerMessage < 1) { + throw new AnalysisError( + 'domain', + 'marksPerMessage must be a positive integer', + ); + } + + let physicalGrammarForms: bigint; + let effectiveMarks = input.marksPerMessage; + switch (input.carrier) { + case 'fixed-token': + case 'fixed-glyph': + physicalGrammarForms = BigInt(input.formCount) ** + BigInt(input.marksPerMessage); + break; + case 'generative-bitmap': + physicalGrammarForms = BITMAP_FORMS; + effectiveMarks = 1; + break; + case 'generative-canvas': { + const maximum = input.maxStrokes ?? 8; + if (!Number.isInteger(maximum) || maximum < 1 || maximum > 64) { + throw new AnalysisError( + 'domain', + 'maxStrokes must be an integer within [1, 64]', + ); + } + physicalGrammarForms = sumPowers(CANVAS_STROKE_FORMS, maximum); + effectiveMarks = 1; + break; + } + case 'generative-tone': + physicalGrammarForms = sumPowers(TONE_FORMS, 8); + effectiveMarks = 1; + break; + } + + return { + carrier: input.carrier, + physicalGrammarForms: physicalGrammarForms.toString(), + physicalGrammarBits: rounded(log2BigInt(physicalGrammarForms)), + effectiveFormCount: input.formCount, + effectiveMessageBits: rounded( + effectiveMarks * Math.log2(input.formCount), + ), + marksPerMessage: effectiveMarks, + }; +} diff --git a/packages/analysis/src/carrier-leakage.ts b/packages/analysis/src/carrier-leakage.ts new file mode 100644 index 0000000..9504af5 --- /dev/null +++ b/packages/analysis/src/carrier-leakage.ts @@ -0,0 +1,311 @@ +/** + * Offline alternate-carrier leakage evaluation (SPEC §9.2, §15.3; ALD-032). + * + * The evaluator operates on immutable, already accepted artifacts. It + * verifies their carrier-qualified mark hashes, derives only pre-declared + * structural features, and never returns a rewritten artifact. + */ +import { + AgentActionProposalSchema, + type AgentActionProposal, + type CarrierLeakageProbePlan, + type RunConfig, + type Sha256Hash, +} from '@ald/types'; +import { hashCarrierMark } from '@ald/hashing'; + +import { AnalysisError } from './errors.js'; + +export const CARRIER_LEAKAGE_ANALYSIS_VERSION = 'carrier-leakage-v2'; + +export type CarrierLeakageProbeDecision = 'pass' | 'fail' | 'inconclusive'; +export type RecognizableGlyphOutcome = + | 'recognizable' + | 'not-recognizable' + | 'unscored'; + +export interface CarrierLeakageObservation { + carrier: RunConfig['carrierMode']; + markHash: Sha256Hash; + artifact: AgentActionProposal['publicArtifact']; + /** Zero-based, opaque task/referent class used only by the offline probe. */ + referentTypeCode: number; +} + +export interface CarrierLeakageInput { + observations: readonly CarrierLeakageObservation[]; + probePlan: CarrierLeakageProbePlan; + /** External OCR/glyph-probe outcomes, keyed by carrier-qualified mark hash. */ + recognizableGlyphOutcomes: Readonly>; +} + +export type { CarrierLeakageProbePlan }; + +export interface CarrierMarkLeakageMetric { + carrier: RunConfig['carrierMode']; + markHash: Sha256Hash; + observations: number; + referentTypeCodes: number[]; + structuralFeatureSignature: string; + recognizableGlyphOutcome: RecognizableGlyphOutcome | 'not-applicable'; +} + +export interface CarrierLeakageProbeResult { + decision: CarrierLeakageProbeDecision; + observations: number; + assessedMarks: number; + metric: number; + bound: number; + reason: string; +} + +export interface CarrierFeatureUseDiagnostic { + status: 'estimated' | 'disabled' | 'insufficient-observations'; + observations: number; + assessedMarks: number; + mutualInformationBits: number; + reason: string; +} + +export interface CarrierLeakageResult { + analysisVersion: typeof CARRIER_LEAKAGE_ANALYSIS_VERSION; + observations: number; + uniqueMarks: number; + reuseRate: number; + markMetrics: CarrierMarkLeakageMetric[]; + recognizableGlyphProbe: CarrierLeakageProbeResult; + intendedCarrierFeatureUseDiagnostic: CarrierFeatureUseDiagnostic; + decision: CarrierLeakageProbeDecision; + artifactHashesVerified: true; + claimBoundary: { + ungroundedLanguageClaim: 'eligible' | 'blocked'; + runValidityImpact: 'none'; + evidenceUse: + | 'eligible-subject-to-other-gates' + | 'valid-negative-or-integrity-evidence'; + }; +} + +function assertProbability(value: number, name: string): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new AnalysisError('domain', `${name} must be within [0, 1]`); + } +} + +function proposalFor(observation: CarrierLeakageObservation): AgentActionProposal { + const kind = { + 'fixed-token': 'emit_symbols', + 'fixed-glyph': 'emit_glyphs', + 'generative-bitmap': 'emit_bitmap', + 'generative-canvas': 'emit_canvas', + 'generative-tone': 'emit_tones', + }[observation.carrier]; + return AgentActionProposalSchema.parse({ kind, publicArtifact: observation.artifact }); +} + +function round(value: number): number { + return Math.round(value * 1_000_000_000_000) / 1_000_000_000_000; +} + +function structuralFeature( + carrier: RunConfig['carrierMode'], + artifact: AgentActionProposal['publicArtifact'], +): string { + if (carrier === 'fixed-token' && 'symbols' in artifact) { + return `symbol-count:${String(artifact.symbols.length)}`; + } + if (carrier === 'fixed-glyph' && 'glyphs' in artifact) { + return `glyph-count:${String(artifact.glyphs.length)}`; + } + if (carrier === 'generative-bitmap' && 'bitmap' in artifact) { + const ink = artifact.bitmap.bits.filter((bit) => bit === 1).length; + return `ink-density-decile:${String(Math.min(9, Math.floor((ink / 256) * 10)))}`; + } + if (carrier === 'generative-canvas' && 'strokes' in artifact) { + const strokes = artifact.strokes; + const widthTotal = strokes.reduce((sum, stroke) => sum + stroke.width, 0); + const axisAligned = strokes.filter( + (stroke) => stroke.startX === stroke.endX || stroke.startY === stroke.endY, + ).length; + return ( + `stroke-count:${String(strokes.length)}|` + + `mean-width-quarter:${String(Math.round((widthTotal / strokes.length) * 4))}|` + + `axis-share-quarter:${String(Math.round((axisAligned / strokes.length) * 4))}` + ); + } + if (carrier === 'generative-tone' && 'tones' in artifact) { + const tones = artifact.tones.tones; + const pitchRange = + tones.length === 0 + ? 0 + : Math.max(...tones.map((tone) => tone.pitchBin)) - + Math.min(...tones.map((tone) => tone.pitchBin)); + const durationTotal = tones.reduce((sum, tone) => sum + tone.durationBin, 0); + return ( + `tone-count:${String(tones.length)}|pitch-range:${String(pitchRange)}|` + + `mean-duration-half:${String(tones.length === 0 ? 0 : Math.round((durationTotal / tones.length) * 2))}` + ); + } + throw new AnalysisError('domain', `artifact does not match carrier ${carrier}`); +} + +function mutualInformationBits( + observations: readonly { feature: string; referent: number }[], +): number { + const features = new Map(); + const referents = new Map(); + const joint = new Map(); + for (const observation of observations) { + features.set(observation.feature, (features.get(observation.feature) ?? 0) + 1); + referents.set(observation.referent, (referents.get(observation.referent) ?? 0) + 1); + const key = `${observation.feature}\u0000${String(observation.referent)}`; + joint.set(key, (joint.get(key) ?? 0) + 1); + } + const total = observations.length; + let information = 0; + for (const [key, count] of joint) { + const split = key.lastIndexOf('\u0000'); + const feature = key.slice(0, split); + const referent = Number(key.slice(split + 1)); + const pxy = count / total; + const px = (features.get(feature) ?? 0) / total; + const py = (referents.get(referent) ?? 0) / total; + information += pxy * Math.log2(pxy / (px * py)); + } + return round(information); +} + +/** Run both pre-registered probes without mutating any input artifact. */ +export function evaluateCarrierLeakage( + input: CarrierLeakageInput, +): CarrierLeakageResult { + if (input.observations.length === 0) { + throw new AnalysisError('empty-sample', 'observations must not be empty'); + } + assertProbability( + input.probePlan.recognizableGlyph.maximumRecognizableRate, + 'recognizableGlyph.maximumRecognizableRate', + ); + const featurePlan = input.probePlan.intendedCarrierFeatureUse; + if (!Number.isInteger(featurePlan.minimumObservations) || featurePlan.minimumObservations < 1) { + throw new AnalysisError( + 'domain', + 'intendedCarrierFeatureUse.minimumObservations must be a positive integer', + ); + } + + const grouped = new Map(); + const featureObservations: Array<{ feature: string; referent: number }> = []; + for (const [index, observation] of input.observations.entries()) { + if (!Number.isInteger(observation.referentTypeCode) || observation.referentTypeCode < 0) { + throw new AnalysisError( + 'domain', + `observations[${String(index)}].referentTypeCode must be a non-negative integer`, + ); + } + const proposal = proposalFor(observation); + const rebuilt = hashCarrierMark(observation.carrier, proposal.publicArtifact); + if (rebuilt !== observation.markHash) { + throw new AnalysisError( + 'domain', + `observations[${String(index)}].markHash does not match its carrier artifact`, + ); + } + const feature = structuralFeature(observation.carrier, proposal.publicArtifact); + featureObservations.push({ feature: `${observation.carrier}|${feature}`, referent: observation.referentTypeCode }); + const key = `${observation.carrier}|${observation.markHash}`; + const existing = grouped.get(key); + if (existing === undefined) { + grouped.set(key, { + carrier: observation.carrier, + markHash: observation.markHash, + observations: 1, + referentTypeCodes: [observation.referentTypeCode], + structuralFeatureSignature: feature, + recognizableGlyphOutcome: + observation.carrier === 'fixed-glyph' + ? (input.recognizableGlyphOutcomes[observation.markHash] ?? 'unscored') + : 'not-applicable', + }); + } else { + existing.observations += 1; + if (!existing.referentTypeCodes.includes(observation.referentTypeCode)) { + existing.referentTypeCodes.push(observation.referentTypeCode); + existing.referentTypeCodes.sort((a, b) => a - b); + } + } + } + + const markMetrics = [...grouped.values()].sort((left, right) => + `${left.carrier}|${left.markHash}`.localeCompare(`${right.carrier}|${right.markHash}`), + ); + const glyphMarks = markMetrics.filter((metric) => metric.carrier === 'fixed-glyph'); + const assessedGlyphs = glyphMarks.filter( + (metric) => metric.recognizableGlyphOutcome !== 'unscored', + ); + const recognizable = assessedGlyphs.filter( + (metric) => metric.recognizableGlyphOutcome === 'recognizable', + ).length; + const recognizableRate = + assessedGlyphs.length === 0 ? 0 : round(recognizable / assessedGlyphs.length); + const glyphComplete = glyphMarks.length > 0 && assessedGlyphs.length === glyphMarks.length; + const glyphDecision: CarrierLeakageProbeDecision = + !input.probePlan.recognizableGlyph.enabled || !glyphComplete + ? 'inconclusive' + : recognizableRate > input.probePlan.recognizableGlyph.maximumRecognizableRate + ? 'fail' + : 'pass'; + const recognizableGlyphProbe: CarrierLeakageProbeResult = { + decision: glyphDecision, + observations: glyphMarks.reduce((sum, metric) => sum + metric.observations, 0), + assessedMarks: assessedGlyphs.length, + metric: recognizableRate, + bound: input.probePlan.recognizableGlyph.maximumRecognizableRate, + reason: + glyphDecision === 'inconclusive' + ? 'probe disabled, no glyph marks, or at least one glyph mark was unscored' + : glyphDecision === 'fail' + ? 'recognizable glyph rate exceeds the pre-registered bound' + : 'recognizable glyph rate is within the pre-registered bound', + }; + + const featureMi = mutualInformationBits(featureObservations); + const featureStatus: CarrierFeatureUseDiagnostic['status'] = !featurePlan.enabled + ? 'disabled' + : input.observations.length < featurePlan.minimumObservations + ? 'insufficient-observations' + : 'estimated'; + const intendedCarrierFeatureUseDiagnostic: CarrierFeatureUseDiagnostic = { + status: featureStatus, + observations: input.observations.length, + assessedMarks: markMetrics.length, + mutualInformationBits: featureMi, + reason: + featureStatus === 'disabled' + ? 'diagnostic disabled by the pre-registered plan' + : featureStatus === 'insufficient-observations' + ? 'fewer than the pre-registered minimum observations were supplied' + : 'reports intended carrier form/referent association; it is not a side-channel test', + }; + + const decision = glyphDecision; + const eligible = decision === 'pass'; + return { + analysisVersion: CARRIER_LEAKAGE_ANALYSIS_VERSION, + observations: input.observations.length, + uniqueMarks: markMetrics.length, + reuseRate: round(1 - markMetrics.length / input.observations.length), + markMetrics, + recognizableGlyphProbe, + intendedCarrierFeatureUseDiagnostic, + decision, + artifactHashesVerified: true, + claimBoundary: { + ungroundedLanguageClaim: eligible ? 'eligible' : 'blocked', + runValidityImpact: 'none', + evidenceUse: eligible + ? 'eligible-subject-to-other-gates' + : 'valid-negative-or-integrity-evidence', + }, + }; +} diff --git a/packages/analysis/src/carrier-perception.ts b/packages/analysis/src/carrier-perception.ts new file mode 100644 index 0000000..28451ed --- /dev/null +++ b/packages/analysis/src/carrier-perception.ts @@ -0,0 +1,215 @@ +/** Handcrafted E13 perceptual-family diagnostics for generated carriers. */ +export const CARRIER_PERCEPTION_ANALYSIS_VERSION = 'carrier-perception/v1'; + +export type BitmapMark = { + readonly carrier: 'generative-bitmap'; + readonly bits: readonly (0 | 1)[]; +}; +export type CanvasStroke = { + readonly startX: number; + readonly startY: number; + readonly endX: number; + readonly endY: number; + readonly width: 1 | 2 | 3; +}; +export type CanvasMark = { + readonly carrier: 'generative-canvas'; + readonly strokes: readonly CanvasStroke[]; +}; +export type ToneMark = { + readonly carrier: 'generative-tone'; + readonly tones: readonly { readonly pitchBin: number; readonly durationBin: number }[]; +}; +export type PerceptualMark = BitmapMark | CanvasMark | ToneMark; + +export interface LabeledPerceptualMark { + readonly id: string; + readonly family: string; + readonly mark: PerceptualMark; +} + +export interface PerceptualGeneralizationResult { + readonly analysisVersion: typeof CARRIER_PERCEPTION_ANALYSIS_VERSION; + readonly carrier: PerceptualMark['carrier']; + readonly prototypeCount: number; + readonly queryCount: number; + readonly families: readonly string[]; + readonly exactNovelQueries: number; + readonly correct: number; + readonly accuracy: number; + readonly predictions: readonly { + readonly queryId: string; + readonly expectedFamily: string; + readonly predictedFamily: string; + readonly distance: number; + readonly exactPrototypeMatch: boolean; + }[]; + readonly claimBoundary: 'handcrafted-distance-diagnostic-only'; +} + +function fail(message: string): never { + throw new Error(`carrier-perception: ${message}`); +} + +function bitmap(mark: BitmapMark): boolean[] { + if (mark.bits.length !== 256 || mark.bits.some((bit) => bit !== 0 && bit !== 1)) { + fail('bitmap must contain exactly 256 binary cells'); + } + return mark.bits.map(Boolean); +} + +function canvas(mark: CanvasMark): boolean[] { + if (mark.strokes.length < 1 || mark.strokes.length > 8) fail('canvas must contain 1-8 strokes'); + const cells = new Array(256).fill(false); + const paint = (x: number, y: number, radius: number): void => { + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + const px = x + dx; + const py = y + dy; + if (px >= 0 && px < 16 && py >= 0 && py < 16) cells[py * 16 + px] = true; + } + } + }; + for (const stroke of mark.strokes) { + const values = [stroke.startX, stroke.startY, stroke.endX, stroke.endY]; + if (values.some((value) => !Number.isInteger(value) || value < 0 || value > 15) || ![1, 2, 3].includes(stroke.width)) fail('canvas stroke is outside the registered grammar'); + let x = stroke.startX; + let y = stroke.startY; + const dx = Math.abs(stroke.endX - stroke.startX); + const sx = stroke.startX < stroke.endX ? 1 : -1; + const dy = -Math.abs(stroke.endY - stroke.startY); + const sy = stroke.startY < stroke.endY ? 1 : -1; + let error = dx + dy; + while (true) { + paint(x, y, Math.floor(stroke.width / 2)); + if (x === stroke.endX && y === stroke.endY) break; + const twice = 2 * error; + if (twice >= dy) { error += dy; x += sx; } + if (twice <= dx) { error += dx; y += sy; } + } + } + return cells; +} + +function hamming(left: readonly boolean[], right: readonly boolean[]): number { + return left.filter((value, index) => value !== right[index]).length / left.length; +} + +function shiftedHamming( + left: readonly boolean[], + right: readonly boolean[], + shiftX: number, + shiftY: number, +): number { + let differences = 0; + for (let y = 0; y < 16; y += 1) { + for (let x = 0; x < 16; x += 1) { + const sourceX = x - shiftX; + const sourceY = y - shiftY; + const rightValue = sourceX >= 0 && sourceX < 16 && sourceY >= 0 && sourceY < 16 + ? (right[sourceY * 16 + sourceX] ?? false) + : false; + if ((left[y * 16 + x] ?? false) !== rightValue) differences += 1; + } + } + return differences / 256; +} + +function canvasDistance(left: CanvasMark, right: CanvasMark): number { + const leftRaster = canvas(left); + const rightRaster = canvas(right); + let minimum = 1; + for (let shiftY = -1; shiftY <= 1; shiftY += 1) { + for (let shiftX = -1; shiftX <= 1; shiftX += 1) { + minimum = Math.min(minimum, shiftedHamming(leftRaster, rightRaster, shiftX, shiftY)); + } + } + return minimum; +} + +function validateTone(mark: ToneMark): void { + if (mark.tones.length < 1 || mark.tones.length > 8) fail('tone marks must contain 1-8 tones'); + for (const tone of mark.tones) { + if ( + !Number.isInteger(tone.pitchBin) + || !Number.isInteger(tone.durationBin) + || tone.pitchBin < 0 + || tone.pitchBin > 7 + || tone.durationBin < 1 + || tone.durationBin > 4 + ) fail('tone is outside the registered grammar'); + } +} + +function toneDistance(left: ToneMark, right: ToneMark): number { + validateTone(left); + validateTone(right); + const width = right.tones.length + 1; + const costs = new Array((left.tones.length + 1) * width).fill(0); + for (let i = 0; i <= left.tones.length; i += 1) costs[i * width] = i; + for (let j = 0; j <= right.tones.length; j += 1) costs[j] = j; + for (let i = 1; i <= left.tones.length; i += 1) { + for (let j = 1; j <= right.tones.length; j += 1) { + const a = left.tones[i - 1]; + const b = right.tones[j - 1]; + if (a === undefined || b === undefined) fail('tone sequence index is invalid'); + const substitution = (Math.abs(a.pitchBin - b.pitchBin) / 7 + Math.abs(a.durationBin - b.durationBin) / 3) / 2; + costs[i * width + j] = Math.min( + (costs[(i - 1) * width + j] ?? 0) + 1, + (costs[i * width + j - 1] ?? 0) + 1, + (costs[(i - 1) * width + j - 1] ?? 0) + substitution, + ); + } + } + return (costs[left.tones.length * width + right.tones.length] ?? 0) / Math.max(left.tones.length, right.tones.length); +} + +export function carrierPerceptualDistance(left: PerceptualMark, right: PerceptualMark): number { + if (left.carrier !== right.carrier) fail('cross-carrier distance is undefined'); + switch (left.carrier) { + case 'generative-bitmap': return hamming(bitmap(left), bitmap(right as BitmapMark)); + case 'generative-canvas': return canvasDistance(left, right as CanvasMark); + case 'generative-tone': return toneDistance(left, right as ToneMark); + } +} + +export function evaluatePerceptualGeneralization(input: { + readonly prototypes: readonly LabeledPerceptualMark[]; + readonly queries: readonly LabeledPerceptualMark[]; +}): PerceptualGeneralizationResult { + if (input.prototypes.length < 2 || input.queries.length < 1) fail('at least two prototypes and one query are required'); + const carrier = input.prototypes[0]?.mark.carrier; + if (carrier === undefined || [...input.prototypes, ...input.queries].some((row) => row.id.length === 0 || row.family.length === 0 || row.mark.carrier !== carrier)) fail('IDs/families must be non-empty and carriers must match'); + const identifiers = [...input.prototypes, ...input.queries].map((row) => row.id); + if (new Set(identifiers).size !== identifiers.length) fail('prototype and query IDs must be unique'); + const families = [...new Set(input.prototypes.map((row) => row.family))].sort(); + if (families.length < 2 || input.queries.some((row) => !families.includes(row.family))) fail('at least two prototype families must cover every query'); + const predictions = input.queries.map((query) => { + const ranked = input.prototypes.map((prototype) => ({ + prototype, + distance: carrierPerceptualDistance(query.mark, prototype.mark), + })).sort((left, right) => left.distance - right.distance || left.prototype.family.localeCompare(right.prototype.family) || left.prototype.id.localeCompare(right.prototype.id)); + const nearest = ranked[0]; + if (nearest === undefined) fail('no prototype available'); + return { + queryId: query.id, + expectedFamily: query.family, + predictedFamily: nearest.prototype.family, + distance: nearest.distance, + exactPrototypeMatch: JSON.stringify(query.mark) === JSON.stringify(nearest.prototype.mark), + }; + }); + const correct = predictions.filter((row) => row.expectedFamily === row.predictedFamily).length; + return { + analysisVersion: CARRIER_PERCEPTION_ANALYSIS_VERSION, + carrier, + prototypeCount: input.prototypes.length, + queryCount: input.queries.length, + families, + exactNovelQueries: predictions.filter((row) => !row.exactPrototypeMatch).length, + correct, + accuracy: correct / predictions.length, + predictions, + claimBoundary: 'handcrafted-distance-diagnostic-only', + }; +} diff --git a/packages/analysis/src/causal-prediction.ts b/packages/analysis/src/causal-prediction.ts new file mode 100644 index 0000000..77e5611 --- /dev/null +++ b/packages/analysis/src/causal-prediction.ts @@ -0,0 +1,541 @@ +/** + * E16 validation-only non-ledger comparator selection and prospective scoring. + * + * The five eligible comparators consume only the information sets frozen in + * `protocols/causal-ledger-and-leakage.v1.json`. Model fitting and selection + * happen on two disjoint validation folds. The selected comparator is then + * refit on all validation rows, and its test predictions are hash-bound next + * to agent-native ledger predictions before this module accepts any test + * outcome. The oracle exists only after outcomes arrive and cannot enter the + * selectable comparator type. + */ +import { hashCanonical, isSha256Hash } from '@ald/hashing'; + +import { AnalysisError } from './errors.js'; + +export const CAUSAL_PREDICTION_PIPELINE_VERSION = + 'e16-causal-prediction-pipeline/v1'; + +export const NON_LEDGER_COMPARATOR_IDS = [ + 'uniform', + 'validation-majority', + 'transcript-only', + 'task-history', + 'policy-state', +] as const; + +export type NonLedgerComparatorId = + (typeof NON_LEDGER_COMPARATOR_IDS)[number]; + +export interface ComparatorInformation { + readonly publicTranscriptHistoryHash: string; + readonly publicTaskHistoryHash: string; + readonly frozenPolicyHash: string; + readonly permittedObservationHash: string; + readonly deliveredMessageHash: string; +} + +export interface PredictionCase { + readonly caseId: string; + readonly actionIds: readonly string[]; + readonly information: ComparatorInformation; +} + +export interface LabeledPredictionCase extends PredictionCase { + readonly targetActionId: string; +} + +export interface ProbabilityPrediction { + readonly caseId: string; + readonly actionIds: readonly string[]; + readonly probabilities: readonly number[]; +} + +interface UniformParameters { + readonly kind: 'uniform'; +} + +interface MarginalFrequencyParameters { + readonly kind: 'marginal-frequency'; + readonly probabilities: readonly number[]; +} + +interface ConditionalParameters { + readonly kind: 'conditional-frequency'; + readonly smoothing: 1; + readonly fallbackProbabilities: readonly number[]; + readonly probabilitiesByFeature: Readonly>; +} + +export interface ComparatorModel { + readonly pipelineVersion: string; + readonly comparatorId: NonLedgerComparatorId; + readonly actionIds: readonly string[]; + readonly fittedCaseIds: readonly string[]; + readonly trainingDataCommitment: string; + readonly parameters: + | UniformParameters + | MarginalFrequencyParameters + | ConditionalParameters; +} + +export interface ComparatorValidationScore { + readonly comparatorId: NonLedgerComparatorId; + readonly modelHash: string; + readonly meanBrierScore: number; + readonly selectionCases: number; +} + +export interface ComparatorSelection { + readonly pipelineVersion: string; + readonly selectionRule: 'minimum-validation-brier-then-fixed-order'; + readonly tieBreakOrder: readonly NonLedgerComparatorId[]; + readonly fitCaseIds: readonly string[]; + readonly selectionCaseIds: readonly string[]; + readonly candidateModels: readonly ComparatorModel[]; + readonly validationScores: readonly ComparatorValidationScore[]; + readonly selectedComparatorId: NonLedgerComparatorId; + readonly lockedModel: ComparatorModel; + readonly lockedModelHash: string; + readonly selectionCommitmentHash: string; +} + +export interface NativeLedgerPredictionSet { + readonly source: 'agent-native-ledger'; + readonly predictionFunctionVersion: string; + readonly sourceCommitment: string; + readonly predictions: readonly ProbabilityPrediction[]; +} + +export interface ProspectivePredictionCommitment { + readonly pipelineVersion: string; + readonly chronology: 'predictions-committed-before-test-outcomes'; + readonly selectedComparatorId: NonLedgerComparatorId; + readonly selectionCommitmentHash: string; + readonly lockedModelHash: string; + readonly testCaseCommitment: string; + readonly baselinePredictions: readonly ProbabilityPrediction[]; + readonly nativeLedger: NativeLedgerPredictionSet; + readonly commitmentHash: string; +} + +export interface PredictionOutcome { + readonly caseId: string; + readonly targetActionId: string; +} + +export interface ScoredPredictionCase { + readonly caseId: string; + readonly targetActionId: string; + readonly baselineBrierScore: number; + readonly nativeLedgerBrierScore: number; + readonly improvement: number; +} + +export interface CausalPredictionScore { + readonly pipelineVersion: string; + readonly selectedComparatorId: NonLedgerComparatorId; + readonly cases: readonly ScoredPredictionCase[]; + readonly meanBaselineBrierScore: number; + readonly meanNativeLedgerBrierScore: number; + /** Positive values favor the native-ledger predictor. */ + readonly meanImprovement: number; + /** Detector-positive diagnostic, formed only after outcomes are supplied. */ + readonly oracleDiagnosticMeanBrierScore: number; + readonly oracleEligibleForSelection: false; + readonly claimBoundary: 'software-qualification-or-registered-analysis-only'; +} + +const INFORMATION_KEYS = [ + 'deliveredMessageHash', + 'frozenPolicyHash', + 'permittedObservationHash', + 'publicTaskHistoryHash', + 'publicTranscriptHistoryHash', +] as const; + +function fail(message: string): never { + throw new AnalysisError('domain', message); +} + +function assertExactKeys( + value: object, + expected: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + fail(`${label} keys must be exactly ${wanted.join(', ')}`); + } +} + +function assertNonEmpty(value: string, label: string): void { + if (typeof value !== 'string' || value.length === 0) fail(`${label} must be non-empty`); +} + +function assertInformation( + information: ComparatorInformation, + label: string, +): void { + if (typeof information !== 'object' || information === null) fail(`${label} must be an object`); + assertExactKeys(information, INFORMATION_KEYS, label); + for (const key of INFORMATION_KEYS) { + if (!isSha256Hash(information[key])) fail(`${label}.${key} must be a SHA-256 hash`); + } +} + +function assertPredictionCase( + row: PredictionCase, + expectedActionIds?: readonly string[], +): void { + if (typeof row !== 'object' || row === null) fail('prediction case must be an object'); + assertExactKeys(row, ['actionIds', 'caseId', 'information'], `case ${String(row.caseId)}`); + assertNonEmpty(row.caseId, 'caseId'); + if (!Array.isArray(row.actionIds) || row.actionIds.length < 2) fail(`${row.caseId}.actionIds must contain at least two actions`); + const unique = new Set(row.actionIds); + if (unique.size !== row.actionIds.length) fail(`${row.caseId}.actionIds must be unique`); + row.actionIds.forEach((action, index) => assertNonEmpty(action, `${row.caseId}.actionIds[${String(index)}]`)); + if (expectedActionIds !== undefined && (row.actionIds.length !== expectedActionIds.length || row.actionIds.some((action, index) => action !== expectedActionIds[index]))) { + fail(`${row.caseId}.actionIds must match the registered action order`); + } + assertInformation(row.information, `${row.caseId}.information`); +} + +function assertLabeledCase( + row: LabeledPredictionCase, + expectedActionIds?: readonly string[], +): void { + if (typeof row !== 'object' || row === null) fail('labeled case must be an object'); + assertExactKeys(row, ['actionIds', 'caseId', 'information', 'targetActionId'], `labeled case ${String(row.caseId)}`); + const unlabeled: PredictionCase = { + caseId: row.caseId, + actionIds: row.actionIds, + information: row.information, + }; + assertPredictionCase(unlabeled, expectedActionIds); + if (!row.actionIds.includes(row.targetActionId)) fail(`${row.caseId}.targetActionId is outside actionIds`); +} + +function assertDisjointUnique( + fit: readonly LabeledPredictionCase[], + selection: readonly LabeledPredictionCase[], +): void { + if (fit.length === 0 || selection.length === 0) fail('both validation folds must be non-empty'); + const seen = new Set(); + for (const row of [...fit, ...selection]) { + if (seen.has(row.caseId)) fail(`duplicate validation caseId ${row.caseId}`); + seen.add(row.caseId); + } +} + +function featureKey( + comparatorId: Exclude, + row: PredictionCase, +): string { + switch (comparatorId) { + case 'transcript-only': + return row.information.publicTranscriptHistoryHash; + case 'task-history': + return row.information.publicTaskHistoryHash; + case 'policy-state': + return [ + row.information.frozenPolicyHash, + row.information.permittedObservationHash, + row.information.deliveredMessageHash, + ].join('|'); + } +} + +function normalizeCounts(counts: readonly number[]): number[] { + const denominator = counts.reduce((sum, value) => sum + value, 0); + return counts.map((value) => value / denominator); +} + +function comparatorTrainingView( + comparatorId: NonLedgerComparatorId, + rows: readonly LabeledPredictionCase[], + actionIds: readonly string[], +): object { + const common = (row: LabeledPredictionCase) => ({ + caseId: row.caseId, + actionIds: row.actionIds, + targetActionId: row.targetActionId, + }); + switch (comparatorId) { + case 'uniform': + return { actionIds }; + case 'validation-majority': + return rows.map(common); + case 'transcript-only': + return rows.map((row) => ({ + ...common(row), + publicTranscriptHistoryHash: row.information.publicTranscriptHistoryHash, + })); + case 'task-history': + return rows.map((row) => ({ + ...common(row), + publicTaskHistoryHash: row.information.publicTaskHistoryHash, + })); + case 'policy-state': + return rows.map((row) => ({ + ...common(row), + frozenPolicyHash: row.information.frozenPolicyHash, + permittedObservationHash: row.information.permittedObservationHash, + deliveredMessageHash: row.information.deliveredMessageHash, + })); + } +} + +function fitComparator( + comparatorId: NonLedgerComparatorId, + rows: readonly LabeledPredictionCase[], + actionIds: readonly string[], +): ComparatorModel { + const base = { + pipelineVersion: CAUSAL_PREDICTION_PIPELINE_VERSION, + comparatorId, + actionIds: [...actionIds], + fittedCaseIds: comparatorId === 'uniform' ? [] : rows.map((row) => row.caseId).sort(), + trainingDataCommitment: hashCanonical( + 'dtsf-e16-validation-data-v1', + comparatorTrainingView(comparatorId, rows, actionIds), + ), + }; + if (comparatorId === 'uniform') return { ...base, parameters: { kind: 'uniform' } }; + + const marginal = actionIds.map((action) => rows.filter((row) => row.targetActionId === action).length); + if (comparatorId === 'validation-majority') { + return { + ...base, + parameters: { + kind: 'marginal-frequency', + probabilities: normalizeCounts(marginal.map((count) => count + 1)), + }, + }; + } + + const grouped = new Map(); + for (const row of rows) { + const key = featureKey(comparatorId, row); + const counts = grouped.get(key) ?? actionIds.map(() => 1); + const targetIndex = actionIds.indexOf(row.targetActionId); + counts[targetIndex] = (counts[targetIndex] ?? 0) + 1; + grouped.set(key, counts); + } + const probabilitiesByFeature = Object.fromEntries( + [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([key, counts]) => [key, normalizeCounts(counts)]), + ); + return { + ...base, + parameters: { + kind: 'conditional-frequency', + smoothing: 1, + fallbackProbabilities: normalizeCounts(marginal.map((count) => count + 1)), + probabilitiesByFeature, + }, + }; +} + +function modelHash(model: ComparatorModel): string { + return hashCanonical('dtsf-e16-comparator-model-v1', model); +} + +function predict(model: ComparatorModel, row: PredictionCase): ProbabilityPrediction { + assertPredictionCase(row, model.actionIds); + const parameters = model.parameters; + let probabilities: readonly number[]; + switch (parameters.kind) { + case 'uniform': + probabilities = model.actionIds.map(() => 1 / model.actionIds.length); + break; + case 'marginal-frequency': + probabilities = parameters.probabilities; + break; + case 'conditional-frequency': { + const comparatorId = model.comparatorId; + if (comparatorId === 'uniform' || comparatorId === 'validation-majority') fail('conditional model has an incompatible comparator ID'); + probabilities = parameters.probabilitiesByFeature[featureKey(comparatorId, row)] ?? parameters.fallbackProbabilities; + break; + } + } + return { caseId: row.caseId, actionIds: [...row.actionIds], probabilities: [...probabilities] }; +} + +export function multiclassBrierScore( + prediction: ProbabilityPrediction, + targetActionId: string, +): number { + if (prediction.actionIds.length !== prediction.probabilities.length || prediction.actionIds.length < 2) fail('prediction action/probability lengths must match'); + const targetIndex = prediction.actionIds.indexOf(targetActionId); + if (targetIndex < 0) fail(`target ${targetActionId} is outside prediction actionIds`); + let sum = 0; + let probabilitySum = 0; + prediction.probabilities.forEach((probability, index) => { + if (!Number.isFinite(probability) || probability < 0 || probability > 1) fail('prediction probabilities must be finite and within [0, 1]'); + probabilitySum += probability; + const observed = index === targetIndex ? 1 : 0; + sum += (probability - observed) ** 2; + }); + if (Math.abs(probabilitySum - 1) > 1e-10) fail('prediction probabilities must sum to one'); + return sum; +} + +function meanBrier( + model: ComparatorModel, + rows: readonly LabeledPredictionCase[], +): number { + return rows.reduce((sum, row) => sum + multiclassBrierScore(predict(model, { + caseId: row.caseId, + actionIds: row.actionIds, + information: row.information, + }), row.targetActionId), 0) / rows.length; +} + +function selectionPayload(selection: Omit): object { + return selection; +} + +export function selectNonLedgerComparator(input: { + readonly fitCases: readonly LabeledPredictionCase[]; + readonly selectionCases: readonly LabeledPredictionCase[]; +}): ComparatorSelection { + assertDisjointUnique(input.fitCases, input.selectionCases); + const actionIds = input.fitCases[0]?.actionIds; + if (actionIds === undefined) fail('fitCases must be non-empty'); + input.fitCases.forEach((row) => assertLabeledCase(row, actionIds)); + input.selectionCases.forEach((row) => assertLabeledCase(row, actionIds)); + + const candidateModels = NON_LEDGER_COMPARATOR_IDS.map((id) => fitComparator(id, input.fitCases, actionIds)); + const validationScores = candidateModels.map((model) => ({ + comparatorId: model.comparatorId, + modelHash: modelHash(model), + meanBrierScore: meanBrier(model, input.selectionCases), + selectionCases: input.selectionCases.length, + })); + const selectedComparatorId = [...validationScores].sort((left, right) => { + const difference = left.meanBrierScore - right.meanBrierScore; + return difference === 0 + ? NON_LEDGER_COMPARATOR_IDS.indexOf(left.comparatorId) - NON_LEDGER_COMPARATOR_IDS.indexOf(right.comparatorId) + : difference; + })[0]?.comparatorId; + if (selectedComparatorId === undefined) fail('no comparator was selected'); + const lockedModel = fitComparator(selectedComparatorId, [...input.fitCases, ...input.selectionCases], actionIds); + const withoutHash = { + pipelineVersion: CAUSAL_PREDICTION_PIPELINE_VERSION, + selectionRule: 'minimum-validation-brier-then-fixed-order' as const, + tieBreakOrder: NON_LEDGER_COMPARATOR_IDS, + fitCaseIds: input.fitCases.map((row) => row.caseId).sort(), + selectionCaseIds: input.selectionCases.map((row) => row.caseId).sort(), + candidateModels, + validationScores, + selectedComparatorId, + lockedModel, + lockedModelHash: modelHash(lockedModel), + }; + return { + ...withoutHash, + selectionCommitmentHash: hashCanonical('dtsf-e16-comparator-selection-v1', selectionPayload(withoutHash)), + }; +} + +function assertPrediction( + prediction: ProbabilityPrediction, + expectedCase: PredictionCase, +): void { + assertExactKeys(prediction, ['actionIds', 'caseId', 'probabilities'], `prediction ${String(prediction.caseId)}`); + if (prediction.caseId !== expectedCase.caseId) fail(`prediction case mismatch for ${expectedCase.caseId}`); + multiclassBrierScore(prediction, expectedCase.actionIds[0] as string); + if (prediction.actionIds.some((action, index) => action !== expectedCase.actionIds[index])) fail(`prediction action order mismatch for ${expectedCase.caseId}`); +} + +export function commitProspectivePredictions(input: { + readonly selection: ComparatorSelection; + readonly testCases: readonly PredictionCase[]; + readonly nativeLedger: NativeLedgerPredictionSet; +}): ProspectivePredictionCommitment { + if (input.testCases.length === 0) fail('testCases must be non-empty'); + const expectedSelectionHash = hashCanonical('dtsf-e16-comparator-selection-v1', selectionPayload({ + pipelineVersion: input.selection.pipelineVersion, + selectionRule: input.selection.selectionRule, + tieBreakOrder: input.selection.tieBreakOrder, + fitCaseIds: input.selection.fitCaseIds, + selectionCaseIds: input.selection.selectionCaseIds, + candidateModels: input.selection.candidateModels, + validationScores: input.selection.validationScores, + selectedComparatorId: input.selection.selectedComparatorId, + lockedModel: input.selection.lockedModel, + lockedModelHash: input.selection.lockedModelHash, + })); + if (expectedSelectionHash !== input.selection.selectionCommitmentHash) fail('comparator selection commitment does not verify'); + if (modelHash(input.selection.lockedModel) !== input.selection.lockedModelHash) fail('locked comparator model hash does not verify'); + assertExactKeys( + input.nativeLedger, + ['predictionFunctionVersion', 'predictions', 'source', 'sourceCommitment'], + 'nativeLedger', + ); + if (input.nativeLedger.source !== 'agent-native-ledger') fail('native ledger source must be agent-native-ledger'); + assertNonEmpty(input.nativeLedger.predictionFunctionVersion, 'predictionFunctionVersion'); + if (!isSha256Hash(input.nativeLedger.sourceCommitment)) fail('native ledger sourceCommitment must be a SHA-256 hash'); + + const actionIds = input.selection.lockedModel.actionIds; + const seen = new Set(); + input.testCases.forEach((row) => { + assertPredictionCase(row, actionIds); + if (seen.has(row.caseId)) fail(`duplicate test caseId ${row.caseId}`); + seen.add(row.caseId); + }); + if (input.nativeLedger.predictions.length !== input.testCases.length) fail('native-ledger prediction count must match test cases'); + input.nativeLedger.predictions.forEach((prediction, index) => assertPrediction(prediction, input.testCases[index] as PredictionCase)); + const baselinePredictions = input.testCases.map((row) => predict(input.selection.lockedModel, row)); + const withoutHash = { + pipelineVersion: CAUSAL_PREDICTION_PIPELINE_VERSION, + chronology: 'predictions-committed-before-test-outcomes' as const, + selectedComparatorId: input.selection.selectedComparatorId, + selectionCommitmentHash: input.selection.selectionCommitmentHash, + lockedModelHash: input.selection.lockedModelHash, + testCaseCommitment: hashCanonical('dtsf-e16-test-cases-v1', input.testCases), + baselinePredictions, + nativeLedger: input.nativeLedger, + }; + return { + ...withoutHash, + commitmentHash: hashCanonical('dtsf-e16-prediction-commitment-v1', withoutHash), + }; +} + +export function scoreCommittedPredictions(input: { + readonly commitment: ProspectivePredictionCommitment; + readonly outcomes: readonly PredictionOutcome[]; +}): CausalPredictionScore { + const { commitmentHash, ...withoutHash } = input.commitment; + if (hashCanonical('dtsf-e16-prediction-commitment-v1', withoutHash) !== commitmentHash) fail('prediction commitment does not verify'); + if (input.outcomes.length !== input.commitment.baselinePredictions.length || input.outcomes.length !== input.commitment.nativeLedger.predictions.length) fail('outcome count must match committed predictions'); + const cases = input.outcomes.map((outcome, index) => { + assertExactKeys(outcome, ['caseId', 'targetActionId'], `outcome ${String(outcome.caseId)}`); + const baseline = input.commitment.baselinePredictions[index] as ProbabilityPrediction; + const native = input.commitment.nativeLedger.predictions[index] as ProbabilityPrediction; + if (outcome.caseId !== baseline.caseId || outcome.caseId !== native.caseId) fail(`outcome order does not match commitment at ${outcome.caseId}`); + const baselineBrierScore = multiclassBrierScore(baseline, outcome.targetActionId); + const nativeLedgerBrierScore = multiclassBrierScore(native, outcome.targetActionId); + return { + caseId: outcome.caseId, + targetActionId: outcome.targetActionId, + baselineBrierScore, + nativeLedgerBrierScore, + improvement: baselineBrierScore - nativeLedgerBrierScore, + }; + }); + const mean = (values: readonly number[]): number => values.reduce((sum, value) => sum + value, 0) / values.length; + return { + pipelineVersion: CAUSAL_PREDICTION_PIPELINE_VERSION, + selectedComparatorId: input.commitment.selectedComparatorId, + cases, + meanBaselineBrierScore: mean(cases.map((row) => row.baselineBrierScore)), + meanNativeLedgerBrierScore: mean(cases.map((row) => row.nativeLedgerBrierScore)), + meanImprovement: mean(cases.map((row) => row.improvement)), + oracleDiagnosticMeanBrierScore: 0, + oracleEligibleForSelection: false, + claimBoundary: 'software-qualification-or-registered-analysis-only', + }; +} diff --git a/packages/analysis/src/composition.ts b/packages/analysis/src/composition.ts new file mode 100644 index 0000000..7e9455f --- /dev/null +++ b/packages/analysis/src/composition.ts @@ -0,0 +1,848 @@ +/** + * E15 composition and held-out generalization readout + * (EXPERIMENT-NOTEBOOK.md E15; SPECIFICATION.md §15.2, §15.3; BACKLOG + * ALD-072, ALD-074 acceptance criterion 3). + * + * E15's procedure names exactly what this module computes, and nothing more: + * + * - "Hold out pre-registered combinations from training" — {@link + * checkHeldOutSplitIntegrity} is the mechanical check that no held-out type + * code appears in a training episode (E15 result checkbox "Held-out split + * integrity verified"). A violation is reported, never repaired. + * - "Compare task success, topographic measures, and behavioral composition" — + * seen versus held-out success with intervals and Cohen's h, a Mantel-style + * topographic similarity with a seeded permutation null, symbol-reuse + * counts, and order sensitivity from the E16 reorder probes. + * - "Run the confirmatory 32-symbol/4-token and 128-symbol/8-token conditions + * … Use at least 75 independent seeds per condition … Treat any additional + * bandwidth or model-capacity levels as exploratory" — the bandwidth + * contrast carries an `inferenceClass` of `confirmatory` only when both + * conditions reach the pre-registered seed floor, and `exploratory` + * otherwise, with a reason code. + * - "Avoid declaring compositionality from one metric" — every metric is a + * separate field and none of them is combined into a verdict. This module + * returns numbers; the E15 checkboxes are the researcher's to tick + * (ALD-072 acceptance criterion 3). + * + * All randomness comes from `SeededPrng`, so a readout replays exactly from + * its recorded seed (SPEC §14.3). + */ +import { SeededPrng } from '@ald/hashing'; + +import { + bootstrapPairedDifferenceCi, + type BootstrapCi, +} from './bootstrap.js'; +import { + proportion, + wilsonInterval, + type ProportionSummary, + type WilsonInterval, +} from './descriptive.js'; +import { cohensH } from './effects.js'; +import { + AnalysisError, + assertCount, + assertLevel, +} from './errors.js'; +import { welchTTest, type WelchTTestResult } from './hierarchical.js'; + +/** Analysis version stamped on the E15 readout. */ +export const COMPOSITION_ANALYSIS_VERSION = 'composition/v1'; + +/** + * E15: "Use at least 75 independent seeds per condition"; anything below that + * is exploratory by pre-registration, not by judgement. + */ +export const E15_CONFIRMATORY_MINIMUM_SEEDS = 75; + +/** Episodes are subsampled to this many before the O(n^2) Mantel test. */ +export const TOPOGRAPHIC_MAX_EPISODES = 100; + +const DEFAULT_PERMUTATIONS = 1_000; + +/** + * One evaluation episode as the analysis sees it. `attributes` and `typeCode` + * are researcher-only ground truth (SPEC §9.6, §10.1) and never reach a Baby; + * `message` is the delivered form sequence — token ids for `fixed-token`, + * `markHash` values for a generative carrier — so this module is + * carrier-agnostic. + */ +export interface CompositionEpisode { + readonly split: 'train' | 'held-out'; + readonly typeCode: number; + readonly attributes: readonly number[]; + readonly message: readonly string[]; + readonly success: boolean; + /** Seed of the run the episode came from; required for the seed-level views. */ + readonly seed?: string; +} + +export interface SeedProportionInput { + readonly seed: string; + readonly successes: number; + readonly n: number; +} + +export interface BandwidthConditionInput { + /** Pre-registered label, e.g. `32-symbols-4-tokens`. */ + readonly label: string; + readonly symbolInventorySize: number; + readonly maxSymbolsPerMessage: number; + /** Per-seed held-out success, one entry per valid seed. */ + readonly perSeedHeldOutSuccess: readonly SeedProportionInput[]; +} + +export interface OrderProbeOutcome { + /** Success on the unprobed delivery of the same episode. */ + readonly baselineSuccess: boolean; + /** Success when the same message was delivered with its marks reordered. */ + readonly reorderedSuccess: boolean; +} + +export interface CompositionInput { + readonly episodes: readonly CompositionEpisode[]; + /** `RunConfig.interventionPlan.heldOutTypeCodes` (SPEC §18, E15). */ + readonly heldOutTypeCodes?: readonly number[]; + /** Seed for the permutation null and the paired bootstrap. */ + readonly seed: string; + readonly permutations?: number; + readonly confidence?: number; + readonly alpha?: number; + readonly orderProbes?: readonly OrderProbeOutcome[]; + readonly bandwidthConditions?: readonly BandwidthConditionInput[]; + readonly minimumSeedsForConfirmatory?: number; +} + +export type SplitIntegrityViolationCode = + | 'held-out-type-code-in-training' + | 'held-out-type-code-absent-from-evaluation' + | 'unknown-split-label'; + +export interface SplitIntegrityResult { + readonly heldOutTypeCodes: readonly number[]; + readonly trainingTypeCodes: readonly number[]; + readonly evaluatedTypeCodes: readonly number[]; + readonly violations: readonly { + readonly code: SplitIntegrityViolationCode; + readonly typeCode: number; + readonly episodes: number; + }[]; + readonly intact: boolean; +} + +export interface ProportionWithInterval { + readonly summary: ProportionSummary; + readonly wilson: WilsonInterval; +} + +export interface TopographicResult { + /** Spearman rank correlation between attribute and message distances. */ + readonly spearman: number; + readonly pairs: number; + readonly episodesUsed: number; + readonly episodesAvailable: number; + readonly permutations: number; + /** Share of permuted correlations at or above the observed one. */ + readonly permutationP: number; + /** Central `confidence` interval of the permuted (null) correlations. */ + readonly nullInterval: { readonly lower: number; readonly upper: number }; + readonly seed: string; + readonly degenerate: boolean; +} + +export interface SymbolReuseResult { + readonly episodes: number; + readonly distinctForms: number; + readonly distinctMessages: number; + readonly meanMessageLength: number; + /** Distinct messages per distinct referent type code. */ + readonly messagesPerTypeCode: number; + /** `1 - distinctMessages / episodes`: how often a message repeats at all. */ + readonly messageRepeatRate: number; + /** Mean uses per distinct form; 1.0 means every form was used once. */ + readonly formUsesPerDistinctForm: number; + /** Forms that occur in more than one distinct message (shared parts). */ + readonly formsSharedAcrossMessages: number; +} + +export interface OrderSensitivityResult { + readonly probes: number; + readonly baseline: ProportionWithInterval; + readonly reordered: ProportionWithInterval; + readonly difference: number; + readonly cohensH: number; +} + +export type BandwidthInferenceClass = 'confirmatory' | 'exploratory'; + +export interface BandwidthContrastResult { + readonly conditions: readonly { + readonly label: string; + readonly symbolInventorySize: number; + readonly maxSymbolsPerMessage: number; + readonly seeds: number; + readonly seedMean: number; + readonly pooledDescriptive: ProportionSummary; + }[]; + readonly seedLevelTest: WelchTTestResult; + /** §15.3 mandatory effect size on the pooled proportions. */ + readonly cohensH: number; + readonly alpha: number; + readonly minimumSeedsForConfirmatory: number; + readonly inferenceClass: BandwidthInferenceClass; + readonly exploratoryReasonCodes: readonly ( + | 'below-seed-minimum' + | 'more-than-two-conditions' + | 'single-condition' + )[]; +} + +export interface CompositionResult { + readonly analysisVersion: string; + readonly splitIntegrity: SplitIntegrityResult; + readonly seen: ProportionWithInterval; + readonly heldOut: ProportionWithInterval; + readonly seenMinusHeldOut: number; + readonly cohensHSeenVersusHeldOut: number; + /** Paired seed-level bootstrap of seen minus held-out; `null` without seeds. */ + readonly seedLevelDifference: BootstrapCi | null; + readonly topographic: TopographicResult; + readonly symbolReuse: SymbolReuseResult; + readonly orderSensitivity: OrderSensitivityResult | null; + readonly bandwidthContrast: BandwidthContrastResult | null; + readonly confidence: number; + readonly alpha: number; + readonly seed: string; +} + +function assertEpisodes(episodes: readonly CompositionEpisode[]): void { + if (episodes.length === 0) { + throw new AnalysisError('empty-sample', 'episodes must not be empty'); + } + episodes.forEach((episode, index) => { + if (episode.split !== 'train' && episode.split !== 'held-out') { + throw new AnalysisError( + 'domain', + `episodes[${index}].split must be "train" or "held-out"`, + ); + } + assertCount(episode.typeCode, `episodes[${index}].typeCode`); + if (episode.attributes.length === 0) { + throw new AnalysisError( + 'domain', + `episodes[${index}].attributes must not be empty`, + ); + } + }); +} + +/** + * E15 "Held-out split integrity verified": every pre-registered held-out type + * code must be absent from every training episode and present in at least one + * evaluation episode. Both directions are reported as violation codes; the + * analysis never rewrites the split. + */ +export function checkHeldOutSplitIntegrity(input: { + readonly episodes: readonly CompositionEpisode[]; + readonly heldOutTypeCodes?: readonly number[]; +}): SplitIntegrityResult { + assertEpisodes(input.episodes); + const heldOutTypeCodes = [...(input.heldOutTypeCodes ?? [])].sort( + (left, right) => left - right, + ); + const trainingCounts = new Map(); + const evaluatedCounts = new Map(); + for (const episode of input.episodes) { + const target = episode.split === 'train' ? trainingCounts : evaluatedCounts; + target.set(episode.typeCode, (target.get(episode.typeCode) ?? 0) + 1); + } + const violations: SplitIntegrityResult['violations'] = heldOutTypeCodes + .flatMap((typeCode) => { + const inTraining = trainingCounts.get(typeCode) ?? 0; + const inEvaluation = evaluatedCounts.get(typeCode) ?? 0; + const found: SplitIntegrityResult['violations'][number][] = []; + if (inTraining > 0) { + found.push({ + code: 'held-out-type-code-in-training', + typeCode, + episodes: inTraining, + }); + } + if (inEvaluation === 0) { + found.push({ + code: 'held-out-type-code-absent-from-evaluation', + typeCode, + episodes: 0, + }); + } + return found; + }); + return { + heldOutTypeCodes, + trainingTypeCodes: [...trainingCounts.keys()].sort( + (left, right) => left - right, + ), + evaluatedTypeCodes: [...evaluatedCounts.keys()].sort( + (left, right) => left - right, + ), + violations, + intact: violations.length === 0, + }; +} + +/** Hamming distance over two equal-length attribute vectors. */ +export function attributeHammingDistance( + left: readonly number[], + right: readonly number[], +): number { + if (left.length !== right.length) { + throw new AnalysisError( + 'length-mismatch', + 'attribute vectors must have equal length', + ); + } + let distance = 0; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + distance += 1; + } + } + return distance; +} + +/** Levenshtein distance over two form sequences (insert/delete/substitute). */ +export function messageEditDistance( + left: readonly string[], + right: readonly string[], +): number { + const rows = left.length; + const columns = right.length; + let previous = Array.from({ length: columns + 1 }, (_unused, index) => index); + for (let row = 1; row <= rows; row += 1) { + const current = new Array(columns + 1); + current[0] = row; + for (let column = 1; column <= columns; column += 1) { + const substitution = + (previous[column - 1] as number) + + (left[row - 1] === right[column - 1] ? 0 : 1); + const deletion = (previous[column] as number) + 1; + const insertion = (current[column - 1] as number) + 1; + current[column] = Math.min(substitution, deletion, insertion); + } + previous = current; + } + return previous[columns] as number; +} + +/** Average (mid) ranks of `values`, ties sharing their mean rank. */ +function midRanks(values: readonly number[]): number[] { + const order = values + .map((value, index) => ({ value, index })) + .sort((left, right) => + left.value === right.value + ? left.index - right.index + : left.value - right.value, + ); + const ranks = new Array(values.length).fill(0); + let position = 0; + while (position < order.length) { + let end = position + 1; + while ( + end < order.length && + (order[end] as { value: number }).value === + (order[position] as { value: number }).value + ) { + end += 1; + } + const rank = (position + end - 1) / 2 + 1; + for (let index = position; index < end; index += 1) { + ranks[(order[index] as { index: number }).index] = rank; + } + position = end; + } + return ranks; +} + +/** Pearson correlation; `NaN` when either input has zero variance. */ +function pearson(a: readonly number[], b: readonly number[]): number { + const n = a.length; + let sumA = 0; + let sumB = 0; + for (let index = 0; index < n; index += 1) { + sumA += a[index] as number; + sumB += b[index] as number; + } + const meanA = sumA / n; + const meanB = sumB / n; + let covariance = 0; + let varianceA = 0; + let varianceB = 0; + for (let index = 0; index < n; index += 1) { + const deltaA = (a[index] as number) - meanA; + const deltaB = (b[index] as number) - meanB; + covariance += deltaA * deltaB; + varianceA += deltaA * deltaA; + varianceB += deltaB * deltaB; + } + if (varianceA === 0 || varianceB === 0) { + return NaN; + } + return covariance / Math.sqrt(varianceA * varianceB); +} + +/** + * Spearman rank correlation between two equal-length samples, with ties + * handled by mid-ranks (Pearson on the ranks). + */ +export function spearmanCorrelation( + a: readonly number[], + b: readonly number[], +): number { + if (a.length !== b.length) { + throw new AnalysisError('length-mismatch', 'samples must have equal length'); + } + if (a.length < 2) { + return NaN; + } + return pearson(midRanks(a), midRanks(b)); +} + +/** + * Topographic similarity: the Spearman correlation between pairwise + * attribute distance and pairwise message edit distance, with a Mantel + * permutation null (episode labels of the message side are permuted, which + * preserves both distance distributions and destroys only their pairing). + * + * `permutationP` is the one-sided share of permuted correlations at or above + * the observed one, computed with the usual `(hits + 1) / (permutations + 1)` + * correction so it is never exactly zero. + */ +export function topographicSimilarity(input: { + readonly episodes: readonly CompositionEpisode[]; + readonly seed: string; + readonly permutations?: number; + readonly confidence?: number; + readonly maxEpisodes?: number; +}): TopographicResult { + assertEpisodes(input.episodes); + const permutations = input.permutations ?? DEFAULT_PERMUTATIONS; + if (!Number.isInteger(permutations) || permutations < 1) { + throw new AnalysisError('domain', 'permutations must be a positive integer'); + } + const confidence = input.confidence ?? 0.95; + assertLevel(confidence, 'confidence'); + const maxEpisodes = input.maxEpisodes ?? TOPOGRAPHIC_MAX_EPISODES; + assertCount(maxEpisodes, 'maxEpisodes'); + + const prng = new SeededPrng(`${input.seed}/topographic`); + const available = input.episodes.length; + const episodes = + available <= maxEpisodes + ? [...input.episodes] + : prng.shuffle(input.episodes).slice(0, maxEpisodes); + const n = episodes.length; + + if (n < 3) { + return { + spearman: NaN, + pairs: (n * (n - 1)) / 2, + episodesUsed: n, + episodesAvailable: available, + permutations, + permutationP: NaN, + nullInterval: { lower: NaN, upper: NaN }, + seed: input.seed, + degenerate: true, + }; + } + + const attributeMatrix: number[][] = episodes.map((left) => + episodes.map((right) => + attributeHammingDistance(left.attributes, right.attributes), + ), + ); + const messageMatrix: number[][] = episodes.map((left) => + episodes.map((right) => messageEditDistance(left.message, right.message)), + ); + + const pairsI: number[] = []; + const pairsJ: number[] = []; + for (let i = 0; i < n; i += 1) { + for (let j = i + 1; j < n; j += 1) { + pairsI.push(i); + pairsJ.push(j); + } + } + const attributeDistances = pairsI.map( + (i, index) => + (attributeMatrix[i] as number[])[pairsJ[index] as number] as number, + ); + const messageDistances = pairsI.map( + (i, index) => + (messageMatrix[i] as number[])[pairsJ[index] as number] as number, + ); + const observed = spearmanCorrelation(attributeDistances, messageDistances); + + if (Number.isNaN(observed)) { + return { + spearman: NaN, + pairs: attributeDistances.length, + episodesUsed: n, + episodesAvailable: available, + permutations, + permutationP: NaN, + nullInterval: { lower: NaN, upper: NaN }, + seed: input.seed, + degenerate: true, + }; + } + + const attributeRanks = midRanks(attributeDistances); + const nullCorrelations = new Array(permutations); + let hits = 0; + const identity = Array.from({ length: n }, (_unused, index) => index); + for (let replicate = 0; replicate < permutations; replicate += 1) { + const permuted = prng.shuffle(identity); + const permutedDistances = pairsI.map( + (i, index) => + (messageMatrix[permuted[i] as number] as number[])[ + permuted[pairsJ[index] as number] as number + ] as number, + ); + const correlation = pearson(attributeRanks, midRanks(permutedDistances)); + nullCorrelations[replicate] = correlation; + if (!Number.isNaN(correlation) && correlation >= observed) { + hits += 1; + } + } + const sortedNull = nullCorrelations + .filter((value) => !Number.isNaN(value)) + .sort((left, right) => left - right); + const tail = (1 - confidence) / 2; + const quantileAt = (probability: number): number => { + if (sortedNull.length === 0) { + return NaN; + } + const position = (sortedNull.length - 1) * probability; + const low = Math.floor(position); + const high = Math.ceil(position); + const lowValue = sortedNull[low] as number; + if (low === high) { + return lowValue; + } + return ( + lowValue + (position - low) * ((sortedNull[high] as number) - lowValue) + ); + }; + + return { + spearman: observed, + pairs: attributeDistances.length, + episodesUsed: n, + episodesAvailable: available, + permutations, + permutationP: (hits + 1) / (permutations + 1), + nullInterval: { lower: quantileAt(tail), upper: quantileAt(1 - tail) }, + seed: input.seed, + degenerate: false, + }; +} + +/** Descriptive form/message reuse counts (E15 "Symbol reuse" column). */ +export function symbolReuse( + episodes: readonly CompositionEpisode[], +): SymbolReuseResult { + assertEpisodes(episodes); + const formCounts = new Map(); + const messageCounts = new Map(); + const formMessages = new Map>(); + const typeCodes = new Set(); + let totalLength = 0; + for (const episode of episodes) { + const key = episode.message.join('\u0000'); + messageCounts.set(key, (messageCounts.get(key) ?? 0) + 1); + typeCodes.add(episode.typeCode); + totalLength += episode.message.length; + for (const form of episode.message) { + formCounts.set(form, (formCounts.get(form) ?? 0) + 1); + const messages = formMessages.get(form) ?? new Set(); + messages.add(key); + formMessages.set(form, messages); + } + } + const distinctForms = formCounts.size; + const totalFormUses = [...formCounts.values()].reduce( + (total, count) => total + count, + 0, + ); + return { + episodes: episodes.length, + distinctForms, + distinctMessages: messageCounts.size, + meanMessageLength: totalLength / episodes.length, + messagesPerTypeCode: + typeCodes.size === 0 ? NaN : messageCounts.size / typeCodes.size, + messageRepeatRate: 1 - messageCounts.size / episodes.length, + formUsesPerDistinctForm: + distinctForms === 0 ? NaN : totalFormUses / distinctForms, + formsSharedAcrossMessages: [...formMessages.values()].filter( + (messages) => messages.size > 1, + ).length, + }; +} + +function withInterval( + successes: number, + n: number, + confidence: number, +): ProportionWithInterval { + const summary = proportion(successes, n); + return { summary, wilson: wilsonInterval(successes, n, confidence) }; +} + +function emptyInterval(confidence: number): ProportionWithInterval { + return { + summary: { successes: 0, n: 0, proportion: NaN }, + wilson: { + lower: NaN, + upper: NaN, + center: NaN, + level: confidence, + successes: 0, + n: 0, + proportion: NaN, + }, + }; +} + +function orderSensitivity( + probes: readonly OrderProbeOutcome[], + confidence: number, +): OrderSensitivityResult { + const baselineSuccesses = probes.filter((probe) => probe.baselineSuccess) + .length; + const reorderedSuccesses = probes.filter((probe) => probe.reorderedSuccess) + .length; + const baseline = withInterval(baselineSuccesses, probes.length, confidence); + const reordered = withInterval(reorderedSuccesses, probes.length, confidence); + return { + probes: probes.length, + baseline, + reordered, + difference: + baseline.summary.proportion - reordered.summary.proportion, + cohensH: cohensH( + baseline.summary.proportion, + reordered.summary.proportion, + ), + }; +} + +function bandwidthContrast( + conditions: readonly BandwidthConditionInput[], + alpha: number, + minimumSeeds: number, +): BandwidthContrastResult { + const summaries = conditions.map((condition) => { + if (condition.perSeedHeldOutSuccess.length === 0) { + throw new AnalysisError( + 'empty-sample', + `bandwidth condition ${condition.label} has no seeds`, + ); + } + const seedProportions = condition.perSeedHeldOutSuccess.map((entry) => { + const summary = proportion(entry.successes, entry.n); + return summary.proportion; + }); + const pooled = proportion( + condition.perSeedHeldOutSuccess.reduce( + (total, entry) => total + entry.successes, + 0, + ), + condition.perSeedHeldOutSuccess.reduce( + (total, entry) => total + entry.n, + 0, + ), + ); + return { + label: condition.label, + symbolInventorySize: condition.symbolInventorySize, + maxSymbolsPerMessage: condition.maxSymbolsPerMessage, + seeds: seedProportions.length, + seedMean: + seedProportions.reduce((total, value) => total + value, 0) / + seedProportions.length, + pooledDescriptive: pooled, + seedProportions, + }; + }); + + const reasons: BandwidthContrastResult['exploratoryReasonCodes'][number][] = + []; + if (conditions.length === 1) { + reasons.push('single-condition'); + } + if (conditions.length > 2) { + reasons.push('more-than-two-conditions'); + } + if (summaries.some((summary) => summary.seeds < minimumSeeds)) { + reasons.push('below-seed-minimum'); + } + + const first = summaries[0]; + const second = summaries[1]; + const test = + first !== undefined && second !== undefined + ? welchTTest(first.seedProportions, second.seedProportions, 'two-sided') + : welchTTest([NaN], [NaN], 'two-sided'); + const h = + first !== undefined && second !== undefined + ? cohensH( + first.pooledDescriptive.proportion, + second.pooledDescriptive.proportion, + ) + : NaN; + + return { + conditions: summaries.map((summary) => ({ + label: summary.label, + symbolInventorySize: summary.symbolInventorySize, + maxSymbolsPerMessage: summary.maxSymbolsPerMessage, + seeds: summary.seeds, + seedMean: summary.seedMean, + pooledDescriptive: summary.pooledDescriptive, + })), + seedLevelTest: test, + cohensH: h, + alpha, + minimumSeedsForConfirmatory: minimumSeeds, + inferenceClass: reasons.length === 0 ? 'confirmatory' : 'exploratory', + exploratoryReasonCodes: reasons, + }; +} + +/** + * The whole E15 readout. Every field is a measurement; the module draws no + * compositionality conclusion from any of them, and E15's own procedure says + * not to ("Avoid declaring compositionality from one metric"). + */ +export function evaluateComposition( + input: CompositionInput, +): CompositionResult { + assertEpisodes(input.episodes); + const confidence = input.confidence ?? 0.95; + const alpha = input.alpha ?? 0.05; + assertLevel(confidence, 'confidence'); + assertLevel(alpha, 'alpha'); + if (typeof input.seed !== 'string' || input.seed.length === 0) { + throw new AnalysisError('domain', 'seed must be a non-empty string'); + } + + const seenEpisodes = input.episodes.filter( + (episode) => episode.split === 'train', + ); + const heldOutEpisodes = input.episodes.filter( + (episode) => episode.split === 'held-out', + ); + const seen = + seenEpisodes.length === 0 + ? emptyInterval(confidence) + : withInterval( + seenEpisodes.filter((episode) => episode.success).length, + seenEpisodes.length, + confidence, + ); + const heldOut = + heldOutEpisodes.length === 0 + ? emptyInterval(confidence) + : withInterval( + heldOutEpisodes.filter((episode) => episode.success).length, + heldOutEpisodes.length, + confidence, + ); + + // Seed-level paired difference: the §15.3 unit of analysis is the run/seed, + // and seen/held-out come from the same run, so the pairs are seed slots. + const seeds = [ + ...new Set( + input.episodes + .map((episode) => episode.seed) + .filter((seed): seed is string => seed !== undefined), + ), + ].sort(); + const pairedSeen: number[] = []; + const pairedHeldOut: number[] = []; + for (const seed of seeds) { + const seenForSeed = seenEpisodes.filter( + (episode) => episode.seed === seed, + ); + const heldOutForSeed = heldOutEpisodes.filter( + (episode) => episode.seed === seed, + ); + if (seenForSeed.length === 0 || heldOutForSeed.length === 0) { + continue; + } + pairedSeen.push( + seenForSeed.filter((episode) => episode.success).length / + seenForSeed.length, + ); + pairedHeldOut.push( + heldOutForSeed.filter((episode) => episode.success).length / + heldOutForSeed.length, + ); + } + const seedLevelDifference = + pairedSeen.length >= 2 + ? bootstrapPairedDifferenceCi(pairedSeen, pairedHeldOut, { + seed: `${input.seed}/seen-vs-held-out`, + iterations: 2_000, + confidence, + }) + : null; + + return { + analysisVersion: COMPOSITION_ANALYSIS_VERSION, + splitIntegrity: checkHeldOutSplitIntegrity({ + episodes: input.episodes, + ...(input.heldOutTypeCodes === undefined + ? {} + : { heldOutTypeCodes: input.heldOutTypeCodes }), + }), + seen, + heldOut, + seenMinusHeldOut: seen.summary.proportion - heldOut.summary.proportion, + cohensHSeenVersusHeldOut: + Number.isNaN(seen.summary.proportion) || + Number.isNaN(heldOut.summary.proportion) + ? NaN + : cohensH(seen.summary.proportion, heldOut.summary.proportion), + seedLevelDifference, + topographic: topographicSimilarity({ + episodes: input.episodes, + seed: input.seed, + ...(input.permutations === undefined + ? {} + : { permutations: input.permutations }), + confidence, + }), + symbolReuse: symbolReuse(input.episodes), + orderSensitivity: + input.orderProbes === undefined || input.orderProbes.length === 0 + ? null + : orderSensitivity(input.orderProbes, confidence), + bandwidthContrast: + input.bandwidthConditions === undefined || + input.bandwidthConditions.length === 0 + ? null + : bandwidthContrast( + input.bandwidthConditions, + alpha, + input.minimumSeedsForConfirmatory ?? + E15_CONFIRMATORY_MINIMUM_SEEDS, + ), + confidence, + alpha, + seed: input.seed, + }; +} diff --git a/packages/analysis/src/descriptive.ts b/packages/analysis/src/descriptive.ts new file mode 100644 index 0000000..aa79f92 --- /dev/null +++ b/packages/analysis/src/descriptive.ts @@ -0,0 +1,220 @@ +/** + * Descriptive statistics for seed-level and episode-level outcomes + * (SPECIFICATION.md §15.3; RESEARCH.md Appendix D §D.6 "The unit of analysis + * is the run/seed success proportion", §D.7 "Wilson intervals over pooled + * episodes are descriptive only"). + */ +import { + AnalysisError, + assertCount, + assertLevel, + assertSample, +} from './errors.js'; +import { normalQuantile } from './special.js'; + +/** A two-sided interval and the level it was computed at. */ +export interface ConfidenceInterval { + readonly lower: number; + readonly upper: number; + /** Nominal coverage, e.g. 0.95. */ + readonly level: number; +} + +export interface DescriptiveSummary { + readonly n: number; + readonly mean: number; + /** Sample (n - 1) variance; `NaN` when n < 2, where it is undefined. */ + readonly variance: number; + /** Square root of `variance`; `NaN` when n < 2. */ + readonly sd: number; + readonly min: number; + readonly max: number; +} + +/** + * Sample summary with the unbiased (n - 1) variance. Throws on an empty + * sample: a zero-seed condition is a data problem for the harness to report, + * not a statistic. For n = 1 the mean/min/max are exact and variance/sd are + * `NaN` (undefined, never silently 0). A sample whose values are all equal + * (`min === max`) reports that common value as the mean and a variance of + * exactly 0, which is what makes the zero-variance rule of `oneSampleTTest` + * exact rather than floating-point dependent. + */ +export function summarize(values: readonly number[]): DescriptiveSummary { + assertSample(values, 'values'); + const n = values.length; + let total = 0; + let min = Infinity; + let max = -Infinity; + for (const value of values) { + total += value; + if (value < min) { + min = value; + } + if (value > max) { + max = value; + } + } + const mean = total / n; + if (n < 2) { + return { n, mean, variance: NaN, sd: NaN, min, max }; + } + if (min === max) { + // Exact point mass: report the common value and a variance of exactly + // zero rather than the ~1e-33 that summing equal floats can leave behind, + // so the degenerate branch of `oneSampleTTest` is reached reliably. + return { n, mean: min, variance: 0, sd: 0, min, max }; + } + let sumSquares = 0; + for (const value of values) { + const deviation = value - mean; + sumSquares += deviation * deviation; + } + const variance = sumSquares / (n - 1); + return { n, mean, variance, sd: Math.sqrt(variance), min, max }; +} + +/** Arithmetic mean of a non-empty finite sample. */ +export function mean(values: readonly number[]): number { + assertSample(values, 'values'); + let total = 0; + for (const value of values) { + total += value; + } + return total / values.length; +} + +/** + * Type-7 (R default) empirical quantile with linear interpolation, computed on + * a sorted copy so the caller's array is never mutated. Deterministic, which + * is what the percentile bootstrap needs. + */ +export function quantile(values: readonly number[], probability: number): number { + assertSample(values, 'values'); + if ( + !Number.isFinite(probability) || + probability < 0 || + probability > 1 + ) { + throw new AnalysisError('domain', 'probability must be within [0, 1]'); + } + const sorted = [...values].sort((left, right) => left - right); + return quantileSorted(sorted, probability); +} + +/** `quantile` for an already ascending array; used by the bootstrap. */ +export function quantileSorted( + sorted: readonly number[], + probability: number, +): number { + assertSample(sorted, 'sorted'); + const position = (sorted.length - 1) * probability; + const low = Math.floor(position); + const high = Math.ceil(position); + const lowValue = sorted[low] as number; + if (low === high) { + return lowValue; + } + const highValue = sorted[high] as number; + return lowValue + (position - low) * (highValue - lowValue); +} + +export interface ProportionSummary { + readonly successes: number; + readonly n: number; + readonly proportion: number; +} + +/** Observed proportion for `successes` out of `n` (n > 0). */ +export function proportion(successes: number, n: number): ProportionSummary { + assertCount(successes, 'successes'); + assertCount(n, 'n'); + if (n === 0) { + throw new AnalysisError('empty-sample', 'n must be greater than zero'); + } + if (successes > n) { + throw new AnalysisError('domain', 'successes must not exceed n'); + } + return { successes, n, proportion: successes / n }; +} + +/** + * Pool independent success counts (Appendix D §D.7: episodes pooled across + * seeds, descriptive only — never treated as independent runs, §D.6). + */ +export function pooledProportion( + parts: readonly ProportionSummary[], +): ProportionSummary { + let successes = 0; + let n = 0; + for (const part of parts) { + successes += part.successes; + n += part.n; + } + return proportion(successes, n); +} + +/** Count of a 0/1 (or truthy-numeric) outcome vector, as a proportion. */ +export function proportionOfSuccesses( + outcomes: readonly number[], +): ProportionSummary { + assertSample(outcomes, 'outcomes'); + let successes = 0; + for (let index = 0; index < outcomes.length; index += 1) { + const outcome = outcomes[index] as number; + if (outcome !== 0 && outcome !== 1) { + throw new AnalysisError( + 'domain', + `outcomes[${index}] must be 0 or 1`, + ); + } + successes += outcome; + } + return proportion(successes, outcomes.length); +} + +export interface WilsonInterval extends ConfidenceInterval { + /** The Wilson score centre (a shrunken point estimate), not `successes / n`. */ + readonly center: number; + readonly successes: number; + readonly n: number; + /** Observed `successes / n`, reported alongside the shrunken centre. */ + readonly proportion: number; +} + +/** + * Wilson score interval for a binomial proportion (SPECIFICATION.md §15.3 + * confidence-interval reporting; RESEARCH.md Appendix D §D.7 pooled-episode + * descriptive intervals). Bounds are clamped to [0, 1]. + * + * Reference value used in tests: k = 8, n = 10 at 95% gives + * [0.49016, 0.94332] with centre 0.71674 (the published four-place rounding + * of this interval is [0.4902, 0.9433]). + */ +export function wilsonInterval( + successes: number, + n: number, + confidence = 0.95, +): WilsonInterval { + const observed = proportion(successes, n); + assertLevel(confidence, 'confidence'); + const z = normalQuantile(1 - (1 - confidence) / 2); + const zSquared = z * z; + const denominator = 1 + zSquared / n; + const center = (observed.proportion + zSquared / (2 * n)) / denominator; + const halfWidth = + (z / denominator) * + Math.sqrt( + (observed.proportion * (1 - observed.proportion)) / n + + zSquared / (4 * n * n), + ); + return { + lower: Math.max(0, center - halfWidth), + upper: Math.min(1, center + halfWidth), + center, + level: confidence, + successes, + n, + proportion: observed.proportion, + }; +} diff --git a/packages/analysis/src/drift.ts b/packages/analysis/src/drift.ts new file mode 100644 index 0000000..9e3869a --- /dev/null +++ b/packages/analysis/src/drift.ts @@ -0,0 +1,478 @@ +/** + * E31 longitudinal drift and stability readout (EXPERIMENT-NOTEBOOK.md E31; + * SPECIFICATION.md §15.3; BACKLOG ALD-072, ALD-076 acceptance criterion 2 — + * "the baseline/statistics scaffold can compare metrics across a long + * sequence of turns/checkpoints for drift analysis"). + * + * Input is one frozen-evaluation summary per checkpoint — E31's procedure + * ("Run extended sessions with periodic frozen evaluations", "Anchor + * checkpoints at the standard interval", "Measure ledger revisions, abandoned + * meanings, and message entropy"). Output is E31's result table plus the two + * things its checkboxes need: stability intervals, and the separation of + * drift that spans a documented distribution shift from drift inside one + * regime ("Drift and task-shift effects separated"). + * + * The pre-registered drift score is the Jensen-Shannon divergence in bits + * between the symbol-usage distributions of two checkpoints — bounded in + * [0, 1] for base-2 logarithms, symmetric, and defined when one distribution + * has zero mass where the other does not, which a KL divergence is not. When + * a checkpoint carries no usage histogram the score is `NaN` and the pair is + * reported as unscored rather than silently zero. + * + * Every function is pure; the only randomness is the seeded bootstrap of the + * regime contrast, so a readout replays exactly from its seed (SPEC §14.3). + */ +import { + bootstrapMeanCi, + type BootstrapCi, +} from './bootstrap.js'; +import { + wilsonInterval, + type WilsonInterval, +} from './descriptive.js'; +import { + AnalysisError, + assertCount, + assertLevel, +} from './errors.js'; + +/** Analysis version stamped on the E31 readout. */ +export const DRIFT_ANALYSIS_VERSION = 'checkpoint-drift/v1'; + +/** + * Default stability tolerance on the pair drift score: consecutive + * checkpoints whose symbol-usage distributions differ by at most this many + * bits of Jensen-Shannon divergence are treated as one stable interval. It is + * a pre-registerable knob, not a finding; 0.05 bits is the value this + * scaffold ships with and records in its output. + */ +export const DEFAULT_STABILITY_TOLERANCE_BITS = 0.05; + +/** One periodic frozen evaluation (E31 "Run extended sessions with periodic frozen evaluations"). */ +export interface CheckpointEvaluation { + /** `CheckpointManifest.checkpointSequence` this evaluation follows. */ + readonly checkpointSequence: number; + /** Turn at which the frozen evaluation ran. */ + readonly turn: number; + /** Frozen-evaluation successes and episodes (learning disabled). */ + readonly success: { readonly successes: number; readonly n: number }; + /** Distinct forms in productive use at this checkpoint. */ + readonly vocabularySize: number; + /** Ledger revisions/abandonments since the previous checkpoint. */ + readonly meaningChanges: number; + /** Entropy of the message distribution in bits, as measured by the caller. */ + readonly messageEntropyBits: number; + /** + * Usage counts per form, in a stable form order shared by every + * checkpoint of the run. Omit to leave the pair drift score unscored. + */ + readonly symbolUsage?: readonly number[]; +} + +export interface DriftInput { + readonly checkpoints: readonly CheckpointEvaluation[]; + /** Turns at which a documented distribution shift was introduced (E31). */ + readonly distributionShiftAt?: readonly number[]; + readonly confidence?: number; + readonly stabilityToleranceBits?: number; + /** Seed for the regime-contrast bootstrap; omit to skip that interval. */ + readonly seed?: string; + readonly bootstrapIterations?: number; +} + +export type PairUnscoredReasonCode = + | 'missing-symbol-usage' + | 'symbol-usage-length-mismatch' + | 'empty-symbol-usage'; + +export interface CheckpointPairDrift { + readonly fromCheckpointSequence: number; + readonly toCheckpointSequence: number; + readonly fromTurn: number; + readonly toTurn: number; + readonly turnsElapsed: number; + /** Jensen-Shannon divergence in bits; `NaN` when unscored. */ + readonly driftScoreBits: number; + readonly unscoredReasonCode: PairUnscoredReasonCode | null; + /** Ledger revisions per turn over the interval. */ + readonly revisionRate: number; + readonly successDelta: number; + readonly vocabularyDelta: number; + readonly messageEntropyDeltaBits: number; + /** True when a documented distribution shift falls inside the interval. */ + readonly spansDistributionShift: boolean; +} + +export interface StabilityInterval { + readonly fromCheckpointSequence: number; + readonly toCheckpointSequence: number; + readonly fromTurn: number; + readonly toTurn: number; + readonly checkpoints: number; + /** Largest pair drift score inside the interval; 0 for a single checkpoint. */ + readonly maxPairDriftBits: number; +} + +export type RegimeSeparationDecision = + | 'separated' + | 'insufficient-shift-spanning-pairs' + | 'insufficient-within-regime-pairs' + | 'no-shifts-declared'; + +export interface RegimeSeparation { + readonly withinRegime: { + readonly pairs: number; + readonly meanDriftBits: number; + readonly bootstrap: BootstrapCi | null; + }; + readonly shiftSpanning: { + readonly pairs: number; + readonly meanDriftBits: number; + readonly bootstrap: BootstrapCi | null; + }; + /** `shiftSpanning - withinRegime`; positive means shifts drift more. */ + readonly meanDifferenceBits: number; + readonly decision: RegimeSeparationDecision; +} + +export interface DriftResult { + readonly analysisVersion: string; + readonly checkpoints: readonly { + readonly checkpointSequence: number; + readonly turn: number; + readonly successRate: number; + readonly successWilson: WilsonInterval; + readonly vocabularySize: number; + readonly meaningChanges: number; + readonly messageEntropyBits: number; + /** Divergence from the first checkpoint's usage distribution, in bits. */ + readonly cumulativeDriftBits: number; + }[]; + readonly pairs: readonly CheckpointPairDrift[]; + /** Mean of the scored consecutive-pair drift scores. */ + readonly meanPairDriftBits: number; + /** Divergence between the first and last checkpoint's usage, in bits. */ + readonly endToEndDriftBits: number; + readonly stabilityToleranceBits: number; + readonly stabilityIntervals: readonly StabilityInterval[]; + readonly regimeSeparation: RegimeSeparation; + readonly distributionShiftAt: readonly number[]; + readonly confidence: number; +} + +/** + * Wilson interval for a checkpoint's frozen evaluation. A checkpoint with no + * episodes reports an all-`NaN` interval rather than an interval computed on + * a substituted denominator. + */ +function successInterval( + successes: number, + n: number, + confidence: number, +): WilsonInterval { + if (n === 0) { + return { + lower: NaN, + upper: NaN, + center: NaN, + level: confidence, + successes, + n, + proportion: NaN, + }; + } + return wilsonInterval(successes, n, confidence); +} + +/** Normalize non-negative counts to a probability vector. */ +function distribution(counts: readonly number[]): number[] | null { + let total = 0; + for (const count of counts) { + if (!Number.isFinite(count) || count < 0) { + throw new AnalysisError( + 'domain', + 'symbolUsage counts must be non-negative finite numbers', + ); + } + total += count; + } + if (total === 0) { + return null; + } + return counts.map((count) => count / total); +} + +function shannonBits(values: readonly number[]): number { + let total = 0; + for (const value of values) { + if (value > 0) { + total -= value * Math.log2(value); + } + } + return total; +} + +/** + * Jensen-Shannon divergence in bits between two form-usage histograms: + * `H(m) - (H(p) + H(q)) / 2` with `m` the mean distribution. Returns 0 for + * identical histograms and 1 for disjoint support. `null` inputs (all-zero + * histograms) yield `NaN`. + */ +export function symbolUsageDivergenceBits( + left: readonly number[], + right: readonly number[], +): number { + if (left.length !== right.length) { + throw new AnalysisError( + 'length-mismatch', + 'symbolUsage histograms must have equal length', + ); + } + const p = distribution(left); + const q = distribution(right); + if (p === null || q === null) { + return NaN; + } + const mixture = p.map((value, index) => (value + (q[index] as number)) / 2); + return shannonBits(mixture) - (shannonBits(p) + shannonBits(q)) / 2; +} + +function pairDriftScore( + from: CheckpointEvaluation, + to: CheckpointEvaluation, +): { score: number; reason: PairUnscoredReasonCode | null } { + if (from.symbolUsage === undefined || to.symbolUsage === undefined) { + return { score: NaN, reason: 'missing-symbol-usage' }; + } + if (from.symbolUsage.length !== to.symbolUsage.length) { + return { score: NaN, reason: 'symbol-usage-length-mismatch' }; + } + const score = symbolUsageDivergenceBits(from.symbolUsage, to.symbolUsage); + if (Number.isNaN(score)) { + return { score, reason: 'empty-symbol-usage' }; + } + return { score, reason: null }; +} + +function assertCheckpoints(checkpoints: readonly CheckpointEvaluation[]): void { + if (checkpoints.length === 0) { + throw new AnalysisError('empty-sample', 'checkpoints must not be empty'); + } + let previousTurn = -1; + checkpoints.forEach((checkpoint, index) => { + assertCount( + checkpoint.checkpointSequence, + `checkpoints[${index}].checkpointSequence`, + ); + assertCount(checkpoint.turn, `checkpoints[${index}].turn`); + assertCount(checkpoint.success.n, `checkpoints[${index}].success.n`); + assertCount( + checkpoint.success.successes, + `checkpoints[${index}].success.successes`, + ); + if (checkpoint.success.successes > checkpoint.success.n) { + throw new AnalysisError( + 'domain', + `checkpoints[${index}].success.successes must not exceed n`, + ); + } + if (checkpoint.turn <= previousTurn) { + throw new AnalysisError( + 'domain', + 'checkpoints must be ordered by strictly increasing turn', + ); + } + previousTurn = checkpoint.turn; + }); +} + +/** + * The whole E31 readout: per-checkpoint metrics with cumulative drift, + * consecutive-pair drift, stability intervals, and the within-regime versus + * shift-spanning contrast. + * + * Nothing here decides that a run drifted: `decision` on the regime + * separation says only whether the comparison had enough pairs on both sides + * to be computed (ALD-072 acceptance criterion 3). + */ +export function evaluateCheckpointDrift(input: DriftInput): DriftResult { + assertCheckpoints(input.checkpoints); + const confidence = input.confidence ?? 0.95; + assertLevel(confidence, 'confidence'); + const tolerance = + input.stabilityToleranceBits ?? DEFAULT_STABILITY_TOLERANCE_BITS; + if (!Number.isFinite(tolerance) || tolerance < 0) { + throw new AnalysisError( + 'domain', + 'stabilityToleranceBits must be a non-negative finite number', + ); + } + const shifts = [...(input.distributionShiftAt ?? [])].sort( + (left, right) => left - right, + ); + + const first = input.checkpoints[0] as CheckpointEvaluation; + const checkpoints = input.checkpoints.map((checkpoint) => { + const rate = + checkpoint.success.n === 0 + ? NaN + : checkpoint.success.successes / checkpoint.success.n; + const cumulative = + checkpoint === first + ? 0 + : pairDriftScore(first, checkpoint).score; + return { + checkpointSequence: checkpoint.checkpointSequence, + turn: checkpoint.turn, + successRate: rate, + successWilson: successInterval( + checkpoint.success.successes, + checkpoint.success.n, + confidence, + ), + vocabularySize: checkpoint.vocabularySize, + meaningChanges: checkpoint.meaningChanges, + messageEntropyBits: checkpoint.messageEntropyBits, + cumulativeDriftBits: cumulative, + }; + }); + + const pairs: CheckpointPairDrift[] = []; + for (let index = 1; index < input.checkpoints.length; index += 1) { + const from = input.checkpoints[index - 1] as CheckpointEvaluation; + const to = input.checkpoints[index] as CheckpointEvaluation; + const { score, reason } = pairDriftScore(from, to); + const turnsElapsed = to.turn - from.turn; + const fromRate = + from.success.n === 0 ? NaN : from.success.successes / from.success.n; + const toRate = to.success.n === 0 ? NaN : to.success.successes / to.success.n; + pairs.push({ + fromCheckpointSequence: from.checkpointSequence, + toCheckpointSequence: to.checkpointSequence, + fromTurn: from.turn, + toTurn: to.turn, + turnsElapsed, + driftScoreBits: score, + unscoredReasonCode: reason, + revisionRate: turnsElapsed === 0 ? NaN : to.meaningChanges / turnsElapsed, + successDelta: toRate - fromRate, + vocabularyDelta: to.vocabularySize - from.vocabularySize, + messageEntropyDeltaBits: to.messageEntropyBits - from.messageEntropyBits, + spansDistributionShift: shifts.some( + (shiftTurn) => shiftTurn > from.turn && shiftTurn <= to.turn, + ), + }); + } + + const scoredPairs = pairs.filter((pair) => !Number.isNaN(pair.driftScoreBits)); + const meanPairDrift = + scoredPairs.length === 0 + ? NaN + : scoredPairs.reduce((total, pair) => total + pair.driftScoreBits, 0) / + scoredPairs.length; + + const last = input.checkpoints[ + input.checkpoints.length - 1 + ] as CheckpointEvaluation; + const endToEnd = + input.checkpoints.length < 2 ? 0 : pairDriftScore(first, last).score; + + // Stability intervals: maximal runs of consecutive checkpoints whose pair + // drift never exceeds the tolerance. An unscored pair breaks the run, so an + // interval never claims stability across a checkpoint with no histogram. + const intervals: StabilityInterval[] = []; + let startIndex = 0; + let maxInside = 0; + for (let index = 0; index < pairs.length; index += 1) { + const pair = pairs[index] as CheckpointPairDrift; + const stable = + !Number.isNaN(pair.driftScoreBits) && pair.driftScoreBits <= tolerance; + if (stable) { + maxInside = Math.max(maxInside, pair.driftScoreBits); + continue; + } + const from = input.checkpoints[startIndex] as CheckpointEvaluation; + const to = input.checkpoints[index] as CheckpointEvaluation; + intervals.push({ + fromCheckpointSequence: from.checkpointSequence, + toCheckpointSequence: to.checkpointSequence, + fromTurn: from.turn, + toTurn: to.turn, + checkpoints: index - startIndex + 1, + maxPairDriftBits: maxInside, + }); + startIndex = index + 1; + maxInside = 0; + } + { + const from = input.checkpoints[startIndex] as + | CheckpointEvaluation + | undefined; + if (from !== undefined) { + intervals.push({ + fromCheckpointSequence: from.checkpointSequence, + toCheckpointSequence: last.checkpointSequence, + fromTurn: from.turn, + toTurn: last.turn, + checkpoints: input.checkpoints.length - startIndex, + maxPairDriftBits: maxInside, + }); + } + } + + const within = scoredPairs + .filter((pair) => !pair.spansDistributionShift) + .map((pair) => pair.driftScoreBits); + const spanning = scoredPairs + .filter((pair) => pair.spansDistributionShift) + .map((pair) => pair.driftScoreBits); + const meanOf = (values: readonly number[]): number => + values.length === 0 + ? NaN + : values.reduce((total, value) => total + value, 0) / values.length; + const bootstrapOf = (values: readonly number[], label: string): BootstrapCi | null => + input.seed === undefined || values.length < 2 + ? null + : bootstrapMeanCi(values, { + seed: `${input.seed}/${label}`, + iterations: input.bootstrapIterations ?? 2_000, + confidence, + }); + + let decision: RegimeSeparationDecision; + if (shifts.length === 0) { + decision = 'no-shifts-declared'; + } else if (spanning.length < 1) { + decision = 'insufficient-shift-spanning-pairs'; + } else if (within.length < 1) { + decision = 'insufficient-within-regime-pairs'; + } else { + decision = 'separated'; + } + + return { + analysisVersion: DRIFT_ANALYSIS_VERSION, + checkpoints, + pairs, + meanPairDriftBits: meanPairDrift, + endToEndDriftBits: endToEnd, + stabilityToleranceBits: tolerance, + stabilityIntervals: intervals, + regimeSeparation: { + withinRegime: { + pairs: within.length, + meanDriftBits: meanOf(within), + bootstrap: bootstrapOf(within, 'within-regime'), + }, + shiftSpanning: { + pairs: spanning.length, + meanDriftBits: meanOf(spanning), + bootstrap: bootstrapOf(spanning, 'shift-spanning'), + }, + meanDifferenceBits: meanOf(spanning) - meanOf(within), + decision, + }, + distributionShiftAt: shifts, + confidence, + }; +} diff --git a/packages/analysis/src/e03-design.ts b/packages/analysis/src/e03-design.ts new file mode 100644 index 0000000..b69951f --- /dev/null +++ b/packages/analysis/src/e03-design.ts @@ -0,0 +1,274 @@ +/** + * Reproducible E03 design simulation and seed manifest (RESEARCH.md Appendix D + * §D.4 and §D.7). + * + * The design check models seed-level success rates as normal with variance + * equal to the registered between-seed variance plus the binomial sampling + * variance from 200 episodes. For each Monte Carlo replicate it samples the + * sufficient statistics of a normal sample directly: the sample mean and the + * independent chi-square sample variance. The registered TOST interval then + * determines whether equivalence to the [0.20, 0.30] bounds would be declared. + * + * This is an outcome-blind single-component check, not a full-rule power + * calculation or an experiment result. D05's independent bounded simulation + * validates the complete numeric rule and is the registration-facing source. + */ +import { SeededPrng, deriveSeedHex } from '@ald/hashing'; + +import { AnalysisError, assertLevel, assertProbability } from './errors.js'; +import { studentTQuantile } from './special.js'; + +export const E03_DESIGN_SIMULATION_VERSION = 2; +export const E03_DESIGN_REPETITIONS = 30_000; +export const E03_DESIGN_MINIMUM_POWER = 0.9; +export const E03_DESIGN_SEED = 'ald-e03-v1-design-check'; +export const E03_SEED_LABEL = 'ald-e03-v1'; + +export const E03_DESIGN_ROWS = [ + { maximumBetweenSeedSd: 0.05, primarySeeds: 25 }, + { maximumBetweenSeedSd: 0.1, primarySeeds: 75 }, + { maximumBetweenSeedSd: 0.15, primarySeeds: 155 }, + { maximumBetweenSeedSd: 0.2, primarySeeds: 300 }, +] as const; + +export const E03_COMMUNICATION_CONDITIONS = [ + 'disabled', + 'constant', + 'random', + 'shuffled', + 'normal', + 'oracle', +] as const; + +export interface E03DesignSimulationOptions { + readonly repetitions?: number; + readonly mean?: number; + readonly episodesPerSeed?: number; + readonly equivalenceLower?: number; + readonly equivalenceUpper?: number; + readonly alpha?: number; + readonly minimumPower?: number; + readonly seed?: string; + readonly rows?: readonly { + readonly maximumBetweenSeedSd: number; + readonly primarySeeds: number; + }[]; +} + +export interface E03DesignPowerRow { + readonly maximumBetweenSeedSd: number; + readonly primarySeeds: number; + readonly totalSeedRateSd: number; + readonly equivalentReplicates: number; + readonly repetitions: number; + readonly estimatedPower: number; + readonly monteCarloStandardError: number; + readonly monteCarloLower95: number; + readonly passesMinimumPower: boolean; +} + +export interface E03DesignSimulation { + readonly version: typeof E03_DESIGN_SIMULATION_VERSION; + readonly claimBoundary: 'outcome-blind-design-simulation'; + readonly scope: 'single-control-equivalence-component'; + readonly registrationFacingReceipt: 'reports/research/statistical-validation.tsv'; + readonly samplingModel: 'normal-seed-rate-with-binomial-episode-variance'; + readonly seed: string; + readonly repetitions: number; + readonly mean: number; + readonly episodesPerSeed: number; + readonly equivalenceBounds: readonly [number, number]; + readonly alpha: number; + readonly minimumPower: number; + readonly rows: E03DesignPowerRow[]; + readonly passes: boolean; +} + +export interface E03SeedManifestEntry { + readonly slot: number; + readonly use: 'primary' | 'reserve'; + readonly scenarioSeed: string; + readonly gatewaySeeds: { + readonly random: string; + readonly shuffled: string; + }; +} + +export interface E03SeedManifest { + readonly version: 1; + readonly seedLabel: typeof E03_SEED_LABEL; + readonly primarySeeds: number; + readonly reserveSeeds: number; + readonly conditions: typeof E03_COMMUNICATION_CONDITIONS; + readonly entries: E03SeedManifestEntry[]; +} + +class NormalSampler { + #spare: number | undefined; + + constructor(private readonly prng: SeededPrng) {} + + next(): number { + if (this.#spare !== undefined) { + const value = this.#spare; + this.#spare = undefined; + return value; + } + let first = 0; + let second = 0; + while (first === 0) first = this.prng.nextFloat(); + while (second === 0) second = this.prng.nextFloat(); + const radius = Math.sqrt(-2 * Math.log(first)); + const angle = 2 * Math.PI * second; + this.#spare = radius * Math.sin(angle); + return radius * Math.cos(angle); + } +} + +/** Marsaglia-Tsang gamma sampler, shape >= 1, scale 1. */ +function sampleGamma(shape: number, prng: SeededPrng): number { + const normal = new NormalSampler(prng); + const d = shape - 1 / 3; + const c = 1 / Math.sqrt(9 * d); + for (;;) { + const z = normal.next(); + const base = 1 + c * z; + if (base <= 0) continue; + const v = base * base * base; + const u = prng.nextFloat(); + if ( + u < 1 - 0.0331 * z ** 4 || + Math.log(u) < 0.5 * z * z + d * (1 - v + Math.log(v)) + ) { + return d * v; + } + } +} + +function positiveInteger(value: number, label: string): void { + if (!Number.isInteger(value) || value < 1) { + throw new AnalysisError('domain', `${label} must be a positive integer`); + } +} + +export function simulateE03DesignPower( + options: E03DesignSimulationOptions = {}, +): E03DesignSimulation { + const repetitions = options.repetitions ?? E03_DESIGN_REPETITIONS; + const mean = options.mean ?? 0.25; + const episodesPerSeed = options.episodesPerSeed ?? 200; + const lower = options.equivalenceLower ?? 0.2; + const upper = options.equivalenceUpper ?? 0.3; + const alpha = options.alpha ?? 0.01; + const minimumPower = options.minimumPower ?? E03_DESIGN_MINIMUM_POWER; + const seed = options.seed ?? E03_DESIGN_SEED; + const rows = options.rows ?? E03_DESIGN_ROWS; + + positiveInteger(repetitions, 'repetitions'); + positiveInteger(episodesPerSeed, 'episodesPerSeed'); + assertProbability(mean, 'mean'); + assertLevel(alpha, 'alpha'); + assertProbability(minimumPower, 'minimumPower'); + if (!(lower < mean && mean < upper)) { + throw new AnalysisError( + 'domain', + 'equivalence bounds must contain the simulated mean', + ); + } + + const results = rows.map((row, rowIndex): E03DesignPowerRow => { + positiveInteger(row.primarySeeds, `rows[${rowIndex}].primarySeeds`); + if (!(row.maximumBetweenSeedSd > 0)) { + throw new AnalysisError( + 'domain', + `rows[${rowIndex}].maximumBetweenSeedSd must be positive`, + ); + } + const df = row.primarySeeds - 1; + if (df < 1) { + throw new AnalysisError('domain', 'each design row requires at least two seeds'); + } + const totalVariance = + row.maximumBetweenSeedSd ** 2 + + (mean * (1 - mean)) / episodesPerSeed; + const totalSeedRateSd = Math.sqrt(totalVariance); + const critical = studentTQuantile(1 - alpha, df); + const prng = new SeededPrng(`${seed}/row-${String(rowIndex + 1)}`); + const normal = new NormalSampler(prng); + let equivalentReplicates = 0; + + for (let replicate = 0; replicate < repetitions; replicate += 1) { + const sampleMean = + mean + (totalSeedRateSd / Math.sqrt(row.primarySeeds)) * normal.next(); + const chiSquare = 2 * sampleGamma(df / 2, prng); + const sampleSd = totalSeedRateSd * Math.sqrt(chiSquare / df); + const halfWidth = critical * (sampleSd / Math.sqrt(row.primarySeeds)); + if (sampleMean - halfWidth > lower && sampleMean + halfWidth < upper) { + equivalentReplicates += 1; + } + } + + const estimatedPower = equivalentReplicates / repetitions; + const monteCarloStandardError = Math.sqrt( + (estimatedPower * (1 - estimatedPower)) / repetitions, + ); + const monteCarloLower95 = estimatedPower - 1.96 * monteCarloStandardError; + return { + maximumBetweenSeedSd: row.maximumBetweenSeedSd, + primarySeeds: row.primarySeeds, + totalSeedRateSd, + equivalentReplicates, + repetitions, + estimatedPower, + monteCarloStandardError, + monteCarloLower95, + passesMinimumPower: monteCarloLower95 >= minimumPower, + }; + }); + + return { + version: E03_DESIGN_SIMULATION_VERSION, + claimBoundary: 'outcome-blind-design-simulation', + scope: 'single-control-equivalence-component', + registrationFacingReceipt: 'reports/research/statistical-validation.tsv', + samplingModel: 'normal-seed-rate-with-binomial-episode-variance', + seed, + repetitions, + mean, + episodesPerSeed, + equivalenceBounds: [lower, upper], + alpha, + minimumPower, + rows: results, + passes: results.every((row) => row.passesMinimumPower), + }; +} + +export function buildE03SeedManifest(primarySeeds: number): E03SeedManifest { + positiveInteger(primarySeeds, 'primarySeeds'); + const reserveSeeds = Math.ceil(primarySeeds * 0.1); + const entries = Array.from( + { length: primarySeeds + reserveSeeds }, + (_, index): E03SeedManifestEntry => { + const slot = index + 1; + const scenarioSeed = deriveSeedHex(E03_SEED_LABEL, String(slot)); + return { + slot, + use: slot <= primarySeeds ? 'primary' : 'reserve', + scenarioSeed, + gatewaySeeds: { + random: deriveSeedHex(scenarioSeed, 'random'), + shuffled: deriveSeedHex(scenarioSeed, 'shuffled'), + }, + }; + }, + ); + return { + version: 1, + seedLabel: E03_SEED_LABEL, + primarySeeds, + reserveSeeds, + conditions: E03_COMMUNICATION_CONDITIONS, + entries, + }; +} diff --git a/packages/analysis/src/e03-registration.ts b/packages/analysis/src/e03-registration.ts new file mode 100644 index 0000000..b0c7117 --- /dev/null +++ b/packages/analysis/src/e03-registration.ts @@ -0,0 +1,144 @@ +/** + * Canonical E03 pre-registration compiler (SPEC §15.1; RESEARCH.md + * Appendix D). It compiles the study matrix before any run-specific outcome + * exists and binds every primary/reserve RunConfig to the same artifact hash. + */ +import { canonicalJson, hashCanonical } from '@ald/hashing'; +import { + HASH_DOMAINS, + PreRegistrationArtifactSchema, + RunConfigSchema, + type PreRegistrationArtifact, + type RunConfig, +} from '@ald/types'; + +import { + E03_COMMUNICATION_CONDITIONS, + buildE03SeedManifest, + type E03SeedManifest, +} from './e03-design.js'; +import { AnalysisError } from './errors.js'; + +export const E03_REGISTRATION_COMPILER_VERSION = 2; +export const E03_REGISTRATION_CLAIM_BOUNDARY = + 'Draft artifact only: immutable repository registration, governance review, and a ' + + 'confirmed pre-run simulated commitment are still required before confirmatory collection.'; + +const VOLATILE_PARAMETER_KEYS = new Set([ + 'runId', + 'randomSeed', + 'preRegistrationHash', + 'communicationCondition', +]); + +export interface CompileE03RegistrationInput { + readonly baseConfig: RunConfig; + readonly hypothesis: string; + readonly analysisPlan: string; + readonly primarySeeds: number; +} + +export interface E03RegisteredRun { + readonly slot: number; + readonly use: 'primary' | 'reserve'; + readonly condition: (typeof E03_COMMUNICATION_CONDITIONS)[number]; + readonly config: RunConfig; +} + +export interface CompiledE03Registration { + readonly version: typeof E03_REGISTRATION_COMPILER_VERSION; + readonly claimBoundary: typeof E03_REGISTRATION_CLAIM_BOUNDARY; + readonly artifact: PreRegistrationArtifact; + readonly canonicalArtifact: string; + readonly preRegistrationHash: string; + readonly seedManifest: E03SeedManifest; + readonly runs: E03RegisteredRun[]; +} + +function assertE03Base(config: RunConfig): void { + const problems: string[] = []; + if (config.experimentId !== 'E03') problems.push('experimentId must be E03'); + if (config.deploymentMode !== 'research-grade') { + problems.push('deploymentMode must be research-grade'); + } + if (config.registrationClass !== 'confirmatory') { + problems.push('registrationClass must be confirmatory'); + } + if (config.learningSignal !== 'none') problems.push('learningSignal must be none'); + if (config.babyA.track !== 'no-learning' || config.babyB.track !== 'no-learning') { + problems.push('both learner tracks must be no-learning'); + } + if ( + config.babyA.trainingIsolation !== 'independent' || + config.babyB.trainingIsolation !== 'independent' + ) { + problems.push('both learners must use independent training isolation'); + } + if (config.evaluationTurns !== 200) problems.push('evaluationTurns must be 200'); + if (problems.length > 0) { + throw new AnalysisError('domain', `invalid E03 registration base: ${problems.join('; ')}`); + } +} + +function registeredParameters(config: RunConfig): Record { + const template = Object.fromEntries( + Object.entries(config).filter(([key]) => !VOLATILE_PARAMETER_KEYS.has(key)), + ); + return { + runConfigTemplate: template, + communicationConditions: E03_COMMUNICATION_CONDITIONS, + seedAllocation: 'shared scenario seed by slot; condition-specific gateway derivation', + reservePolicy: 'next unused reserve slot; no unregistered replacement', + }; +} + +export function compileE03Registration( + input: CompileE03RegistrationInput, +): CompiledE03Registration { + const baseConfig = RunConfigSchema.parse(input.baseConfig); + assertE03Base(baseConfig); + const seedManifest = buildE03SeedManifest(input.primarySeeds); + if (baseConfig.evaluationSeeds !== input.primarySeeds) { + throw new AnalysisError( + 'domain', + 'baseConfig.evaluationSeeds must equal primarySeeds', + ); + } + + const artifact = PreRegistrationArtifactSchema.parse({ + version: 1, + experimentId: 'E03', + protocolGitCommit: baseConfig.protocolGitCommit, + registrationClass: 'confirmatory', + hypothesis: input.hypothesis, + parameters: registeredParameters(baseConfig), + seeds: seedManifest.entries.map((entry) => entry.scenarioSeed), + analysisPlan: input.analysisPlan, + }); + const canonicalArtifact = canonicalJson(artifact); + const preRegistrationHash = hashCanonical(HASH_DOMAINS.preRegistration, artifact); + const runs = seedManifest.entries.flatMap((entry) => + E03_COMMUNICATION_CONDITIONS.map((condition): E03RegisteredRun => ({ + slot: entry.slot, + use: entry.use, + condition, + config: RunConfigSchema.parse({ + ...baseConfig, + runId: `e03-${condition}-s${String(entry.slot)}`, + randomSeed: entry.scenarioSeed, + communicationCondition: condition, + preRegistrationHash, + }), + })), + ); + + return { + version: E03_REGISTRATION_COMPILER_VERSION, + claimBoundary: E03_REGISTRATION_CLAIM_BOUNDARY, + artifact, + canonicalArtifact, + preRegistrationHash, + seedManifest, + runs, + }; +} diff --git a/packages/analysis/src/e03.ts b/packages/analysis/src/e03.ts new file mode 100644 index 0000000..3a81c8d --- /dev/null +++ b/packages/analysis/src/e03.ts @@ -0,0 +1,636 @@ +/** + * E03 analysis: the pre-registered chance-baseline analysis of RESEARCH.md + * Appendix D (§D.6 primary outcomes, §D.7 sensitivity, §D.10 decision rule) + * for EXPERIMENT-NOTEBOOK.md E03 and SPECIFICATION.md §15.3. + * + * The function is a calculator: it computes each registered quantity and + * evaluates the registered decision rule mechanically. It draws no scientific + * conclusion, stores nothing, and reads nothing — `qualifies` is "the + * registered arithmetic came out this way", and the two §D.10 clauses that + * depend on evidence verification and the leakage audit are supplied by the + * harness, not by this package (ALD-072 acceptance criterion 3). + */ +import { deriveSeedHex } from '@ald/hashing'; + +import { + bootstrapMeanReplicates, + bootstrapPairedDifferenceReplicates, + percentileInterval, + type BootstrapCi, +} from './bootstrap.js'; +import { + mean as sampleMean, + proportion, + pooledProportion, + summarize, + wilsonInterval, + type DescriptiveSummary, + type ConfidenceInterval, + type ProportionSummary, + type WilsonInterval, +} from './descriptive.js'; +import { cohensH, rankBiserial } from './effects.js'; +import { + AnalysisError, + assertLevel, + assertProbability, + assertSample, +} from './errors.js'; +import { + evaluateControlEquivalence, + type EquivalenceDecision, +} from './equivalence.js'; +import { + holmBonferroni, + oneSampleTTest, + type OneSampleTTestResult, + type TostResult, +} from './hypothesis.js'; +import { studentTQuantile } from './special.js'; + +/** Appendix D §D.10 clause 3: seeds at or above this success rate are audited. */ +export const E03_HIGH_SEED_THRESHOLD = 0.35; +/** Appendix D §D.10 clause 3: at most this share of a condition's seeds may be high. */ +export const E03_HIGH_SEED_SHARE_LIMIT = 0.05; +/** Appendix D §D.2/§D.3 oracle adequacy floor. */ +export const E03_ORACLE_LOWER_BOUND = 0.9; +/** Appendix D §D.6 item 3 oracle separation floor. */ +export const E03_SEPARATION_LOWER_BOUND = 0.6; +/** SPECIFICATION.md §15.3 floor for E00-E03. */ +export const E03_MINIMUM_SEEDS = 5; + +const DEFAULT_ITERATIONS = 10_000; +const DEFAULT_CONFIDENCE = 0.95; + +/** Per-seed episode counts, or one count that applies to every seed. */ +export type EpisodeCounts = number | readonly number[]; + +export interface E03AnalysisInput { + readonly alpha: number; + /** Lower equivalence bound (Appendix D §D.6: 0.20). */ + readonly equivalenceLower: number; + /** Upper equivalence bound (Appendix D §D.6: 0.30). */ + readonly equivalenceUpper: number; + /** Oracle adequacy floor (Appendix D §D.6 item 2: 0.90). */ + readonly oracleLowerBound: number; + /** Oracle separation floor (Appendix D §D.6 item 3: 0.60). */ + readonly separationLowerBound: number; + /** Root seed for every bootstrap stream; child seeds are derived from it. */ + readonly seed: string; + /** + * Seed-level success proportions per non-oracle condition, keyed by + * `communicationCondition` (Appendix D §D.5: disabled, constant, random, + * shuffled, normal no-learning). Every array must be paired with `oracle` + * by seed slot, hence of equal length. + */ + readonly conditions: Readonly>; + /** Seed-level success proportions for the oracle condition. */ + readonly oracle: readonly number[]; + /** Bootstrap resamples per interval (default 10000). */ + readonly bootstrapIterations?: number; + /** Nominal two-sided bootstrap coverage (default 0.95). */ + readonly confidence?: number; + /** + * Optional episode counts per condition (and `oracle`), enabling the + * descriptive pooled-episode Wilson intervals of Appendix D §D.7. + */ + readonly episodeCounts?: Readonly>; + /** Override the §D.10 high-seed threshold (default 0.35). */ + readonly highSeedThreshold?: number; + /** Override the §D.10 high-seed share limit (default 0.05). */ + readonly highSeedShareLimit?: number; + /** Qualification seed floor; defaults to the normative five-seed minimum. */ + readonly minimumSeeds?: number; +} + +export interface E03HighSeedAudit { + readonly threshold: number; + readonly shareLimit: number; + readonly count: number; + readonly share: number; + /** Zero-based seed slots at or above `threshold`, for the leakage audit. */ + readonly seedIndices: number[]; + /** Diagnostic threshold only; every listed seed requires a leakage audit. */ + readonly auditRequired: boolean; + /** Retained for transparent comparison with the original diagnostic cap. */ + readonly withinLimit: boolean; +} + +export interface E03SeparationResult { + /** Nominal paired bootstrap interval, retained as a sensitivity estimate. */ + readonly interval: BootstrapCi; + /** One-sided seed-level t test of difference above the practical floor. */ + readonly test: OneSampleTTestResult; + readonly rawP: number; + readonly holmAdjustedP: number; + /** Holm rank by ascending raw p, 1 = smallest p. */ + readonly rank: number; + /** Conservative one-sided Bonferroni simultaneous interval. */ + readonly simultaneousInterval: ConfidenceInterval; + /** Holm rejects and the simultaneous lower bound exceeds the floor. */ + readonly meets: boolean; +} + +export interface E03ConditionResult { + readonly condition: string; + /** Number of valid seeds analysed for this condition. */ + readonly n: number; + readonly summary: DescriptiveSummary; + readonly tost: TostResult; + /** TOST p before multiplicity correction: `max(pLower, pUpper)`. */ + readonly rawP: number; + /** Holm-adjusted TOST p across the non-oracle conditions; `NaN` if excluded. */ + readonly holmAdjustedP: number; + /** Decision before correction, from `evaluateControlEquivalence`. */ + readonly unadjustedDecision: EquivalenceDecision; + /** Registered decision: Holm-adjusted equivalence across conditions. */ + readonly decision: EquivalenceDecision; + /** Cohen's h of the observed mean against the equivalence-bound midpoint. */ + readonly cohensHVersusMidpoint: number; + /** Rank-biserial correlation of oracle over this condition (seed level). */ + readonly rankBiserialOracleOverCondition: number; + readonly separation: E03SeparationResult; + readonly highSeeds: E03HighSeedAudit; + /** Pooled-episode Wilson interval, present only when episode counts are given. */ + readonly pooledEpisodes?: WilsonInterval; +} + +export interface E03OracleResult { + readonly n: number; + readonly summary: DescriptiveSummary; + /** Two-sided bootstrap interval retained as a sensitivity estimate. */ + readonly adequacy: BootstrapCi; + /** Primary one-sided seed-level t test above `lowerBound`. */ + readonly test: OneSampleTTestResult; + readonly simultaneousInterval: ConfidenceInterval; + readonly lowerBound: number; + readonly meetsAdequacy: boolean; + readonly pooledEpisodes?: WilsonInterval; +} + +export interface E03Criteria { + /** §D.10 clause 1: all non-oracle conditions meet equivalence. */ + readonly allControlsEquivalent: boolean; + /** §D.10 clause 2a: oracle adequacy. */ + readonly oracleAdequate: boolean; + /** §D.10 clause 2b: oracle separation for every non-oracle condition. */ + readonly allSeparationsMeet: boolean; +} + +export interface E03Analysis { + readonly alpha: number; + readonly equivalenceLower: number; + readonly equivalenceUpper: number; + readonly oracleLowerBound: number; + readonly separationLowerBound: number; + readonly seed: string; + readonly bootstrapIterations: number; + readonly confidence: number; + /** Conditions in the caller's key order. */ + readonly conditions: E03ConditionResult[]; + readonly oracle: E03OracleResult; + readonly criteria: E03Criteria; + /** + * Every registered clause this run fails, as stable codes such as + * `equivalence:shuffled`, `separation:random`, or `oracle-adequacy`. + * Empty iff `qualifies` is true. + */ + readonly unmetCriteria: string[]; + /** + * The three computable §D.10 clauses all hold. The two remaining clauses + * (all included evidence bundles verify; no unplanned metadata or channel + * leakage detected) are the harness's to add. + */ + readonly qualifies: boolean; + /** High-tail diagnostics requiring case-level leakage review by the harness. */ + readonly auditTriggers: string[]; + readonly minimumSeeds: number; + /** Human-readable provenance of the rule that produced `qualifies`. */ + readonly decisionRule: string; +} + +const DECISION_RULE = + 'RESEARCH.md Appendix D §D.10 numeric clauses (Holm control equivalence, ' + + 'one-sided seed-level oracle adequacy, and Holm separation). High-tail ' + + 'case review, evidence-bundle verification, and leakage disposition are ' + + 'supplied by the harness.'; + +/** Seed-level success proportions must be probabilities (Appendix D §D.6). */ +function assertProportions(rates: readonly number[], label: string): void { + rates.forEach((rate, index) => { + assertProbability(rate, `${label}[${index}]`); + }); +} + +function episodeCountsFor( + counts: EpisodeCounts | undefined, + seeds: number, + label: string, +): number[] | undefined { + if (counts === undefined) { + return undefined; + } + if (typeof counts === 'number') { + if (!Number.isInteger(counts) || counts < 1) { + throw new AnalysisError( + 'domain', + `episodeCounts.${label} must be a positive integer`, + ); + } + return new Array(seeds).fill(counts); + } + if (counts.length !== seeds) { + throw new AnalysisError( + 'length-mismatch', + `episodeCounts.${label} must have one count per seed`, + ); + } + return counts.map((count, index) => { + if (!Number.isInteger(count) || count < 1) { + throw new AnalysisError( + 'domain', + `episodeCounts.${label}[${index}] must be a positive integer`, + ); + } + return count; + }); +} + +/** + * Pooled-episode Wilson interval. Seed-level proportions are converted back to + * episode counts by `round(rate * episodes)`, which is exact whenever the rate + * came from that episode count. Appendix D §D.7 marks these intervals + * descriptive only: the inferential unit stays the seed (§D.6). + */ +function pooledWilson( + rates: readonly number[], + counts: readonly number[], + confidence: number, +): WilsonInterval { + const parts: ProportionSummary[] = rates.map((rate, index) => { + const episodes = counts[index] as number; + return proportion(Math.round(rate * episodes), episodes); + }); + const pooled = pooledProportion(parts); + return wilsonInterval(pooled.successes, pooled.n, confidence); +} + +function highSeedAudit( + rates: readonly number[], + threshold: number, + shareLimit: number, +): E03HighSeedAudit { + const seedIndices: number[] = []; + rates.forEach((rate, index) => { + if (rate >= threshold) { + seedIndices.push(index); + } + }); + const share = seedIndices.length / rates.length; + return { + threshold, + shareLimit, + count: seedIndices.length, + share, + seedIndices, + auditRequired: seedIndices.length > 0, + withinLimit: share <= shareLimit, + }; +} + +function oneSidedLowerInterval( + test: OneSampleTTestResult, + alpha: number, +): ConfidenceInterval { + const level = 1 - alpha; + if (test.n < 2 || Number.isNaN(test.se)) { + return { lower: NaN, upper: 1, level }; + } + const critical = studentTQuantile(level, test.df); + return { + lower: test.mean - critical * test.se, + upper: 1, + level, + }; +} + +/** + * Run the complete Appendix D §D.6 analysis and evaluate the §D.10 rule. + * + * Multiplicity choices, both pre-registerable and both documented here + * because Appendix D fixes the correction family but not its mechanics: + * + * 1. **Equivalence.** Holm-Bonferroni is applied to each condition's TOST p + * value, `max(pLower, pUpper)`. That maximum *is* the TOST p value, so + * rejecting it at the Holm-adjusted alpha is exactly §D.6's "both + * one-sided tests reject at the Holm-adjusted alpha", and the family is + * the non-oracle conditions. Conditions with fewer than + * `MINIMUM_EQUIVALENCE_SEEDS` seeds are excluded from the family (their + * p is undefined) and reported as `insufficient-seeds`. + * 2. **Adequacy and separation.** Primary decisions use one-sided seed-level + * t tests. Separation p values receive Holm correction. A conservative + * Bonferroni one-sided simultaneous interval accompanies each separation; + * the percentile bootstrap remains a sensitivity interval. This avoids + * ranking confidence levels by observed effect magnitude, which is not + * equivalent to Holm ordering when standard errors differ. + */ +export function e03Analysis(input: E03AnalysisInput): E03Analysis { + assertLevel(input.alpha, 'alpha'); + assertSample(input.oracle, 'oracle'); + if (typeof input.seed !== 'string' || input.seed.length === 0) { + throw new AnalysisError('domain', 'seed must be a non-empty string'); + } + if (!(input.equivalenceLower < input.equivalenceUpper)) { + throw new AnalysisError( + 'domain', + 'equivalenceLower must be below equivalenceUpper', + ); + } + const names = Object.keys(input.conditions); + if (names.length === 0) { + throw new AnalysisError( + 'empty-sample', + 'at least one non-oracle condition is required', + ); + } + const iterations = input.bootstrapIterations ?? DEFAULT_ITERATIONS; + const confidence = input.confidence ?? DEFAULT_CONFIDENCE; + assertLevel(confidence, 'confidence'); + const threshold = input.highSeedThreshold ?? E03_HIGH_SEED_THRESHOLD; + const shareLimit = input.highSeedShareLimit ?? E03_HIGH_SEED_SHARE_LIMIT; + const minimumSeeds = input.minimumSeeds ?? E03_MINIMUM_SEEDS; + if (!Number.isInteger(minimumSeeds) || minimumSeeds < 2) { + throw new AnalysisError('domain', 'minimumSeeds must be an integer at least 2'); + } + const midpoint = (input.equivalenceLower + input.equivalenceUpper) / 2; + + const oracleRates = input.oracle; + assertProportions(oracleRates, 'oracle'); + const oracleMean = sampleMean(oracleRates); + + // --- per-condition descriptive statistics, TOST, effect sizes ----------- + interface Working { + readonly condition: string; + readonly rates: readonly number[]; + readonly summary: DescriptiveSummary; + readonly tost: TostResult; + readonly unadjustedDecision: EquivalenceDecision; + readonly rawP: number; + readonly replicates: readonly number[]; + readonly differences: readonly number[]; + readonly separationTest: OneSampleTTestResult; + readonly difference: number; + readonly highSeeds: E03HighSeedAudit; + readonly pooledEpisodes?: WilsonInterval; + } + const working: Working[] = names.map((condition) => { + const rates = input.conditions[condition] as readonly number[]; + assertSample(rates, `conditions.${condition}`); + assertProportions(rates, `conditions.${condition}`); + if (rates.length !== oracleRates.length) { + throw new AnalysisError( + 'length-mismatch', + `conditions.${condition} must be paired with oracle by seed slot`, + ); + } + const equivalence = evaluateControlEquivalence({ + seedSuccessRates: rates, + lower: input.equivalenceLower, + upper: input.equivalenceUpper, + alpha: input.alpha, + }); + const counts = episodeCountsFor( + input.episodeCounts?.[condition], + rates.length, + condition, + ); + const replicates = bootstrapPairedDifferenceReplicates( + oracleRates, + rates, + { + seed: deriveSeedHex(input.seed, 'e03-separation', condition), + iterations, + confidence, + }, + ); + const differences = oracleRates.map( + (oracleRate, index) => oracleRate - (rates[index] as number), + ); + return { + condition, + rates, + summary: summarize(rates), + tost: equivalence.tost, + unadjustedDecision: equivalence.decision, + rawP: equivalence.tost.p, + replicates, + differences, + separationTest: oneSampleTTest( + differences, + input.separationLowerBound, + 'greater', + ), + difference: oracleMean - sampleMean(rates), + highSeeds: highSeedAudit(rates, threshold, shareLimit), + ...(counts === undefined + ? {} + : { pooledEpisodes: pooledWilson(rates, counts, confidence) }), + }; + }); + + // --- Holm across conditions for the equivalence family ------------------ + const family = working.filter( + (entry) => + entry.rates.length >= minimumSeeds && + entry.unadjustedDecision !== 'insufficient-seeds' && + Number.isFinite(entry.rawP), + ); + const holmAdjusted = new Map(); + const holmRejected = new Map(); + if (family.length > 0) { + const holm = holmBonferroni( + family.map((entry) => entry.rawP), + input.alpha, + ); + family.forEach((entry, index) => { + holmAdjusted.set(entry.condition, holm.adjusted[index] as number); + holmRejected.set(entry.condition, holm.rejected[index] as boolean); + }); + } + + // --- Holm separation tests and simultaneous intervals ------------------- + const separationFamily = working.filter( + (entry) => + entry.rates.length >= minimumSeeds && + Number.isFinite(entry.separationTest.p), + ); + const separationAdjusted = new Map(); + const separationRejected = new Map(); + if (separationFamily.length > 0) { + const holm = holmBonferroni( + separationFamily.map((entry) => entry.separationTest.p), + input.alpha, + ); + separationFamily.forEach((entry, index) => { + separationAdjusted.set(entry.condition, holm.adjusted[index] as number); + separationRejected.set(entry.condition, holm.rejected[index] as boolean); + }); + } + const separationOrder = [...working].sort((left, right) => { + const leftP = Number.isFinite(left.separationTest.p) + ? left.separationTest.p + : Infinity; + const rightP = Number.isFinite(right.separationTest.p) + ? right.separationTest.p + : Infinity; + return leftP - rightP; + }); + const m = working.length; + const separations = new Map(); + separationOrder.forEach((entry, index) => { + const rank = index + 1; + const nominal = percentileInterval(entry.replicates, confidence); + const base = { + estimate: entry.difference, + n: entry.rates.length, + iterations, + seed: deriveSeedHex(input.seed, 'e03-separation', entry.condition), + }; + const adjustedP = separationAdjusted.get(entry.condition) ?? NaN; + const simultaneousInterval = oneSidedLowerInterval( + entry.separationTest, + input.alpha / m, + ); + const meets = + (separationRejected.get(entry.condition) ?? false) && + simultaneousInterval.lower > input.separationLowerBound; + separations.set(entry.condition, { + interval: { ...nominal, ...base }, + test: entry.separationTest, + rawP: entry.separationTest.p, + holmAdjustedP: adjustedP, + rank, + simultaneousInterval, + meets, + }); + }); + + // --- oracle adequacy ---------------------------------------------------- + const oracleSeed = deriveSeedHex(input.seed, 'e03-oracle-adequacy'); + const oracleReplicates = bootstrapMeanReplicates(oracleRates, { + seed: oracleSeed, + iterations, + confidence, + }); + const oracleInterval = percentileInterval(oracleReplicates, confidence); + const oracleTest = oneSampleTTest( + oracleRates, + input.oracleLowerBound, + 'greater', + ); + const oracleSimultaneousInterval = oneSidedLowerInterval( + oracleTest, + input.alpha, + ); + const oracleCounts = episodeCountsFor( + input.episodeCounts?.['oracle'], + oracleRates.length, + 'oracle', + ); + const oracle: E03OracleResult = { + n: oracleRates.length, + summary: summarize(oracleRates), + adequacy: { + ...oracleInterval, + estimate: oracleMean, + n: oracleRates.length, + iterations, + seed: oracleSeed, + }, + test: oracleTest, + simultaneousInterval: oracleSimultaneousInterval, + lowerBound: input.oracleLowerBound, + meetsAdequacy: + oracleRates.length >= minimumSeeds && + oracleTest.p < input.alpha && + oracleSimultaneousInterval.lower > input.oracleLowerBound, + ...(oracleCounts === undefined + ? {} + : { + pooledEpisodes: pooledWilson(oracleRates, oracleCounts, confidence), + }), + }; + + // --- assemble ----------------------------------------------------------- + const unmetCriteria: string[] = []; + const auditTriggers: string[] = []; + const conditions: E03ConditionResult[] = working.map((entry) => { + const adjustedP = holmAdjusted.get(entry.condition) ?? NaN; + const rejected = holmRejected.get(entry.condition) ?? false; + const decision: EquivalenceDecision = + entry.rates.length < minimumSeeds || + entry.unadjustedDecision === 'insufficient-seeds' + ? 'insufficient-seeds' + : rejected + ? 'equivalent' + : 'not-equivalent'; + const separation = separations.get(entry.condition) as E03SeparationResult; + if (decision !== 'equivalent') { + unmetCriteria.push(`equivalence:${entry.condition}`); + } + if (!separation.meets) { + unmetCriteria.push(`separation:${entry.condition}`); + } + if (entry.highSeeds.auditRequired) { + auditTriggers.push(`high-seed-review:${entry.condition}`); + } + return { + condition: entry.condition, + n: entry.rates.length, + summary: entry.summary, + tost: entry.tost, + rawP: entry.rawP, + holmAdjustedP: adjustedP, + unadjustedDecision: entry.unadjustedDecision, + decision, + cohensHVersusMidpoint: cohensH(entry.summary.mean, midpoint), + rankBiserialOracleOverCondition: rankBiserial(oracleRates, entry.rates).r, + separation, + highSeeds: entry.highSeeds, + ...(entry.pooledEpisodes === undefined + ? {} + : { pooledEpisodes: entry.pooledEpisodes }), + }; + }); + if (!oracle.meetsAdequacy) { + unmetCriteria.push('oracle-adequacy'); + } + const criteria: E03Criteria = { + allControlsEquivalent: conditions.every( + (entry) => entry.decision === 'equivalent', + ), + oracleAdequate: oracle.meetsAdequacy, + allSeparationsMeet: conditions.every((entry) => entry.separation.meets), + }; + return { + alpha: input.alpha, + equivalenceLower: input.equivalenceLower, + equivalenceUpper: input.equivalenceUpper, + oracleLowerBound: input.oracleLowerBound, + separationLowerBound: input.separationLowerBound, + seed: input.seed, + bootstrapIterations: iterations, + confidence, + conditions, + oracle, + criteria, + unmetCriteria, + auditTriggers, + qualifies: + criteria.allControlsEquivalent && + criteria.oracleAdequate && + criteria.allSeparationsMeet, + minimumSeeds, + decisionRule: DECISION_RULE, + }; +} diff --git a/packages/analysis/src/e11.ts b/packages/analysis/src/e11.ts new file mode 100644 index 0000000..fb3e664 --- /dev/null +++ b/packages/analysis/src/e11.ts @@ -0,0 +1,183 @@ +/** + * E11 summary: the from-scratch RL naming-game readout of + * EXPERIMENT-NOTEBOOK.md E11 ("Training success", "Held-out success", + * "Compare against E03 controls"), using the SPECIFICATION.md §15.3 statistics + * and the E03 chance baseline. + * + * Like `e03Analysis` this is a calculator: it returns a windowed training + * curve, the evaluation proportion with its Wilson interval, and a one-sided + * chance comparison. It attaches no verdict — E11's acceptance checkboxes are + * the researcher's to tick (ALD-072 acceptance criterion 3). + */ +import { + proportion, + wilsonInterval, + type ProportionSummary, + type WilsonInterval, +} from './descriptive.js'; +import { cohensH } from './effects.js'; +import { AnalysisError, assertLevel, assertProbability } from './errors.js'; +import { binomialTest, type BinomialTestResult } from './hypothesis.js'; + +/** + * Appendix D §D.3 chance success for the four-candidate referential task; the + * default null rate for the E11 above-chance comparison. + */ +export const E03_CHANCE_RATE = 0.25; + +export interface E11SummaryInput { + /** Per-turn training outcomes, 0 or 1, in turn order. */ + readonly trainingSuccess: readonly number[]; + /** Per-turn evaluation outcomes (learning disabled), 0 or 1. */ + readonly evaluationSuccess: readonly number[]; + /** Turns per training-curve window; the last window may be shorter. */ + readonly windowSize: number; + /** Null success probability for the chance comparison (default 0.25). */ + readonly chanceRate?: number; + /** Wilson/interval coverage (default 0.95). */ + readonly confidence?: number; +} + +export interface E11TrainingWindow { + /** Zero-based window index. */ + readonly index: number; + /** Inclusive first turn of the window. */ + readonly startTurn: number; + /** Exclusive last turn of the window. */ + readonly endTurn: number; + readonly n: number; + readonly successes: number; + readonly rate: number; + /** False for a trailing window shorter than `windowSize`. */ + readonly complete: boolean; + readonly wilson: WilsonInterval; +} + +export interface E11Summary { + readonly windowSize: number; + readonly confidence: number; + readonly chanceRate: number; + /** Pooled training outcome across every turn. */ + readonly training: ProportionSummary; + /** Non-overlapping training windows in turn order. */ + readonly trainingCurve: E11TrainingWindow[]; + readonly evaluation: ProportionSummary; + readonly evaluationWilson: WilsonInterval; + /** + * One-sided (`greater`) comparison of the evaluation proportion against + * `chanceRate`, with both the exact binomial tail and the normal + * approximation. + */ + readonly chanceComparison: BinomialTestResult; + /** Cohen's h of the evaluation proportion against `chanceRate` (§15.3). */ + readonly cohensHVersusChance: number; +} + +function countSuccesses( + outcomes: readonly number[], + label: string, + from: number, + to: number, +): number { + let successes = 0; + for (let index = from; index < to; index += 1) { + const outcome = outcomes[index] as number; + if (outcome !== 0 && outcome !== 1) { + throw new AnalysisError('domain', `${label}[${index}] must be 0 or 1`); + } + successes += outcome; + } + return successes; +} + +/** + * Windowed training curve plus the evaluation readout and chance comparison. + * + * Windows are non-overlapping and taken in turn order; a trailing partial + * window is reported with its true `n` and `complete: false` rather than + * dropped, so no turn silently disappears from the curve. Each window carries + * its own Wilson interval, which is descriptive (turns within a run are not + * independent seeds — RESEARCH.md §7.3, Appendix D §D.6). + */ +export function e11Summary(input: E11SummaryInput): E11Summary { + const { trainingSuccess, evaluationSuccess } = input; + if (!Number.isInteger(input.windowSize) || input.windowSize < 1) { + throw new AnalysisError( + 'domain', + 'windowSize must be a positive integer', + ); + } + if (trainingSuccess.length === 0) { + throw new AnalysisError('empty-sample', 'trainingSuccess must not be empty'); + } + if (evaluationSuccess.length === 0) { + throw new AnalysisError( + 'empty-sample', + 'evaluationSuccess must not be empty', + ); + } + const confidence = input.confidence ?? 0.95; + assertLevel(confidence, 'confidence'); + const chanceRate = input.chanceRate ?? E03_CHANCE_RATE; + assertProbability(chanceRate, 'chanceRate'); + + const trainingCurve: E11TrainingWindow[] = []; + for ( + let start = 0, index = 0; + start < trainingSuccess.length; + start += input.windowSize, index += 1 + ) { + const end = Math.min(start + input.windowSize, trainingSuccess.length); + const successes = countSuccesses( + trainingSuccess, + 'trainingSuccess', + start, + end, + ); + const n = end - start; + trainingCurve.push({ + index, + startTurn: start, + endTurn: end, + n, + successes, + rate: successes / n, + complete: n === input.windowSize, + wilson: wilsonInterval(successes, n, confidence), + }); + } + + const trainingSuccesses = countSuccesses( + trainingSuccess, + 'trainingSuccess', + 0, + trainingSuccess.length, + ); + const evaluationSuccesses = countSuccesses( + evaluationSuccess, + 'evaluationSuccess', + 0, + evaluationSuccess.length, + ); + const evaluation = proportion(evaluationSuccesses, evaluationSuccess.length); + return { + windowSize: input.windowSize, + confidence, + chanceRate, + training: proportion(trainingSuccesses, trainingSuccess.length), + trainingCurve, + evaluation, + evaluationWilson: wilsonInterval( + evaluation.successes, + evaluation.n, + confidence, + ), + chanceComparison: binomialTest( + evaluation.successes, + evaluation.n, + chanceRate, + 'greater', + ), + cohensHVersusChance: cohensH(evaluation.proportion, chanceRate), + }; +} diff --git a/packages/analysis/src/effects.ts b/packages/analysis/src/effects.ts new file mode 100644 index 0000000..bbf9061 --- /dev/null +++ b/packages/analysis/src/effects.ts @@ -0,0 +1,65 @@ +/** + * Effect sizes SPECIFICATION.md §15.3 makes mandatory alongside every + * significance test: Cohen's h for proportions and the rank-biserial + * correlation for ordinal comparisons. + */ +import { + AnalysisError, + assertProbability, + assertSample, +} from './errors.js'; + +/** + * Cohen's h — the arcsine-transformed difference between two proportions, + * `2*asin(sqrt(p1)) - 2*asin(sqrt(p2))` (SPECIFICATION.md §15.3 "Cohen's h + * for proportions"). Positive when `p1 > p2`; the conventional small/medium/ + * large landmarks are 0.2 / 0.5 / 0.8. Interpretation is the researcher's, not + * this toolkit's. + */ +export function cohensH(p1: number, p2: number): number { + assertProbability(p1, 'p1'); + assertProbability(p2, 'p2'); + return 2 * Math.asin(Math.sqrt(p1)) - 2 * Math.asin(Math.sqrt(p2)); +} + +export interface RankBiserialResult { + /** Rank-biserial correlation in [-1, 1]; positive when `a` tends to exceed `b`. */ + readonly r: number; + /** Mann-Whitney U for sample `a` (ties counted as one half). */ + readonly u: number; + readonly nA: number; + readonly nB: number; +} + +/** + * Rank-biserial correlation from the Mann-Whitney U statistic + * (SPECIFICATION.md §15.3 "rank-biserial for ordinal comparisons"). + * + * `u` counts pairs `(a_i, b_j)` with `a_i > b_j`, adding one half per tie, and + * `r = 2 * u / (nA * nB) - 1`, i.e. `P(a > b) - P(a < b)` with ties split. + * Computed by direct pair enumeration, which is exact, tie-safe, and — at the + * seed counts of Appendix D §D.7 (at most a few hundred per condition) — fast + * enough that no rank-sum shortcut is needed. + */ +export function rankBiserial( + a: readonly number[], + b: readonly number[], +): RankBiserialResult { + assertSample(a, 'a'); + assertSample(b, 'b'); + let u = 0; + for (const left of a) { + for (const right of b) { + if (left > right) { + u += 1; + } else if (left === right) { + u += 0.5; + } + } + } + const pairs = a.length * b.length; + if (pairs === 0) { + throw new AnalysisError('empty-sample', 'both samples must be non-empty'); + } + return { r: (2 * u) / pairs - 1, u, nA: a.length, nB: b.length }; +} diff --git a/packages/analysis/src/equivalence.ts b/packages/analysis/src/equivalence.ts new file mode 100644 index 0000000..14b55f1 --- /dev/null +++ b/packages/analysis/src/equivalence.ts @@ -0,0 +1,66 @@ +/** + * Control-equivalence decision helper (SPECIFICATION.md §15.3 "Any conclusion + * that performance is equivalent to chance ... MUST use a pre-registered + * equivalence/non-inferiority bound"; RESEARCH.md Appendix D §D.6 item 1). + */ +import { tost, type TostResult } from './hypothesis.js'; +import { assertSample } from './errors.js'; + +export type EquivalenceDecision = + | 'equivalent' + | 'not-equivalent' + | 'insufficient-seeds'; + +/** + * Minimum seeds this helper will run a TOST on. Two seeds give df = 1 and a + * t interval so wide that the test is uninformative, and SPECIFICATION.md + * §15.3 sets an engineering floor of five seeds per condition for E00-E03; + * three is the point below which the toolkit refuses to produce a decision at + * all and reports `insufficient-seeds` instead. + */ +export const MINIMUM_EQUIVALENCE_SEEDS = 3; + +export interface ControlEquivalenceInput { + /** Seed-level success proportions, one per valid seed (Appendix D §D.6). */ + readonly seedSuccessRates: readonly number[]; + readonly lower: number; + readonly upper: number; + readonly alpha: number; +} + +export interface ControlEquivalenceResult { + readonly tost: TostResult; + readonly decision: EquivalenceDecision; + readonly minimumSeeds: number; +} + +/** + * Run the TOST for one control condition and map it to a decision. The + * decision is the mechanical result of the pre-registered rule: `equivalent` + * iff both one-sided tests reject at `alpha`, `insufficient-seeds` when fewer + * than `MINIMUM_EQUIVALENCE_SEEDS` seeds are supplied (never silently + * `not-equivalent`, which would read as evidence of a difference). + */ +export function evaluateControlEquivalence( + input: ControlEquivalenceInput, +): ControlEquivalenceResult { + assertSample(input.seedSuccessRates, 'seedSuccessRates'); + const result = tost( + input.seedSuccessRates, + input.lower, + input.upper, + input.alpha, + ); + if (input.seedSuccessRates.length < MINIMUM_EQUIVALENCE_SEEDS) { + return { + tost: result, + decision: 'insufficient-seeds', + minimumSeeds: MINIMUM_EQUIVALENCE_SEEDS, + }; + } + return { + tost: result, + decision: result.equivalent ? 'equivalent' : 'not-equivalent', + minimumSeeds: MINIMUM_EQUIVALENCE_SEEDS, + }; +} diff --git a/packages/analysis/src/errors.ts b/packages/analysis/src/errors.ts new file mode 100644 index 0000000..6e2ec86 --- /dev/null +++ b/packages/analysis/src/errors.ts @@ -0,0 +1,70 @@ +/** + * Error type for the pre-registered statistics toolkit (BACKLOG ALD-072). + * + * The toolkit is pure and deterministic: every failure is an input-domain or + * numerical-convergence failure, never I/O. Callers get a stable `code` so a + * harness can record the failure in evidence without string matching. + */ +export type AnalysisErrorCode = + | 'domain' + | 'empty-sample' + | 'length-mismatch' + | 'no-convergence'; + +export class AnalysisError extends Error { + constructor( + public readonly code: AnalysisErrorCode, + message: string, + ) { + super(message); + this.name = 'AnalysisError'; + } +} + +/** Every value must be a finite number; the toolkit never silently drops NaN. */ +export function assertFiniteValues( + values: readonly number[], + label: string, +): void { + for (let index = 0; index < values.length; index += 1) { + const value = values[index] as number; + if (!Number.isFinite(value)) { + throw new AnalysisError( + 'domain', + `${label}[${index}] must be a finite number`, + ); + } + } +} + +/** Non-empty finite sample; `summarize` and the tests below all require it. */ +export function assertSample(values: readonly number[], label: string): void { + if (values.length === 0) { + throw new AnalysisError('empty-sample', `${label} must not be empty`); + } + assertFiniteValues(values, label); +} + +/** A probability in the closed unit interval. */ +export function assertProbability(value: number, label: string): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new AnalysisError('domain', `${label} must be within [0, 1]`); + } +} + +/** A confidence level strictly inside (0, 1), e.g. 0.95. */ +export function assertLevel(level: number, label: string): void { + if (!Number.isFinite(level) || level <= 0 || level >= 1) { + throw new AnalysisError('domain', `${label} must be within (0, 1)`); + } +} + +/** A non-negative integer count. */ +export function assertCount(value: number, label: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new AnalysisError( + 'domain', + `${label} must be a non-negative integer`, + ); + } +} diff --git a/packages/analysis/src/hierarchical.ts b/packages/analysis/src/hierarchical.ts new file mode 100644 index 0000000..22b68e5 --- /dev/null +++ b/packages/analysis/src/hierarchical.ts @@ -0,0 +1,620 @@ +/** + * Seed-clustered inference for the SPECIFICATION.md §15.2 intervention suite + * (BACKLOG ALD-072, EXPERIMENT-NOTEBOOK.md E16). + * + * §15.2 fixes two things and separates them deliberately: + * + * 1. a *descriptive* readiness threshold — "within each run, ledger-predicted + * direction matches observed behavior change in at least 70% of probed + * instances. This is not an inferential test" — which lives in + * `@ald/interventions` next to the probe schedule; and + * 2. *confirmatory* inference, which "MUST account for probe clustering within + * run/seed using a hierarchical Bernoulli model or a pre-registered + * seed-level equivalent against the E03 chance baseline at alpha = 0.05". + * + * This module implements both halves of (2) so a pre-registration can name + * which one is primary: + * + * - {@link seedLevelAgreement} — the pre-registered seed-level equivalent. The + * unit of analysis is the per-seed agreement proportion (RESEARCH.md + * Appendix D §D.6), so probe clustering is handled by aggregation: a + * one-sample t test of the seed proportions against the chance baseline, + * with the pooled Wilson interval reported alongside it as descriptive only + * (§D.7) and a seeded percentile bootstrap of the seed mean. + * - {@link betaBinomialAgreement} — the two-level hierarchical Bernoulli + * model: probes within a seed are Bernoulli(p_seed), and p_seed is + * Beta(mu * s, (1 - mu) * s), i.e. the beta-binomial marginal likelihood. + * `mu` is the population agreement rate and `rho = 1 / (s + 1)` the + * intra-seed correlation that a pooled-probe analysis would ignore. The + * fit is a deterministic grid search with fixed refinement rounds (no + * iterative solver, no randomness), and its uncertainty is a seeded + * bootstrap over *seeds*, which is the clustered resampling unit. + * + * {@link welchTTest} is the unequal-variance two-sample test the E15 bandwidth + * contrast needs on seed means (`composition.ts`). + * + * Nothing here interprets a result. A `decision` field is the mechanical + * outcome of the pre-registered rule applied to the numbers (ALD-072 + * acceptance criterion 3): `above-chance` means "the pre-registered rule fired + * on this sample", never "communication happened". + */ +import { SeededPrng } from '@ald/hashing'; + +import { + bootstrapMeanCi, + percentileInterval, + type BootstrapCi, +} from './bootstrap.js'; +import { + proportion, + summarize, + wilsonInterval, + type ConfidenceInterval, + type ProportionSummary, + type WilsonInterval, +} from './descriptive.js'; +import { cohensH } from './effects.js'; +import { + AnalysisError, + assertCount, + assertLevel, + assertProbability, +} from './errors.js'; +import { oneSampleTTest, type OneSampleTTestResult } from './hypothesis.js'; +import { logBeta, logBinomialCoefficient, studentTCdf } from './special.js'; + +/** Analysis version stamped on every readout of this module. */ +export const HIERARCHICAL_ANALYSIS_VERSION = 'hierarchical-agreement/v1'; + +/** + * §15.3 minimum seeds: 5 per condition for the qualification-stage + * experiments (E00-E03), 10 for any publication-facing claim (E10 onward). + * The default is the qualification floor; a confirmatory pre-registration + * passes `minimumSeeds: 10` (or E15/E16's own 75). + */ +export const QUALIFICATION_MINIMUM_SEEDS = 5; + +/** One seed's probe tally. `agreements` counts scored probes that agreed. */ +export interface SeedAgreementCount { + readonly seed: string; + readonly agreements: number; + readonly probes: number; +} + +export interface SeedAgreementProportion extends SeedAgreementCount { + readonly proportion: number; +} + +export type AgreementDecision = + | 'above-chance' + | 'not-above-chance' + | 'insufficient-seeds'; + +export interface SeedLevelAgreementInput { + readonly seeds: readonly SeedAgreementCount[]; + /** E03 chance baseline for the task, e.g. 0.25 for four candidates. */ + readonly chanceRate: number; + /** Pre-registered α per primary hypothesis (§15.3 default 0.05). */ + readonly alpha?: number; + /** Coverage of the reported intervals (default 0.95). */ + readonly confidence?: number; + /** §15.3 seed floor for the claim this analysis backs (default 5). */ + readonly minimumSeeds?: number; + /** Omit to skip the bootstrap; the t test does not need it. */ + readonly bootstrap?: { readonly seed: string; readonly iterations?: number }; +} + +export interface SeedLevelAgreementResult { + readonly analysisVersion: string; + readonly method: 'seed-level-equivalent'; + readonly seeds: readonly SeedAgreementProportion[]; + readonly seedCount: number; + /** Mean of the per-seed proportions — the §15.3/§D.6 unit of analysis. */ + readonly seedMean: number; + readonly seedSd: number; + /** Probes pooled across seeds. Descriptive only (Appendix D §D.7). */ + readonly pooledDescriptive: ProportionSummary; + /** Wilson interval over the pooled probes. Descriptive only (§D.7). */ + readonly pooledWilsonDescriptive: WilsonInterval; + /** One-sided (`greater`) t test of the seed proportions against chance. */ + readonly seedMeanTTest: OneSampleTTestResult; + /** Seeded percentile bootstrap of the seed mean; `null` when not requested. */ + readonly seedMeanBootstrap: BootstrapCi | null; + /** §15.3 mandatory effect size: seed mean versus the chance baseline. */ + readonly cohensHVersusChance: number; + readonly seedsAtOrBelowChance: number; + readonly chanceRate: number; + readonly alpha: number; + readonly confidence: number; + readonly minimumSeeds: number; + readonly meetsSeedMinimum: boolean; + readonly decision: AgreementDecision; +} + +function assertSeeds(seeds: readonly SeedAgreementCount[]): void { + if (seeds.length === 0) { + throw new AnalysisError('empty-sample', 'seeds must not be empty'); + } + const seen = new Set(); + seeds.forEach((entry, index) => { + if (typeof entry.seed !== 'string' || entry.seed.length === 0) { + throw new AnalysisError( + 'domain', + `seeds[${index}].seed must be a non-empty string`, + ); + } + if (seen.has(entry.seed)) { + throw new AnalysisError( + 'domain', + `seeds[${index}].seed is a duplicate: ${entry.seed}`, + ); + } + seen.add(entry.seed); + assertCount(entry.agreements, `seeds[${index}].agreements`); + assertCount(entry.probes, `seeds[${index}].probes`); + if (entry.probes === 0) { + throw new AnalysisError( + 'empty-sample', + `seeds[${index}].probes must be greater than zero`, + ); + } + if (entry.agreements > entry.probes) { + throw new AnalysisError( + 'domain', + `seeds[${index}].agreements must not exceed probes`, + ); + } + }); +} + +/** + * The pre-registered seed-level equivalent of a hierarchical model + * (SPECIFICATION.md §15.2): aggregate each seed's probes into one proportion + * and test the seed proportions against the E03 chance baseline. + * + * `decision` is `insufficient-seeds` whenever fewer than two seeds are + * present (a t test has no variance estimate at n = 1) or the sample is + * degenerate in the sense `oneSampleTTest` documents; otherwise it is + * `above-chance` exactly when the one-sided p value is below `alpha`. + * `meetsSeedMinimum` is reported separately: an `above-chance` decision on + * three seeds still fails the §15.3 seed policy, and the caller must not + * present it as a publication-facing result. + */ +export function seedLevelAgreement( + input: SeedLevelAgreementInput, +): SeedLevelAgreementResult { + assertSeeds(input.seeds); + assertProbability(input.chanceRate, 'chanceRate'); + const alpha = input.alpha ?? 0.05; + const confidence = input.confidence ?? 0.95; + assertLevel(alpha, 'alpha'); + assertLevel(confidence, 'confidence'); + const minimumSeeds = input.minimumSeeds ?? QUALIFICATION_MINIMUM_SEEDS; + assertCount(minimumSeeds, 'minimumSeeds'); + + const seeds: SeedAgreementProportion[] = input.seeds.map((entry) => ({ + ...entry, + proportion: entry.agreements / entry.probes, + })); + const proportions = seeds.map((entry) => entry.proportion); + const stats = summarize(proportions); + const pooled = proportion( + seeds.reduce((total, entry) => total + entry.agreements, 0), + seeds.reduce((total, entry) => total + entry.probes, 0), + ); + const tTest = oneSampleTTest(proportions, input.chanceRate, 'greater'); + const bootstrap = + input.bootstrap === undefined + ? null + : bootstrapMeanCi(proportions, { + seed: input.bootstrap.seed, + ...(input.bootstrap.iterations === undefined + ? {} + : { iterations: input.bootstrap.iterations }), + confidence, + }); + + let decision: AgreementDecision; + if (seeds.length < 2 || Number.isNaN(tTest.p)) { + decision = 'insufficient-seeds'; + } else { + decision = tTest.p < alpha ? 'above-chance' : 'not-above-chance'; + } + + return { + analysisVersion: HIERARCHICAL_ANALYSIS_VERSION, + method: 'seed-level-equivalent', + seeds, + seedCount: seeds.length, + seedMean: stats.mean, + seedSd: stats.sd, + pooledDescriptive: pooled, + pooledWilsonDescriptive: wilsonInterval( + pooled.successes, + pooled.n, + confidence, + ), + seedMeanTTest: tTest, + seedMeanBootstrap: bootstrap, + cohensHVersusChance: cohensH( + Math.min(1, Math.max(0, stats.mean)), + input.chanceRate, + ), + seedsAtOrBelowChance: proportions.filter( + (value) => value <= input.chanceRate, + ).length, + chanceRate: input.chanceRate, + alpha, + confidence, + minimumSeeds, + meetsSeedMinimum: seeds.length >= minimumSeeds, + decision, + }; +} + +// --------------------------------------------------------------------------- +// Two-level hierarchical Bernoulli (beta-binomial) fit +// --------------------------------------------------------------------------- + +/** Grid points per parameter per refinement round. Fixed, so the fit replays. */ +const GRID_POINTS = 25; +/** Refinement rounds; each shrinks the search window by `GRID_SHRINK`. */ +const GRID_ROUNDS = 3; +const GRID_SHRINK = 4; +/** Search window for `log10(s)`; `s` is the beta precision `a + b`. */ +const LOG_PRECISION_MIN = -2; +const LOG_PRECISION_MAX = 4; +const DEFAULT_BOOTSTRAP_ITERATIONS = 500; + +export interface BetaBinomialFit { + /** Population agreement rate `a / (a + b)`. */ + readonly mu: number; + /** Beta precision `s = a + b`. */ + readonly precision: number; + readonly alphaParameter: number; + readonly betaParameter: number; + /** Intra-seed correlation `1 / (s + 1)`: the clustering a pooled test hides. */ + readonly intraSeedCorrelation: number; + /** Marginal log-likelihood at the reported optimum. */ + readonly logLikelihood: number; + /** + * False when the optimum sat on an edge of the final `log10(s)` window, + * which happens for perfectly homogeneous or perfectly split data. `mu` is + * still the maximiser on the searched grid; the precision is not identified. + */ + readonly precisionIdentified: boolean; +} + +export interface BetaBinomialAgreementInput { + readonly seeds: readonly SeedAgreementCount[]; + readonly chanceRate: number; + readonly alpha?: number; + readonly confidence?: number; + readonly minimumSeeds?: number; + /** + * Seeded cluster bootstrap over seeds. Required for an interval: the + * beta-binomial likelihood has no closed-form standard error here, and the + * toolkit never draws unseeded randomness. + */ + readonly bootstrap?: { readonly seed: string; readonly iterations?: number }; +} + +export interface BetaBinomialAgreementResult extends BetaBinomialFit { + readonly analysisVersion: string; + readonly method: 'beta-binomial-hierarchical-bernoulli'; + readonly seedCount: number; + readonly totalProbes: number; + readonly totalAgreements: number; + readonly grid: { + readonly points: number; + readonly rounds: number; + readonly logPrecisionWindow: readonly [number, number]; + }; + readonly bootstrap: { + readonly seed: string; + readonly iterations: number; + readonly muInterval: ConfidenceInterval; + /** One-sided `1 - alpha` lower bound on `mu` from the same replicates. */ + readonly muOneSidedLowerBound: number; + } | null; + readonly chanceRate: number; + readonly alpha: number; + readonly confidence: number; + readonly minimumSeeds: number; + readonly meetsSeedMinimum: boolean; + /** + * `above-chance` only when the bootstrap one-sided lower bound on `mu` + * exceeds `chanceRate`; without a bootstrap the model reports + * `insufficient-seeds` rather than deciding from a point estimate. + */ + readonly decision: AgreementDecision; +} + +/** Beta-binomial marginal log-likelihood of the clustered probe tallies. */ +function betaBinomialLogLikelihood( + seeds: readonly SeedAgreementCount[], + mu: number, + precision: number, +): number { + const a = mu * precision; + const b = (1 - mu) * precision; + if (!(a > 0) || !(b > 0)) { + return -Infinity; + } + const reference = logBeta(a, b); + let total = 0; + for (const entry of seeds) { + total += + logBinomialCoefficient(entry.probes, entry.agreements) + + logBeta(entry.agreements + a, entry.probes - entry.agreements + b) - + reference; + } + return total; +} + +function gridPoints(low: number, high: number, points: number): number[] { + if (points < 2) { + return [(low + high) / 2]; + } + const step = (high - low) / (points - 1); + return Array.from({ length: points }, (_unused, index) => low + index * step); +} + +/** + * Deterministic maximum-likelihood fit by nested grid search over + * `(mu, log10(s))`. Three rounds of a 25 x 25 grid, each round centred on the + * previous winner with a window shrunk by four, is enough to place `mu` to + * better than 1e-4 while staying a pure function of the data — no iterative + * solver, no starting-point randomness, so the fit replays bit-for-bit + * (SPEC §14.3). + */ +export function fitBetaBinomial( + seeds: readonly SeedAgreementCount[], +): BetaBinomialFit { + assertSeeds(seeds); + let muLow = 0; + let muHigh = 1; + let logLow = LOG_PRECISION_MIN; + let logHigh = LOG_PRECISION_MAX; + let bestMu = 0.5; + let bestLog = 0; + let bestValue = -Infinity; + + for (let round = 0; round < GRID_ROUNDS; round += 1) { + const margin = (muHigh - muLow) / (2 * GRID_POINTS); + const mus = gridPoints(muLow + margin, muHigh - margin, GRID_POINTS); + const logs = gridPoints(logLow, logHigh, GRID_POINTS); + bestValue = -Infinity; + for (const mu of mus) { + for (const log of logs) { + const value = betaBinomialLogLikelihood(seeds, mu, 10 ** log); + if (value > bestValue) { + bestValue = value; + bestMu = mu; + bestLog = log; + } + } + } + const muWidth = (muHigh - muLow) / GRID_SHRINK; + const logWidth = (logHigh - logLow) / GRID_SHRINK; + muLow = Math.max(0, bestMu - muWidth / 2); + muHigh = Math.min(1, bestMu + muWidth / 2); + logLow = Math.max(LOG_PRECISION_MIN, bestLog - logWidth / 2); + logHigh = Math.min(LOG_PRECISION_MAX, bestLog + logWidth / 2); + } + + const precision = 10 ** bestLog; + const identified = + bestLog > LOG_PRECISION_MIN + 1e-9 && bestLog < LOG_PRECISION_MAX - 1e-9; + return { + mu: bestMu, + precision, + alphaParameter: bestMu * precision, + betaParameter: (1 - bestMu) * precision, + intraSeedCorrelation: 1 / (precision + 1), + logLikelihood: bestValue, + precisionIdentified: identified, + }; +} + +/** + * The §15.2 "hierarchical Bernoulli model" branch: fit the beta-binomial to + * the clustered probe tallies and bound `mu` by a cluster bootstrap that + * resamples *seeds* (never probes), because seeds are the independent unit. + */ +export function betaBinomialAgreement( + input: BetaBinomialAgreementInput, +): BetaBinomialAgreementResult { + assertSeeds(input.seeds); + assertProbability(input.chanceRate, 'chanceRate'); + const alpha = input.alpha ?? 0.05; + const confidence = input.confidence ?? 0.95; + assertLevel(alpha, 'alpha'); + assertLevel(confidence, 'confidence'); + const minimumSeeds = input.minimumSeeds ?? QUALIFICATION_MINIMUM_SEEDS; + assertCount(minimumSeeds, 'minimumSeeds'); + + const fit = fitBetaBinomial(input.seeds); + const seedCount = input.seeds.length; + + let bootstrap: BetaBinomialAgreementResult['bootstrap'] = null; + if (input.bootstrap !== undefined) { + const iterations = + input.bootstrap.iterations ?? DEFAULT_BOOTSTRAP_ITERATIONS; + if (!Number.isInteger(iterations) || iterations < 1) { + throw new AnalysisError( + 'domain', + 'bootstrap.iterations must be a positive integer', + ); + } + if ( + typeof input.bootstrap.seed !== 'string' || + input.bootstrap.seed.length === 0 + ) { + throw new AnalysisError('domain', 'bootstrap.seed must be a non-empty string'); + } + const prng = new SeededPrng(input.bootstrap.seed); + const replicates = new Array(iterations); + for (let replicate = 0; replicate < iterations; replicate += 1) { + const resampled: SeedAgreementCount[] = []; + for (let draw = 0; draw < seedCount; draw += 1) { + const picked = input.seeds[prng.nextInt(seedCount)] as SeedAgreementCount; + // The resampled cluster keeps its tally but needs a unique label, + // because `assertSeeds` rejects duplicates inside one fit. + resampled.push({ + seed: `${picked.seed}#${draw}`, + agreements: picked.agreements, + probes: picked.probes, + }); + } + replicates[replicate] = fitBetaBinomial(resampled).mu; + } + const interval = percentileInterval(replicates, confidence); + const sorted = [...replicates].sort((left, right) => left - right); + const lowerIndex = Math.max( + 0, + Math.min(sorted.length - 1, Math.floor(alpha * (sorted.length - 1))), + ); + bootstrap = { + seed: input.bootstrap.seed, + iterations, + muInterval: interval, + muOneSidedLowerBound: sorted[lowerIndex] as number, + }; + } + + let decision: AgreementDecision; + if (seedCount < 2 || bootstrap === null) { + decision = 'insufficient-seeds'; + } else { + decision = + bootstrap.muOneSidedLowerBound > input.chanceRate + ? 'above-chance' + : 'not-above-chance'; + } + + return { + ...fit, + analysisVersion: HIERARCHICAL_ANALYSIS_VERSION, + method: 'beta-binomial-hierarchical-bernoulli', + seedCount, + totalProbes: input.seeds.reduce((total, entry) => total + entry.probes, 0), + totalAgreements: input.seeds.reduce( + (total, entry) => total + entry.agreements, + 0, + ), + grid: { + points: GRID_POINTS, + rounds: GRID_ROUNDS, + logPrecisionWindow: [LOG_PRECISION_MIN, LOG_PRECISION_MAX], + }, + bootstrap, + chanceRate: input.chanceRate, + alpha, + confidence, + minimumSeeds, + meetsSeedMinimum: seedCount >= minimumSeeds, + decision, + }; +} + +// --------------------------------------------------------------------------- +// Unequal-variance two-sample test (E15 bandwidth contrast on seed means) +// --------------------------------------------------------------------------- + +export interface WelchTTestResult { + readonly nA: number; + readonly nB: number; + readonly meanA: number; + readonly meanB: number; + readonly difference: number; + readonly se: number; + readonly t: number; + /** Welch-Satterthwaite degrees of freedom. */ + readonly df: number; + readonly p: number; + readonly alternative: 'two-sided' | 'greater' | 'less'; + readonly degenerate: boolean; +} + +/** + * Welch's unequal-variance two-sample t test on `a - b`. + * + * Degenerate samples follow the same explicit rules as `oneSampleTTest`: + * fewer than two values in either sample yields `NaN` statistics with + * `degenerate: true`; a zero pooled standard error is a point-mass contrast, + * where `t` is +/-Infinity and `p` is 0 for the supported alternative and 1 + * for the opposite one, or `t = 0`/`p = 1` when the means coincide. + */ +export function welchTTest( + a: readonly number[], + b: readonly number[], + alternative: WelchTTestResult['alternative'] = 'two-sided', +): WelchTTestResult { + const left = summarize(a); + const right = summarize(b); + const difference = left.mean - right.mean; + if (left.n < 2 || right.n < 2) { + return { + nA: left.n, + nB: right.n, + meanA: left.mean, + meanB: right.mean, + difference, + se: NaN, + t: NaN, + df: NaN, + p: NaN, + alternative, + degenerate: true, + }; + } + const varianceA = left.variance / left.n; + const varianceB = right.variance / right.n; + const se = Math.sqrt(varianceA + varianceB); + if (se === 0) { + const supported = + alternative === 'two-sided' || + (alternative === 'greater' && difference > 0) || + (alternative === 'less' && difference < 0); + return { + nA: left.n, + nB: right.n, + meanA: left.mean, + meanB: right.mean, + difference, + se, + t: difference === 0 ? 0 : difference > 0 ? Infinity : -Infinity, + df: NaN, + p: difference === 0 ? 1 : supported ? 0 : 1, + alternative, + degenerate: true, + }; + } + const df = + (varianceA + varianceB) ** 2 / + (varianceA ** 2 / (left.n - 1) + varianceB ** 2 / (right.n - 1)); + const t = difference / se; + const cdf = studentTCdf(t, df); + let p: number; + if (alternative === 'greater') { + p = 1 - cdf; + } else if (alternative === 'less') { + p = cdf; + } else { + p = 2 * Math.min(cdf, 1 - cdf); + } + return { + nA: left.n, + nB: right.n, + meanA: left.mean, + meanB: right.mean, + difference, + se, + t, + df, + p, + alternative, + degenerate: false, + }; +} diff --git a/packages/analysis/src/hypothesis.ts b/packages/analysis/src/hypothesis.ts new file mode 100644 index 0000000..015ca18 --- /dev/null +++ b/packages/analysis/src/hypothesis.ts @@ -0,0 +1,372 @@ +/** + * Hypothesis tests named by SPECIFICATION.md §15.3: one-sample t tests on + * seed-level outcomes, the two one-sided tests (TOST) that back every + * "equivalent to chance" claim, Holm-Bonferroni correction across the primary + * metrics of one experiment, and the one-sided binomial chance comparison E11 + * reports. + * + * Nothing here decides anything scientific: a `rejected` or `equivalent` flag + * is the mechanical outcome of the pre-registered rule applied to the numbers + * (ALD-072 acceptance criterion 3). + */ +import { + AnalysisError, + assertCount, + assertLevel, + assertProbability, + assertSample, +} from './errors.js'; +import { + summarize, + type ConfidenceInterval, + type DescriptiveSummary, +} from './descriptive.js'; +import { + binomialLogPmf, + normalCdf, + studentTCdf, + studentTQuantile, +} from './special.js'; + +export type Alternative = 'two-sided' | 'greater' | 'less'; + +export interface OneSampleTTestResult { + readonly n: number; + readonly mean: number; + /** Standard error of the mean; `0` for a zero-variance sample, `NaN` when n < 2. */ + readonly se: number; + readonly t: number; + readonly df: number; + readonly p: number; + readonly mu0: number; + readonly alternative: Alternative; + /** + * True when the sample cannot support an ordinary t test: n < 2 (no + * variance estimate at all, `t`/`p` are `NaN`) or zero sample variance + * (`t` is +/-Infinity or 0 and `p` comes from the documented degenerate + * rule below). + */ + readonly degenerate: boolean; +} + +/** + * Convert a t statistic to a p value for the requested alternative. + */ +function tTestP(t: number, df: number, alternative: Alternative): number { + const cdf = studentTCdf(t, df); + if (alternative === 'greater') { + return 1 - cdf; + } + if (alternative === 'less') { + return cdf; + } + return 2 * Math.min(cdf, 1 - cdf); +} + +/** + * One-sample t test of `mean(values) === mu0` (SPECIFICATION.md §15.3; + * RESEARCH.md Appendix D §D.6 runs two of these per control condition). + * + * Degenerate samples are handled explicitly rather than emitting a silent + * `NaN`: + * + * - **n < 2** — no variance estimate exists, so `t`, `se`, and `p` are `NaN` + * and `degenerate` is true. A caller must not treat this as a failure to + * reject; `evaluateControlEquivalence` reports `insufficient-seeds`. + * - **zero sample variance** — the sample is a point mass. If `mean === mu0` + * the observed data are exactly the null, so `t = 0` and `p = 1` for every + * alternative. Otherwise the data are infinitely far from the null in the + * observed direction: `t` is +/-Infinity, `p = 0` for the alternative that + * the observed direction supports (`two-sided` always, plus `greater` when + * `mean > mu0` or `less` when `mean < mu0`) and `p = 1` for the opposite + * one-sided alternative. + */ +export function oneSampleTTest( + values: readonly number[], + mu0: number, + alternative: Alternative = 'two-sided', +): OneSampleTTestResult { + assertSample(values, 'values'); + if (!Number.isFinite(mu0)) { + throw new AnalysisError('domain', 'mu0 must be a finite number'); + } + const stats: DescriptiveSummary = summarize(values); + const n = stats.n; + const df = n - 1; + if (n < 2) { + return { + n, + mean: stats.mean, + se: NaN, + t: NaN, + df, + p: NaN, + mu0, + alternative, + degenerate: true, + }; + } + const se = stats.sd / Math.sqrt(n); + if (se === 0) { + const difference = stats.mean - mu0; + let t: number; + let p: number; + if (difference === 0) { + t = 0; + p = 1; + } else { + t = difference > 0 ? Infinity : -Infinity; + const supported = + alternative === 'two-sided' || + (alternative === 'greater' && difference > 0) || + (alternative === 'less' && difference < 0); + p = supported ? 0 : 1; + } + return { n, mean: stats.mean, se, t, df, p, mu0, alternative, degenerate: true }; + } + const t = (stats.mean - mu0) / se; + return { + n, + mean: stats.mean, + se, + t, + df, + p: tTestP(t, df, alternative), + mu0, + alternative, + degenerate: false, + }; +} + +export interface TostResult { + readonly n: number; + readonly mean: number; + readonly se: number; + readonly df: number; + /** Lower equivalence bound (Appendix D §D.6: 0.20 for E03). */ + readonly equivalenceLower: number; + /** Upper equivalence bound (Appendix D §D.6: 0.30 for E03). */ + readonly equivalenceUpper: number; + readonly alpha: number; + /** One-sided p for H0: mean <= equivalenceLower (alternative `greater`). */ + readonly pLower: number; + /** One-sided p for H0: mean >= equivalenceUpper (alternative `less`). */ + readonly pUpper: number; + /** The TOST p value: `max(pLower, pUpper)`. */ + readonly p: number; + /** True iff both one-sided tests reject at `alpha` (strictly `p < alpha`). */ + readonly equivalent: boolean; + /** Two-sided (1 - 2 * alpha) interval, the conventional TOST interval. */ + readonly interval: ConfidenceInterval; + readonly degenerate: boolean; +} + +/** + * Two one-sided tests for equivalence (SPECIFICATION.md §15.3 "equivalence / + * non-inferiority bound"; RESEARCH.md Appendix D §D.6 item 1 "Control + * equivalence": two one-sided one-sample tests against bounds 0.20 and 0.30, + * equivalence requiring both to reject). + * + * `p` is the TOST p value `max(pLower, pUpper)`, which is the quantity a + * multiplicity correction is applied to when several conditions are tested + * (see `holmBonferroni` and `e03Analysis`). + */ +export function tost( + values: readonly number[], + lower: number, + upper: number, + alpha: number, +): TostResult { + assertSample(values, 'values'); + assertLevel(alpha, 'alpha'); + if (!Number.isFinite(lower) || !Number.isFinite(upper)) { + throw new AnalysisError('domain', 'bounds must be finite numbers'); + } + if (!(lower < upper)) { + throw new AnalysisError('domain', 'lower bound must be below upper bound'); + } + const greater = oneSampleTTest(values, lower, 'greater'); + const less = oneSampleTTest(values, upper, 'less'); + const pLower = greater.p; + const pUpper = less.p; + const p = Number.isNaN(pLower) || Number.isNaN(pUpper) + ? NaN + : Math.max(pLower, pUpper); + const level = 1 - 2 * alpha; + const critical = + greater.df > 0 && Number.isFinite(greater.se) + ? studentTQuantile(1 - alpha, greater.df) + : NaN; + const halfWidth = critical * greater.se; + return { + n: greater.n, + mean: greater.mean, + se: greater.se, + df: greater.df, + equivalenceLower: lower, + equivalenceUpper: upper, + alpha, + pLower, + pUpper, + p, + equivalent: pLower < alpha && pUpper < alpha, + interval: { + lower: greater.mean - halfWidth, + upper: greater.mean + halfWidth, + level, + }, + degenerate: greater.degenerate || less.degenerate, + }; +} + +export interface HolmBonferroniResult { + /** Holm step-down adjusted p values, in the caller's input order. */ + readonly adjusted: number[]; + /** `adjusted[i] <= alpha`, in the caller's input order. */ + readonly rejected: boolean[]; + readonly alpha: number; +} + +/** + * Holm-Bonferroni step-down correction across the primary metrics of a single + * experiment (SPECIFICATION.md §15.3; RESEARCH.md §7.3). + * + * Adjusted values are `(m - rank) * p` accumulated as a running maximum over + * the ascending-p order, then clamped to 1 — the standard construction, which + * keeps them monotone so that once one hypothesis fails to reject every + * larger p also fails. A hypothesis is rejected when its adjusted value is + * `<= alpha` (textbook Holm; note `tost` uses a strict `<` on the raw p + * values, so a family-adjusted TOST decision should read `rejected` here). + * + * Textbook example pinned in the tests: p = [0.01, 0.04, 0.03, 0.005] at + * alpha = 0.05 gives adjusted [0.03, 0.06, 0.06, 0.02] and rejected + * [true, false, false, true]. + */ +export function holmBonferroni( + pValues: readonly number[], + alpha: number, +): HolmBonferroniResult { + assertLevel(alpha, 'alpha'); + const m = pValues.length; + if (m === 0) { + throw new AnalysisError('empty-sample', 'pValues must not be empty'); + } + pValues.forEach((value, index) => { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new AnalysisError( + 'domain', + `pValues[${index}] must be within [0, 1]`, + ); + } + }); + const order = pValues + .map((value, index) => ({ value, index })) + .sort((left, right) => + left.value === right.value + ? left.index - right.index + : left.value - right.value, + ); + const adjusted = new Array(m).fill(NaN); + let running = 0; + order.forEach((entry, rank) => { + const scaled = (m - rank) * entry.value; + running = Math.max(running, scaled); + adjusted[entry.index] = Math.min(1, running); + }); + return { + adjusted, + rejected: adjusted.map((value) => value <= alpha), + alpha, + }; +} + +export interface BinomialTestResult { + readonly successes: number; + readonly n: number; + readonly observedRate: number; + /** Null success probability, e.g. the E03 chance rate 0.25. */ + readonly chanceRate: number; + readonly alternative: Alternative; + /** Exact binomial tail probability. */ + readonly exactP: number; + /** Normal-approximation z statistic (no continuity correction). */ + readonly z: number; + /** Normal-approximation p value for the same alternative. */ + readonly normalP: number; +} + +/** Exact tail sum of the binomial pmf, computed in log space. */ +function binomialTailP( + successes: number, + n: number, + chanceRate: number, + upper: boolean, +): number { + let total = 0; + const from = upper ? successes : 0; + const to = upper ? n : successes; + for (let k = from; k <= to; k += 1) { + total += Math.exp(binomialLogPmf(k, n, chanceRate)); + } + return Math.min(1, total); +} + +/** + * One-sample binomial test against a fixed chance rate — the E11 + * above-chance comparison (EXPERIMENT-NOTEBOOK.md E11; the chance baseline + * itself comes from E03, SPECIFICATION.md §15.3). + * + * Both the exact tail probability and the normal approximation are returned; + * the pre-registration says which one is primary. Two-sided exact p uses the + * doubled smaller tail, clamped to 1. + */ +export function binomialTest( + successes: number, + n: number, + chanceRate: number, + alternative: Alternative = 'greater', +): BinomialTestResult { + assertCount(successes, 'successes'); + assertCount(n, 'n'); + if (n === 0) { + throw new AnalysisError('empty-sample', 'n must be greater than zero'); + } + if (successes > n) { + throw new AnalysisError('domain', 'successes must not exceed n'); + } + assertProbability(chanceRate, 'chanceRate'); + const observedRate = successes / n; + let exactP: number; + if (alternative === 'greater') { + exactP = binomialTailP(successes, n, chanceRate, true); + } else if (alternative === 'less') { + exactP = binomialTailP(successes, n, chanceRate, false); + } else { + const upperTail = binomialTailP(successes, n, chanceRate, true); + const lowerTail = binomialTailP(successes, n, chanceRate, false); + exactP = Math.min(1, 2 * Math.min(upperTail, lowerTail)); + } + const variance = (chanceRate * (1 - chanceRate)) / n; + const z = + variance > 0 ? (observedRate - chanceRate) / Math.sqrt(variance) : NaN; + let normalP: number; + if (Number.isNaN(z)) { + normalP = NaN; + } else if (alternative === 'greater') { + normalP = 1 - normalCdf(z); + } else if (alternative === 'less') { + normalP = normalCdf(z); + } else { + normalP = 2 * Math.min(normalCdf(z), 1 - normalCdf(z)); + } + return { + successes, + n, + observedRate, + chanceRate, + alternative, + exactP, + z, + normalP, + }; +} diff --git a/packages/analysis/src/index.ts b/packages/analysis/src/index.ts new file mode 100644 index 0000000..16a76fd --- /dev/null +++ b/packages/analysis/src/index.ts @@ -0,0 +1,279 @@ +/** + * @ald/analysis — the pre-registered statistics toolkit for the experiment + * harnesses (BACKLOG ALD-032 carrier leakage and ALD-072 statistics scaffold; + * SPECIFICATION.md §15.2-§15.3; RESEARCH.md §7.3 and Appendix D §D.6-§D.7; + * EXPERIMENT-NOTEBOOK.md E03 and E11). + * + * Every export is a pure, deterministic function of its arguments. The package + * performs no I/O, holds no state, and takes randomness only from + * `SeededPrng` (@ald/hashing), so an analysis replays bit-for-bit from a + * registered seed — the property Appendix D §D.1 needs from an analysis + * script. Nothing here interprets a result: `e03Analysis` and `e11Summary` + * return data structures, and their booleans are the mechanical outcome of a + * pre-registered rule applied to the numbers, never a scientific conclusion + * (ALD-072 acceptance criterion 3). + * + * Layers, lowest first: + * + * - `special.ts` — log-gamma, incomplete beta/gamma, normal and Student t + * CDFs and quantiles, binomial log-pmf. + * - `descriptive.ts` — sample summaries, quantiles, proportions, Wilson + * intervals. + * - `hypothesis.ts` — one-sample t test, TOST, Holm-Bonferroni, binomial test. + * - `effects.ts` — Cohen's h, rank-biserial correlation (§15.3 mandatory + * effect sizes). + * - `bootstrap.ts` — seeded percentile bootstrap, unpaired and paired. + * - `equivalence.ts` — the control-equivalence decision helper. + * - `e03.ts` / `e11.ts` — the two experiment-shaped readouts. + * - `carrier-leakage.ts` — immutable, pre-registered mark-level leakage probes. + */ +export { + AnalysisError, + type AnalysisErrorCode, +} from './errors.js'; +export { + carrierCapacity, + type CarrierCapacity, + type CarrierCapacityInput, +} from './carrier-capacity.js'; +export { + binomialLogPmf, + logBeta, + logBinomialCoefficient, + logGamma, + normalCdf, + normalPdf, + normalQuantile, + regularizedIncompleteBeta, + regularizedLowerGamma, + regularizedUpperGamma, + studentTCdf, + studentTQuantile, +} from './special.js'; +export { + mean, + pooledProportion, + proportion, + proportionOfSuccesses, + quantile, + quantileSorted, + summarize, + wilsonInterval, + type ConfidenceInterval, + type DescriptiveSummary, + type ProportionSummary, + type WilsonInterval, +} from './descriptive.js'; +export { + binomialTest, + holmBonferroni, + oneSampleTTest, + tost, + type Alternative, + type BinomialTestResult, + type HolmBonferroniResult, + type OneSampleTTestResult, + type TostResult, +} from './hypothesis.js'; +export { + cohensH, + rankBiserial, + type RankBiserialResult, +} from './effects.js'; +export { + bootstrapMeanCi, + bootstrapMeanReplicates, + bootstrapPairedDifferenceCi, + bootstrapPairedDifferenceReplicates, + percentileInterval, + type BootstrapCi, + type BootstrapOptions, +} from './bootstrap.js'; +export { + MINIMUM_EQUIVALENCE_SEEDS, + evaluateControlEquivalence, + type ControlEquivalenceInput, + type ControlEquivalenceResult, + type EquivalenceDecision, +} from './equivalence.js'; +export { + E03_HIGH_SEED_SHARE_LIMIT, + E03_HIGH_SEED_THRESHOLD, + E03_MINIMUM_SEEDS, + E03_ORACLE_LOWER_BOUND, + E03_SEPARATION_LOWER_BOUND, + e03Analysis, + type E03Analysis, + type E03AnalysisInput, + type E03ConditionResult, + type E03Criteria, + type E03HighSeedAudit, + type E03OracleResult, + type E03SeparationResult, + type EpisodeCounts, +} from './e03.js'; +export { + E03_COMMUNICATION_CONDITIONS, + E03_DESIGN_MINIMUM_POWER, + E03_DESIGN_REPETITIONS, + E03_DESIGN_ROWS, + E03_DESIGN_SEED, + E03_DESIGN_SIMULATION_VERSION, + E03_SEED_LABEL, + buildE03SeedManifest, + simulateE03DesignPower, + type E03DesignPowerRow, + type E03DesignSimulation, + type E03DesignSimulationOptions, + type E03SeedManifest, + type E03SeedManifestEntry, +} from './e03-design.js'; +export { + E03_REGISTRATION_CLAIM_BOUNDARY, + E03_REGISTRATION_COMPILER_VERSION, + compileE03Registration, + type CompileE03RegistrationInput, + type CompiledE03Registration, + type E03RegisteredRun, +} from './e03-registration.js'; +export { + E03_CHANCE_RATE, + e11Summary, + type E11Summary, + type E11SummaryInput, + type E11TrainingWindow, +} from './e11.js'; +export { + AFFECT_LEAKAGE_ANALYSIS_VERSION, + AFFECT_LEAKAGE_ESTIMATOR, + E20_EXCESS_CMI_BOUND_BITS, + E20_MINIMUM_SEEDS, + E20_MINIMUM_WINDOWS_PER_SEED, + E20_PERMUTATIONS, + evaluateAffectLeakage, + type AffectLeakageDecision, + type AffectLeakageInput, + type AffectLeakageResult, + type AffectLeakageSeedInput, + type AffectLeakageSeedResult, + type AffectLeakageWindow, +} from './affect-leakage.js'; +export { + CARRIER_LEAKAGE_ANALYSIS_VERSION, + evaluateCarrierLeakage, + type CarrierLeakageInput, + type CarrierLeakageObservation, + type CarrierLeakageProbeDecision, + type CarrierLeakageProbePlan, + type CarrierLeakageProbeResult, + type CarrierLeakageResult, + type CarrierMarkLeakageMetric, + type RecognizableGlyphOutcome, +} from './carrier-leakage.js'; +export { + CAUSAL_PREDICTION_PIPELINE_VERSION, + NON_LEDGER_COMPARATOR_IDS, + commitProspectivePredictions, + multiclassBrierScore, + scoreCommittedPredictions, + selectNonLedgerComparator, + type CausalPredictionScore, + type ComparatorInformation, + type ComparatorModel, + type ComparatorSelection, + type ComparatorValidationScore, + type LabeledPredictionCase, + type NativeLedgerPredictionSet, + type NonLedgerComparatorId, + type PredictionCase, + type PredictionOutcome, + type ProbabilityPrediction, + type ProspectivePredictionCommitment, + type ScoredPredictionCase, +} from './causal-prediction.js'; +export { + REGISTRATION_BINDING_KEYS, + REGISTRATION_PACKET_COMPILER_VERSION, + compileRegistrationPacket, + type CompileRegistrationPacketInput, + type CompiledRegistrationPacket, + type RegistrationBinding, + type RegistrationBindingKey, + type RegistrationPacketArtifact, +} from './registration-packet.js'; +export { + CARRIER_PERCEPTION_ANALYSIS_VERSION, + carrierPerceptualDistance, + evaluatePerceptualGeneralization, + type BitmapMark, + type CanvasMark, + type CanvasStroke, + type LabeledPerceptualMark, + type PerceptualGeneralizationResult, + type PerceptualMark, + type ToneMark, +} from './carrier-perception.js'; + +// --------------------------------------------------------------------------- +// Intervention-suite and experiment-readiness scaffolds (BACKLOG ALD-072; +// ALD-074 E14/E15/E16, ALD-076 E31). Owned by the `@ald/interventions` +// workstream: seed-clustered inference (SPEC §15.2), the E15 composition +// readout, and the E31 checkpoint-drift readout. +// --------------------------------------------------------------------------- +export { + HIERARCHICAL_ANALYSIS_VERSION, + QUALIFICATION_MINIMUM_SEEDS, + betaBinomialAgreement, + fitBetaBinomial, + seedLevelAgreement, + welchTTest, + type AgreementDecision, + type BetaBinomialAgreementInput, + type BetaBinomialAgreementResult, + type BetaBinomialFit, + type SeedAgreementCount, + type SeedAgreementProportion, + type SeedLevelAgreementInput, + type SeedLevelAgreementResult, + type WelchTTestResult, +} from './hierarchical.js'; +export { + COMPOSITION_ANALYSIS_VERSION, + E15_CONFIRMATORY_MINIMUM_SEEDS, + TOPOGRAPHIC_MAX_EPISODES, + attributeHammingDistance, + checkHeldOutSplitIntegrity, + evaluateComposition, + messageEditDistance, + spearmanCorrelation, + symbolReuse, + topographicSimilarity, + type BandwidthConditionInput, + type BandwidthContrastResult, + type BandwidthInferenceClass, + type CompositionEpisode, + type CompositionInput, + type CompositionResult, + type OrderProbeOutcome, + type OrderSensitivityResult, + type ProportionWithInterval, + type SeedProportionInput, + type SplitIntegrityResult, + type SplitIntegrityViolationCode, + type SymbolReuseResult, + type TopographicResult, +} from './composition.js'; +export { + DEFAULT_STABILITY_TOLERANCE_BITS, + DRIFT_ANALYSIS_VERSION, + evaluateCheckpointDrift, + symbolUsageDivergenceBits, + type CheckpointEvaluation, + type CheckpointPairDrift, + type DriftInput, + type DriftResult, + type PairUnscoredReasonCode, + type RegimeSeparation, + type RegimeSeparationDecision, + type StabilityInterval, +} from './drift.js'; diff --git a/packages/analysis/src/information.ts b/packages/analysis/src/information.ts new file mode 100644 index 0000000..98474f1 --- /dev/null +++ b/packages/analysis/src/information.ts @@ -0,0 +1,494 @@ +/** + * Discrete information-theoretic estimators for the SPECIFICATION.md §15.3 + * leakage analyses (SPEC §9.3 rule 7, §9.2; EXPERIMENT-NOTEBOOK.md E20). + * + * E20 names the estimator exactly, and this module implements that name and + * nothing more: + * + * > Estimate conditional mutual information between affect and four-way + * > referent after stratifying by binary success/failure outcome, using a + * > Miller-Madow bias-corrected discrete estimator and within-outcome + * > permutation null. + * + * Everything here is a pure function of counts plus, where randomness is + * needed, a `SeededPrng` stream, so a leakage analysis replays bit-for-bit + * from the registered seed (SPEC §14.3, RESEARCH.md Appendix D §D.1). + * + * Estimator notes, stated plainly because they bound what any result means: + * + * - the plug-in ("maximum likelihood") entropy of a finite sample is + * *downward* biased and the plug-in mutual information is therefore + * *upward* biased. Miller-Madow adds the first-order correction + * `(K̂ − 1) / (2 N ln 2)` bits, where `K̂` counts the bins actually observed. + * It reduces, and does not remove, that bias — which is exactly why E20 + * pairs it with a permutation null and compares the *excess* over the null + * mean against the bound rather than the raw estimate; + * - a conditional estimate is the stratum-weighted mean of within-stratum + * estimates, so a stratum with no observations contributes nothing and a + * stratum with one observation contributes zero information; + * - none of these functions decides anything. `affect-leakage.ts` applies the + * pre-registered rule; the numbers here are inputs to that rule, never a + * scientific conclusion. + */ +import { SeededPrng } from '@ald/hashing'; + +import { AnalysisError, assertCount, assertLevel, assertSample } from './errors.js'; +import { mean as sampleMean, quantileSorted } from './descriptive.js'; + +const LN2 = Math.LN2; + +/** A joint contingency table: `table[x][y]` is a non-negative count. */ +export type JointCounts = readonly (readonly number[])[]; + +/** One observation of two discrete variables inside one stratum. */ +export interface StratifiedObservation { + /** Zero-based level of the first variable. */ + x: number; + /** Zero-based level of the second variable. */ + y: number; + /** Zero-based stratum index (E20: the binary outcome). */ + stratum: number; +} + +function assertCounts(counts: readonly number[], label: string): void { + if (counts.length === 0) { + throw new AnalysisError('empty-sample', `${label} must not be empty`); + } + for (let index = 0; index < counts.length; index += 1) { + assertCount(counts[index] as number, `${label}[${index}]`); + } +} + +function total(counts: readonly number[]): number { + let sum = 0; + for (const count of counts) { + sum += count; + } + return sum; +} + +/** Number of bins with a non-zero count: Miller-Madow's `K̂`. */ +export function observedSupport(counts: readonly number[]): number { + let support = 0; + for (const count of counts) { + if (count > 0) { + support += 1; + } + } + return support; +} + +/** + * Plug-in Shannon entropy in bits. An empty sample (every count zero) has + * entropy 0 by convention, matching the `0 log 0 = 0` convention inside the + * sum. + */ +export function shannonEntropyBits(counts: readonly number[]): number { + assertCounts(counts, 'counts'); + const n = total(counts); + if (n === 0) { + return 0; + } + let entropy = 0; + for (const count of counts) { + if (count > 0) { + const p = count / n; + entropy -= p * Math.log2(p); + } + } + return entropy; +} + +/** + * Miller-Madow bias-corrected entropy in bits: + * `Ĥ_MM = Ĥ_plug-in + (K̂ − 1) / (2 N ln 2)`. + */ +export function millerMadowEntropyBits(counts: readonly number[]): number { + assertCounts(counts, 'counts'); + const n = total(counts); + if (n === 0) { + return 0; + } + const support = observedSupport(counts); + return shannonEntropyBits(counts) + (support - 1) / (2 * n * LN2); +} + +function assertJoint(joint: JointCounts): void { + if (joint.length === 0) { + throw new AnalysisError('empty-sample', 'joint must have at least one row'); + } + const width = (joint[0] as readonly number[]).length; + if (width === 0) { + throw new AnalysisError('empty-sample', 'joint rows must not be empty'); + } + for (let row = 0; row < joint.length; row += 1) { + const values = joint[row] as readonly number[]; + if (values.length !== width) { + throw new AnalysisError( + 'length-mismatch', + 'every joint row must have the same length', + ); + } + assertCounts(values, `joint[${row}]`); + } +} + +function flatten(joint: JointCounts): number[] { + const flat: number[] = []; + for (const row of joint) { + for (const count of row) { + flat.push(count); + } + } + return flat; +} + +function rowMargin(joint: JointCounts): number[] { + return joint.map((row) => total(row as readonly number[])); +} + +function columnMargin(joint: JointCounts): number[] { + const width = (joint[0] as readonly number[]).length; + const margin = new Array(width).fill(0); + for (const row of joint) { + for (let column = 0; column < width; column += 1) { + margin[column] = (margin[column] as number) + ((row[column] as number) ?? 0); + } + } + return margin; +} + +/** Plug-in mutual information in bits, `Ĥ(X) + Ĥ(Y) − Ĥ(X,Y)`. */ +export function mutualInformationBits(joint: JointCounts): number { + assertJoint(joint); + const flat = flatten(joint); + if (total(flat) === 0) { + return 0; + } + return Math.max( + 0, + shannonEntropyBits(rowMargin(joint)) + + shannonEntropyBits(columnMargin(joint)) - + shannonEntropyBits(flat), + ); +} + +/** + * Miller-Madow mutual information in bits: the same identity with each of the + * three entropies corrected separately, so the joint table's larger observed + * support drives the correction's sign. + * + * The result is *not* clamped at zero: the correction can push an estimate + * slightly negative on independent data, and clamping would bias the + * permutation null upward and make the E20 comparison optimistic. + */ +export function millerMadowMutualInformationBits(joint: JointCounts): number { + assertJoint(joint); + const flat = flatten(joint); + if (total(flat) === 0) { + return 0; + } + return ( + millerMadowEntropyBits(rowMargin(joint)) + + millerMadowEntropyBits(columnMargin(joint)) - + millerMadowEntropyBits(flat) + ); +} + +/** + * Stratum-weighted conditional mutual information `Î(X;Y|Z)` in bits, with + * `Î` the Miller-Madow estimator inside each stratum and weights `N_z / N`. + */ +export function millerMadowConditionalMutualInformationBits( + strata: readonly JointCounts[], +): number { + if (strata.length === 0) { + throw new AnalysisError('empty-sample', 'strata must not be empty'); + } + const sizes = strata.map((joint) => { + assertJoint(joint); + return total(flatten(joint)); + }); + const n = total(sizes); + if (n === 0) { + return 0; + } + let cmi = 0; + for (let index = 0; index < strata.length; index += 1) { + const size = sizes[index] as number; + if (size === 0) { + continue; + } + cmi += + (size / n) * + millerMadowMutualInformationBits(strata[index] as JointCounts); + } + return cmi; +} + +/** Plug-in conditional mutual information in bits, for comparison/tests. */ +export function conditionalMutualInformationBits( + strata: readonly JointCounts[], +): number { + if (strata.length === 0) { + throw new AnalysisError('empty-sample', 'strata must not be empty'); + } + const sizes = strata.map((joint) => { + assertJoint(joint); + return total(flatten(joint)); + }); + const n = total(sizes); + if (n === 0) { + return 0; + } + let cmi = 0; + for (let index = 0; index < strata.length; index += 1) { + const size = sizes[index] as number; + if (size === 0) { + continue; + } + cmi += (size / n) * mutualInformationBits(strata[index] as JointCounts); + } + return cmi; +} + +export interface LevelCounts { + /** Number of levels of `x` (E20: six affect displays). */ + xLevels: number; + /** Number of levels of `y` (E20: the four-way referent). */ + yLevels: number; + /** Number of strata (E20: two, success and failure). */ + strata: number; +} + +function assertLevels(levels: LevelCounts): void { + for (const [label, value] of [ + ['xLevels', levels.xLevels], + ['yLevels', levels.yLevels], + ['strata', levels.strata], + ] as const) { + if (!Number.isInteger(value) || value < 1) { + throw new AnalysisError('domain', `${label} must be a positive integer`); + } + } +} + +function emptyTable(xLevels: number, yLevels: number): number[][] { + return Array.from({ length: xLevels }, () => + new Array(yLevels).fill(0), + ); +} + +/** Contingency tables, one per stratum, built from raw observations. */ +export function stratifiedJointCounts( + observations: readonly StratifiedObservation[], + levels: LevelCounts, +): number[][][] { + assertLevels(levels); + const tables = Array.from({ length: levels.strata }, () => + emptyTable(levels.xLevels, levels.yLevels), + ); + for (let index = 0; index < observations.length; index += 1) { + const observation = observations[index] as StratifiedObservation; + assertIndex(observation.x, levels.xLevels, `observations[${index}].x`); + assertIndex(observation.y, levels.yLevels, `observations[${index}].y`); + assertIndex( + observation.stratum, + levels.strata, + `observations[${index}].stratum`, + ); + const table = tables[observation.stratum] as number[][]; + const row = table[observation.x] as number[]; + row[observation.y] = (row[observation.y] as number) + 1; + } + return tables; +} + +function assertIndex(value: number, levels: number, label: string): void { + if (!Number.isInteger(value) || value < 0 || value >= levels) { + throw new AnalysisError( + 'domain', + `${label} must be an integer within [0, ${levels - 1}]`, + ); + } +} + +export interface PermutationNullOptions { + /** Number of within-stratum permutations (E20 registers 1,000). */ + permutations: number; + /** Required: the toolkit never draws unseeded randomness. */ + seed: string; +} + +export interface PermutationNullResult { + /** Replicate statistics, in draw order. */ + replicates: number[]; + /** Mean of the replicates: E20's quantity to subtract from the observed. */ + mean: number; + permutations: number; + seed: string; +} + +/** + * The E20 within-outcome permutation null: inside each stratum, the `y` labels + * are shuffled against the `x` labels, which destroys any within-stratum + * association while preserving both margins and the stratum sizes. The + * statistic is the Miller-Madow conditional mutual information. + * + * Shuffling *within* strata is the whole point: a permutation across strata + * would also destroy the outcome→display and outcome→referent relationships + * that §9.3 rule 7 says to control for, and would make the null too easy to + * beat. + */ +export function permutationNullWithinStrata( + observations: readonly StratifiedObservation[], + levels: LevelCounts, + options: PermutationNullOptions, +): PermutationNullResult { + assertLevels(levels); + if (!Number.isInteger(options.permutations) || options.permutations < 1) { + throw new AnalysisError('domain', 'permutations must be a positive integer'); + } + if (typeof options.seed !== 'string' || options.seed.length === 0) { + throw new AnalysisError('domain', 'seed must be a non-empty string'); + } + + // Group the (x, y) pairs by stratum once; each replicate then shuffles the + // stratum's y column in place over a copy. + const xByStratum: number[][] = Array.from( + { length: levels.strata }, + () => [], + ); + const yByStratum: number[][] = Array.from( + { length: levels.strata }, + () => [], + ); + for (let index = 0; index < observations.length; index += 1) { + const observation = observations[index] as StratifiedObservation; + assertIndex(observation.x, levels.xLevels, `observations[${index}].x`); + assertIndex(observation.y, levels.yLevels, `observations[${index}].y`); + assertIndex( + observation.stratum, + levels.strata, + `observations[${index}].stratum`, + ); + (xByStratum[observation.stratum] as number[]).push(observation.x); + (yByStratum[observation.stratum] as number[]).push(observation.y); + } + + const prng = new SeededPrng(options.seed); + const replicates = new Array(options.permutations); + for (let replicate = 0; replicate < options.permutations; replicate += 1) { + const tables: number[][][] = []; + for (let stratum = 0; stratum < levels.strata; stratum += 1) { + const xs = xByStratum[stratum] as number[]; + const ys = shuffleInPlace([...(yByStratum[stratum] as number[])], prng); + const table = emptyTable(levels.xLevels, levels.yLevels); + for (let index = 0; index < xs.length; index += 1) { + const row = table[xs[index] as number] as number[]; + const column = ys[index] as number; + row[column] = (row[column] as number) + 1; + } + tables.push(table); + } + replicates[replicate] = + millerMadowConditionalMutualInformationBits(tables); + } + + return { + replicates, + mean: replicates.length === 0 ? 0 : sampleMean(replicates), + permutations: options.permutations, + seed: options.seed, + }; +} + +/** Fisher-Yates over the given array, using the shared seeded stream. */ +function shuffleInPlace(values: number[], prng: SeededPrng): number[] { + for (let index = values.length - 1; index > 0; index -= 1) { + const swap = prng.nextInt(index + 1); + const held = values[index] as number; + values[index] = values[swap] as number; + values[swap] = held; + } + return values; +} + +export interface OneSidedBoundOptions { + seed: string; + iterations?: number; + /** One-sided coverage, e.g. `0.95`. */ + level?: number; +} + +export interface OneSidedUpperBound { + /** Mean of the original sample. */ + estimate: number; + /** One-sided upper bound at `level` over bootstrap replicate means. */ + upperBound: number; + n: number; + iterations: number; + level: number; + seed: string; +} + +const DEFAULT_BOOTSTRAP_ITERATIONS = 10_000; +const DEFAULT_ONE_SIDED_LEVEL = 0.95; + +/** + * E20's seed-bootstrap one-sided upper bound: resample the seed-level values + * with replacement and take the `level` quantile of the replicate means. The + * bound is one-sided by construction — the pre-registered decision is "is the + * upper bound below 0.02 bits", so only the upper tail matters. + */ +export function seedBootstrapUpperBound( + values: readonly number[], + options: OneSidedBoundOptions, +): OneSidedUpperBound { + assertSample(values, 'values'); + const iterations = options.iterations ?? DEFAULT_BOOTSTRAP_ITERATIONS; + const level = options.level ?? DEFAULT_ONE_SIDED_LEVEL; + if (typeof options.seed !== 'string' || options.seed.length === 0) { + throw new AnalysisError('domain', 'seed must be a non-empty string'); + } + if (!Number.isInteger(iterations) || iterations < 1) { + throw new AnalysisError('domain', 'iterations must be a positive integer'); + } + assertLevel(level, 'level'); + + const prng = new SeededPrng(options.seed); + const n = values.length; + const replicates = new Array(iterations); + for (let replicate = 0; replicate < iterations; replicate += 1) { + let sum = 0; + for (let draw = 0; draw < n; draw += 1) { + sum += values[prng.nextInt(n)] as number; + } + replicates[replicate] = sum / n; + } + const sorted = [...replicates].sort((a, b) => a - b); + + return { + estimate: sampleMean(values), + upperBound: quantileSorted(sorted, level), + n, + iterations, + level, + seed: options.seed, + }; +} + +/** The `probability` quantile of an unsorted replicate sample. */ +export function replicateQuantile( + replicates: readonly number[], + probability: number, +): number { + assertSample(replicates, 'replicates'); + if (!Number.isFinite(probability) || probability < 0 || probability > 1) { + throw new AnalysisError('domain', 'probability must be within [0, 1]'); + } + return quantileSorted( + [...replicates].sort((a, b) => a - b), + probability, + ); +} diff --git a/packages/analysis/src/registration-packet.ts b/packages/analysis/src/registration-packet.ts new file mode 100644 index 0000000..0cd5e80 --- /dev/null +++ b/packages/analysis/src/registration-packet.ts @@ -0,0 +1,134 @@ +/** Fail-closed canonical registration packet compiler for E00-E50. */ +import { canonicalJson, hashCanonical, isSha256Hash } from '@ald/hashing'; +import { HASH_DOMAINS } from '@ald/types'; + +import { AnalysisError } from './errors.js'; + +export const REGISTRATION_PACKET_COMPILER_VERSION = 1; +export const REGISTRATION_BINDING_KEYS = [ + 'protocolCard', + 'runConfigurations', + 'practicalMargins', + 'analysisVersions', + 'modelAssets', + 'selectedSeedPrefix', + 'executionHost', + 'scenarioBundle', + 'exclusionRules', + 'stoppingRules', + 'evidenceAndAnchorPolicy', +] as const; + +export type RegistrationBindingKey = + (typeof REGISTRATION_BINDING_KEYS)[number]; + +export interface RegistrationBinding { + readonly key: RegistrationBindingKey; + readonly sha256: string; + readonly content: unknown; +} + +export interface RegistrationPacketArtifact { + readonly schemaVersion: 1; + readonly compilerVersion: typeof REGISTRATION_PACKET_COMPILER_VERSION; + readonly experimentId: string; + readonly registrationClass: string; + readonly bindings: readonly RegistrationBinding[]; +} + +export interface CompiledRegistrationPacket { + readonly artifact: RegistrationPacketArtifact; + readonly canonicalArtifact: string; + readonly preRegistrationHash: string; + readonly claimBoundary: 'draft-until-repository-registered-and-pre-run-committed'; +} + +export interface CompileRegistrationPacketInput { + readonly experimentId: string; + readonly registrationClass: string; + readonly bindings: Readonly>; +} + +const PLACEHOLDER = /^(?:tbd|todo|unknown|unset|unresolved|pending|n\/a)$/iu; + +function invalid(path: string, detail: string): never { + throw new AnalysisError('domain', `${path} ${detail}`); +} + +function assertConcrete(value: unknown, path: string): void { + if (value === null || value === undefined) invalid(path, 'must be resolved'); + if (typeof value === 'string') { + if (value.trim().length === 0 || PLACEHOLDER.test(value.trim())) { + invalid(path, 'contains an empty or placeholder value'); + } + return; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) invalid(path, 'must be finite'); + return; + } + if (typeof value === 'boolean') return; + if (Array.isArray(value)) { + if (value.length === 0) invalid(path, 'must not be an empty array'); + value.forEach((entry, index) => assertConcrete(entry, `${path}[${String(index)}]`)); + return; + } + if (typeof value === 'object') { + const entries = Object.entries(value); + if (entries.length === 0) invalid(path, 'must not be an empty object'); + for (const [key, entry] of entries) { + if (key.length === 0) invalid(path, 'contains an empty key'); + assertConcrete(entry, `${path}.${key}`); + } + return; + } + invalid(path, `contains unsupported ${typeof value}`); +} + +function assertExactBindingKeys(bindings: object): void { + const actual = Object.keys(bindings).sort(); + const expected = [...REGISTRATION_BINDING_KEYS].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + invalid('bindings', `must contain exactly ${expected.join(', ')}`); + } +} + +export function compileRegistrationPacket( + input: CompileRegistrationPacketInput, +): CompiledRegistrationPacket { + if (!/^E\d{2}$/u.test(input.experimentId)) { + invalid('experimentId', 'must have the form E00'); + } + if (input.registrationClass.trim().length === 0 || PLACEHOLDER.test(input.registrationClass)) { + invalid('registrationClass', 'must be concrete'); + } + assertExactBindingKeys(input.bindings); + const bindings = REGISTRATION_BINDING_KEYS.map((key): RegistrationBinding => { + const content = input.bindings[key]; + assertConcrete(content, `bindings.${key}`); + return { + key, + sha256: hashCanonical(HASH_DOMAINS.preRegistration, { key, content }), + content, + }; + }); + const artifact: RegistrationPacketArtifact = { + schemaVersion: 1, + compilerVersion: REGISTRATION_PACKET_COMPILER_VERSION, + experimentId: input.experimentId, + registrationClass: input.registrationClass, + bindings, + }; + const canonicalArtifact = canonicalJson(artifact); + const preRegistrationHash = hashCanonical(HASH_DOMAINS.preRegistration, artifact); + if (!isSha256Hash(preRegistrationHash)) invalid('preRegistrationHash', 'did not encode as SHA-256'); + return { + artifact, + canonicalArtifact, + preRegistrationHash, + claimBoundary: 'draft-until-repository-registered-and-pre-run-committed', + }; +} diff --git a/packages/analysis/src/special.ts b/packages/analysis/src/special.ts new file mode 100644 index 0000000..cc6c0a7 --- /dev/null +++ b/packages/analysis/src/special.ts @@ -0,0 +1,447 @@ +/** + * Special functions the pre-registered statistics of SPECIFICATION.md §15.3 + * depend on. Dependency-free, deterministic, and double-precision: the same + * inputs give bit-identical outputs on every machine that runs the analysis, + * which is what makes a re-run of a registered analysis script reproducible + * (RESEARCH.md §7.3, Appendix D §D.1). + * + * Algorithms follow the standard references: Lanczos for `logGamma`, the + * modified Lentz continued fraction of Numerical Recipes (`betacf`) for the + * regularized incomplete beta, the series/continued-fraction pair for the + * regularized incomplete gamma (which supplies `erf` and hence `normalCdf`), + * and Acklam's rational approximation plus one Halley step for + * `normalQuantile`. + */ +import { AnalysisError, assertProbability } from './errors.js'; + +/** Relative convergence target for the continued fractions below. */ +const EPSILON = 3e-16; +/** Guard against division by a vanishing denominator in Lentz's method. */ +const TINY = 1e-300; +const MAX_ITERATIONS = 400; + +const LANCZOS_G = 7; +const LANCZOS_COEFFICIENTS: readonly number[] = [ + 0.9999999999998099, 676.5203681218851, -1259.1392167224028, + 771.3234287776531, -176.6150291621406, 12.507343278686905, + -0.13857109526572012, 9.984369578019572e-6, 1.5056327351493116e-7, +]; + +const LOG_SQRT_TWO_PI = 0.9189385332046727; +const SQRT_TWO_PI = 2.5066282746310002; + +/** + * Natural log of the gamma function for x > 0 (Lanczos, g = 7, 9 terms). + * Relative error is at the 1e-15 level across the range the toolkit uses + * (beta/binomial coefficients with small half-integer and integer arguments). + */ +export function logGamma(x: number): number { + if (!Number.isFinite(x) || x <= 0) { + throw new AnalysisError('domain', 'logGamma requires a finite x > 0'); + } + const shifted = x - 1; + let series = LANCZOS_COEFFICIENTS[0] as number; + for (let index = 1; index < LANCZOS_COEFFICIENTS.length; index += 1) { + series += (LANCZOS_COEFFICIENTS[index] as number) / (shifted + index); + } + const t = shifted + LANCZOS_G + 0.5; + return ( + LOG_SQRT_TWO_PI + (shifted + 0.5) * Math.log(t) - t + Math.log(series) + ); +} + +/** Natural log of the beta function B(a, b). */ +export function logBeta(a: number, b: number): number { + return logGamma(a) + logGamma(b) - logGamma(a + b); +} + +/** Natural log of the binomial coefficient C(n, k) for integers 0 <= k <= n. */ +export function logBinomialCoefficient(n: number, k: number): number { + if (!Number.isInteger(n) || !Number.isInteger(k) || k < 0 || k > n) { + throw new AnalysisError( + 'domain', + 'logBinomialCoefficient requires integers with 0 <= k <= n', + ); + } + return logGamma(n + 1) - logGamma(k + 1) - logGamma(n - k + 1); +} + +/** + * Numerical Recipes `betacf`: the continued fraction for the incomplete beta, + * evaluated with the modified Lentz algorithm. Only called for + * x < (a + 1) / (a + b + 2), where it converges quickly; the caller applies + * the symmetry swap for the other half of the range. + */ +function betaContinuedFraction(a: number, b: number, x: number): number { + const qab = a + b; + const qap = a + 1; + const qam = a - 1; + let c = 1; + let d = 1 - (qab * x) / qap; + if (Math.abs(d) < TINY) { + d = TINY; + } + d = 1 / d; + let h = d; + for (let m = 1; m <= MAX_ITERATIONS; m += 1) { + const m2 = 2 * m; + let numerator = (m * (b - m) * x) / ((qam + m2) * (a + m2)); + d = 1 + numerator * d; + if (Math.abs(d) < TINY) { + d = TINY; + } + c = 1 + numerator / c; + if (Math.abs(c) < TINY) { + c = TINY; + } + d = 1 / d; + h *= d * c; + numerator = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2)); + d = 1 + numerator * d; + if (Math.abs(d) < TINY) { + d = TINY; + } + c = 1 + numerator / c; + if (Math.abs(c) < TINY) { + c = TINY; + } + d = 1 / d; + const delta = d * c; + h *= delta; + if (Math.abs(delta - 1) < EPSILON) { + return h; + } + } + throw new AnalysisError( + 'no-convergence', + 'incomplete beta continued fraction did not converge', + ); +} + +/** + * Regularized incomplete beta I_x(a, b) — the CDF of the Beta(a, b) + * distribution, and the engine behind `studentTCdf`. + * + * Exact reference value used in tests: I_0.5(2, 3) = 11/16 = 0.6875. + */ +export function regularizedIncompleteBeta( + x: number, + a: number, + b: number, +): number { + if (!Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) { + throw new AnalysisError( + 'domain', + 'regularizedIncompleteBeta requires a > 0 and b > 0', + ); + } + if (!Number.isFinite(x) || x < 0 || x > 1) { + throw new AnalysisError( + 'domain', + 'regularizedIncompleteBeta requires x within [0, 1]', + ); + } + if (x === 0) { + return 0; + } + if (x === 1) { + return 1; + } + const front = Math.exp( + logGamma(a + b) - + logGamma(a) - + logGamma(b) + + a * Math.log(x) + + b * Math.log1p(-x), + ); + if (x < (a + 1) / (a + b + 2)) { + return (front * betaContinuedFraction(a, b, x)) / a; + } + return 1 - (front * betaContinuedFraction(b, a, 1 - x)) / b; +} + +/** Series expansion for P(a, x); used when x < a + 1. */ +function lowerGammaSeries(a: number, x: number): number { + let ap = a; + let sum = 1 / a; + let delta = sum; + for (let index = 0; index < MAX_ITERATIONS; index += 1) { + ap += 1; + delta *= x / ap; + sum += delta; + if (Math.abs(delta) < Math.abs(sum) * EPSILON) { + return sum * Math.exp(-x + a * Math.log(x) - logGamma(a)); + } + } + throw new AnalysisError( + 'no-convergence', + 'lower incomplete gamma series did not converge', + ); +} + +/** Modified Lentz continued fraction for Q(a, x); used when x >= a + 1. */ +function upperGammaContinuedFraction(a: number, x: number): number { + let b = x + 1 - a; + let c = 1 / TINY; + let d = 1 / b; + let h = d; + for (let index = 1; index <= MAX_ITERATIONS; index += 1) { + const an = -index * (index - a); + b += 2; + d = an * d + b; + if (Math.abs(d) < TINY) { + d = TINY; + } + c = b + an / c; + if (Math.abs(c) < TINY) { + c = TINY; + } + d = 1 / d; + const delta = d * c; + h *= delta; + if (Math.abs(delta - 1) < EPSILON) { + return h * Math.exp(-x + a * Math.log(x) - logGamma(a)); + } + } + throw new AnalysisError( + 'no-convergence', + 'upper incomplete gamma continued fraction did not converge', + ); +} + +/** Regularized lower incomplete gamma P(a, x). */ +export function regularizedLowerGamma(a: number, x: number): number { + if (!Number.isFinite(a) || a <= 0 || !Number.isFinite(x) || x < 0) { + throw new AnalysisError( + 'domain', + 'regularizedLowerGamma requires a > 0 and x >= 0', + ); + } + if (x === 0) { + return 0; + } + return x < a + 1 + ? lowerGammaSeries(a, x) + : 1 - upperGammaContinuedFraction(a, x); +} + +/** Regularized upper incomplete gamma Q(a, x) = 1 - P(a, x). */ +export function regularizedUpperGamma(a: number, x: number): number { + if (!Number.isFinite(a) || a <= 0 || !Number.isFinite(x) || x < 0) { + throw new AnalysisError( + 'domain', + 'regularizedUpperGamma requires a > 0 and x >= 0', + ); + } + if (x === 0) { + return 1; + } + return x < a + 1 + ? 1 - lowerGammaSeries(a, x) + : upperGammaContinuedFraction(a, x); +} + +/** + * Standard normal CDF via the incomplete gamma identity + * `erf(z / sqrt(2)) = P(1/2, z^2 / 2)`, taking the upper-tail branch for + * z < 0 so far-tail probabilities keep full relative precision. + */ +export function normalCdf(z: number): number { + if (Number.isNaN(z)) { + throw new AnalysisError('domain', 'normalCdf requires a number'); + } + if (z === Infinity) { + return 1; + } + if (z === -Infinity) { + return 0; + } + const x = (z * z) / 2; + return z >= 0 + ? 0.5 + 0.5 * regularizedLowerGamma(0.5, x) + : 0.5 * regularizedUpperGamma(0.5, x); +} + +/** Standard normal probability density. */ +export function normalPdf(z: number): number { + if (!Number.isFinite(z)) { + throw new AnalysisError('domain', 'normalPdf requires a finite z'); + } + return Math.exp(-0.5 * z * z) / SQRT_TWO_PI; +} + +const ACKLAM_A: readonly number[] = [ + -3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, + 1.38357751867269e2, -3.066479806614716e1, 2.506628277459239, +]; +const ACKLAM_B: readonly number[] = [ + -5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, + 6.680131188771972e1, -1.328068155288572e1, 1, +]; +const ACKLAM_C: readonly number[] = [ + -7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, + -2.549732539343734, 4.374664141464968, 2.938163982698783, +]; +const ACKLAM_D: readonly number[] = [ + 7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, + 3.754408661907416, 1, +]; +const ACKLAM_LOW = 0.02425; + +/** Horner evaluation of a polynomial whose coefficients are highest-order first. */ +function evaluatePolynomial(coefficients: readonly number[], x: number): number { + let value = 0; + for (const coefficient of coefficients) { + value = value * x + coefficient; + } + return value; +} + +/** + * Standard normal quantile: Acklam's rational approximation (relative error + * ~1.15e-9) refined by one Halley step against `normalCdf`, which brings the + * result to double-precision agreement with the CDF. `normalQuantile(0.975)` + * is 1.959963984540054. + */ +export function normalQuantile(p: number): number { + assertProbability(p, 'p'); + if (p === 0) { + return -Infinity; + } + if (p === 1) { + return Infinity; + } + let x: number; + if (p < ACKLAM_LOW) { + const q = Math.sqrt(-2 * Math.log(p)); + x = evaluatePolynomial(ACKLAM_C, q) / evaluatePolynomial(ACKLAM_D, q); + } else if (p <= 1 - ACKLAM_LOW) { + const q = p - 0.5; + const r = q * q; + x = + (q * evaluatePolynomial(ACKLAM_A, r)) / evaluatePolynomial(ACKLAM_B, r); + } else { + const q = Math.sqrt(-2 * Math.log1p(-p)); + x = -evaluatePolynomial(ACKLAM_C, q) / evaluatePolynomial(ACKLAM_D, q); + } + const error = normalCdf(x) - p; + const u = (error * SQRT_TWO_PI) / Math.exp(-0.5 * x * x); + return x - u / (1 + (x * u) / 2); +} + +/** + * Student t CDF with `df` degrees of freedom, from the incomplete beta: + * `P(T <= t) = 1 - I_{df/(df+t^2)}(df/2, 1/2) / 2` for t >= 0, mirrored below + * zero. `studentTCdf(0, df)` is exactly 0.5. + */ +export function studentTCdf(t: number, df: number): number { + if (!Number.isFinite(df) || df <= 0) { + throw new AnalysisError('domain', 'studentTCdf requires df > 0'); + } + if (Number.isNaN(t)) { + throw new AnalysisError('domain', 'studentTCdf requires a number t'); + } + if (t === Infinity) { + return 1; + } + if (t === -Infinity) { + return 0; + } + const x = df / (df + t * t); + const half = 0.5 * regularizedIncompleteBeta(x, df / 2, 0.5); + return t >= 0 ? 1 - half : half; +} + +/** + * Convergence target for the quantile search, in probability units: tighter + * than the 1e-10 the analysis plan needs so the returned quantile inverts the + * CDF to better than 1e-10 in every case. + */ +const QUANTILE_TOLERANCE = 1e-12; + +/** + * Student t quantile: bisection on `studentTCdf` (monotone, so bisection is + * unconditionally safe) refined until the CDF matches `p` to within 1e-12, or + * the bracket collapses to double precision. `studentTQuantile(0.975, 10)` is + * 2.2281388519... + */ +export function studentTQuantile(p: number, df: number): number { + assertProbability(p, 'p'); + if (!Number.isFinite(df) || df <= 0) { + throw new AnalysisError('domain', 'studentTQuantile requires df > 0'); + } + if (p === 0) { + return -Infinity; + } + if (p === 1) { + return Infinity; + } + if (p === 0.5) { + return 0; + } + if (p < 0.5) { + return -studentTQuantile(1 - p, df); + } + let low = 0; + let high = 1; + while (studentTCdf(high, df) < p) { + low = high; + high *= 2; + if (high > 1e300) { + return Infinity; + } + } + let middle = (low + high) / 2; + for (let index = 0; index < 200; index += 1) { + middle = (low + high) / 2; + const value = studentTCdf(middle, df); + if (Math.abs(value - p) <= QUANTILE_TOLERANCE) { + return middle; + } + if (value < p) { + low = middle; + } else { + high = middle; + } + if (high - low <= Number.EPSILON * Math.max(1, Math.abs(middle))) { + return middle; + } + } + return middle; +} + +/** + * Log of the binomial pmf `P(X = successes)` for `trials` Bernoulli draws with + * success probability `probability`. Computed in log space so E11's exact + * chance-rate tail (which sums hundreds of terms) does not underflow. + */ +export function binomialLogPmf( + successes: number, + trials: number, + probability: number, +): number { + if (!Number.isInteger(trials) || trials < 0) { + throw new AnalysisError( + 'domain', + 'binomialLogPmf requires a non-negative integer trials', + ); + } + if (!Number.isInteger(successes) || successes < 0 || successes > trials) { + throw new AnalysisError( + 'domain', + 'binomialLogPmf requires 0 <= successes <= trials', + ); + } + assertProbability(probability, 'probability'); + if (probability === 0) { + return successes === 0 ? 0 : -Infinity; + } + if (probability === 1) { + return successes === trials ? 0 : -Infinity; + } + return ( + logBinomialCoefficient(trials, successes) + + successes * Math.log(probability) + + (trials - successes) * Math.log1p(-probability) + ); +} diff --git a/packages/analysis/tsconfig.json b/packages/analysis/tsconfig.json new file mode 100644 index 0000000..925f012 --- /dev/null +++ b/packages/analysis/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../types" }, + { "path": "../hashing" }, + { "path": "../lifecycle" } + ] +} diff --git a/packages/anchor/__tests__/api.test.ts b/packages/anchor/__tests__/api.test.ts new file mode 100644 index 0000000..8b92a7b --- /dev/null +++ b/packages/anchor/__tests__/api.test.ts @@ -0,0 +1,209 @@ +/** + * Guards the curated public API and the two invariants the whole package + * exists to protect: the on-chain payload encoding (LEDGER §12) and the + * canonical, crash-durable pending-submission sidecar. + */ +import { + lstatSync, + readFileSync, + readdirSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +import { canonicalJson, decodeHash } from '@ald/hashing'; +import { afterAll, describe, expect, it } from 'vitest'; + +import * as anchor from '../src/index.js'; +import * as testing from '../src/testing.js'; +import { + ANCHOR_CHAIN_IDS, + ANCHOR_INPUT_DATA_LENGTH, + PENDING_FILE_MODE, + PendingFileInvalidError, + addPendingSubmission, + anchorInputData, + findPendingSubmission, + readPendingSubmissions, + removePendingSubmission, +} from '../src/index.js'; +import type { PendingAnchorSubmission } from '../src/index.js'; +import { cleanupTemporaryDirectories, temporaryDirectory } from './support.js'; + +const EXPECTED_EXPORTS = [ + 'ANCHOR_CHAIN_IDS', + 'ANCHOR_INPUT_DATA_LENGTH', + 'ANCHOR_KEY_FILE_MODE', + 'AnchorError', + 'AnchorKeyFileError', + 'AnchorNetworkMismatchError', + 'AnchorPayloadMismatchError', + 'AnchorSubmissionFailedError', + 'BASE_BLOCK_TIME_SECONDS', + 'BaseAnchorPublisher', + 'DEFAULT_CONFIRMATION_POLL_ATTEMPTS', + 'DEFAULT_CONFIRMATION_POLL_INTERVAL_MS', + 'DEFAULT_FAKE_FROM_ADDRESS', + 'DEFAULT_INITIAL_BACKOFF_MS', + 'DEFAULT_MAX_BACKOFF_MS', + 'DEFAULT_RETRY_ATTEMPTS', + 'FORBIDDEN_KEY_FILE_MODE_BITS', + 'FakeChainTransport', + 'InvalidFinalityPolicyError', + 'MAINNET_ANCHORING_ENV_VAR', + 'MainnetAnchoringDisabledError', + 'PENDING_FILE_MODE', + 'PendingAnchorFileSchema', + 'PendingAnchorSubmissionSchema', + 'PendingFileInvalidError', + 'SAFE_TAG_CONFIRMATION_PROXY', + 'TransientChainError', + 'UnknownAnchorCheckpointError', + 'UnknownAnchorRunError', + 'ViemChainTransport', + 'addPendingSubmission', + 'anchorInputData', + 'expectedChainId', + 'findPendingSubmission', + 'generateAnchorKey', + 'isAnchorError', + 'loadAnchorKeyFile', + 'readPendingSubmissions', + 'removePendingSubmission', + 'requiredConfirmations', + 'verifyAnchorReceipt', + 'writeAnchorKeyFile', + 'writePendingSubmissions', +]; + +afterAll(async () => { + await cleanupTemporaryDirectories(); +}); + +describe('public API surface', () => { + it('exports exactly the curated runtime symbols', () => { + expect(Object.keys(anchor).sort()).toEqual(EXPECTED_EXPORTS); + }); + + it('keeps the test double in its own entry point', () => { + expect(Object.keys(testing).sort()).toEqual([ + 'DEFAULT_FAKE_FROM_ADDRESS', + 'FakeChainTransport', + ]); + }); + + it('pins the Base chain ids', () => { + expect(ANCHOR_CHAIN_IDS).toEqual({ + 'base-sepolia': 84532, + 'base-mainnet': 8453, + }); + }); +}); + +describe('anchorInputData (LEDGER §12)', () => { + it('is the raw digest and nothing else', () => { + const hash = `sha256:${'ab'.repeat(32)}`; + const encoded = anchorInputData(hash); + + expect(encoded).toBe(`0x${'ab'.repeat(32)}`); + expect(encoded).toHaveLength(ANCHOR_INPUT_DATA_LENGTH); + expect(Buffer.from(encoded.slice(2), 'hex')).toEqual(decodeHash(hash)); + }); + + it('refuses anything that is not a sha256 digest', () => { + expect(() => anchorInputData('0xdeadbeef')).toThrow(TypeError); + expect(() => anchorInputData(`sha256:${'zz'.repeat(32)}`)).toThrow( + TypeError, + ); + }); +}); + +describe('pending submission sidecar', () => { + const submission: PendingAnchorSubmission = { + version: 1, + runId: 'run-anchor-001', + checkpointSequence: 0, + checkpointHash: `sha256:${'cd'.repeat(32)}`, + anchorClass: 'simulated', + network: 'base-sepolia', + chainId: 84532, + transactionHash: `0x${'11'.repeat(32)}`, + from: `0x${'22'.repeat(20)}`, + to: `0x${'33'.repeat(20)}`, + inputData: `0x${'cd'.repeat(32)}`, + finalityPolicy: '1-confirmation', + rpcEndpointLabel: 'fake-base-sepolia', + submittedAt: '2026-01-01T00:00:00.000Z', + }; + + it('writes canonical JSON, dedupes, finds, and removes', async () => { + const path = join(await temporaryDirectory(), 'pending.json'); + + expect(readPendingSubmissions(path)).toEqual([]); + addPendingSubmission(path, submission); + addPendingSubmission(path, submission); + + expect(readPendingSubmissions(path)).toEqual([submission]); + expect(readFileSync(path, 'utf8')).toBe( + `${canonicalJson({ version: 1, submissions: [submission] })}\n`, + ); + expect( + findPendingSubmission( + readPendingSubmissions(path), + 84532, + submission.checkpointHash, + ), + ).toEqual(submission); + + removePendingSubmission(path, 84532, submission.transactionHash); + expect(readPendingSubmissions(path)).toEqual([]); + }); + + it('refuses a corrupted sidecar rather than silently losing a tx hash', async () => { + const path = join(await temporaryDirectory(), 'pending.json'); + writeFileSync(path, '{"version":1}', 'utf8'); + + expect(() => readPendingSubmissions(path)).toThrow(PendingFileInvalidError); + }); + + it('is 0600 and ignores anything planted at the old fixed temp path', async () => { + const directory = await temporaryDirectory(); + const path = join(directory, 'pending.json'); + const victim = join(directory, 'victim.txt'); + writeFileSync(victim, 'ORIGINAL\n', { mode: 0o600 }); + // The sidecar used to be staged at this exact sibling path, so a planted + // file donated its permissions and a planted symlink took the write. + const planted = `${path}.tmp`; + symlinkSync(victim, planted); + + addPendingSubmission(path, submission); + + expect(readPendingSubmissions(path)).toEqual([submission]); + expect(statSync(path).mode & 0o777).toBe(PENDING_FILE_MODE); + expect(lstatSync(path).isSymbolicLink()).toBe(false); + expect(readFileSync(victim, 'utf8')).toBe('ORIGINAL\n'); + // A unique temp name is used and cleaned up by the rename. + expect( + readdirSync(directory).filter((entry) => entry.endsWith('.tmp')), + ).toEqual(['pending.json.tmp']); + }); + + it('refuses a symlinked sidecar instead of writing through it', async () => { + const directory = await temporaryDirectory(); + const path = join(directory, 'pending.json'); + const victim = join(directory, 'victim.json'); + // Valid sidecar content, so the read succeeds and only the write can + // refuse: the sidecar must be a regular file this process owns. + const original = `${canonicalJson({ version: 1, submissions: [] })}\n`; + writeFileSync(victim, original, { mode: 0o600 }); + symlinkSync(victim, path); + + expect(readPendingSubmissions(path)).toEqual([]); + expect(() => addPendingSubmission(path, submission)).toThrow( + PendingFileInvalidError, + ); + expect(readFileSync(victim, 'utf8')).toBe(original); + }); +}); diff --git a/packages/anchor/__tests__/key-file.test.ts b/packages/anchor/__tests__/key-file.test.ts new file mode 100644 index 0000000..eaa1fbc --- /dev/null +++ b/packages/anchor/__tests__/key-file.test.ts @@ -0,0 +1,258 @@ +/** + * ALD-019 — the anchoring wallet key is stored separately from the per-run + * event/witness keys, never logged, and rotating it leaves prior receipts + * verifiable (LEDGER §11, SPEC §13.5). + */ +import { + chmod, + mkdir, + readFile, + readdir, + stat, + writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { inspect } from 'node:util'; + +import { FileKeyStore } from '@ald/hashing'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { + ANCHOR_KEY_FILE_MODE, + AnchorKeyFileError, + BaseAnchorPublisher, + FakeChainTransport, + generateAnchorKey, + loadAnchorKeyFile, + verifyAnchorReceipt, + writeAnchorKeyFile, +} from '../src/index.js'; +import type { AnchorTestContext } from './support.js'; +import { + ANCHOR_ADDRESS, + cleanupTemporaryDirectories, + createAnchorContext, + immediateSleep, + temporaryDirectory, +} from './support.js'; + +const openContexts: AnchorTestContext[] = []; + +afterAll(async () => { + for (const open of openContexts.splice(0)) { + open.close(); + } + await cleanupTemporaryDirectories(); +}); + +describe('anchor key files (ALD-019)', () => { + it('writes 0600 and round-trips the key and address', async () => { + const directory = await temporaryDirectory(); + const path = join(directory, 'keys', 'anchor.key'); + const generated = generateAnchorKey(); + + const written = await writeAnchorKeyFile(path, generated.privateKey); + expect(written).toEqual(generated); + + const stats = await stat(path); + expect(stats.mode & 0o777).toBe(ANCHOR_KEY_FILE_MODE); + + const loaded = await loadAnchorKeyFile(path); + expect(loaded.privateKey).toBe(generated.privateKey); + expect(loaded.address).toBe(generated.address); + }); + + it('refuses a group/other-readable key file unless explicitly allowed', async () => { + const directory = await temporaryDirectory(); + const path = join(directory, 'anchor.key'); + const key = generateAnchorKey(); + await writeAnchorKeyFile(path, key.privateKey); + await chmod(path, 0o644); + + const failure = await loadAnchorKeyFile(path).catch( + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(AnchorKeyFileError); + expect((failure as AnchorKeyFileError).code).toBe('ANCHOR_KEY_FILE'); + // LEDGER §11: never log the key — not even inside an error message. + expect((failure as Error).message).not.toContain(key.privateKey.slice(2)); + + await expect( + loadAnchorKeyFile(path, { allowInsecurePermissions: true }), + ).resolves.toMatchObject({ address: key.address }); + }); + + it('rejects malformed contents without echoing them', async () => { + const directory = await temporaryDirectory(); + const path = join(directory, 'bad.key'); + await writeFile(path, 'not-a-key-0xdeadbeef\n', { mode: 0o600 }); + + const failure = await loadAnchorKeyFile(path).catch( + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(AnchorKeyFileError); + expect((failure as Error).message).not.toContain('not-a-key'); + + await expect(loadAnchorKeyFile(join(directory, 'missing.key'))).rejects.toBeInstanceOf( + AnchorKeyFileError, + ); + }); + + it('never echoes an out-of-range key, not even through a cause', async () => { + const directory = await temporaryDirectory(); + const path = join(directory, 'out-of-range.key'); + // Correctly shaped (0x + 64 hex) but >= the secp256k1 group order, so the + // curve library raises a bare Error that prints the scalar in decimal. + const key = `0x${'ff'.repeat(32)}`; + const decimal = BigInt(key).toString(10); + await writeFile(path, `${key}\n`, { mode: 0o600 }); + + const failure = await loadAnchorKeyFile(path).catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(AnchorKeyFileError); + expect((failure as AnchorKeyFileError).code).toBe('ANCHOR_KEY_FILE'); + const rendered = `${(failure as Error).message} ${inspect(failure, { + depth: 6, + })}`; + expect(rendered).not.toContain(key.slice(2)); + expect(rendered).not.toContain(decimal); + expect((failure as Error).cause).toBeUndefined(); + + // Same containment on the write path, and the bad key never lands on disk. + const writePath = join(directory, 'zero.key'); + const zero = `0x${'00'.repeat(32)}`; + const writeFailure = await writeAnchorKeyFile(writePath, zero).catch( + (error: unknown) => error, + ); + expect(writeFailure).toBeInstanceOf(AnchorKeyFileError); + expect((writeFailure as Error).message).not.toContain(zero.slice(2)); + await expect(stat(writePath)).rejects.toThrow(); + }); + + it('refuses a key file in a group/other-writable directory', async () => { + const directory = await temporaryDirectory(); + const exposed = join(directory, 'exposed'); + await mkdir(exposed, { recursive: true, mode: 0o700 }); + const path = join(exposed, 'anchor.key'); + const key = generateAnchorKey(); + await writeAnchorKeyFile(path, key.privateKey); + // Anyone who can write the directory can replace the key file in it. + await chmod(exposed, 0o777); + + const failure = await loadAnchorKeyFile(path).catch( + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(AnchorKeyFileError); + expect((failure as Error).message).toContain('group/other write'); + expect((failure as Error).message).not.toContain(key.privateKey.slice(2)); + + await expect( + loadAnchorKeyFile(path, { allowInsecurePermissions: true }), + ).resolves.toMatchObject({ address: key.address }); + + await chmod(exposed, 0o700); + await expect(loadAnchorKeyFile(path)).resolves.toEqual(key); + }); + + it('accepts a trailing-whitespace key file and refuses to clobber it', async () => { + const directory = await temporaryDirectory(); + const path = join(directory, 'anchor.key'); + const key = generateAnchorKey(); + await writeFile(path, ` ${key.privateKey}\n\n`, { mode: 0o600 }); + + await expect(loadAnchorKeyFile(path)).resolves.toEqual(key); + await expect( + writeAnchorKeyFile(path, generateAnchorKey().privateKey), + ).rejects.toBeInstanceOf(AnchorKeyFileError); + }); + + it('lives outside the per-run event signer key store', async () => { + const directory = await temporaryDirectory(); + const keyStoreDir = join(directory, 'event-keys'); + const anchorKeyPath = join(directory, 'anchor', 'anchor.key'); + + const store = new FileKeyStore(keyStoreDir); + store.provisionRun('run-anchor-keys'); + const anchorKey = generateAnchorKey(); + await writeAnchorKeyFile(anchorKeyPath, anchorKey.privateKey); + + // The event/witness seeds and the wallet key share no file and no bytes. + const runFiles = await readdir(join(keyStoreDir, 'run-anchor-keys')); + expect(runFiles).not.toContain('anchor.key'); + const seedText = await readFile( + store.seedFile('run-anchor-keys'), + 'utf8', + ); + expect(seedText).not.toContain(anchorKey.privateKey.slice(2)); + expect(await readFile(anchorKeyPath, 'utf8')).not.toContain('ed25519'); + }); + + it('rotates the wallet key without invalidating prior receipts', async () => { + const ctx = await createAnchorContext('run-anchor-rotation'); + openContexts.push(ctx); + const directory = await temporaryDirectory(); + + const keyA = await writeAnchorKeyFile( + join(directory, 'anchor-a.key'), + generateAnchorKey().privateKey, + ); + const keyB = await writeAnchorKeyFile( + join(directory, 'anchor-b.key'), + generateAnchorKey().privateKey, + ); + expect(keyA.address).not.toBe(keyB.address); + + const anchorWith = async ( + from: string, + manifestIndex: number, + ): Promise<{ transport: FakeChainTransport; receipt: Awaited> }> => { + const manifest = ctx.manifests[manifestIndex]; + if (manifest === undefined) { + throw new Error(`no manifest at index ${manifestIndex}`); + } + const transport = new FakeChainTransport({ from }); + const publisher = new BaseAnchorPublisher({ + transport, + anchorClass: 'simulated', + evidence: ctx.writer, + clock: ctx.clock, + anchorAddress: ANCHOR_ADDRESS, + finalityPolicy: '1-confirmation', + retry: { attempts: 2, initialBackoffMs: 1, sleep: immediateSleep }, + }); + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + return { transport, receipt: await publisher.awaitConfirmation(submitted) }; + }; + + const first = await ctx.addCheckpoint(); + const beforeRotation = await anchorWith(keyA.address, 0); + + // Rotation: a new key file and a new publisher, no code change. + const second = await ctx.addCheckpoint('event-interval'); + const afterRotation = await anchorWith(keyB.address, 1); + + expect(beforeRotation.receipt.from).toBe(keyA.address); + expect(afterRotation.receipt.from).toBe(keyB.address); + expect(beforeRotation.receipt.from).not.toBe(afterRotation.receipt.from); + + await expect( + verifyAnchorReceipt( + beforeRotation.receipt, + first.checkpointHash, + beforeRotation.transport, + ), + ).resolves.toMatchObject({ ok: true }); + await expect( + verifyAnchorReceipt( + afterRotation.receipt, + second.checkpointHash, + afterRotation.transport, + ), + ).resolves.toMatchObject({ ok: true }); + + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(2); + }); +}); diff --git a/packages/anchor/__tests__/publisher.test.ts b/packages/anchor/__tests__/publisher.test.ts new file mode 100644 index 0000000..f06243a --- /dev/null +++ b/packages/anchor/__tests__/publisher.test.ts @@ -0,0 +1,695 @@ +/** + * ALD-018 (receipt storage), ALD-020 (Sepolia client), ALD-021 (confirmation + * + retry/backoff), ALD-022 (mainnet opt-in) behavioural suite, against the + * real `SqliteEvidenceWriter` and the deterministic fake chain. + */ +import { join } from 'node:path'; + +import { decodeHash } from '@ald/hashing'; +import { AnchorReceiptSchema } from '@ald/types'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; + +import { + ANCHOR_CHAIN_IDS, + AnchorSubmissionFailedError, + BASE_BLOCK_TIME_SECONDS, + BaseAnchorPublisher, + DEFAULT_CONFIRMATION_POLL_ATTEMPTS, + DEFAULT_CONFIRMATION_POLL_INTERVAL_MS, + DEFAULT_INITIAL_BACKOFF_MS, + DEFAULT_RETRY_ATTEMPTS, + FakeChainTransport, + InvalidFinalityPolicyError, + MAINNET_ANCHORING_ENV_VAR, + MainnetAnchoringDisabledError, + SAFE_TAG_CONFIRMATION_PROXY, + UnknownAnchorCheckpointError, + UnknownAnchorRunError, + anchorInputData, + readPendingSubmissions, + requiredConfirmations, + verifyAnchorReceipt, +} from '../src/index.js'; +import type { AnchorTestContext } from './support.js'; +import { + ANCHOR_ADDRESS, + cleanupTemporaryDirectories, + createAnchorContext, + immediateSleep, +} from './support.js'; + +const openContexts: AnchorTestContext[] = []; + +async function context(): Promise { + const created = await createAnchorContext(); + openContexts.push(created); + return created; +} + +interface PublisherOptions { + finalityPolicy?: string; + /** Send-retry budget. */ + attempts?: number; + /** Confirmation-poll budget; defaults to the production default. */ + pollAttempts?: number; + sleep?: (milliseconds: number) => Promise; + pendingFile?: string; + allowMainnet?: boolean; +} + +function publisherFor( + ctx: AnchorTestContext, + transport: FakeChainTransport, + options: PublisherOptions = {}, +): BaseAnchorPublisher { + return new BaseAnchorPublisher({ + transport, + anchorClass: 'simulated', + evidence: ctx.writer, + clock: ctx.clock, + anchorAddress: ANCHOR_ADDRESS, + finalityPolicy: options.finalityPolicy ?? '1-confirmation', + retry: { + attempts: options.attempts ?? 5, + initialBackoffMs: 1, + maxBackoffMs: 4, + sleep: options.sleep ?? immediateSleep, + }, + confirmationPoll: { + intervalMs: 1, + ...(options.pollAttempts === undefined + ? {} + : { attempts: options.pollAttempts }), + }, + ...(options.pendingFile === undefined + ? {} + : { pendingFile: options.pendingFile }), + ...(options.allowMainnet === undefined + ? {} + : { allowMainnet: options.allowMainnet }), + }); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +afterAll(async () => { + for (const open of openContexts.splice(0)) { + open.close(); + } + await cleanupTemporaryDirectories(); +}); + +describe('BaseAnchorPublisher.submit (ALD-020)', () => { + it('anchors the bare 32-byte checkpoint digest and nothing else', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport); + + const receipt = await publisher.submit(manifest); + + const digestHex = decodeHash(manifest.checkpointHash).toString('hex'); + expect(receipt.inputData).toBe(`0x${digestHex}`); + expect(receipt.inputData).toBe(anchorInputData(manifest.checkpointHash)); + expect(receipt.inputData).toHaveLength(66); + expect(transport.submissions[0]?.inputData).toBe(receipt.inputData); + + expect(receipt.status).toBe('submitted'); + expect(receipt.blockNumber).toBeNull(); + expect(receipt.blockHash).toBeNull(); + expect(receipt.confirmations).toBe(0); + expect(receipt.network).toBe('base-sepolia'); + expect(receipt.chainId).toBe(ANCHOR_CHAIN_IDS['base-sepolia']); + expect(receipt.to).toBe(ANCHOR_ADDRESS); + expect(receipt.runId).toBe(ctx.runId); + expect(receipt.checkpointSequence).toBe(0); + expect(receipt.rpcEndpointLabel).toBe(transport.endpointLabel); + expect(AnchorReceiptSchema.safeParse(receipt).success).toBe(true); + + // The append-only row is written at a terminal decision, not at submit. + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(0); + }); + + it('defaults to Base Sepolia with no opt-in (ALD-020 criterion 2)', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport); + + expect(publisher.network).toBe('base-sepolia'); + expect(publisher.chainId).toBe(84532); + await expect(publisher.submit(manifest)).resolves.toMatchObject({ + network: 'base-sepolia', + chainId: 84532, + }); + }); + + it('refuses to anchor a checkpoint that is not in the evidence store', async () => { + const ctx = await context(); + const stored = await ctx.addCheckpoint(); + const publisher = publisherFor(ctx, new FakeChainTransport()); + + const foreign = { + ...stored, + checkpointHash: `sha256:${'1'.repeat(64)}`, + }; + await expect(publisher.submit(foreign)).rejects.toBeInstanceOf( + UnknownAnchorCheckpointError, + ); + }); + + it('refuses a manifest whose runIdHash matches no run', async () => { + const ctx = await context(); + const stored = await ctx.addCheckpoint(); + const publisher = publisherFor(ctx, new FakeChainTransport()); + + await expect( + publisher.submit({ ...stored, runIdHash: `sha256:${'2'.repeat(64)}` }), + ).rejects.toBeInstanceOf(UnknownAnchorRunError); + }); +}); + +describe('BaseAnchorPublisher.awaitConfirmation (ALD-021)', () => { + it("confirms at depth 1 after one block under '1-confirmation'", async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport, { + pendingFile: join(ctx.directory, 'pending.json'), + }); + + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + const confirmed = await publisher.awaitConfirmation(submitted); + + expect(confirmed.status).toBe('confirmed'); + expect(confirmed.confirmations).toBe(1); + expect(confirmed.blockNumber).toBe(1); + expect(confirmed.blockHash).not.toBeNull(); + expect(AnchorReceiptSchema.safeParse(confirmed).success).toBe(true); + + const stored = ctx.writer.readAnchorReceipts(ctx.runId); + expect(stored).toHaveLength(1); + expect(stored[0]?.status).toBe('confirmed'); + expect(stored[0]?.checkpointHash).toBe(manifest.checkpointHash); + expect(publisher.pendingSubmissions()).toHaveLength(0); + }); + + it("requires three blocks under '3-confirmations'", async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport, { + finalityPolicy: '3-confirmations', + pollAttempts: 6, + // Each poll interval advances the fake chain by one block. + sleep: async () => { + transport.mineBlock(1); + }, + }); + + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + expect(publisher.requiredConfirmations).toBe(3); + + const confirmed = await publisher.awaitConfirmation(submitted); + + expect(confirmed.status).toBe('confirmed'); + expect(confirmed.confirmations).toBe(3); + expect(confirmed.blockNumber).toBe(1); + expect(transport.blockNumber).toBe(3); + }); + + it("stays 'submitted', unstored, and carries the observed depth", async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const pendingFile = join(ctx.directory, 'pending.json'); + const publisher = publisherFor(ctx, transport, { + finalityPolicy: '3-confirmations', + pollAttempts: 2, + pendingFile, + }); + + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + const result = await publisher.awaitConfirmation(submitted); + + // Giving up is not a terminal decision: LEDGER §10 allows only one + // append-only row per (chainId, transactionHash), and a 'submitted' row + // could never be upgraded to the confirmation that follows. + expect(result.status).toBe('submitted'); + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(0); + // What the poll did observe is reported to the caller. + expect(result.blockNumber).toBe(1); + expect(result.blockHash).not.toBeNull(); + expect(result.confirmations).toBe(1); + expect(readPendingSubmissions(pendingFile)).toHaveLength(1); + }); + + it("records 'failed' for a reverted transaction", async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const pendingFile = join(ctx.directory, 'pending.json'); + const publisher = publisherFor(ctx, transport, { pendingFile }); + + const submitted = await publisher.submit(manifest); + transport.failNext(); + transport.mineBlock(1); + const failed = await publisher.awaitConfirmation(submitted); + + expect(failed.status).toBe('failed'); + expect(failed.blockNumber).toBe(1); + expect(AnchorReceiptSchema.safeParse(failed).success).toBe(true); + expect(ctx.writer.readAnchorReceipts(ctx.runId)[0]?.status).toBe('failed'); + expect(readPendingSubmissions(pendingFile)).toHaveLength(0); + }); + + it("gives up as 'submitted', writes no row, and keeps the pending entry", async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const pendingFile = join(ctx.directory, 'pending.json'); + const publisher = publisherFor(ctx, transport, { + pendingFile, + pollAttempts: 3, + }); + + const submitted = await publisher.submit(manifest); + const result = await publisher.awaitConfirmation(submitted); + + expect(result.status).toBe('submitted'); + expect(result.blockNumber).toBeNull(); + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(0); + expect(publisher.pendingSubmissions()).toHaveLength(1); + expect(publisher.pendingSubmissions()[0]?.transactionHash).toBe( + submitted.transactionHash, + ); + }); + + it('records the confirmation when a given-up poll is resumed later', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const pendingFile = join(ctx.directory, 'pending.json'); + const publisher = publisherFor(ctx, transport, { + finalityPolicy: '3-confirmations', + pollAttempts: 2, + pendingFile, + }); + + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + const gaveUp = await publisher.awaitConfirmation(submitted); + expect(gaveUp.status).toBe('submitted'); + expect(gaveUp.confirmations).toBe(1); + + // SPEC §7.2 `sealing-blocked` -> retry succeeds -> `sealing`: an operator + // resumes the poll over the surviving pending entry. + transport.mineBlock(10); + const resumed = await publisher.awaitConfirmation(gaveUp); + + expect(resumed.status).toBe('confirmed'); + expect(resumed.blockNumber).toBe(1); + const stored = ctx.writer.readAnchorReceipts(ctx.runId); + expect(stored).toHaveLength(1); + expect(stored[0]?.status).toBe('confirmed'); + expect(stored[0]?.transactionHash).toBe(submitted.transactionHash); + expect(stored[0]?.confirmations).toBeGreaterThanOrEqual(3); + expect(readPendingSubmissions(pendingFile)).toHaveLength(0); + await expect( + verifyAnchorReceipt( + stored[0] ?? resumed, + manifest.checkpointHash, + transport, + ), + ).resolves.toMatchObject({ ok: true }); + + // A further poll and a further submit neither duplicate the row nor + // contradict it, and no second transaction is ever paid for. + const again = await publisher.awaitConfirmation(resumed); + expect(again.status).toBe('confirmed'); + const resubmitted = await publisher.submit(manifest); + expect(resubmitted.status).toBe('confirmed'); + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(1); + expect(transport.submissions).toHaveLength(1); + }); + + it('resumes a given-up submission from the sidecar after a restart', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const pendingFile = join(ctx.directory, 'pending.json'); + const publisher = publisherFor(ctx, transport, { + finalityPolicy: '3-confirmations', + pollAttempts: 1, + pendingFile, + }); + + const submitted = await publisher.submit(manifest); + await publisher.awaitConfirmation(submitted); + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(0); + + transport.mineBlock(4); + const restarted = publisherFor(ctx, transport, { + finalityPolicy: '3-confirmations', + pollAttempts: 1, + pendingFile, + }); + const recovered = await restarted.submit(manifest); + expect(recovered.transactionHash).toBe(submitted.transactionHash); + const confirmed = await restarted.awaitConfirmation(recovered); + + expect(confirmed.status).toBe('confirmed'); + expect(transport.submissions).toHaveLength(1); + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(1); + expect(readPendingSubmissions(pendingFile)).toHaveLength(0); + }); + + it('anchorAndConfirm submits and waits in one call', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport, { + pollAttempts: 3, + sleep: async () => { + transport.mineBlock(1); + }, + }); + + const receipt = await publisher.anchorAndConfirm(manifest); + expect(receipt.status).toBe('confirmed'); + expect(receipt.confirmations).toBeGreaterThanOrEqual(1); + }); +}); + +describe('retry and idempotency (ALD-021 criterion 2, ALD-018 criterion 3)', () => { + it('retries a transient send failure without duplicate submission', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + transport.dropNext(2); + const publisher = publisherFor(ctx, transport, { attempts: 5 }); + + const receipt = await publisher.submit(manifest); + + expect(transport.sendAttempts).toBe(3); + expect(transport.submissions).toHaveLength(1); + expect(receipt.transactionHash).toBe( + transport.submissions[0]?.transactionHash, + ); + }); + + it('fails after the attempt budget without submitting anything', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + transport.dropNext(5); + const publisher = publisherFor(ctx, transport, { attempts: 3 }); + + await expect(publisher.submit(manifest)).rejects.toBeInstanceOf( + AnchorSubmissionFailedError, + ); + expect(transport.sendAttempts).toBe(3); + expect(transport.submissions).toHaveLength(0); + }); + + it('returns the existing pending state for a duplicate submit', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport, { + pendingFile: join(ctx.directory, 'pending.json'), + }); + + const first = await publisher.submit(manifest); + const second = await publisher.submit(manifest); + + expect(second).toEqual(first); + expect(transport.submissions).toHaveLength(1); + }); + + it('returns the stored receipt and never re-anchors a confirmed checkpoint', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport, { + pendingFile: join(ctx.directory, 'pending.json'), + }); + + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + const confirmed = await publisher.awaitConfirmation(submitted); + + const again = await publisher.submit(manifest); + expect(again).toEqual(confirmed); + expect(transport.submissions).toHaveLength(1); + + // One receipt per chain per checkpoint, even across repeated polls. + await publisher.awaitConfirmation(submitted); + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(1); + }); + + it('returns the first submission for a duplicate submit with no pendingFile', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + // The documented default: no sidecar configured at all. + const publisher = publisherFor(ctx, transport); + + const first = await publisher.submit(manifest); + const second = await publisher.submit(manifest); + + expect(second).toEqual(first); + expect(transport.submissions).toHaveLength(1); + + transport.mineBlock(1); + await publisher.awaitConfirmation(first); + await publisher.awaitConfirmation(second); + + const rows = ctx.writer.readAnchorReceipts(ctx.runId); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe('confirmed'); + }); + + it('collapses concurrent submits for one checkpoint into one transaction', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport); + + const [first, second, third] = await Promise.all([ + publisher.submit(manifest), + publisher.submit(manifest), + publisher.submit(manifest), + ]); + + expect(second.transactionHash).toBe(first.transactionHash); + expect(third.transactionHash).toBe(first.transactionHash); + expect(transport.submissions).toHaveLength(1); + }); + + it('round-trips the pending file so a crash cannot lose the tx hash', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const pendingFile = join(ctx.directory, 'pending.json'); + const publisher = publisherFor(ctx, transport, { pendingFile }); + + const submitted = await publisher.submit(manifest); + + const persisted = readPendingSubmissions(pendingFile); + expect(persisted).toHaveLength(1); + expect(persisted[0]).toMatchObject({ + version: 1, + runId: ctx.runId, + checkpointHash: manifest.checkpointHash, + transactionHash: submitted.transactionHash, + from: transport.from, + to: ANCHOR_ADDRESS, + inputData: submitted.inputData, + chainId: 84532, + }); + expect(persisted[0]?.submittedAt).toBe(submitted.recordedAt); + + // A fresh publisher (process restart) recovers the submission instead of + // paying for a second transaction. + const restarted = publisherFor(ctx, transport, { pendingFile }); + const recovered = await restarted.submit(manifest); + expect(recovered.transactionHash).toBe(submitted.transactionHash); + expect(transport.submissions).toHaveLength(1); + + transport.mineBlock(1); + const confirmed = await restarted.awaitConfirmation(recovered); + expect(confirmed.status).toBe('confirmed'); + expect(readPendingSubmissions(pendingFile)).toHaveLength(0); + }); +}); + +describe('finality policy parsing (SPEC §13.4, ADR-05)', () => { + it('parses the supported policies', () => { + expect(requiredConfirmations('1-confirmation')).toBe(1); + expect(requiredConfirmations('3-confirmations')).toBe(3); + expect(requiredConfirmations('12-confirmations')).toBe(12); + expect(requiredConfirmations('safe-tag')).toBe( + SAFE_TAG_CONFIRMATION_PROXY, + ); + }); + + it("brackets Base's safe head with one L1 epoch of Base blocks", () => { + // One Ethereum epoch = 32 slots x 12 s = 384 s; Base blocks are 2 s. + const epochSeconds = 32 * 12; + expect(SAFE_TAG_CONFIRMATION_PROXY).toBe( + epochSeconds / BASE_BLOCK_TIME_SECONDS, + ); + expect(SAFE_TAG_CONFIRMATION_PROXY).toBe(192); + }); + + it('budgets enough confirmation polls to reach the safe-tag depth', () => { + const pollableBlocks = + (DEFAULT_CONFIRMATION_POLL_ATTEMPTS * + DEFAULT_CONFIRMATION_POLL_INTERVAL_MS) / + 1_000 / + BASE_BLOCK_TIME_SECONDS; + expect(pollableBlocks).toBeGreaterThanOrEqual(SAFE_TAG_CONFIRMATION_PROXY); + + // The old shared send-retry budget could not: 5 attempts of exponential + // backoff from 500 ms is under four Base blocks. + const retryBlocks = + ((DEFAULT_RETRY_ATTEMPTS - 1) * DEFAULT_INITIAL_BACKOFF_MS) / + 1_000 / + BASE_BLOCK_TIME_SECONDS; + expect(retryBlocks).toBeLessThan(SAFE_TAG_CONFIRMATION_PROXY); + }); + + it("reaches the safe-tag depth under the default poll budget", async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = publisherFor(ctx, transport, { + finalityPolicy: 'safe-tag', + // No pollAttempts: the production default has to suffice. + sleep: async () => { + transport.mineBlock(1); + }, + }); + + expect(publisher.requiredConfirmations).toBe(SAFE_TAG_CONFIRMATION_PROXY); + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + const confirmed = await publisher.awaitConfirmation(submitted); + + expect(confirmed.status).toBe('confirmed'); + expect(confirmed.confirmations).toBe(SAFE_TAG_CONFIRMATION_PROXY); + + // A publisher budgeted like the old shared send-retry path gives up + // without ever writing a row. + const secondManifest = await ctx.addCheckpoint('event-interval'); + const shallow = publisherFor(ctx, new FakeChainTransport(), { + finalityPolicy: 'safe-tag', + pollAttempts: DEFAULT_RETRY_ATTEMPTS, + }); + const gaveUp = await shallow.anchorAndConfirm(secondManifest); + expect(gaveUp.status).toBe('submitted'); + expect( + ctx.writer + .readAnchorReceipts(ctx.runId) + .filter((row) => row.checkpointHash === secondManifest.checkpointHash), + ).toHaveLength(0); + }); + + it('rejects an unparseable policy at construction time', async () => { + const ctx = await context(); + expect(() => requiredConfirmations('eventually')).toThrow( + InvalidFinalityPolicyError, + ); + expect(() => + publisherFor(ctx, new FakeChainTransport(), { + finalityPolicy: '0-confirmations', + }), + ).toThrow(InvalidFinalityPolicyError); + }); +}); + +describe('mainnet policy switch (ALD-022)', () => { + const mainnetTransport = (): FakeChainTransport => + new FakeChainTransport({ network: 'base-mainnet' }); + + it('makes zero chain calls with no opt-in at all', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = mainnetTransport(); + const publisher = publisherFor(ctx, transport); + + await expect(publisher.submit(manifest)).rejects.toBeInstanceOf( + MainnetAnchoringDisabledError, + ); + await expect( + publisher.awaitConfirmation({ + ...(await publisherFor(ctx, new FakeChainTransport()).submit(manifest)), + network: 'base-mainnet', + chainId: 8453, + }), + ).rejects.toBeInstanceOf(MainnetAnchoringDisabledError); + expect(transport.rpcCalls).toBe(0); + expect(transport.chainId).toBe(8453); + }); + + it('refuses with only the constructor opt-in', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + const transport = mainnetTransport(); + const publisher = publisherFor(ctx, transport, { allowMainnet: true }); + + await expect(publisher.submit(manifest)).rejects.toMatchObject({ + code: 'MAINNET_ANCHORING_DISABLED', + reason: 'missing-env', + }); + expect(transport.rpcCalls).toBe(0); + }); + + it('refuses with only the environment opt-in', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + vi.stubEnv(MAINNET_ANCHORING_ENV_VAR, 'true'); + const transport = mainnetTransport(); + const publisher = publisherFor(ctx, transport); + + await expect(publisher.submit(manifest)).rejects.toMatchObject({ + reason: 'missing-option', + }); + expect(transport.rpcCalls).toBe(0); + }); + + it('anchors to mainnet with both opt-ins, and reverts when either is removed', async () => { + const ctx = await context(); + const manifest = await ctx.addCheckpoint(); + vi.stubEnv(MAINNET_ANCHORING_ENV_VAR, 'true'); + const transport = mainnetTransport(); + const publisher = publisherFor(ctx, transport, { allowMainnet: true }); + + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + const confirmed = await publisher.awaitConfirmation(submitted); + + expect(confirmed.network).toBe('base-mainnet'); + expect(confirmed.chainId).toBe(8453); + expect(confirmed.status).toBe('confirmed'); + expect(AnchorReceiptSchema.safeParse(confirmed).success).toBe(true); + + // Flipping the environment switch off restores the refusal with no code + // change; a Sepolia publisher over the same evidence store still works. + vi.stubEnv(MAINNET_ANCHORING_ENV_VAR, ''); + const secondManifest = await ctx.addCheckpoint('event-interval'); + await expect(publisher.submit(secondManifest)).rejects.toBeInstanceOf( + MainnetAnchoringDisabledError, + ); + const sepolia = publisherFor(ctx, new FakeChainTransport()); + await expect(sepolia.submit(secondManifest)).resolves.toMatchObject({ + network: 'base-sepolia', + }); + }); +}); diff --git a/packages/anchor/__tests__/support.ts b/packages/anchor/__tests__/support.ts new file mode 100644 index 0000000..6b8d28e --- /dev/null +++ b/packages/anchor/__tests__/support.ts @@ -0,0 +1,134 @@ +/** + * Shared fixtures: a temp-directory SQLite evidence store with one registered + * run and real, chain-linked checkpoint manifests, so every anchoring test + * runs against the production `SqliteEvidenceWriter` rather than a stub. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + SqliteEvidenceWriter, + openEvidenceDatabase, + type EvidenceDatabase, +} from '@ald/evidence'; +import { InMemorySignerRegistry, hashCanonical, hashRunId } from '@ald/hashing'; +import { buildRunConfig } from '@ald/lifecycle'; +import { EMPTY_MERKLE_ROOT } from '@ald/merkle'; +import { GENESIS_HASH, HASH_DOMAINS } from '@ald/types'; +import type { + CheckpointManifest, + CheckpointReason, + Clock, +} from '@ald/types'; + +/** Designated destination of every test anchor transaction. */ +export const ANCHOR_ADDRESS = `0x${'d0'.repeat(20)}`; + +const temporaryDirectories: string[] = []; + +export async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'ald-anchor-')); + temporaryDirectories.push(directory); + return directory; +} + +export async function cleanupTemporaryDirectories(): Promise { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +} + +/** Deterministic clock: one millisecond per call from a fixed epoch. */ +export class StepClock implements Clock { + private current = Date.UTC(2026, 0, 1, 0, 0, 0); + + now(): string { + const value = new Date(this.current).toISOString(); + this.current += 1; + return value; + } +} + +/** Every checkpoint tree is empty in these fixtures; only its hash matters. */ +const EMPTY_TREE = { + treeSize: 0, + merkleRoot: EMPTY_MERKLE_ROOT, + lastEntryHash: GENESIS_HASH, +}; + +export interface AnchorTestContext { + runId: string; + directory: string; + clock: StepClock; + writer: SqliteEvidenceWriter; + database: EvidenceDatabase; + manifests: CheckpointManifest[]; + addCheckpoint(reason?: CheckpointReason): Promise; + close(): void; +} + +export async function createAnchorContext( + runId = 'run-anchor-001', +): Promise { + const directory = await temporaryDirectory(); + const config = buildRunConfig({ + runId, + experimentId: 'E00', + randomSeed: 'seed-anchor-001', + }); + const database = openEvidenceDatabase(join(directory, 'evidence.sqlite')); + const signers = InMemorySignerRegistry.generate(runId); + const clock = new StepClock(); + const writer = new SqliteEvidenceWriter({ database, signers, clock }); + const { configurationHash } = writer.registerRun(config); + + const manifests: CheckpointManifest[] = []; + + const context: AnchorTestContext = { + runId, + directory, + clock, + writer, + database, + manifests, + async addCheckpoint(reason = 'run-initialized'): Promise { + const previous = manifests.at(-1); + const unsigned = { + version: 1 as const, + runIdHash: hashRunId(runId), + checkpointSequence: manifests.length, + previousCheckpointHash: previous?.checkpointHash ?? GENESIS_HASH, + babyA: EMPTY_TREE, + babyB: EMPTY_TREE, + channel: EMPTY_TREE, + auxiliaryTrees: {}, + runConfigurationHash: configurationHash, + promptBundleHash: config.promptBundleHash, + softwareCommit: 'anchor-test-commit', + createdAt: clock.now(), + witnessKeyId: signers.signer('witness').keyId, + reason, + }; + const checkpointHash = hashCanonical(HASH_DOMAINS.checkpoint, unsigned); + const manifest: CheckpointManifest = { + ...unsigned, + checkpointHash, + witnessSignature: await signers.signer('witness').sign(checkpointHash), + }; + writer.insertCheckpointManifest(manifest); + manifests.push(manifest); + return manifest; + }, + close(): void { + database.close(); + }, + }; + + return context; +} + +/** Never-waiting sleep so retry/backoff tests stay fast. */ +export const immediateSleep = async (): Promise => {}; diff --git a/packages/anchor/__tests__/verify-anchor.test.ts b/packages/anchor/__tests__/verify-anchor.test.ts new file mode 100644 index 0000000..bb0863d --- /dev/null +++ b/packages/anchor/__tests__/verify-anchor.test.ts @@ -0,0 +1,270 @@ +/** + * ALD-021 verifier half: a genuine receipt verifies against the chain, and + * every wrong-chain / false-anchor / failed / missing case fails with a + * located problem (LEDGER §17 anchor mutation cases, E00 readiness). + */ +import { decodeHash } from '@ald/hashing'; +import type { AnchorReceipt } from '@ald/types'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { + BaseAnchorPublisher, + FakeChainTransport, + expectedChainId, + verifyAnchorReceipt, +} from '../src/index.js'; +import type { AnchorTestContext } from './support.js'; +import { + ANCHOR_ADDRESS, + cleanupTemporaryDirectories, + createAnchorContext, + immediateSleep, +} from './support.js'; + +const openContexts: AnchorTestContext[] = []; + +interface Anchored { + ctx: AnchorTestContext; + transport: FakeChainTransport; + receipt: AnchorReceipt; + checkpointHash: string; +} + +async function anchored(): Promise { + const ctx = await createAnchorContext(); + openContexts.push(ctx); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = new BaseAnchorPublisher({ + transport, + anchorClass: 'simulated', + evidence: ctx.writer, + clock: ctx.clock, + anchorAddress: ANCHOR_ADDRESS, + finalityPolicy: '1-confirmation', + retry: { attempts: 3, initialBackoffMs: 1, sleep: immediateSleep }, + }); + const submitted = await publisher.submit(manifest); + transport.mineBlock(1); + const receipt = await publisher.awaitConfirmation(submitted); + return { + ctx, + transport, + receipt, + checkpointHash: manifest.checkpointHash, + }; +} + +afterAll(async () => { + for (const open of openContexts.splice(0)) { + open.close(); + } + await cleanupTemporaryDirectories(); +}); + +describe('expectedChainId', () => { + it('pins the Base chain ids', () => { + expect(expectedChainId('base-sepolia')).toBe(84532); + expect(expectedChainId('base-mainnet')).toBe(8453); + }); +}); + +describe('verifyAnchorReceipt', () => { + it('accepts a genuine anchored receipt against the chain', async () => { + const { receipt, checkpointHash, transport } = await anchored(); + + const result = await verifyAnchorReceipt(receipt, checkpointHash, transport); + + expect(result.problems).toEqual([]); + expect(result.ok).toBe(true); + expect(result.checks).toEqual({ + inputDataMatchesCheckpoint: true, + chainIdMatchesNetwork: true, + statusConfirmed: true, + chainTransactionFound: true, + chainInputMatches: true, + chainToMatches: true, + receiptStatusSuccess: true, + blockIncluded: true, + }); + }); + + it('verifies the pure checks offline with no reader', async () => { + const { receipt, checkpointHash } = await anchored(); + + const result = await verifyAnchorReceipt(receipt, checkpointHash, null); + + expect(result.ok).toBe(true); + expect(result.checks.inputDataMatchesCheckpoint).toBe(true); + expect(result.checks.chainTransactionFound).toBeNull(); + expect(result.checks.receiptStatusSuccess).toBeNull(); + expect(result.checks.blockIncluded).toBeNull(); + }); + + it('fails when the reader is on the wrong chain', async () => { + const { receipt, checkpointHash } = await anchored(); + const wrongChain = new FakeChainTransport({ network: 'base-mainnet' }); + + const result = await verifyAnchorReceipt( + receipt, + checkpointHash, + wrongChain, + ); + + expect(result.ok).toBe(false); + expect(result.problems.join('\n')).toMatch(/chain reader is connected/u); + expect(result.checks.chainTransactionFound).toBeNull(); + }); + + it('fails when the receipt chain id contradicts its network', async () => { + const { receipt, checkpointHash } = await anchored(); + + const result = await verifyAnchorReceipt( + { ...receipt, chainId: 8453 }, + checkpointHash, + null, + ); + + expect(result.ok).toBe(false); + expect(result.checks.chainIdMatchesNetwork).toBe(false); + }); + + it('fails when the calldata is not the checkpoint digest', async () => { + const { receipt, checkpointHash } = await anchored(); + + const tampered = await verifyAnchorReceipt( + { ...receipt, inputData: `0x${'ab'.repeat(32)}` }, + checkpointHash, + null, + ); + expect(tampered.ok).toBe(false); + expect(tampered.checks.inputDataMatchesCheckpoint).toBe(false); + + // Same receipt, but the verifier recomputed a different checkpoint hash: + // a false anchor of some other checkpoint. + const otherCheckpoint = await verifyAnchorReceipt( + receipt, + `sha256:${'9'.repeat(64)}`, + null, + ); + expect(otherCheckpoint.ok).toBe(false); + expect(otherCheckpoint.checks.inputDataMatchesCheckpoint).toBe(false); + }); + + it('fails when the on-chain calldata differs from the receipt', async () => { + const { receipt, checkpointHash } = await anchored(); + const forged = new FakeChainTransport(); + forged.setTransaction( + { + hash: receipt.transactionHash, + from: receipt.from, + to: receipt.to, + input: `0x${'cd'.repeat(32)}`, + blockNumber: 1, + blockHash: receipt.blockHash, + }, + { status: 'success', blockNumber: 1, blockHash: receipt.blockHash ?? '' }, + ); + + const result = await verifyAnchorReceipt(receipt, checkpointHash, forged); + + expect(result.ok).toBe(false); + expect(result.checks.chainInputMatches).toBe(false); + }); + + it('fails when the transaction does not exist on chain', async () => { + const { receipt, checkpointHash } = await anchored(); + const emptyChain = new FakeChainTransport(); + + const result = await verifyAnchorReceipt( + receipt, + checkpointHash, + emptyChain, + ); + + expect(result.ok).toBe(false); + expect(result.checks.chainTransactionFound).toBe(false); + expect(result.problems.join('\n')).toMatch(/does not exist on chain/u); + }); + + it('fails when the on-chain transaction reverted', async () => { + const ctx = await createAnchorContext('run-anchor-reverted'); + openContexts.push(ctx); + const manifest = await ctx.addCheckpoint(); + const transport = new FakeChainTransport(); + const publisher = new BaseAnchorPublisher({ + transport, + anchorClass: 'simulated', + evidence: ctx.writer, + clock: ctx.clock, + anchorAddress: ANCHOR_ADDRESS, + finalityPolicy: '1-confirmation', + retry: { attempts: 2, initialBackoffMs: 1, sleep: immediateSleep }, + }); + const submitted = await publisher.submit(manifest); + transport.failNext(); + transport.mineBlock(1); + const failed = await publisher.awaitConfirmation(submitted); + + const asStored = await verifyAnchorReceipt( + failed, + manifest.checkpointHash, + transport, + ); + expect(asStored.ok).toBe(false); + expect(asStored.checks.statusConfirmed).toBe(false); + + // Even a receipt that *claims* confirmation fails on the chain receipt. + const overclaimed = await verifyAnchorReceipt( + { ...failed, status: 'confirmed' }, + manifest.checkpointHash, + transport, + ); + expect(overclaimed.ok).toBe(false); + expect(overclaimed.checks.receiptStatusSuccess).toBe(false); + }); + + it('fails when the on-chain destination is not the receipt destination', async () => { + const { receipt, checkpointHash } = await anchored(); + const forged = new FakeChainTransport(); + forged.setTransaction( + { + hash: receipt.transactionHash, + from: receipt.from, + to: `0x${'e1'.repeat(20)}`, + input: `0x${decodeHash(checkpointHash).toString('hex')}`, + blockNumber: 1, + blockHash: receipt.blockHash, + }, + { status: 'success', blockNumber: 1, blockHash: receipt.blockHash ?? '' }, + ); + + const result = await verifyAnchorReceipt(receipt, checkpointHash, forged); + + expect(result.ok).toBe(false); + expect(result.checks.chainToMatches).toBe(false); + }); + + it('fails when the transaction is not yet in the claimed block', async () => { + const { receipt, checkpointHash } = await anchored(); + const mempoolOnly = new FakeChainTransport(); + mempoolOnly.setTransaction({ + hash: receipt.transactionHash, + from: receipt.from, + to: receipt.to, + input: receipt.inputData, + blockNumber: null, + blockHash: null, + }); + + const result = await verifyAnchorReceipt( + receipt, + checkpointHash, + mempoolOnly, + ); + + expect(result.ok).toBe(false); + expect(result.checks.blockIncluded).toBe(false); + expect(result.checks.receiptStatusSuccess).toBe(false); + }); +}); diff --git a/packages/anchor/__tests__/viem-transport.test.ts b/packages/anchor/__tests__/viem-transport.test.ts new file mode 100644 index 0000000..d2fceb4 --- /dev/null +++ b/packages/anchor/__tests__/viem-transport.test.ts @@ -0,0 +1,631 @@ +/** + * ALD-020 — `ViemChainTransport` against a minimal in-process JSON-RPC + * server. No real network, no funded wallet: the server answers exactly the + * methods the anchor flow needs, which is enough to prove the calldata really + * is the bare checkpoint digest, that the mappings for `getTransaction` / + * `getTransactionReceipt` / `latestBlockNumber` are correct, that a retried + * send re-broadcasts one pinned nonce instead of paying twice (LEDGER §16 + * Phase 2: "retry and nonce management"), and that neither the private key nor + * an RPC credential ever reaches a message, a receipt, or an error chain + * (LEDGER §11). + */ +import { createServer } from 'node:http'; +import type { Server, ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { inspect } from 'node:util'; + +import { decodeHash } from '@ald/hashing'; +import { keccak256, parseTransaction } from 'viem'; +import type { Hex } from 'viem'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { + BaseAnchorPublisher, + TransientChainError, + ViemChainTransport, + anchorInputData, + generateAnchorKey, +} from '../src/index.js'; +import type { AnchorTestContext } from './support.js'; +import { + cleanupTemporaryDirectories, + createAnchorContext, + immediateSleep, +} from './support.js'; + +const CHECKPOINT_HASH = `sha256:${'3f'.repeat(32)}`; +const DIGEST_HEX = decodeHash(CHECKPOINT_HASH).toString('hex'); +const BLOCK_HASH = `0x${'b1'.repeat(32)}`; +const DESTINATION = `0x${'d0'.repeat(20)}`; +const UNKNOWN_HASH = `0x${'ee'.repeat(32)}`; + +/** Credentials planted in the RPC URL; must never surface anywhere. */ +const URL_USERINFO = 'anchor:USERINFO_SECRET'; +const URL_API_KEY = 'PATH_API_KEY_SECRET'; + +const anchorKey = generateAnchorKey(); + +interface AcceptedTransaction { + raw: string; + hash: Hex; + nonce: number; +} + +interface FakeNodeOptions { + /** Record the raw transaction, then destroy the socket without replying. */ + dropSendResponses?: number; + /** Whether an accepted transaction becomes visible on the chain. */ + visible?: boolean; + /** Answer every request with this HTTP status and body instead of JSON-RPC. */ + httpFailure?: { status: number; body: string }; + /** Answer `eth_getTransactionByHash`/`Receipt` with a JSON-RPC null result. */ + emptyResults?: boolean; +} + +/** + * A single-purpose JSON-RPC node: it records every method it is asked for and + * every raw transaction it accepts, so a test can count sends and nonces. + */ +class FakeNode { + readonly methods: string[] = []; + readonly accepted: AcceptedTransaction[] = []; + + private server: Server | undefined; + private port = 0; + private dropsLeft: number; + + private constructor(private readonly options: FakeNodeOptions) { + this.dropsLeft = options.dropSendResponses ?? 0; + } + + static async start(options: FakeNodeOptions = {}): Promise { + const node = new FakeNode(options); + await node.listen(); + return node; + } + + get url(): string { + return `http://127.0.0.1:${String(this.port)}`; + } + + /** URL carrying both userinfo and a path API key, as providers do. */ + get credentialUrl(): string { + return `http://${URL_USERINFO}@127.0.0.1:${String(this.port)}/v2/${URL_API_KEY}`; + } + + get host(): string { + return `127.0.0.1:${String(this.port)}`; + } + + callsTo(method: string): number { + return this.methods.filter((seen) => seen === method).length; + } + + async stop(): Promise { + const server = this.server; + if (server === undefined) { + return; + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + + private async listen(): Promise { + const server = createServer((request, response) => { + let body = ''; + request.on('data', (chunk: Buffer) => { + body += chunk.toString('utf8'); + }); + request.on('end', () => { + this.handle(body, response); + }); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + this.server = server; + this.port = (server.address() as AddressInfo).port; + } + + private handle(body: string, response: ServerResponse): void { + const failure = this.options.httpFailure; + if (failure !== undefined) { + response.writeHead(failure.status, { 'content-type': 'text/plain' }); + response.end(failure.body); + return; + } + + const payload: unknown = JSON.parse(body); + const calls = Array.isArray(payload) ? payload : [payload]; + let drop = false; + const responses = calls.map((call) => { + const { id, method, params } = call as { + id: number; + method: string; + params?: unknown[]; + }; + this.methods.push(method); + if (method === 'eth_sendRawTransaction' && this.dropsLeft > 0) { + this.dropsLeft -= 1; + this.accept(params ?? []); + drop = true; + return { jsonrpc: '2.0', id, result: null }; + } + return { jsonrpc: '2.0', id, result: this.resultFor(method, params ?? []) }; + }); + + if (drop) { + // The node took the transaction but the answer never comes back. + response.destroy(); + return; + } + + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify(Array.isArray(payload) ? responses : responses[0]), + ); + } + + private accept(params: readonly unknown[]): AcceptedTransaction { + const raw = typeof params[0] === 'string' ? params[0] : '0x'; + const parsed = parseTransaction(raw as Hex); + const accepted: AcceptedTransaction = { + raw, + hash: keccak256(raw as Hex), + nonce: parsed.nonce ?? 0, + }; + this.accepted.push(accepted); + return accepted; + } + + private knows(hash: unknown): AcceptedTransaction | undefined { + if (this.options.visible === false || this.options.emptyResults === true) { + return undefined; + } + return this.accepted.find( + (candidate) => + typeof hash === 'string' && + candidate.hash.toLowerCase() === hash.toLowerCase(), + ); + } + + private resultFor(method: string, params: readonly unknown[]): unknown { + switch (method) { + case 'eth_chainId': + return '0x14a34'; + case 'eth_blockNumber': + return '0x11'; + case 'eth_getBlockByNumber': + return { + number: '0x11', + hash: BLOCK_HASH, + parentHash: `0x${'00'.repeat(32)}`, + nonce: '0x0000000000000000', + sha3Uncles: `0x${'00'.repeat(32)}`, + logsBloom: `0x${'00'.repeat(256)}`, + transactionsRoot: `0x${'00'.repeat(32)}`, + stateRoot: `0x${'00'.repeat(32)}`, + receiptsRoot: `0x${'00'.repeat(32)}`, + miner: `0x${'11'.repeat(20)}`, + difficulty: '0x0', + totalDifficulty: '0x0', + extraData: '0x', + size: '0x100', + gasLimit: '0x1c9c380', + gasUsed: '0x5208', + timestamp: '0x65000000', + baseFeePerGas: '0x7', + transactions: [], + uncles: [], + }; + case 'eth_maxPriorityFeePerGas': + return '0x5f5e100'; + case 'eth_gasPrice': + return '0x3b9aca00'; + case 'eth_estimateGas': + return '0x5208'; + case 'eth_getTransactionCount': + // A pool-size nonce: a second read would hand out a *new* nonce, so a + // transport that re-reads it per attempt duplicates the transaction. + return `0x${this.accepted.length.toString(16)}`; + case 'eth_sendRawTransaction': + return this.accept(params).hash; + case 'eth_getTransactionByHash': { + const known = this.knows(params[0]); + if (known === undefined) { + return null; + } + return { + hash: known.hash, + nonce: `0x${known.nonce.toString(16)}`, + blockHash: BLOCK_HASH, + blockNumber: '0x11', + transactionIndex: '0x0', + from: anchorKey.address, + to: DESTINATION, + value: '0x0', + gas: '0x5208', + gasPrice: '0x3b9aca00', + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x5f5e100', + input: `0x${DIGEST_HEX}`, + type: '0x2', + chainId: '0x14a34', + v: '0x1', + r: `0x${'01'.repeat(32)}`, + s: `0x${'02'.repeat(32)}`, + }; + } + case 'eth_getTransactionReceipt': { + const known = this.knows(params[0]); + if (known === undefined) { + return null; + } + return { + transactionHash: known.hash, + transactionIndex: '0x0', + blockHash: BLOCK_HASH, + blockNumber: '0x11', + from: anchorKey.address, + to: DESTINATION, + cumulativeGasUsed: '0x5208', + gasUsed: '0x5208', + contractAddress: null, + logs: [], + logsBloom: `0x${'00'.repeat(256)}`, + status: '0x1', + effectiveGasPrice: '0x3b9aca00', + type: '0x2', + }; + } + default: + return null; + } + } +} + +const openNodes: FakeNode[] = []; + +async function node(options: FakeNodeOptions = {}): Promise { + const started = await FakeNode.start(options); + openNodes.push(started); + return started; +} + +function transportFor( + rpcUrl: string, + endpointLabel?: string, +): ViemChainTransport { + return ViemChainTransport.create({ + rpcUrl, + privateKey: anchorKey.privateKey, + network: 'base-sepolia', + // One HTTP attempt per logical call keeps the send/nonce counts readable. + retryCount: 0, + ...(endpointLabel === undefined ? {} : { endpointLabel }), + }); +} + +const openContexts: AnchorTestContext[] = []; + +async function anchorContext(runId: string): Promise { + const created = await createAnchorContext(runId); + openContexts.push(created); + return created; +} + +function publisherOver( + ctx: AnchorTestContext, + transport: ViemChainTransport, +): BaseAnchorPublisher { + return new BaseAnchorPublisher({ + transport, + anchorClass: 'public-chain', + evidence: ctx.writer, + clock: ctx.clock, + anchorAddress: DESTINATION, + finalityPolicy: '1-confirmation', + retry: { attempts: 1, initialBackoffMs: 1, sleep: immediateSleep }, + confirmationPoll: { attempts: 1, intervalMs: 0 }, + }); +} + +afterAll(async () => { + for (const open of openContexts.splice(0)) { + open.close(); + } + await Promise.all(openNodes.splice(0).map(async (open) => open.stop())); + await cleanupTemporaryDirectories(); +}); + +describe('ViemChainTransport', () => { + it('reports the Base Sepolia chain identity and never exposes the key', async () => { + const rpc = await node(); + const client = transportFor(rpc.url, 'fake-node'); + + expect(client.chainId).toBe(84532); + expect(client.network).toBe('base-sepolia'); + expect(client.endpointLabel).toBe('fake-node'); + expect(client.address).toBe(anchorKey.address); + expect(client.toJSON()).toEqual({ + network: 'base-sepolia', + chainId: 84532, + endpointLabel: 'fake-node', + address: anchorKey.address, + }); + expect(JSON.stringify(client)).not.toContain( + anchorKey.privateKey.slice(2), + ); + }); + + it('defaults the endpoint label to the RPC host, without credentials', async () => { + const rpc = await node(); + const client = transportFor(rpc.credentialUrl); + + expect(client.endpointLabel).toBe(rpc.host); + expect(client.endpointLabel).not.toContain(URL_API_KEY); + expect(client.endpointLabel).not.toContain('USERINFO_SECRET'); + }); + + it('submits calldata that is exactly the 32-byte checkpoint digest', async () => { + const rpc = await node(); + const client = transportFor(rpc.url, 'fake-node'); + + const sent = await client.sendAnchorTransaction({ + checkpointHash: CHECKPOINT_HASH, + to: DESTINATION, + }); + + const accepted = rpc.accepted[0]; + expect(rpc.accepted).toHaveLength(1); + expect(sent.transactionHash).toBe(accepted?.hash); + expect(sent.from).toBe(anchorKey.address); + expect(sent.to).toBe(DESTINATION); + expect(sent.inputData).toBe(anchorInputData(CHECKPOINT_HASH)); + expect(sent.inputData).toHaveLength(66); + expect(Object.keys(sent).sort()).toEqual([ + 'from', + 'inputData', + 'to', + 'transactionHash', + ]); + expect(JSON.stringify(sent)).not.toContain(anchorKey.privateKey.slice(2)); + + // The signed transaction really carries the digest as its calldata. + expect(accepted?.raw).toContain(DIGEST_HEX); + expect(rpc.callsTo('eth_sendRawTransaction')).toBe(1); + }); + + it('maps getTransaction, getTransactionReceipt and the head block', async () => { + const rpc = await node(); + const client = transportFor(rpc.url, 'fake-node'); + const sent = await client.sendAnchorTransaction({ + checkpointHash: CHECKPOINT_HASH, + to: DESTINATION, + }); + + await expect(client.getTransaction(sent.transactionHash)).resolves.toEqual({ + hash: sent.transactionHash, + from: anchorKey.address, + to: DESTINATION, + input: `0x${DIGEST_HEX}`, + blockNumber: 17, + blockHash: BLOCK_HASH, + }); + await expect( + client.getTransactionReceipt(sent.transactionHash), + ).resolves.toEqual({ + status: 'success', + blockNumber: 17, + blockHash: BLOCK_HASH, + }); + await expect(client.latestBlockNumber()).resolves.toBe(17); + }); + + it('returns null for an unknown transaction instead of throwing', async () => { + const rpc = await node({ emptyResults: true }); + const client = transportFor(rpc.url, 'fake-node'); + + await expect(client.getTransaction(UNKNOWN_HASH)).resolves.toBeNull(); + await expect( + client.getTransactionReceipt(UNKNOWN_HASH), + ).resolves.toBeNull(); + }); +}); + +describe('nonce pinning (LEDGER §16 Phase 2)', () => { + it('re-broadcasts one pinned nonce when a send is retried', async () => { + // The node swallows the first send (records the tx, drops the response) + // and never makes it visible, which is exactly the publisher's + // "throw means nothing was submitted, so retry" path. + const rpc = await node({ dropSendResponses: 1, visible: false }); + const client = transportFor(rpc.url, 'fake-node'); + const input = { checkpointHash: CHECKPOINT_HASH, to: DESTINATION }; + + await expect(client.sendAnchorTransaction(input)).rejects.toBeInstanceOf( + TransientChainError, + ); + const retried = await client.sendAnchorTransaction(input); + + // Two broadcasts, one nonce, one set of signed bytes: the chain can only + // accept them as the same transaction, never as a second paid one. + expect(rpc.accepted).toHaveLength(2); + expect(new Set(rpc.accepted.map((tx) => tx.nonce))).toEqual(new Set([0])); + expect(new Set(rpc.accepted.map((tx) => tx.raw)).size).toBe(1); + expect(new Set(rpc.accepted.map((tx) => tx.hash)).size).toBe(1); + expect(retried.transactionHash).toBe(rpc.accepted[0]?.hash); + // The pending nonce is read once per logical submission, not per attempt. + expect(rpc.callsTo('eth_getTransactionCount')).toBe(1); + expect(rpc.callsTo('eth_sendRawTransaction')).toBe(2); + }); + + it('reports the existing transaction when a send loses its response', async () => { + // Same lost response, but this time the node does know the transaction. + const rpc = await node({ dropSendResponses: 1 }); + const client = transportFor(rpc.url, 'fake-node'); + + const sent = await client.sendAnchorTransaction({ + checkpointHash: CHECKPOINT_HASH, + to: DESTINATION, + }); + + expect(rpc.accepted).toHaveLength(1); + expect(sent.transactionHash).toBe(rpc.accepted[0]?.hash); + expect(sent.inputData).toBe(anchorInputData(CHECKPOINT_HASH)); + }); +}); + +describe('error redaction (LEDGER §11)', () => { + const secrets = [URL_API_KEY, 'USERINFO_SECRET', 'anchor:USERINFO_SECRET']; + + function assertRedacted(error: unknown, method: string, host: string): void { + expect(error).toBeInstanceOf(TransientChainError); + const failure = error as TransientChainError; + const rendered = `${failure.message} ${inspect(failure, { depth: 6 })}`; + for (const secret of secrets) { + expect(rendered).not.toContain(secret); + } + expect(rendered).not.toContain('/v2/'); + expect(failure.message).toContain(host); + expect(failure.message).toContain(method); + expect(failure.code).toBe('TRANSIENT_CHAIN_ERROR'); + // No cause to walk: inspecting a chain would re-expose viem's message. + expect(failure.cause).toBeUndefined(); + } + + it('never leaks the RPC URL from a failing provider', async () => { + const rpc = await node({ + httpFailure: { status: 500, body: 'upstream unavailable' }, + }); + const client = transportFor(rpc.credentialUrl); + + await expect(client.latestBlockNumber()).rejects.toBeInstanceOf( + TransientChainError, + ); + assertRedacted( + await client.latestBlockNumber().catch((error: unknown) => error), + 'eth_blockNumber', + rpc.host, + ); + assertRedacted( + await client + .getTransactionReceipt(UNKNOWN_HASH) + .catch((error: unknown) => error), + 'eth_getTransactionReceipt', + rpc.host, + ); + assertRedacted( + await client.getTransaction(UNKNOWN_HASH).catch((error: unknown) => error), + 'eth_getTransactionByHash', + rpc.host, + ); + assertRedacted( + await client + .sendAnchorTransaction({ + checkpointHash: CHECKPOINT_HASH, + to: DESTINATION, + }) + .catch((error: unknown) => error), + 'eth_getTransactionCount', + rpc.host, + ); + }); + + it('keeps the provider status code, which is not a credential', async () => { + const rpc = await node({ + httpFailure: { status: 503, body: 'over quota' }, + }); + const client = transportFor(rpc.credentialUrl); + + const failure = await client + .latestBlockNumber() + .catch((error: unknown) => error); + expect((failure as Error).message).toContain('HTTP 503'); + expect((failure as Error).message).not.toContain('over quota'); + }); + + it('never turns a provider failure into the chain fact "not found"', async () => { + // The exact body a 404 from a mis-pathed provider returns; classifying it + // by message substring would let the verifier report an affirmative + // "transaction does not exist on chain". + const rpc = await node({ + httpFailure: { + status: 404, + body: 'The requested resource could not be found on this server.', + }, + }); + const client = transportFor(rpc.credentialUrl); + + await expect(client.getTransaction(UNKNOWN_HASH)).rejects.toBeInstanceOf( + TransientChainError, + ); + await expect( + client.getTransactionReceipt(UNKNOWN_HASH), + ).rejects.toBeInstanceOf(TransientChainError); + }); + + it('never turns a provider auth error into the chain fact "not found"', async () => { + const rpc = await node({ + httpFailure: { + status: 200, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + error: { code: -32_000, message: 'API key could not be found' }, + }), + }, + }); + const client = transportFor(rpc.credentialUrl); + + const failure = await client + .getTransaction(UNKNOWN_HASH) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(TransientChainError); + expect((failure as Error).message).not.toContain('API key'); + }); +}); + +describe('anchoring over a credentialed RPC URL', () => { + it('keeps the credential out of the receipt and out of the failure', async () => { + const ctx = await anchorContext('run-anchor-viem-ok'); + const manifest = await ctx.addCheckpoint(); + const rpc = await node(); + const publisher = publisherOver(ctx, transportFor(rpc.credentialUrl)); + + const receipt = await publisher.submit(manifest); + expect(receipt.rpcEndpointLabel).toBe(rpc.host); + expect(JSON.stringify(receipt)).not.toContain(URL_API_KEY); + expect(JSON.stringify(receipt)).not.toContain('USERINFO_SECRET'); + + const confirmed = await publisher.awaitConfirmation(receipt); + expect(confirmed.status).toBe('confirmed'); + const stored = ctx.writer.readAnchorReceipts(ctx.runId); + expect(JSON.stringify(stored)).not.toContain(URL_API_KEY); + expect(JSON.stringify(stored)).not.toContain('USERINFO_SECRET'); + }); + + it('keeps the credential out of an anchor-unavailable deviation', async () => { + const ctx = await anchorContext('run-anchor-viem-broken'); + const manifest = await ctx.addCheckpoint(); + const rpc = await node({ + httpFailure: { status: 500, body: 'upstream unavailable' }, + }); + const publisher = publisherOver(ctx, transportFor(rpc.credentialUrl)); + + const failure = await publisher + .anchorAndConfirm(manifest) + .catch((error: unknown) => error); + + // The exact string the orchestrator records as a run deviation, and the + // whole error chain behind it (SPEC §14.5, LEDGER §11). + const deviation = `anchor-unavailable: ${ + failure instanceof Error ? failure.message : String(failure) + } ${inspect(failure, { depth: 8 })}`; + expect(deviation).not.toContain(URL_API_KEY); + expect(deviation).not.toContain('USERINFO_SECRET'); + expect(deviation).not.toContain('/v2/'); + expect(deviation).toContain(rpc.host); + expect(ctx.writer.readAnchorReceipts(ctx.runId)).toHaveLength(0); + }); +}); diff --git a/packages/anchor/package.json b/packages/anchor/package.json new file mode 100644 index 0000000..09602a9 --- /dev/null +++ b/packages/anchor/package.json @@ -0,0 +1,26 @@ +{ + "name": "@ald/anchor", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/evidence": "*", + "@ald/hashing": "*", + "@ald/lifecycle": "*", + "@ald/merkle": "*", + "@ald/types": "*", + "viem": "2.56.3", + "zod": "4.4.3" + } +} diff --git a/packages/anchor/src/errors.ts b/packages/anchor/src/errors.ts new file mode 100644 index 0000000..fde71ed --- /dev/null +++ b/packages/anchor/src/errors.ts @@ -0,0 +1,168 @@ +/** + * Anchoring error taxonomy. + * + * Anchoring is a background, best-effort activity: a failure to reach the + * chain must never take the run down (SPECIFICATION.md §14.5 — anchoring + * failure is a named, handled failure mode). Every condition the anchor + * publisher refuses outright therefore carries a machine-readable `code` so + * the caller can decide between "retry later", "pause the run", and + * "operator misconfiguration". + * + * No error message in this module ever embeds an anchoring private key + * (LEDGER-INTEGRITY-DESIGN.md §11). + */ + +export type AnchorErrorCode = + /** ALD-022: mainnet requested without both explicit opt-ins. */ + | 'MAINNET_ANCHORING_DISABLED' + /** Transport chain id does not match its declared network. */ + | 'ANCHOR_NETWORK_MISMATCH' + /** Every send attempt threw; nothing was submitted. */ + | 'ANCHOR_SUBMISSION_FAILED' + /** A transport returned calldata that is not the bare checkpoint digest. */ + | 'ANCHOR_PAYLOAD_MISMATCH' + /** `finalityPolicy` string could not be parsed into a confirmation depth. */ + | 'INVALID_FINALITY_POLICY' + /** `runIdHash` in the manifest matches no run in the evidence store. */ + | 'UNKNOWN_ANCHOR_RUN' + /** The manifest being anchored is not stored, so no receipt could link to it. */ + | 'UNKNOWN_ANCHOR_CHECKPOINT' + /** + * Key file missing, malformed, not a valid secp256k1 scalar, world/group + * readable, or sitting in a group/other-writable directory. + */ + | 'ANCHOR_KEY_FILE' + /** Retryable RPC/transport failure; no transaction was submitted. */ + | 'TRANSIENT_CHAIN_ERROR' + /** Pending-submission sidecar file could not be read. */ + | 'PENDING_FILE_INVALID'; + +/** Base class for every anchoring failure this package raises. */ +export class AnchorError extends Error { + constructor( + readonly code: AnchorErrorCode, + message: string, + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = new.target.name; + } +} + +export function isAnchorError(value: unknown): value is AnchorError { + return value instanceof AnchorError; +} + +/** + * ALD-022 / SPEC §13.4: Base Sepolia is the unconditional default. Mainnet + * requires BOTH a constructor opt-in and `ALD_ALLOW_MAINNET_ANCHORING=true` + * in the environment; either one alone is refused before any RPC call. + */ +export class MainnetAnchoringDisabledError extends AnchorError { + constructor(readonly reason: 'missing-option' | 'missing-env' | 'both') { + super( + 'MAINNET_ANCHORING_DISABLED', + 'Base mainnet anchoring is disabled: it requires both the ' + + '`allowMainnet: true` publisher option and ' + + '`ALD_ALLOW_MAINNET_ANCHORING=true` in the environment ' + + `(missing: ${reason})`, + ); + } +} + +export class AnchorNetworkMismatchError extends AnchorError { + constructor(network: string, declaredChainId: number, expected: number) { + super( + 'ANCHOR_NETWORK_MISMATCH', + `Transport reports chain id ${declaredChainId} for network ${network}, expected ${expected}`, + ); + } +} + +export class AnchorSubmissionFailedError extends AnchorError { + constructor(readonly attempts: number, options?: { cause?: unknown }) { + super( + 'ANCHOR_SUBMISSION_FAILED', + `Anchor transaction was not submitted after ${attempts} attempt(s)`, + options, + ); + } +} + +export class AnchorPayloadMismatchError extends AnchorError { + constructor(expected: string, actual: string) { + super( + 'ANCHOR_PAYLOAD_MISMATCH', + `Anchor calldata must be exactly the 32-byte checkpoint digest ${expected}, got ${actual}`, + ); + } +} + +export class InvalidFinalityPolicyError extends AnchorError { + constructor(policy: string) { + super( + 'INVALID_FINALITY_POLICY', + `Unsupported finality policy '${policy}': expected '1-confirmation', 'safe-tag', or '-confirmations'`, + ); + } +} + +export class UnknownAnchorRunError extends AnchorError { + constructor(runIdHash: string) { + super( + 'UNKNOWN_ANCHOR_RUN', + `No run in the evidence store hashes to runIdHash ${runIdHash}`, + ); + } +} + +/** + * ALD-018 criterion 1: a receipt always references an existing checkpoint + * manifest. Checked before submitting, so a missing manifest costs no gas + * instead of failing later on the `anchor_receipts` foreign key. + */ +export class UnknownAnchorCheckpointError extends AnchorError { + constructor(runId: string, checkpointHash: string) { + super( + 'UNKNOWN_ANCHOR_CHECKPOINT', + `Run ${runId} has no stored checkpoint manifest with hash ${checkpointHash}`, + ); + } +} + +/** + * Never carries the key material itself — only the path and the reason + * (LEDGER §11: the anchoring private key is never logged or telemetered). + * + * `cause` is therefore attached only for failures raised by the filesystem; + * a curve-library rejection is re-raised without one, because its message + * prints the candidate scalar — i.e. the file's bytes. + */ +export class AnchorKeyFileError extends AnchorError { + constructor( + readonly path: string, + reason: string, + options?: { cause?: unknown }, + ) { + super('ANCHOR_KEY_FILE', `Anchor key file ${path}: ${reason}`, options); + } +} + +/** Signals to the publisher that a retry is safe: nothing was submitted. */ +export class TransientChainError extends AnchorError { + readonly transient = true as const; + + constructor(message: string, options?: { cause?: unknown }) { + super('TRANSIENT_CHAIN_ERROR', message, options); + } +} + +export class PendingFileInvalidError extends AnchorError { + constructor(path: string, options?: { cause?: unknown }) { + super( + 'PENDING_FILE_INVALID', + `Pending anchor submission file ${path} is not a valid canonical pending-file document`, + options, + ); + } +} diff --git a/packages/anchor/src/fake-transport.ts b/packages/anchor/src/fake-transport.ts new file mode 100644 index 0000000..ce78a65 --- /dev/null +++ b/packages/anchor/src/fake-transport.ts @@ -0,0 +1,224 @@ +/** + * Deterministic in-memory chain used by every anchoring test in the project + * (and by the verifier's wrong-chain / false-anchor suites). No network, no + * wallet, no funds — but the same {@link ChainTransport} contract as + * {@link import('./viem-transport.js').ViemChainTransport}, so the publisher + * and verifier under test are the production ones. + * + * Determinism: transaction hashes are `hashCanonical` over + * `{nonce, inputData}` and block hashes over `{blockNumber}`, so the same + * sequence of calls always yields the same identifiers. + * + * Fault injection: + * - {@link FakeChainTransport.dropNext} — the next send throws a + * {@link TransientChainError} *before* recording anything, which is what + * makes "retry without duplicate submission" testable. + * - {@link FakeChainTransport.failNext} — the next mined transaction lands + * with status `reverted`. + * - {@link FakeChainTransport.setTransaction} — seed a foreign transaction + * (wrong calldata, wrong destination, reverted) for verifier tests. + */ +import { decodeHash, hashCanonical } from '@ald/hashing'; + +import { TransientChainError } from './errors.js'; +import { ANCHOR_CHAIN_IDS, anchorInputData } from './transport.js'; +import type { + AnchorNetwork, + AnchorTransactionInput, + ChainTransaction, + ChainTransactionReceipt, + ChainTransport, + SentAnchorTransaction, +} from './transport.js'; + +const FAKE_TX_DOMAIN = 'ald-fake-chain-transaction-v1'; +const FAKE_BLOCK_DOMAIN = 'ald-fake-chain-block-v1'; + +/** Fixed sender of every fake anchor transaction unless `from` is given. */ +export const DEFAULT_FAKE_FROM_ADDRESS = `0x${'a11ce'.padEnd(40, '0')}`; + +export interface FakeChainTransportOptions { + chainId?: number; + network?: AnchorNetwork; + endpointLabel?: string; + /** Sender address reported by `sendAnchorTransaction`. */ + from?: string; +} + +function evmHash(domain: string, value: unknown): string { + return `0x${decodeHash(hashCanonical(domain, value)).toString('hex')}`; +} + +export class FakeChainTransport implements ChainTransport { + readonly chainId: number; + readonly network: AnchorNetwork; + readonly endpointLabel: string; + readonly from: string; + + private nonce = 0; + private blockHeight = 0; + private attempts = 0; + private revertNextCount = 0; + private dropNextCount = 0; + private calls = 0; + + private readonly pendingHashes: string[] = []; + private readonly transactions = new Map(); + private readonly receipts = new Map(); + private readonly sent: SentAnchorTransaction[] = []; + + constructor(options: FakeChainTransportOptions = {}) { + this.network = options.network ?? 'base-sepolia'; + this.chainId = options.chainId ?? ANCHOR_CHAIN_IDS[this.network]; + this.endpointLabel = options.endpointLabel ?? `fake-${this.network}`; + this.from = options.from ?? DEFAULT_FAKE_FROM_ADDRESS; + } + + // ------------------------------------------------------------------------- + // Test observation + // ------------------------------------------------------------------------- + + /** Sends that were *attempted*, including the ones `dropNext` failed. */ + get sendAttempts(): number { + return this.attempts; + } + + /** Transactions actually submitted; must stay 1 across a retried submit. */ + get submissions(): readonly SentAnchorTransaction[] { + return this.sent; + } + + /** + * Every RPC-shaped call this transport received. ALD-022 asserts this stays + * at 0 for a mainnet transport under default configuration. + */ + get rpcCalls(): number { + return this.calls; + } + + get blockNumber(): number { + return this.blockHeight; + } + + // ------------------------------------------------------------------------- + // Fault injection and seeding + // ------------------------------------------------------------------------- + + /** The next `count` mined transactions land with status `reverted`. */ + failNext(count = 1): this { + this.revertNextCount += count; + return this; + } + + /** The next `count` sends throw before submitting anything. */ + dropNext(count = 1): this { + this.dropNextCount += count; + return this; + } + + /** Seed an arbitrary transaction (and optional receipt) into the chain. */ + setTransaction( + transaction: ChainTransaction, + receipt: ChainTransactionReceipt | null = null, + ): this { + this.transactions.set(transaction.hash.toLowerCase(), transaction); + if (receipt === null) { + this.receipts.delete(transaction.hash.toLowerCase()); + } else { + this.receipts.set(transaction.hash.toLowerCase(), receipt); + } + return this; + } + + /** + * Include every pending transaction in the next block, then advance the + * head by `count` blocks in total (later blocks are empty). + */ + mineBlock(count = 1): number { + for (let index = 0; index < count; index += 1) { + this.blockHeight += 1; + const blockNumber = this.blockHeight; + const blockHash = evmHash(FAKE_BLOCK_DOMAIN, { blockNumber }); + const included = this.pendingHashes.splice(0); + for (const hash of included) { + const transaction = this.transactions.get(hash); + if (transaction === undefined) { + continue; + } + this.transactions.set(hash, { ...transaction, blockNumber, blockHash }); + const reverted = this.revertNextCount > 0; + if (reverted) { + this.revertNextCount -= 1; + } + this.receipts.set(hash, { + status: reverted ? 'reverted' : 'success', + blockNumber, + blockHash, + }); + } + } + return this.blockHeight; + } + + // ------------------------------------------------------------------------- + // ChainTransport + // ------------------------------------------------------------------------- + + async sendAnchorTransaction( + input: AnchorTransactionInput, + ): Promise { + this.calls += 1; + this.attempts += 1; + if (this.dropNextCount > 0) { + this.dropNextCount -= 1; + throw new TransientChainError( + `fake ${this.network} RPC dropped the request (injected)`, + ); + } + + const inputData = anchorInputData(input.checkpointHash); + this.nonce += 1; + const transactionHash = evmHash(FAKE_TX_DOMAIN, { + nonce: this.nonce, + inputData, + }); + + this.transactions.set(transactionHash.toLowerCase(), { + hash: transactionHash, + from: this.from, + to: input.to, + input: inputData, + blockNumber: null, + blockHash: null, + }); + this.pendingHashes.push(transactionHash.toLowerCase()); + + const submission: SentAnchorTransaction = { + transactionHash, + from: this.from, + to: input.to, + inputData, + }; + this.sent.push(submission); + return submission; + } + + async getTransactionReceipt( + transactionHash: string, + ): Promise { + this.calls += 1; + return this.receipts.get(transactionHash.toLowerCase()) ?? null; + } + + async latestBlockNumber(): Promise { + this.calls += 1; + return this.blockHeight; + } + + async getTransaction( + transactionHash: string, + ): Promise { + this.calls += 1; + return this.transactions.get(transactionHash.toLowerCase()) ?? null; + } +} diff --git a/packages/anchor/src/index.ts b/packages/anchor/src/index.ts new file mode 100644 index 0000000..9227d08 --- /dev/null +++ b/packages/anchor/src/index.ts @@ -0,0 +1,95 @@ +/** + * `@ald/anchor` — Base anchoring for checkpoint manifests + * (LEDGER-INTEGRITY-DESIGN.md §10–§12, SPECIFICATION.md §11.8, §13.4, §13.5). + * + * Only the 32-byte checkpoint digest is ever written on chain. A checkpoint + * manifest goes in, a zero-value transaction from a dedicated low-balance + * wallet goes out, and an Anchor Receipt — one per chain per checkpoint — + * comes back and is stored exactly once, at a terminal decision, in the + * append-only evidence store. + * + * Owning backlog items: ALD-018 (receipt storage), ALD-019 (anchor key + * management, separate from every event/witness key), ALD-020 (Base Sepolia + * client, the unconditional default), ALD-021 (confirmation, retry/backoff, + * and the verifier's chain-read half), ALD-022 (mainnet opt-in switch, off + * unless both the option and the environment variable are set). + */ +export { + AnchorError, + AnchorKeyFileError, + AnchorNetworkMismatchError, + AnchorPayloadMismatchError, + AnchorSubmissionFailedError, + InvalidFinalityPolicyError, + MainnetAnchoringDisabledError, + PendingFileInvalidError, + TransientChainError, + UnknownAnchorCheckpointError, + UnknownAnchorRunError, + isAnchorError, + type AnchorErrorCode, +} from './errors.js'; +export { + ANCHOR_CHAIN_IDS, + ANCHOR_INPUT_DATA_LENGTH, + anchorInputData, + type AnchorNetwork, + type AnchorTransactionInput, + type ChainReader, + type ChainTransaction, + type ChainTransactionReceipt, + type ChainTransport, + type SentAnchorTransaction, +} from './transport.js'; +export { + DEFAULT_FAKE_FROM_ADDRESS, + FakeChainTransport, + type FakeChainTransportOptions, +} from './fake-transport.js'; +export { + ViemChainTransport, + type ViemChainTransportOptions, +} from './viem-transport.js'; +export { + ANCHOR_KEY_FILE_MODE, + FORBIDDEN_KEY_FILE_MODE_BITS, + generateAnchorKey, + loadAnchorKeyFile, + writeAnchorKeyFile, + type AnchorKey, + type HexString, + type LoadAnchorKeyFileOptions, +} from './key-file.js'; +export { + PENDING_FILE_MODE, + PendingAnchorFileSchema, + PendingAnchorSubmissionSchema, + addPendingSubmission, + findPendingSubmission, + readPendingSubmissions, + removePendingSubmission, + writePendingSubmissions, + type PendingAnchorSubmission, +} from './pending.js'; +export { + BASE_BLOCK_TIME_SECONDS, + BaseAnchorPublisher, + DEFAULT_CONFIRMATION_POLL_ATTEMPTS, + DEFAULT_CONFIRMATION_POLL_INTERVAL_MS, + DEFAULT_INITIAL_BACKOFF_MS, + DEFAULT_MAX_BACKOFF_MS, + DEFAULT_RETRY_ATTEMPTS, + MAINNET_ANCHORING_ENV_VAR, + SAFE_TAG_CONFIRMATION_PROXY, + requiredConfirmations, + type AnchorConfirmationPollOptions, + type AnchorEvidenceStore, + type AnchorRetryOptions, + type BaseAnchorPublisherOptions, +} from './publisher.js'; +export { + expectedChainId, + verifyAnchorReceipt, + type AnchorVerificationChecks, + type AnchorVerificationResult, +} from './verify-anchor.js'; diff --git a/packages/anchor/src/key-file.ts b/packages/anchor/src/key-file.ts new file mode 100644 index 0000000..95311ed --- /dev/null +++ b/packages/anchor/src/key-file.ts @@ -0,0 +1,211 @@ +/** + * ALD-019 — anchoring signer key management (LEDGER-INTEGRITY-DESIGN.md §11, + * SPEC §13.5, §19 ADR-02). + * + * The on-chain anchor wallet is a *separate key kind* from every event and + * witness key: those are per-run Ed25519 keys generated inside the isolated + * signer services (`@ald/hashing` `FileKeyStore` / `InMemorySignerRegistry`) + * and recorded in the run manifest as public keys, while this is one + * long-lived secp256k1 wallet key that funds transactions. Neither can be + * derived from the other: different curves, different files, different + * lifetimes, and nothing in this module ever reads or writes the event + * key store. + * + * **Rotation** is intentionally trivial: write a new key file and construct a + * new publisher with a transport built from it. Receipts already stored keep + * their own `from` address and stay verifiable forever, because verification + * (see `verify-anchor.ts`) never consults a wallet key — only chain data and + * the receipt's own fields. + * + * The private key is never logged, never telemetered, never returned inside + * an error, and never written into an evidence bundle: `AnchorKeyFileError` + * carries the path and a reason only, and every value returned from here is + * handed straight to viem's local account. + */ +import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + +import { AnchorKeyFileError } from './errors.js'; + +export type HexString = `0x${string}`; + +/** Octal permission bits that must be clear on a POSIX key file. */ +export const FORBIDDEN_KEY_FILE_MODE_BITS = 0o077; + +/** Mode `writeAnchorKeyFile` creates: owner read/write only. */ +export const ANCHOR_KEY_FILE_MODE = 0o600; + +const PRIVATE_KEY_PATTERN = /^0x[0-9a-fA-F]{64}$/u; + +export interface AnchorKey { + /** secp256k1 private key, `0x` + 64 lowercase hex. Never log this. */ + privateKey: HexString; + /** Checksummed address derived from {@link AnchorKey.privateKey}. */ + address: HexString; +} + +export interface LoadAnchorKeyFileOptions { + /** + * Skip the POSIX permission check. Intended only for environments where + * file modes are not meaningful (mounted secrets, Windows); it is never a + * default (LEDGER §11). + */ + allowInsecurePermissions?: boolean; +} + +/** Validate and normalize a key without ever including it in a message. */ +function normalizePrivateKey(path: string, candidate: string): HexString { + if (!PRIVATE_KEY_PATTERN.test(candidate)) { + throw new AnchorKeyFileError( + path, + 'expected exactly one 0x-prefixed 32-byte hex private key (the key itself is never logged)', + ); + } + return `0x${candidate.slice(2).toLowerCase()}` as HexString; +} + +function toAnchorKey(privateKey: HexString): AnchorKey { + return { + privateKey, + address: privateKeyToAccount(privateKey).address, + }; +} + +/** + * Derive the address, converting *any* failure into an `AnchorKeyFileError` + * that carries neither the key nor a cause. + * + * `PRIVATE_KEY_PATTERN` only checks the 0x + 64-hex shape, so a syntactically + * valid but out-of-range scalar reaches the curve library, which raises a bare + * `Error` whose message prints the candidate scalar in decimal — the key + * itself. LEDGER §11 forbids that reaching a log, so the cause is dropped + * rather than chained. + */ +function deriveAnchorKey(path: string, privateKey: HexString): AnchorKey { + try { + return toAnchorKey(privateKey); + } catch { + throw new AnchorKeyFileError( + path, + 'is not a valid secp256k1 private key (the key itself is never logged)', + ); + } +} + +/** Octal bits that must be clear on the directory holding a key file. */ +const FORBIDDEN_KEY_DIRECTORY_MODE_BITS = 0o022; + +/** + * Refuse a key file whose directory is group- or other-writable: anyone with + * write access to the directory can replace the key file with their own, so + * the anchor wallet would no longer be the dedicated key LEDGER §11 requires. + */ +async function assertDirectorySafe(path: string): Promise { + const directory = dirname(path); + let stats; + try { + stats = await stat(directory); + } catch (cause) { + throw new AnchorKeyFileError(path, 'cannot be read', { cause }); + } + const insecureBits = stats.mode & FORBIDDEN_KEY_DIRECTORY_MODE_BITS; + if (insecureBits !== 0) { + throw new AnchorKeyFileError( + path, + `lives in directory ${directory} with mode ` + + `${(stats.mode & 0o777).toString(8).padStart(3, '0')}, which allows ` + + `group/other write; run 'chmod go-w' on it or pass ` + + 'allowInsecurePermissions', + ); + } +} + +/** + * Read the anchor wallet key from `path`. + * + * Refuses a file whose mode grants any group or other permission on POSIX, + * because a readable anchor key is a spendable anchor key, and equally refuses + * one whose parent directory is group/other writable, because a writable + * directory is a replaceable key file (LEDGER §11). + */ +export async function loadAnchorKeyFile( + path: string, + options: LoadAnchorKeyFileOptions = {}, +): Promise { + const checkPermissions = + process.platform !== 'win32' && options.allowInsecurePermissions !== true; + if (checkPermissions) { + await assertDirectorySafe(path); + } + + let stats; + try { + stats = await stat(path); + } catch (cause) { + throw new AnchorKeyFileError(path, 'cannot be read', { cause }); + } + + if (!stats.isFile()) { + throw new AnchorKeyFileError(path, 'is not a regular file'); + } + + const insecureBits = stats.mode & FORBIDDEN_KEY_FILE_MODE_BITS; + if (checkPermissions && insecureBits !== 0) { + throw new AnchorKeyFileError( + path, + `mode ${(stats.mode & 0o777).toString(8).padStart(3, '0')} allows group/other access; ` + + `run 'chmod 600' on it or pass allowInsecurePermissions`, + ); + } + + let contents: string; + try { + contents = await readFile(path, 'utf8'); + } catch (cause) { + throw new AnchorKeyFileError(path, 'cannot be read', { cause }); + } + + return deriveAnchorKey(path, normalizePrivateKey(path, contents.trim())); +} + +/** + * Write a new anchor key file with mode 0600, refusing to clobber an existing + * file (`wx`) so rotation never silently destroys the previous wallet key. + */ +export async function writeAnchorKeyFile( + path: string, + privateKey: string, +): Promise { + const normalized = normalizePrivateKey(path, privateKey.trim()); + // Derive first: a key the curve rejects must never reach the filesystem. + const key = deriveAnchorKey(path, normalized); + // Owner-only on any directory this call creates, so the file it is about to + // write is not left in a directory `loadAnchorKeyFile` must then refuse. + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + try { + await writeFile(path, `${normalized}\n`, { + encoding: 'utf8', + mode: ANCHOR_KEY_FILE_MODE, + flag: 'wx', + }); + } catch (cause) { + throw new AnchorKeyFileError(path, 'could not be created', { cause }); + } + // `mode` is masked by the process umask on creation; enforce it explicitly. + if (process.platform !== 'win32') { + await chmod(path, ANCHOR_KEY_FILE_MODE); + } + return key; +} + +/** + * Generate a fresh anchor wallet key in memory (viem `generatePrivateKey`). + * Persist it with {@link writeAnchorKeyFile}; a dedicated, low-balance wallet + * with no other authority is the documented development custody model + * (SPEC §19 ADR-02), with a managed signer required before mainnet runs. + */ +export function generateAnchorKey(): AnchorKey { + return toAnchorKey(generatePrivateKey()); +} diff --git a/packages/anchor/src/pending.ts b/packages/anchor/src/pending.ts new file mode 100644 index 0000000..288fe42 --- /dev/null +++ b/packages/anchor/src/pending.ts @@ -0,0 +1,214 @@ +/** + * Crash-durable record of anchor transactions that were submitted but whose + * terminal state is not yet in the evidence store. + * + * `anchor_receipts` is append-only with one row per `(chainId, + * transactionHash)`, so the publisher inserts a row exactly once, at a + * terminal decision — `confirmed` or `failed` only; giving up on a + * confirmation poll is *not* terminal and writes nothing. The window between + * "chain accepted the transaction" and "row inserted" is the only place a + * crash could lose a transaction hash and orphan a paid transaction, so the + * hash is written to this sidecar file first, in canonical JSON, and removed + * only after the row exists. That makes this file the resume handle for a + * given-up poll (SPEC §7.2 `sealing-blocked` -> retry succeeds -> `sealing`). + * + * The file is a plain operational sidecar, not evidence: it is never part of + * an exported bundle and contains only public chain routing metadata (never + * a key, never run content). + */ +import { randomUUID } from 'node:crypto'; +import { + chmodSync, + closeSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from 'node:fs'; +import { dirname } from 'node:path'; + +import { canonicalJson } from '@ald/hashing'; +import { z } from 'zod'; + +import { PendingFileInvalidError } from './errors.js'; + +export const PendingAnchorSubmissionSchema = z.object({ + version: z.literal(1), + runId: z.string().min(1), + checkpointSequence: z.number().int().nonnegative(), + checkpointHash: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + anchorClass: z.enum(['simulated', 'public-chain']), + network: z.enum(['base-sepolia', 'base-mainnet']), + chainId: z.number().int().positive(), + transactionHash: z.string().regex(/^0x[a-f0-9]{64}$/iu), + from: z.string().regex(/^0x[a-f0-9]{40}$/iu), + to: z.string().regex(/^0x[a-f0-9]{40}$/iu), + inputData: z.string().regex(/^0x[a-f0-9]*$/iu), + finalityPolicy: z.string().min(1), + rpcEndpointLabel: z.string().min(1), + submittedAt: z.string().min(1), +}); + +export const PendingAnchorFileSchema = z.object({ + version: z.literal(1), + submissions: z.array(PendingAnchorSubmissionSchema), +}); + +export type PendingAnchorSubmission = z.infer< + typeof PendingAnchorSubmissionSchema +>; + +function samePending( + left: PendingAnchorSubmission, + chainId: number, + transactionHash: string, +): boolean { + return ( + left.chainId === chainId && + left.transactionHash.toLowerCase() === transactionHash.toLowerCase() + ); +} + +/** Read the sidecar; a missing file is an empty list, not an error. */ +export function readPendingSubmissions( + path: string, +): PendingAnchorSubmission[] { + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw new PendingFileInvalidError(path, { cause }); + } + + try { + return PendingAnchorFileSchema.parse(JSON.parse(text)).submissions; + } catch (cause) { + throw new PendingFileInvalidError(path, { cause }); + } +} + +/** Mode the sidecar and its temporary file are created and kept at. */ +export const PENDING_FILE_MODE = 0o600; + +/** Best-effort cleanup; a missing temp file is not itself a failure. */ +function discard(descriptor: number | null, temporaryPath: string): void { + if (descriptor !== null) { + try { + closeSync(descriptor); + } catch { + // The descriptor was already closed by the successful path. + } + } + try { + unlinkSync(temporaryPath); + } catch { + // The temp file was never created, or the rename already consumed it. + } +} + +/** + * Refuse a sidecar path that is a symlink. + * + * The sidecar is the only crash-durable record of a paid but unrecorded anchor + * transaction (LEDGER §10), so it must be a regular file this process owns: + * a planted symlink would redirect both the read (attacker-chosen content) and + * the durability guarantee this module exists to provide. + */ +function assertRegularFile(path: string): void { + let stats; + try { + stats = lstatSync(path); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw new PendingFileInvalidError(path, { cause }); + } + if (!stats.isFile()) { + throw new PendingFileInvalidError(path, { + cause: new Error('sidecar path is not a regular file (symlink refused)'), + }); + } +} + +/** + * Replace the sidecar atomically. + * + * The temporary file gets a unique name and is created with `O_EXCL` at mode + * 0600, so a pre-existing file (whose looser permissions would otherwise be + * inherited), a planted symlink, and a second publisher sharing the same + * sidecar path can none of them influence this write. The descriptor is + * `fsync`ed before the rename so a crash cannot leave a renamed-but-empty + * sidecar and orphan a paid transaction. + */ +export function writePendingSubmissions( + path: string, + submissions: readonly PendingAnchorSubmission[], +): void { + mkdirSync(dirname(path), { recursive: true }); + assertRegularFile(path); + + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + let descriptor: number | null = null; + try { + descriptor = openSync(temporaryPath, 'wx', PENDING_FILE_MODE); + writeSync( + descriptor, + `${canonicalJson({ version: 1, submissions })}\n`, + null, + 'utf8', + ); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + // `mode` is masked by the process umask on creation; enforce it explicitly. + if (process.platform !== 'win32') { + chmodSync(temporaryPath, PENDING_FILE_MODE); + } + renameSync(temporaryPath, path); + } catch (cause) { + discard(descriptor, temporaryPath); + throw new PendingFileInvalidError(path, { cause }); + } +} + +/** Idempotent by `(chainId, transactionHash)`. */ +export function addPendingSubmission( + path: string, + submission: PendingAnchorSubmission, +): void { + const existing = readPendingSubmissions(path).filter( + (entry) => + !samePending(entry, submission.chainId, submission.transactionHash), + ); + writePendingSubmissions(path, [...existing, submission]); +} + +export function removePendingSubmission( + path: string, + chainId: number, + transactionHash: string, +): void { + const remaining = readPendingSubmissions(path).filter( + (entry) => !samePending(entry, chainId, transactionHash), + ); + writePendingSubmissions(path, remaining); +} + +export function findPendingSubmission( + submissions: readonly PendingAnchorSubmission[], + chainId: number, + checkpointHash: string, +): PendingAnchorSubmission | undefined { + return submissions.find( + (entry) => + entry.chainId === chainId && entry.checkpointHash === checkpointHash, + ); +} diff --git a/packages/anchor/src/publisher.ts b/packages/anchor/src/publisher.ts new file mode 100644 index 0000000..014865b --- /dev/null +++ b/packages/anchor/src/publisher.ts @@ -0,0 +1,606 @@ +/** + * ALD-020 / ALD-021 / ALD-022 — the Base anchoring client. + * + * One checkpoint manifest in, one Anchor Receipt out (LEDGER §10): a + * zero-value transaction from the dedicated anchor wallet to a designated + * project address, carrying the 32-byte checkpoint digest as calldata and + * nothing else (LEDGER §12). + * + * Three properties drive the design: + * + * 1. **Exactly one submission per checkpoint per chain.** A send that returns + * a hash is never retried; only a send that *threw* — meaning nothing + * reached the chain — is retried with exponential backoff. Before sending, + * an existing stored receipt, an in-memory reservation, or a pending + * sidecar entry for the same `(chainId, checkpointHash)` short-circuits and + * the existing state is returned (ALD-018: at most one receipt per chain + * per checkpoint). The reservation is unconditional, so the guard holds + * with or without a configured `pendingFile`. + * 2. **`anchor_receipts` is append-only.** `submit` therefore does *not* + * insert: it returns a `submitted` receipt and records the transaction + * hash in the reservation map and the pending sidecar. The single row is + * inserted by `awaitConfirmation` at a *terminal* decision — `confirmed` + * or `failed` — so the stored row is never contradicted by a later row it + * cannot replace. "Gave up waiting" is deliberately **not** terminal: no + * row is written, the pending entry stays as the resume handle, and the + * unstored `submitted` receipt carries whatever confirmation depth was + * observed. + * 3. **Two independent budgets.** `retry` bounds how long a *send* outage is + * ridden out; `confirmationPoll` bounds how long the publisher waits for + * the configured depth, and its defaults are sized to actually reach the + * `safe-tag` depth on Base (SPEC §13.4). Sharing one budget made the + * mainnet default unreachable. + * 4. **Mainnet is off unless asked for twice** (ALD-022, SPEC §13.4): both + * the `allowMainnet` option and `ALD_ALLOW_MAINNET_ANCHORING=true`. The + * check runs before any RPC call, so a default-configured mainnet + * publisher makes zero chain calls. + */ +import { hashRunId } from '@ald/hashing'; +import { AnchorReceiptSchema } from '@ald/types'; +import type { AnchorPublisher, AnchorReceipt, CheckpointManifest, Clock } from '@ald/types'; + +import { + AnchorNetworkMismatchError, + AnchorPayloadMismatchError, + AnchorSubmissionFailedError, + InvalidFinalityPolicyError, + MainnetAnchoringDisabledError, + UnknownAnchorCheckpointError, + UnknownAnchorRunError, +} from './errors.js'; +import { + addPendingSubmission, + findPendingSubmission, + readPendingSubmissions, + removePendingSubmission, + type PendingAnchorSubmission, +} from './pending.js'; +import { anchorInputData } from './transport.js'; +import type { AnchorNetwork, ChainTransport } from './transport.js'; +import { expectedChainId } from './verify-anchor.js'; + +/** Environment opt-in required in addition to the constructor option. */ +export const MAINNET_ANCHORING_ENV_VAR = 'ALD_ALLOW_MAINNET_ANCHORING'; + +/** + * Interim confirmation depth used for the `safe-tag` policy. + * + * SPEC §13.4 requires mainnet runs to wait for the `safe` block tag "or + * equivalent finality/confirmation-depth policy", and SPEC §19 ADR-05 defers + * the provider-specific tag naming to ADR-01. Until that is resolved, + * `safe-tag` is implemented as a confirmation *depth* that brackets Base's + * safe head, so the policy string can already be recorded in run configs and + * receipts without depending on a tag the fake chain cannot offer. + * + * The arithmetic, in Base blocks (the unit this depth is counted in): Base's + * `safe` tag tracks the L1 justified checkpoint, one Ethereum epoch behind — + * 32 slots x 12 s = 384 s — and Base produces one block every 2 s, so one L1 + * epoch is 384 / 2 = 192 Base blocks. A depth of 32 Base blocks (~64 s) would + * be roughly a sixth of that and would report a checkpoint anchored-final + * about five L1 blocks deep, so 192 is the depth this proxy uses. + */ +export const SAFE_TAG_CONFIRMATION_PROXY = 192; + +/** Base block time in seconds; the unit {@link SAFE_TAG_CONFIRMATION_PROXY} counts. */ +export const BASE_BLOCK_TIME_SECONDS = 2; + +export const DEFAULT_RETRY_ATTEMPTS = 5; +export const DEFAULT_INITIAL_BACKOFF_MS = 500; +export const DEFAULT_MAX_BACKOFF_MS = 8_000; + +/** + * Confirmation polling is budgeted separately from send retries (SPEC §13.4): + * a send retry is bounded by how long an RPC outage is worth riding out, while + * a confirmation poll must be able to *reach* the configured depth. The + * defaults poll once per Base block for 240 polls — 480 s of chain time — + * which clears the 384 s (192-block) `safe-tag` depth above with margin. The + * old shared `retry.attempts` budget (5 polls, ~7.5 s of backoff, under 4 Base + * blocks) could never reach it. + */ +export const DEFAULT_CONFIRMATION_POLL_ATTEMPTS = 240; +export const DEFAULT_CONFIRMATION_POLL_INTERVAL_MS = + BASE_BLOCK_TIME_SECONDS * 1_000; + +const NUMBERED_CONFIRMATIONS = /^(\d+)-confirmations$/u; + +/** + * Parse a `finalityPolicy` string into the confirmation depth a receipt must + * reach before it may be reported as `confirmed` (SPEC §13.4). + */ +export function requiredConfirmations(policy: string): number { + if (policy === '1-confirmation') { + return 1; + } + if (policy === 'safe-tag') { + return SAFE_TAG_CONFIRMATION_PROXY; + } + const match = NUMBERED_CONFIRMATIONS.exec(policy); + if (match?.[1] !== undefined) { + const depth = Number.parseInt(match[1], 10); + if (Number.isSafeInteger(depth) && depth > 0) { + return depth; + } + } + throw new InvalidFinalityPolicyError(policy); +} + +/** + * The slice of the Evidence Writer/Reader the publisher uses. Narrowed so a + * test double (or a future remote evidence service) can stand in for + * `SqliteEvidenceWriter` without implementing the whole contract. + */ +export interface AnchorEvidenceStore { + insertAnchorReceipt(receipt: AnchorReceipt): void; + readCheckpoints(runId: string): CheckpointManifest[]; + readAnchorReceipts(runId: string): AnchorReceipt[]; + listRuns(): string[]; +} + +export interface AnchorRetryOptions { + /** Total *send* attempts. Default 5. Confirmation polls are separate. */ + attempts?: number; + initialBackoffMs?: number; + maxBackoffMs?: number; + /** Injectable delay; tests pass a synchronous stub that mines a block. */ + sleep?: (milliseconds: number) => Promise; +} + +/** + * Confirmation-poll budget, independent of the send-retry budget: this one has + * to be able to reach {@link BaseAnchorPublisher.requiredConfirmations} + * (SPEC §13.4). Defaults: {@link DEFAULT_CONFIRMATION_POLL_ATTEMPTS} polls, + * one per Base block ({@link DEFAULT_CONFIRMATION_POLL_INTERVAL_MS}). + */ +export interface AnchorConfirmationPollOptions { + attempts?: number; + intervalMs?: number; +} + +export interface BaseAnchorPublisherOptions { + transport: ChainTransport; + /** Declares whether receipts came from a deterministic simulation or a public chain. */ + anchorClass: AnchorReceipt['anchorClass']; + evidence: AnchorEvidenceStore; + clock: Clock; + /** Designated destination of the zero-value anchor transaction. */ + anchorAddress: string; + /** `'1-confirmation'`, `'safe-tag'`, or `'-confirmations'`. */ + finalityPolicy: string; + retry?: AnchorRetryOptions; + confirmationPoll?: AnchorConfirmationPollOptions; + /** Path of the crash-durable pending-submission sidecar. */ + pendingFile?: string; + /** ALD-022 first opt-in; the env var is the second. */ + allowMainnet?: boolean; +} + +/** What a poll saw on chain: the block the transaction landed in, and depth. */ +interface ObservedInclusion { + blockNumber: number; + blockHash: string; + confirmations: number; +} + +/** ALD-018 idempotency key: one receipt per chain per checkpoint. */ +function reservationKey(chainId: number, checkpointHash: string): string { + return `${String(chainId)}:${checkpointHash.toLowerCase()}`; +} + +async function defaultSleep(milliseconds: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +export class BaseAnchorPublisher implements AnchorPublisher { + readonly anchorClass: AnchorReceipt['anchorClass']; + readonly network: AnchorNetwork; + readonly chainId: number; + readonly finalityPolicy: string; + readonly requiredConfirmations: number; + + private readonly transport: ChainTransport; + private readonly evidence: AnchorEvidenceStore; + private readonly clock: Clock; + private readonly anchorAddress: string; + private readonly attempts: number; + private readonly initialBackoffMs: number; + private readonly maxBackoffMs: number; + private readonly sleep: (milliseconds: number) => Promise; + private readonly confirmationPollAttempts: number; + private readonly confirmationPollIntervalMs: number; + private readonly pendingFile: string | null; + private readonly allowMainnet: boolean; + /** + * Unconditional `(chainId, checkpointHash)` reservations, so a second + * `submit()` for the same checkpoint can never produce a second transaction + * even when no `pendingFile` is configured (ALD-018). + */ + private readonly reservations = new Map(); + /** Reservations still being sent, so concurrent submits share one send. */ + private readonly inFlight = new Map>(); + + constructor(options: BaseAnchorPublisherOptions) { + this.anchorClass = options.anchorClass; + this.transport = options.transport; + this.evidence = options.evidence; + this.clock = options.clock; + this.anchorAddress = options.anchorAddress; + this.network = options.transport.network; + this.chainId = options.transport.chainId; + this.finalityPolicy = options.finalityPolicy; + this.requiredConfirmations = requiredConfirmations(options.finalityPolicy); + this.attempts = options.retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS; + this.initialBackoffMs = + options.retry?.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS; + this.maxBackoffMs = options.retry?.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS; + this.sleep = options.retry?.sleep ?? defaultSleep; + this.confirmationPollAttempts = + options.confirmationPoll?.attempts ?? DEFAULT_CONFIRMATION_POLL_ATTEMPTS; + this.confirmationPollIntervalMs = + options.confirmationPoll?.intervalMs ?? + DEFAULT_CONFIRMATION_POLL_INTERVAL_MS; + this.pendingFile = options.pendingFile ?? null; + this.allowMainnet = options.allowMainnet === true; + + const expected = expectedChainId(this.network); + if (this.chainId !== expected) { + throw new AnchorNetworkMismatchError(this.network, this.chainId, expected); + } + if (this.attempts < 1) { + throw new RangeError('retry.attempts must be at least 1'); + } + if (this.confirmationPollAttempts < 1) { + throw new RangeError('confirmationPoll.attempts must be at least 1'); + } + if (this.confirmationPollIntervalMs < 0) { + throw new RangeError('confirmationPoll.intervalMs must not be negative'); + } + } + + /** Pending submissions still awaiting a terminal receipt row. */ + pendingSubmissions(): PendingAnchorSubmission[] { + return this.pendingFile === null + ? [] + : readPendingSubmissions(this.pendingFile); + } + + /** + * Submit the checkpoint digest and return the `submitted` receipt. The + * receipt is deliberately *not* stored yet; see the class comment. + * + * Idempotent by `(chainId, checkpointHash)` under every configuration: a + * stored receipt wins, then an in-memory reservation, then the pending + * sidecar, then a send already in flight. Only when all four miss does a + * transaction reach the chain (ALD-018, LEDGER §10). + */ + async submit(manifest: CheckpointManifest): Promise { + this.assertNetworkAllowed(); + + const runId = this.resolveRunId(manifest.runIdHash); + this.assertCheckpointStored(runId, manifest.checkpointHash); + const existing = this.storedReceiptFor(runId, manifest.checkpointHash); + if (existing !== undefined) { + return existing; + } + + const key = reservationKey(this.chainId, manifest.checkpointHash); + const reserved = this.reservedSubmission(key, manifest.checkpointHash); + if (reserved !== undefined) { + return this.receiptFromPending(reserved); + } + + const started = this.inFlight.get(key); + if (started !== undefined) { + return await started; + } + + const sending = this.sendAndReserve(runId, manifest, key); + this.inFlight.set(key, sending); + try { + return await sending; + } finally { + this.inFlight.delete(key); + } + } + + /** + * Poll until the transaction reaches {@link requiredConfirmations} or + * reverts, then insert the single append-only receipt row and return it. + * + * If the confirmation-poll budget runs out first that is *not* a terminal + * decision: no row is inserted, the pending entry stays as the resume + * handle, and the returned (unstored) `submitted` receipt carries the depth + * observed so far, so a later call over the same pending entry can still + * record the confirmation (SPEC §7.2 `sealing-blocked` -> retry -> `sealing`). + */ + async awaitConfirmation(receipt: AnchorReceipt): Promise { + this.assertNetworkAllowed(); + + let observed: ObservedInclusion | null = null; + + for ( + let attempt = 1; + attempt <= this.confirmationPollAttempts; + attempt += 1 + ) { + const chainReceipt = await this.transport.getTransactionReceipt( + receipt.transactionHash, + ); + + if (chainReceipt !== null) { + const head = await this.transport.latestBlockNumber(); + observed = { + blockNumber: chainReceipt.blockNumber, + blockHash: chainReceipt.blockHash, + confirmations: Math.max(0, head - chainReceipt.blockNumber + 1), + }; + + if (chainReceipt.status === 'reverted') { + return this.finalize({ ...receipt, status: 'failed', ...observed }); + } + if (observed.confirmations >= this.requiredConfirmations) { + return this.finalize({ ...receipt, status: 'confirmed', ...observed }); + } + } + + if (attempt < this.confirmationPollAttempts) { + await this.sleep(this.confirmationPollIntervalMs); + } + } + + return this.giveUp(receipt, observed); + } + + /** Convenience for the common submit-then-wait path. */ + async anchorAndConfirm(manifest: CheckpointManifest): Promise { + const submitted = await this.submit(manifest); + if (submitted.status !== 'submitted') { + return submitted; + } + return this.awaitConfirmation(submitted); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** ALD-022: both opt-ins, checked before any RPC call. */ + private assertNetworkAllowed(): void { + if (this.network !== 'base-mainnet') { + return; + } + const envAllows = process.env[MAINNET_ANCHORING_ENV_VAR] === 'true'; + if (this.allowMainnet && envAllows) { + return; + } + if (!this.allowMainnet && !envAllows) { + throw new MainnetAnchoringDisabledError('both'); + } + throw new MainnetAnchoringDisabledError( + this.allowMainnet ? 'missing-env' : 'missing-option', + ); + } + + private backoffFor(attempt: number): number { + return Math.min( + this.maxBackoffMs, + this.initialBackoffMs * 2 ** (attempt - 1), + ); + } + + /** LEDGER §8 manifests carry `runIdHash`; receipts carry the run id. */ + private resolveRunId(runIdHash: string): string { + for (const runId of this.evidence.listRuns()) { + if (hashRunId(runId) === runIdHash) { + return runId; + } + } + throw new UnknownAnchorRunError(runIdHash); + } + + /** ALD-018: the receipt's one-to-one manifest must already be stored. */ + private assertCheckpointStored(runId: string, checkpointHash: string): void { + const stored = this.evidence + .readCheckpoints(runId) + .some((manifest) => manifest.checkpointHash === checkpointHash); + if (!stored) { + throw new UnknownAnchorCheckpointError(runId, checkpointHash); + } + } + + private storedReceiptFor( + runId: string, + checkpointHash: string, + ): AnchorReceipt | undefined { + return this.evidence + .readAnchorReceipts(runId) + .find( + (candidate) => + candidate.chainId === this.chainId && + candidate.checkpointHash === checkpointHash, + ); + } + + private storedReceiptForTransaction( + runId: string, + transactionHash: string, + ): AnchorReceipt | undefined { + return this.evidence + .readAnchorReceipts(runId) + .find( + (candidate) => + candidate.chainId === this.chainId && + candidate.transactionHash.toLowerCase() === + transactionHash.toLowerCase(), + ); + } + + private receiptFromPending( + submission: PendingAnchorSubmission, + ): AnchorReceipt { + return AnchorReceiptSchema.parse({ + version: 1, + runId: submission.runId, + checkpointSequence: submission.checkpointSequence, + checkpointHash: submission.checkpointHash, + anchorClass: submission.anchorClass, + network: submission.network, + chainId: submission.chainId, + transactionHash: submission.transactionHash, + from: submission.from, + to: submission.to, + inputData: submission.inputData, + blockNumber: null, + blockHash: null, + status: 'submitted', + confirmations: 0, + finalityPolicy: submission.finalityPolicy, + rpcEndpointLabel: submission.rpcEndpointLabel, + recordedAt: submission.submittedAt, + }); + } + + /** Reservation for this checkpoint, in memory or in the sidecar. */ + private reservedSubmission( + key: string, + checkpointHash: string, + ): PendingAnchorSubmission | undefined { + return ( + this.reservations.get(key) ?? + findPendingSubmission( + this.pendingSubmissions(), + this.chainId, + checkpointHash, + ) + ); + } + + /** + * Send once, then reserve the `(chainId, checkpointHash)` key in memory and + * — when configured — in the crash-durable sidecar, before the receipt is + * handed back. The in-memory half is what makes the guard unconditional. + */ + private async sendAndReserve( + runId: string, + manifest: CheckpointManifest, + key: string, + ): Promise { + const inputData = anchorInputData(manifest.checkpointHash); + const sent = await this.sendWithRetry(manifest.checkpointHash); + if (sent.inputData.toLowerCase() !== inputData.toLowerCase()) { + throw new AnchorPayloadMismatchError(inputData, sent.inputData); + } + + const submission: PendingAnchorSubmission = { + version: 1, + runId, + checkpointSequence: manifest.checkpointSequence, + checkpointHash: manifest.checkpointHash, + anchorClass: this.anchorClass, + network: this.network, + chainId: this.chainId, + transactionHash: sent.transactionHash, + from: sent.from, + to: sent.to, + inputData: sent.inputData, + finalityPolicy: this.finalityPolicy, + rpcEndpointLabel: this.transport.endpointLabel, + submittedAt: this.clock.now(), + }; + this.reservations.set(key, submission); + if (this.pendingFile !== null) { + addPendingSubmission(this.pendingFile, submission); + } + + return this.receiptFromPending(submission); + } + + /** + * Confirmation-poll budget exhausted: a non-terminal outcome, so nothing is + * written to the append-only `anchor_receipts` table and nothing is removed + * from the pending sidecar. The returned receipt reports the observed depth + * and is explicitly *not* stored (LEDGER §10: only `confirmed` and `failed` + * are terminal, and a `submitted` row could never be upgraded because + * `(chainId, transactionHash)` is the primary key). + */ + private giveUp( + receipt: AnchorReceipt, + observed: ObservedInclusion | null, + ): AnchorReceipt { + const stored = this.storedReceiptForTransaction( + receipt.runId, + receipt.transactionHash, + ); + if (stored !== undefined) { + return stored; + } + return AnchorReceiptSchema.parse({ + ...receipt, + status: 'submitted', + blockNumber: observed?.blockNumber ?? receipt.blockNumber, + blockHash: observed?.blockHash ?? receipt.blockHash, + confirmations: observed?.confirmations ?? receipt.confirmations, + recordedAt: this.clock.now(), + }); + } + + /** + * Insert the one append-only row for this transaction, then drop the pending + * entry and the reservation. + * + * If a row already exists it cannot be replaced, so the *stored* row is + * returned — never the freshly computed one, which no caller may mistake for + * persisted evidence — and the pending entry is left untouched: a call that + * inserted nothing must not destroy another call's resume handle. + */ + private finalize(receipt: AnchorReceipt): AnchorReceipt { + const final = AnchorReceiptSchema.parse({ + ...receipt, + recordedAt: this.clock.now(), + }); + + const alreadyStored = this.storedReceiptForTransaction( + final.runId, + final.transactionHash, + ); + if (alreadyStored !== undefined) { + return alreadyStored; + } + + this.evidence.insertAnchorReceipt(final); + this.reservations.delete( + reservationKey(final.chainId, final.checkpointHash), + ); + if (this.pendingFile !== null) { + removePendingSubmission( + this.pendingFile, + final.chainId, + final.transactionHash, + ); + } + return final; + } + + private async sendWithRetry( + checkpointHash: string, + ): Promise<{ transactionHash: string; from: string; to: string; inputData: string }> { + let lastError: unknown; + for (let attempt = 1; attempt <= this.attempts; attempt += 1) { + try { + return await this.transport.sendAnchorTransaction({ + checkpointHash, + to: this.anchorAddress, + }); + } catch (error) { + // A throw means the transport did not return a hash, so nothing was + // submitted and retrying cannot duplicate a transaction. + lastError = error; + if (attempt < this.attempts) { + await this.sleep(this.backoffFor(attempt)); + } + } + } + throw new AnchorSubmissionFailedError(this.attempts, { cause: lastError }); + } +} diff --git a/packages/anchor/src/testing.ts b/packages/anchor/src/testing.ts new file mode 100644 index 0000000..b56ed06 --- /dev/null +++ b/packages/anchor/src/testing.ts @@ -0,0 +1,11 @@ +/** + * Test doubles other packages may import: the deterministic in-memory chain. + * + * Kept in its own entry point so a reader can see at a glance that nothing + * here is production anchoring code. + */ +export { + DEFAULT_FAKE_FROM_ADDRESS, + FakeChainTransport, + type FakeChainTransportOptions, +} from './fake-transport.js'; diff --git a/packages/anchor/src/transport.ts b/packages/anchor/src/transport.ts new file mode 100644 index 0000000..60181ee --- /dev/null +++ b/packages/anchor/src/transport.ts @@ -0,0 +1,122 @@ +/** + * The chain boundary of the anchoring subsystem. + * + * Everything above this interface is provider-agnostic (SPEC §19 ADR-01: the + * RPC vendor is an operational choice, and the Verifier MUST work against any + * provider that can confirm a chain id, return calldata, and report block + * numbers). `ChainTransport` is the write+read half used by the publisher; + * `ChainReader` is the strictly read-only half the independent verifier needs + * (ALD-021) and is deliberately a `Pick<>` so a transport is always a valid + * reader. + * + * The anchored payload is the bare 32-byte checkpoint digest and nothing else + * (LEDGER-INTEGRITY-DESIGN.md §12: "Only checkpoint hashes are written + * on-chain"). {@link anchorInputData} is the single place that encoding is + * produced, and both the publisher and the verifier compare against it. + */ +import { decodeHash, isSha256Hash } from '@ald/hashing'; +import type { AnchorReceipt } from '@ald/types'; + +/** The two Base networks this project anchors to (SPEC §13.4). */ +export type AnchorNetwork = AnchorReceipt['network']; + +/** Chain ids fixed by Base itself; never configurable. */ +export const ANCHOR_CHAIN_IDS: Readonly> = + Object.freeze({ + 'base-sepolia': 84532, + 'base-mainnet': 8453, + }); + +/** Number of hex characters in a 32-byte payload plus the `0x` prefix. */ +export const ANCHOR_INPUT_DATA_LENGTH = 66; + +/** + * LEDGER §10/§12 anchor payload: `0x` followed by the raw 32 bytes of the + * checkpoint digest, lowercase hex, exactly 66 characters. No selector, no + * ABI encoding, no run identifier, no content — a third party can therefore + * compare calldata to a locally recomputed checkpoint hash byte for byte. + */ +export function anchorInputData(checkpointHash: string): string { + if (!isSha256Hash(checkpointHash)) { + throw new TypeError( + `Checkpoint hash must be sha256:<64 hex>, got ${String(checkpointHash).slice(0, 80)}`, + ); + } + return `0x${decodeHash(checkpointHash).toString('hex')}`; +} + +export interface AnchorTransactionInput { + /** `sha256:` checkpoint digest to commit on chain. */ + checkpointHash: string; + /** Destination address of the zero-value anchor transaction. */ + to: string; +} + +export interface SentAnchorTransaction { + transactionHash: string; + from: string; + to: string; + /** MUST equal {@link anchorInputData} of the submitted checkpoint hash. */ + inputData: string; +} + +export interface ChainTransactionReceipt { + status: 'success' | 'reverted'; + blockNumber: number; + blockHash: string; +} + +export interface ChainTransaction { + hash: string; + from: string; + /** `null` only for contract-creation transactions. */ + to: string | null; + input: string; + blockNumber: number | null; + blockHash: string | null; +} + +/** + * Write+read chain access used by {@link import('./publisher.js').BaseAnchorPublisher}. + * + * Implementations must throw {@link import('./errors.js').TransientChainError} + * (or any error) from `sendAnchorTransaction` only when nothing was submitted: + * the publisher treats a throw as "safe to retry" and a returned hash as + * "submitted exactly once". An implementation that can lose the *response* to + * a broadcast (any HTTP transport) therefore has to sign at a pinned nonce and + * reconcile against the chain before reporting a failure — see + * `ViemChainTransport` — so that a retry replaces the same transaction instead + * of paying for a second one (LEDGER §16 Phase 2: "retry and nonce + * management"). + * + * Error messages crossing this boundary must be secret-free: `endpointLabel`, + * the RPC method, and the provider's error name/code, never the RPC URL, its + * userinfo, or its query (LEDGER §11). + */ +export interface ChainTransport { + readonly chainId: number; + readonly network: AnchorNetwork; + /** Human-readable, secret-free RPC label recorded in the receipt. */ + readonly endpointLabel: string; + sendAnchorTransaction( + input: AnchorTransactionInput, + ): Promise; + getTransactionReceipt( + transactionHash: string, + ): Promise; + latestBlockNumber(): Promise; + getTransaction(transactionHash: string): Promise; +} + +/** + * The read-only slice an independently configured verifier RPC needs + * (ALD-021): chain identity plus transaction, receipt, and head lookups. + */ +export type ChainReader = Pick< + ChainTransport, + | 'chainId' + | 'network' + | 'getTransaction' + | 'getTransactionReceipt' + | 'latestBlockNumber' +>; diff --git a/packages/anchor/src/verify-anchor.ts b/packages/anchor/src/verify-anchor.ts new file mode 100644 index 0000000..61a001a --- /dev/null +++ b/packages/anchor/src/verify-anchor.ts @@ -0,0 +1,216 @@ +/** + * ALD-021, verifier half: validate one Anchor Receipt with no trust in the + * process that produced it. + * + * Two layers, in this order: + * + * 1. **Pure checks** (no network): the receipt's calldata is exactly the + * 32-byte digest of the checkpoint hash the caller recomputed locally + * (LEDGER §10/§12), its chain id matches its declared network, and its + * status is `confirmed`. An offline bundle check passes `reader: null` and + * still gets all three. + * 2. **Chain checks** (only with a reader): the transaction exists on an + * independently configured RPC, its calldata and destination match the + * receipt, its own receipt says `success`, and it is included in a block. + * + * Nothing here throws for a *failed* check — a verifier reports, it does not + * crash. Only a reader that itself rejects a call propagates, and that is + * caught and turned into a problem string. + */ +import type { AnchorReceipt } from '@ald/types'; + +import { ANCHOR_CHAIN_IDS, anchorInputData } from './transport.js'; +import type { AnchorNetwork, ChainReader } from './transport.js'; + +/** Chain id Base fixes for `network`; the receipt must agree with it. */ +export function expectedChainId(network: AnchorNetwork): number { + return ANCHOR_CHAIN_IDS[network]; +} + +/** + * `null` means "not evaluated" — either no reader was supplied, or an earlier + * check (reader on the wrong chain, transaction absent) made the later ones + * meaningless. + */ +export interface AnchorVerificationChecks { + /** + * Calldata is `0x` + the raw digest of `expectedCheckpointHash`, and the + * receipt commits to that same checkpoint hash. + */ + inputDataMatchesCheckpoint: boolean; + chainIdMatchesNetwork: boolean; + statusConfirmed: boolean; + chainTransactionFound: boolean | null; + chainInputMatches: boolean | null; + chainToMatches: boolean | null; + receiptStatusSuccess: boolean | null; + blockIncluded: boolean | null; +} + +export interface AnchorVerificationResult { + ok: boolean; + checks: AnchorVerificationChecks; + problems: string[]; +} + +function sameHex(left: string | null, right: string | null): boolean { + if (left === null || right === null) { + return false; + } + return left.toLowerCase() === right.toLowerCase(); +} + +/** + * Verify one receipt against the checkpoint hash the caller recomputed from + * the local bundle, and (optionally) against the chain itself. + * + * @param receipt - receipt as stored in the evidence bundle. + * @param expectedCheckpointHash - locally recomputed `sha256:` digest. + * @param reader - independently configured RPC, or `null` for offline mode. + */ +export async function verifyAnchorReceipt( + receipt: AnchorReceipt, + expectedCheckpointHash: string, + reader: ChainReader | null, +): Promise { + const problems: string[] = []; + const checks: AnchorVerificationChecks = { + inputDataMatchesCheckpoint: false, + chainIdMatchesNetwork: false, + statusConfirmed: false, + chainTransactionFound: null, + chainInputMatches: null, + chainToMatches: null, + receiptStatusSuccess: null, + blockIncluded: null, + }; + + // --- Pure check 1: the on-chain payload is this checkpoint and nothing else. + let expectedInput: string | null = null; + try { + expectedInput = anchorInputData(expectedCheckpointHash); + } catch (error) { + problems.push( + `expected checkpoint hash is not a sha256 digest: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + if (receipt.checkpointHash !== expectedCheckpointHash) { + problems.push( + `receipt commits to checkpoint ${receipt.checkpointHash}, expected ${expectedCheckpointHash}`, + ); + } + if (expectedInput !== null) { + if (!sameHex(receipt.inputData, expectedInput)) { + problems.push( + `receipt inputData ${receipt.inputData} is not the 32-byte checkpoint digest ${expectedInput}`, + ); + } else if (receipt.checkpointHash === expectedCheckpointHash) { + checks.inputDataMatchesCheckpoint = true; + } + } + + // --- Pure check 2: declared network and chain id agree. + const chainId = expectedChainId(receipt.network); + checks.chainIdMatchesNetwork = receipt.chainId === chainId; + if (!checks.chainIdMatchesNetwork) { + problems.push( + `receipt chainId ${receipt.chainId} does not match network ${receipt.network} (expected ${chainId})`, + ); + } + + // --- Pure check 3: only a confirmed receipt counts as anchored. + checks.statusConfirmed = receipt.status === 'confirmed'; + if (!checks.statusConfirmed) { + problems.push(`receipt status is '${receipt.status}', not 'confirmed'`); + } + + if (reader === null) { + return { ok: problems.length === 0, checks, problems }; + } + + // --- Chain checks. A reader pointed at another chain proves nothing about + // this receipt, so refuse rather than "verify" against the wrong chain. + if (reader.chainId !== receipt.chainId) { + problems.push( + `chain reader is connected to chain ${reader.chainId} (${reader.network}), receipt anchors to chain ${receipt.chainId} (${receipt.network})`, + ); + return { ok: false, checks, problems }; + } + + let transaction; + try { + transaction = await reader.getTransaction(receipt.transactionHash); + } catch (error) { + problems.push( + `chain reader failed to fetch transaction ${receipt.transactionHash}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { ok: false, checks, problems }; + } + + checks.chainTransactionFound = transaction !== null; + if (transaction === null) { + problems.push( + `transaction ${receipt.transactionHash} does not exist on chain ${receipt.chainId}`, + ); + return { ok: false, checks, problems }; + } + + checks.chainInputMatches = + sameHex(transaction.input, receipt.inputData) && + (expectedInput === null || sameHex(transaction.input, expectedInput)); + if (!checks.chainInputMatches) { + problems.push( + `on-chain calldata ${transaction.input} does not match receipt inputData ${receipt.inputData}`, + ); + } + + checks.chainToMatches = sameHex(transaction.to, receipt.to); + if (!checks.chainToMatches) { + problems.push( + `on-chain destination ${String(transaction.to)} does not match receipt destination ${receipt.to}`, + ); + } + + checks.blockIncluded = + transaction.blockNumber !== null && + (receipt.blockNumber === null || + transaction.blockNumber === receipt.blockNumber) && + (receipt.blockHash === null || + sameHex(transaction.blockHash, receipt.blockHash)); + if (!checks.blockIncluded) { + problems.push( + `transaction ${receipt.transactionHash} is not included in the block the receipt claims ` + + `(receipt block ${String(receipt.blockNumber)}, chain block ${String(transaction.blockNumber)})`, + ); + } + + let chainReceipt; + try { + chainReceipt = await reader.getTransactionReceipt(receipt.transactionHash); + } catch (error) { + problems.push( + `chain reader failed to fetch the receipt for ${receipt.transactionHash}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { ok: false, checks, problems }; + } + + checks.receiptStatusSuccess = chainReceipt?.status === 'success'; + if (chainReceipt === null) { + problems.push( + `transaction ${receipt.transactionHash} has no chain receipt yet`, + ); + } else if (!checks.receiptStatusSuccess) { + problems.push( + `on-chain transaction status is '${chainReceipt.status}', not 'success'`, + ); + } + + return { ok: problems.length === 0, checks, problems }; +} diff --git a/packages/anchor/src/viem-transport.ts b/packages/anchor/src/viem-transport.ts new file mode 100644 index 0000000..90a9f06 --- /dev/null +++ b/packages/anchor/src/viem-transport.ts @@ -0,0 +1,431 @@ +/** + * ALD-020 — the real Base transport, built on viem (SPEC §19 ADR-01: any + * provider that can confirm a chain id, return calldata, and report blocks + * is acceptable; nothing above this class knows which one is configured). + * + * The transaction is the LEDGER §10 "simplest viable anchor": zero value, + * from the dedicated anchor wallet, to a designated project address, with the + * 32-byte checkpoint digest as calldata. + * + * Two hazards this class exists to contain: + * + * 1. **Nonce management** (LEDGER §16 Phase 2: "retry and nonce management"). + * The publisher retries a send that *threw*, so a send must be idempotent. + * Each logical submission (one destination + one checkpoint digest) is + * signed exactly once, at a nonce read once from the pending pool, and + * cached: a retry re-broadcasts the identical signed bytes, which the chain + * can only accept as the same transaction — never as a second paid one. + * Because the hash is derived locally from those bytes, a send whose HTTP + * response is lost can still be reconciled: before reporting a failure the + * transport asks the chain whether that transaction already exists. + * 2. **Credential leakage** (LEDGER §11). The private key is passed to + * `privateKeyToAccount` and then only lives inside viem's local account; + * this class stores no key field and its `toJSON` returns just the public + * chain identity. viem's own error messages, however, print the full RPC + * URL — which for most providers carries an API key in its path or query — + * so every provider failure is re-wrapped as a {@link TransientChainError} + * whose message carries only the secret-free endpoint label, the RPC + * method, and the provider's own error name/code. The original error is not + * attached as `cause`, because inspecting a cause chain would re-expose it. + */ +import { + TransactionNotFoundError, + TransactionReceiptNotFoundError, + createPublicClient, + http, + keccak256, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { base, baseSepolia } from 'viem/chains'; +import type { Chain, Hex, HttpTransport, PrivateKeyAccount, PublicClient } from 'viem'; + +import { AnchorNetworkMismatchError, TransientChainError } from './errors.js'; +import { ANCHOR_CHAIN_IDS, anchorInputData } from './transport.js'; +import type { + AnchorNetwork, + AnchorTransactionInput, + ChainTransaction, + ChainTransactionReceipt, + ChainTransport, + SentAnchorTransaction, +} from './transport.js'; + +const CHAINS: Readonly> = Object.freeze({ + 'base-sepolia': baseSepolia, + 'base-mainnet': base, +}); + +/** How far up a `cause` chain the error classifiers look. */ +const MAX_CAUSE_DEPTH = 8; + +/** Provider error codes worth keeping: numbers, or `ECONNREFUSED`-style. */ +const SAFE_STRING_CODE = /^[A-Z][A-Z0-9_]{1,31}$/u; + +export interface ViemChainTransportOptions { + rpcUrl: string; + /** `0x` + 64 hex secp256k1 anchor wallet key. Never logged. */ + privateKey: string; + network: AnchorNetwork; + /** + * Secret-free label recorded in every receipt. Defaults to the RPC host so + * an API key embedded in the URL path or query is never persisted. + */ + endpointLabel?: string; + /** + * HTTP-level retries viem performs *inside* one logical call. Defaults to + * viem's own default; the publisher's send-retry budget (SPEC §13.4) sits + * above this and is configured separately. Retries are safe here because a + * logical submission is signed once and re-broadcast byte-identically. + */ + retryCount?: number; +} + +/** One logical submission: signed once, re-broadcast as-is on every retry. */ +interface PinnedSubmission { + nonce: number; + serializedTransaction: Hex; + transactionHash: Hex; +} + +/** + * Host only, so userinfo (`https://user:key@host/...`), the path, and the + * query — all common places to carry an API key — are dropped. + */ +function labelFor(rpcUrl: string): string { + try { + return new URL(rpcUrl).host; + } catch { + return 'rpc'; + } +} + +/** Walk `error` and its causes, bounded, so a cycle cannot spin. */ +function* causeChain(error: unknown): Generator { + let current: unknown = error; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (current === undefined || current === null) { + return; + } + yield current; + current = (current as { cause?: unknown }).cause; + } +} + +/** + * `null`-not-`throw` classification, by viem's error *classes* only. + * + * Never by message substring: a provider that answers "API key could not be + * found" (or an HTTP 404 body) would otherwise be reported to the verifier as + * the affirmative chain fact "this transaction does not exist". + */ +function isNotFound(error: unknown): boolean { + for (const link of causeChain(error)) { + if ( + link instanceof TransactionNotFoundError || + link instanceof TransactionReceiptNotFoundError + ) { + return true; + } + } + return false; +} + +/** First safe provider code found on the error chain, if any. */ +function providerCode(error: unknown): string | undefined { + for (const link of causeChain(error)) { + const code: unknown = (link as { code?: unknown }).code; + if (typeof code === 'number' && Number.isFinite(code)) { + return String(code); + } + if (typeof code === 'string' && SAFE_STRING_CODE.test(code)) { + return code; + } + const status: unknown = (link as { status?: unknown }).status; + if (typeof status === 'number' && Number.isFinite(status)) { + return `HTTP ${String(status)}`; + } + } + return undefined; +} + +/** + * Redacted description of a provider failure: the error's class name plus its + * numeric/symbolic code, and nothing the provider wrote as free text. + */ +function describeFailure(error: unknown): string { + const name = error instanceof Error ? error.name : typeof error; + const code = providerCode(error); + return code === undefined ? name : `${name}, ${code}`; +} + +export class ViemChainTransport implements ChainTransport { + readonly chainId: number; + readonly network: AnchorNetwork; + readonly endpointLabel: string; + /** Anchor wallet address; the public half of the key (LEDGER §11). */ + readonly address: Hex; + + /** One pinned submission per `(destination, calldata)` — see the header. */ + private readonly pinned = new Map>(); + + private constructor( + private readonly publicClient: PublicClient, + private readonly account: PrivateKeyAccount, + private readonly chain: Chain, + network: AnchorNetwork, + endpointLabel: string, + ) { + this.network = network; + this.chainId = chain.id; + this.endpointLabel = endpointLabel; + this.address = account.address; + } + + static create(options: ViemChainTransportOptions): ViemChainTransport { + const chain = CHAINS[options.network]; + if (chain.id !== ANCHOR_CHAIN_IDS[options.network]) { + throw new AnchorNetworkMismatchError( + options.network, + chain.id, + ANCHOR_CHAIN_IDS[options.network], + ); + } + + const account = privateKeyToAccount(options.privateKey as Hex); + const publicClient = createPublicClient({ + chain, + transport: http( + options.rpcUrl, + options.retryCount === undefined + ? {} + : { retryCount: options.retryCount }, + ), + }); + + return new ViemChainTransport( + publicClient, + account, + chain, + options.network, + options.endpointLabel ?? labelFor(options.rpcUrl), + ); + } + + /** Public identity only — deliberately excludes any key material. */ + toJSON(): { + network: AnchorNetwork; + chainId: number; + endpointLabel: string; + address: string; + } { + return { + network: this.network, + chainId: this.chainId, + endpointLabel: this.endpointLabel, + address: this.address, + }; + } + + /** + * Broadcast the anchor transaction for `input`, or report the submission + * that already exists for it. + * + * The publisher's contract (see `transport.ts`) is that a throw means + * nothing was submitted. This method keeps that true even when a send fails + * *after* the node accepted the bytes: the transaction is signed at a pinned + * nonce, so its hash is known locally, and a failed send is only reported as + * a failure once the chain says no such transaction exists. + */ + async sendAnchorTransaction( + input: AnchorTransactionInput, + ): Promise { + const inputData = anchorInputData(input.checkpointHash); + const submission = await this.pinnedSubmission(inputData, input.to); + + try { + await this.publicClient.sendRawTransaction({ + serializedTransaction: submission.serializedTransaction, + }); + } catch (error) { + if (!(await this.transactionExists(submission.transactionHash))) { + throw this.transient('eth_sendRawTransaction', error); + } + } + + return { + transactionHash: submission.transactionHash, + from: this.address, + to: input.to, + inputData, + }; + } + + async getTransactionReceipt( + transactionHash: string, + ): Promise { + try { + const receipt = await this.publicClient.getTransactionReceipt({ + hash: transactionHash as Hex, + }); + return { + status: receipt.status === 'success' ? 'success' : 'reverted', + blockNumber: Number(receipt.blockNumber), + blockHash: receipt.blockHash, + }; + } catch (error) { + if (isNotFound(error)) { + return null; + } + throw this.transient('eth_getTransactionReceipt', error); + } + } + + async latestBlockNumber(): Promise { + try { + return Number(await this.publicClient.getBlockNumber({ cacheTime: 0 })); + } catch (error) { + throw this.transient('eth_blockNumber', error); + } + } + + async getTransaction( + transactionHash: string, + ): Promise { + try { + const transaction = await this.publicClient.getTransaction({ + hash: transactionHash as Hex, + }); + return { + hash: transaction.hash, + from: transaction.from, + to: transaction.to ?? null, + input: transaction.input, + blockNumber: + transaction.blockNumber === null + ? null + : Number(transaction.blockNumber), + blockHash: transaction.blockHash ?? null, + }; + } catch (error) { + if (isNotFound(error)) { + return null; + } + throw this.transient('eth_getTransactionByHash', error); + } + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Every provider failure leaves this class as a redacted + * {@link TransientChainError}: endpoint label + RPC method + the provider's + * own error name and code, with no URL, no userinfo, no query string, and no + * `cause` to inspect (LEDGER §11). + */ + private transient(method: string, error: unknown): TransientChainError { + return new TransientChainError( + `${this.endpointLabel} ${method} failed (${describeFailure(error)})`, + ); + } + + private async call(method: string, operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + throw this.transient(method, error); + } + } + + /** True only when the chain affirmatively knows the transaction. */ + private async transactionExists(transactionHash: Hex): Promise { + try { + return (await this.getTransaction(transactionHash)) !== null; + } catch { + // The reconciliation lookup itself failed; the caller reports the send + // failure, which the publisher retries with these same pinned bytes. + return false; + } + } + + /** + * The signed transaction for this `(destination, calldata)` pair, signed at + * most once. A concurrent or retried send awaits the same promise, so one + * logical submission can never occupy two nonces. + */ + private async pinnedSubmission( + inputData: string, + to: string, + ): Promise { + const key = `${to.toLowerCase()}:${inputData.toLowerCase()}`; + const started = this.pinned.get(key); + if (started !== undefined) { + return await started; + } + + const signing = this.signAnchorTransaction(inputData, to); + this.pinned.set(key, signing); + try { + return await signing; + } catch (error) { + // Nothing was broadcast, so the nonce is not spoken for: let the next + // attempt read a fresh one instead of caching the failure. + this.pinned.delete(key); + throw error; + } + } + + /** + * Read the pending nonce and the fee parameters once, then sign locally. + * Every RPC call here happens strictly before any broadcast, so wrapping + * their failures as transient keeps the publisher's retry contract honest. + */ + private async signAnchorTransaction( + inputData: string, + to: string, + ): Promise { + const nonce = await this.call('eth_getTransactionCount', async () => + this.publicClient.getTransactionCount({ + address: this.address, + blockTag: 'pending', + }), + ); + const fees = await this.call('eth_maxPriorityFeePerGas', async () => + this.publicClient.estimateFeesPerGas(), + ); + const gas = await this.call('eth_estimateGas', async () => + this.publicClient.estimateGas({ + // The *address*, not the local account: a local account would make + // viem prepare (and re-read the nonce for) the request it is only + // being asked to price. + account: this.address, + to: to as Hex, + value: 0n, + data: inputData as Hex, + }), + ); + + const serializedTransaction = await this.call( + 'sign-transaction', + async () => + this.account.signTransaction({ + chainId: this.chain.id, + to: to as Hex, + value: 0n, + data: inputData as Hex, + nonce, + gas, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + type: 'eip1559', + }), + ); + + return { + nonce, + serializedTransaction, + transactionHash: keccak256(serializedTransaction), + }; + } +} diff --git a/packages/anchor/tsconfig.json b/packages/anchor/tsconfig.json new file mode 100644 index 0000000..8a56f12 --- /dev/null +++ b/packages/anchor/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../types" }, + { "path": "../hashing" }, + { "path": "../merkle" }, + { "path": "../evidence" }, + { "path": "../lifecycle" } + ] +} diff --git a/packages/checkpoint/__tests__/checkpoint-service.test.ts b/packages/checkpoint/__tests__/checkpoint-service.test.ts new file mode 100644 index 0000000..39369d3 --- /dev/null +++ b/packages/checkpoint/__tests__/checkpoint-service.test.ts @@ -0,0 +1,587 @@ +import { UnknownRunError } from '@ald/evidence'; +import { hashCanonical, hashRunId, omitFields, verifyHashSignature } from '@ald/hashing'; +import { + EMPTY_MERKLE_ROOT, + merkleLeafHashes, + merkleRoot, + verifyConsistency, + verifyInclusion, +} from '@ald/merkle'; +import { + CheckpointManifestSchema, + ConsistencyProofSchema, + GENESIS_HASH, + HASH_DOMAINS, + InclusionProofSchema, + MANIFEST_SIGNATURE_FIELDS, + type CheckpointManifest, + type EventStream, +} from '@ald/types'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { EvidenceCheckpointService } from '../src/checkpoint-service.js'; +import { + CheckpointIntegrityError, + CheckpointNotFoundError, + CheckpointProofRangeError, +} from '../src/errors.js'; +import { referenceFor } from '../src/trees.js'; +import { + RewritingEvidenceStore, + cleanupTemporaryDirectories, + commitTurns, + createContext, + hash, + type CheckpointContext, +} from './support.js'; + +afterEach(cleanupTemporaryDirectories); + +const EMPTY_TREE = { + treeSize: 0, + merkleRoot: EMPTY_MERKLE_ROOT, + lastEntryHash: GENESIS_HASH, +}; + +function witnessKey(context: CheckpointContext): string { + const witness = context.signers + .publicKeys() + .find((signer) => signer.domain === 'witness'); + if (witness === undefined) { + throw new Error('the run has no witness signer'); + } + return witness.publicKey; +} + +function recomputeHash(manifest: CheckpointManifest): string { + return hashCanonical( + HASH_DOMAINS.checkpoint, + omitFields(manifest, MANIFEST_SIGNATURE_FIELDS), + ); +} + +describe('EvidenceCheckpointService.createCheckpoint', () => { + it('writes checkpoint 0 for an empty run with three empty mandatory trees', async () => { + const context = await createContext(); + + const manifest = await context.service.createCheckpoint( + context.runId, + 'run-initialized', + ); + + expect(manifest.checkpointSequence).toBe(0); + expect(manifest.previousCheckpointHash).toBe(GENESIS_HASH); + expect(manifest.babyA).toEqual(EMPTY_TREE); + expect(manifest.babyB).toEqual(EMPTY_TREE); + expect(manifest.channel).toEqual(EMPTY_TREE); + expect(manifest.auxiliaryTrees).toEqual({}); + expect(manifest.runIdHash).toBe(hashRunId(context.runId)); + expect(manifest.reason).toBe('run-initialized'); + expect(manifest.witnessKeyId).toBe( + context.signers.signer('witness').keyId, + ); + expect(manifest.softwareCommit).toBe('git:test-commit'); + expect(CheckpointManifestSchema.parse(manifest)).toEqual(manifest); + expect(context.writer.readCheckpoints(context.runId)).toEqual([manifest]); + + context.close(); + }); + + it('binds the run configuration and prompt bundle hashes', async () => { + const context = await createContext({ promptBundleHash: hash('7') }); + + const manifest = await context.service.createCheckpoint( + context.runId, + 'run-initialized', + ); + + const metadata = context.writer.readRunMetadata(context.runId); + expect(manifest.runConfigurationHash).toBe(metadata?.configurationHash); + expect(manifest.promptBundleHash).toBe(hash('7')); + + context.close(); + }); + + it('normalizes an unprefixed configuration prompt-bundle hash', async () => { + const bare = 'ab'.repeat(32).toUpperCase(); + const context = await createContext({ promptBundleHash: bare }); + + const manifest = await context.service.createCheckpoint( + context.runId, + 'run-initialized', + ); + + expect(manifest.promptBundleHash).toBe(`sha256:${'ab'.repeat(32)}`); + expect(CheckpointManifestSchema.safeParse(manifest).success).toBe(true); + + context.close(); + }); + + it('carries a witness signature and a reproducible checkpoint hash', async () => { + const context = await createContext(); + await commitTurns(context, 3); + + const manifest = await context.service.createCheckpoint( + context.runId, + 'policy-checkpoint', + ); + + expect(recomputeHash(manifest)).toBe(manifest.checkpointHash); + expect( + verifyHashSignature( + manifest.checkpointHash, + manifest.witnessSignature, + witnessKey(context), + ), + ).toBe(true); + expect( + verifyHashSignature( + recomputeHash({ ...manifest, softwareCommit: 'git:other' }), + manifest.witnessSignature, + witnessKey(context), + ), + ).toBe(false); + + context.close(); + }); + + it('commits every tree the Evidence Store holds, with independently recomputed roots', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 5); + + const manifest = await context.service.createCheckpoint( + context.runId, + 'event-interval', + ); + + expect(manifest.checkpointSequence).toBe(1); + const streams: EventStream[] = [ + 'baby-a-ledger', + 'baby-b-ledger', + 'channel', + 'turns', + ]; + for (const stream of streams) { + const events = context.writer.readEvents(context.runId, stream); + expect(events).toHaveLength(5); + const expected = { + treeSize: 5, + merkleRoot: merkleRoot(merkleLeafHashes(events)), + lastEntryHash: events[4]?.entryHash, + }; + expect(referenceFor(manifest, stream)).toEqual(expected); + } + expect(Object.keys(manifest.auxiliaryTrees)).toEqual(['turns']); + expect(manifest.auxiliaryTrees.affect).toBeUndefined(); + expect(manifest.auxiliaryTrees.audit).toBeUndefined(); + + context.close(); + }); + + it('chains each checkpoint to the previous checkpoint hash', async () => { + const context = await createContext(); + + const first = await context.service.createCheckpoint( + context.runId, + 'run-initialized', + ); + await commitTurns(context, 2); + const second = await context.service.createCheckpoint( + context.runId, + 'pause', + ); + await commitTurns(context, 2, 3); + const third = await context.service.createCheckpoint( + context.runId, + 'run-sealed', + ); + + expect(second.previousCheckpointHash).toBe(first.checkpointHash); + expect(third.previousCheckpointHash).toBe(second.checkpointHash); + expect([ + first.checkpointSequence, + second.checkpointSequence, + third.checkpointSequence, + ]).toEqual([0, 1, 2]); + expect(third.channel.treeSize).toBeGreaterThan(second.channel.treeSize); + + context.close(); + }); + + it('refuses a manifest whose auxiliary trees are not the trees the store holds', async () => { + const context = await createContext(); + await commitTurns(context, 2); + const manifest = await context.service.createCheckpoint( + context.runId, + 'run-initialized', + ); + + // An extra tree the store does not hold. + expect(() => + context.service.verifyManifestTrees(context.runId, { + ...manifest, + auxiliaryTrees: { + ...manifest.auxiliaryTrees, + affect: { ...EMPTY_TREE, treeSize: 1, lastEntryHash: hash('9') }, + }, + }), + ).toThrow(CheckpointIntegrityError); + + // A tree the store holds, missing from the manifest. + expect(() => + context.service.verifyManifestTrees(context.runId, { + ...manifest, + auxiliaryTrees: {}, + }), + ).toThrow(CheckpointIntegrityError); + + // A mandatory tree with a rewritten root. + expect(() => + context.service.verifyManifestTrees(context.runId, { + ...manifest, + channel: { ...manifest.channel, merkleRoot: hash('a') }, + }), + ).toThrow(CheckpointIntegrityError); + + expect(() => + context.service.verifyManifestTrees(context.runId, manifest), + ).not.toThrow(); + + context.close(); + }); + + it('refuses to build a checkpoint over a rewritten or truncated prefix', async () => { + const context = await createContext(); + await commitTurns(context, 3); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 1, 4); + + const rewritten = new EvidenceCheckpointService({ + evidence: new RewritingEvidenceStore(context.writer, 'baby-a-ledger', (events) => + events.map((event, index) => + index === 1 ? { ...event, entryHash: hash('b') } : event, + ), + ), + signers: context.signers, + clock: context.clock, + softwareCommit: 'git:test-commit', + }); + await expect( + rewritten.createCheckpoint(context.runId, 'pause'), + ).rejects.toThrow(CheckpointIntegrityError); + + const truncated = new EvidenceCheckpointService({ + evidence: new RewritingEvidenceStore(context.writer, 'channel', (events) => + events.slice(0, 2), + ), + signers: context.signers, + clock: context.clock, + softwareCommit: 'git:test-commit', + }); + await expect( + truncated.createCheckpoint(context.runId, 'pause'), + ).rejects.toThrow(CheckpointIntegrityError); + + // Nothing was written by either failed attempt. + expect(context.writer.readCheckpoints(context.runId)).toHaveLength(1); + // The honest service still checkpoints the grown trees. + const next = await context.service.createCheckpoint(context.runId, 'pause'); + expect(next.checkpointSequence).toBe(1); + + context.close(); + }); + + it('refuses a checkpoint over a stream whose sequences are not contiguous', async () => { + const context = await createContext(); + await commitTurns(context, 3); + + const renumbered = new EvidenceCheckpointService({ + evidence: new RewritingEvidenceStore(context.writer, 'turns', (events) => + events.map((event) => ({ ...event, sequence: event.sequence + 1 })), + ), + signers: context.signers, + clock: context.clock, + softwareCommit: 'git:test-commit', + }); + + await expect( + renumbered.createCheckpoint(context.runId, 'run-initialized'), + ).rejects.toThrow(CheckpointIntegrityError); + + context.close(); + }); + + it('refuses an unregistered run', async () => { + const context = await createContext(); + + await expect( + context.service.createCheckpoint('run-missing', 'run-initialized'), + ).rejects.toThrow(UnknownRunError); + + context.close(); + }); +}); + +describe('EvidenceCheckpointService.createCheckpointIfChanged', () => { + it('skips an interval trigger when no tree grew', async () => { + const context = await createContext(); + await commitTurns(context, 2); + const first = await context.service.createCheckpoint( + context.runId, + 'run-initialized', + ); + + const skipped = await context.service.createCheckpointIfChanged( + context.runId, + 'event-interval', + ); + + expect(skipped.created).toBe(false); + expect(skipped.manifest).toEqual(first); + expect(context.writer.readCheckpoints(context.runId)).toHaveLength(1); + + const timeSkipped = await context.service.createCheckpointIfChanged( + context.runId, + 'time-interval', + ); + expect(timeSkipped.created).toBe(false); + expect(context.writer.readCheckpoints(context.runId)).toHaveLength(1); + + context.close(); + }); + + it('creates an interval checkpoint once a tree grows', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 1); + + const created = await context.service.createCheckpointIfChanged( + context.runId, + 'event-interval', + ); + + expect(created.created).toBe(true); + expect(created.manifest.checkpointSequence).toBe(1); + expect(context.writer.readCheckpoints(context.runId)).toHaveLength(2); + + context.close(); + }); + + it('always creates for a lifecycle reason, even with no new events', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + + for (const reason of [ + 'pause', + 'intervention', + 'recovery', + 'policy-checkpoint', + 'run-sealed', + 'run-aborted', + ] as const) { + const result = await context.service.createCheckpointIfChanged( + context.runId, + reason, + ); + expect(result.created).toBe(true); + expect(result.manifest.reason).toBe(reason); + } + expect(context.writer.readCheckpoints(context.runId)).toHaveLength(7); + + context.close(); + }); +}); + +describe('EvidenceCheckpointService.inclusionProof', () => { + it('proves the first and last event of every mandatory tree', async () => { + const context = await createContext(); + await commitTurns(context, 6); + const manifest = await context.service.createCheckpoint( + context.runId, + 'run-sealed', + ); + + for (const stream of ['baby-a-ledger', 'baby-b-ledger', 'channel'] as const) { + for (const sequence of [1, 6]) { + const proof = context.service.inclusionProof( + context.runId, + stream, + sequence, + manifest.checkpointSequence, + ); + expect(InclusionProofSchema.parse(proof)).toEqual(proof); + expect(proof.leafIndex).toBe(sequence - 1); + expect(proof.treeSize).toBe(6); + expect(proof.root).toBe(referenceFor(manifest, stream).merkleRoot); + expect(verifyInclusion(proof)).toBe(true); + expect( + verifyInclusion({ ...proof, leafHash: hash('f') }), + ).toBe(false); + } + } + + context.close(); + }); + + it('refuses a sequence outside the committed tree and an unknown checkpoint', async () => { + const context = await createContext(); + await commitTurns(context, 2); + const manifest = await context.service.createCheckpoint( + context.runId, + 'run-sealed', + ); + + expect(() => + context.service.inclusionProof(context.runId, 'channel', 3, 0), + ).toThrow(CheckpointProofRangeError); + expect(() => + context.service.inclusionProof(context.runId, 'affect', 1, 0), + ).toThrow(CheckpointProofRangeError); + expect(() => + context.service.inclusionProof( + context.runId, + 'channel', + 1, + manifest.checkpointSequence + 5, + ), + ).toThrow(CheckpointNotFoundError); + expect(() => + context.service.inclusionProof(context.runId, 'intervention', 1, 0), + ).toThrow(CheckpointProofRangeError); + + context.close(); + }); +}); + +describe('EvidenceCheckpointService.consistencyProof', () => { + it('verifies every consecutive checkpoint transition against the stored roots', async () => { + const context = await createContext(); + const zero = await context.service.createCheckpoint( + context.runId, + 'run-initialized', + ); + await commitTurns(context, 3); + const one = await context.service.createCheckpoint(context.runId, 'pause'); + await commitTurns(context, 4, 4); + const two = await context.service.createCheckpoint( + context.runId, + 'run-sealed', + ); + + const first = context.service.consistencyProof( + context.runId, + 'channel', + zero.checkpointSequence, + one.checkpointSequence, + ); + expect(ConsistencyProofSchema.parse(first)).toEqual(first); + expect(first.fromSize).toBe(0); + expect(first.toSize).toBe(3); + expect(first.fromRoot).toBe(zero.channel.merkleRoot); + expect(first.toRoot).toBe(one.channel.merkleRoot); + expect(verifyConsistency(first)).toBe(true); + + const second = context.service.consistencyProof( + context.runId, + 'channel', + one.checkpointSequence, + two.checkpointSequence, + ); + expect(second.fromSize).toBe(3); + expect(second.toSize).toBe(7); + expect(second.fromRoot).toBe(one.channel.merkleRoot); + expect(second.toRoot).toBe(two.channel.merkleRoot); + expect(verifyConsistency(second)).toBe(true); + expect(verifyConsistency({ ...second, path: [] })).toBe(false); + + const skip = context.service.consistencyProof( + context.runId, + 'turns', + zero.checkpointSequence, + two.checkpointSequence, + ); + expect(verifyConsistency(skip)).toBe(true); + expect(skip.treeName).toBe('turns'); + + context.close(); + }); + + it('detects a rewritten prefix instead of re-rooting it', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 3); + await context.service.createCheckpoint(context.runId, 'pause'); + await commitTurns(context, 3, 4); + await context.service.createCheckpoint(context.runId, 'run-sealed'); + + // Honest reads still verify. + expect( + verifyConsistency( + context.service.consistencyProof(context.runId, 'channel', 1, 2), + ), + ).toBe(true); + + const tampered = new EvidenceCheckpointService({ + evidence: new RewritingEvidenceStore( + context.writer, + 'channel', + (events) => + events.map((event, index) => + index === 0 ? { ...event, entryHash: hash('c') } : event, + ), + ), + signers: context.signers, + clock: context.clock, + softwareCommit: 'git:test-commit', + }); + + expect(() => + tampered.consistencyProof(context.runId, 'channel', 1, 2), + ).toThrow(CheckpointIntegrityError); + expect(() => + tampered.inclusionProof(context.runId, 'channel', 1, 2), + ).toThrow(CheckpointIntegrityError); + // Untouched streams still prove out through the same tampering store. + expect(() => + tampered.consistencyProof(context.runId, 'baby-a-ledger', 1, 2), + ).not.toThrow(); + + context.close(); + }); + + it('detects a truncated prefix', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 4); + await context.service.createCheckpoint(context.runId, 'run-sealed'); + + const truncating = new EvidenceCheckpointService({ + evidence: new RewritingEvidenceStore(context.writer, 'channel', (events) => + events.slice(0, -1), + ), + signers: context.signers, + clock: context.clock, + softwareCommit: 'git:test-commit', + }); + + expect(() => + truncating.consistencyProof(context.runId, 'channel', 0, 1), + ).toThrow(CheckpointIntegrityError); + + context.close(); + }); + + it('refuses a descending checkpoint pair', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 1); + await context.service.createCheckpoint(context.runId, 'run-sealed'); + + expect(() => + context.service.consistencyProof(context.runId, 'channel', 1, 0), + ).toThrow(CheckpointProofRangeError); + + context.close(); + }); +}); diff --git a/packages/checkpoint/__tests__/proof-files-verifier.test.ts b/packages/checkpoint/__tests__/proof-files-verifier.test.ts new file mode 100644 index 0000000..c8e98fa --- /dev/null +++ b/packages/checkpoint/__tests__/proof-files-verifier.test.ts @@ -0,0 +1,113 @@ +/** + * Regression for the finding that `writeProofFiles` could emit a consistency + * proof `@ald/verifier` itself rejected (docs/evidence-bundle-format.md §6; + * LEDGER-INTEGRITY-DESIGN.md §8, §17). The verifier now reads an auxiliary + * tree absent from the `from` checkpoint as the empty tree + * (`packages/verifier/src/checkpoints.ts` `emptyCheckpointTree`, exercised by + * `packages/verifier/__tests__/empty-tree-consistency.test.ts`), so this test + * does not change `writeProofFiles` — it closes the gap the finding actually + * asked for: an end-to-end check that runs the real, independent + * `@ald/verifier` over a bundle this package produced, rather than only + * re-verifying proofs locally the way `proof-files.test.ts` does. + * + * LEDGER §17 requires the verifier to "accept an unchanged run bundle"; this + * is the cross-package test that pins that acceptance for the exact shape the + * finding reproduced: checkpoint 0 created before any turn (so `turns` is + * legitimately undeclared there), three turns each followed by an + * event-interval checkpoint, and a run-sealed checkpoint. + */ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { exportRunBundle } from '@ald/evidence'; +import { loadLearnerContract, promptBundleHash } from '@ald/learners'; +import { verifyBundle } from '@ald/verifier'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + cleanupTemporaryDirectories, + commitTurns, + createContext, + temporaryDirectory, +} from './support.js'; + +afterEach(cleanupTemporaryDirectories); + +describe('writeProofFiles against the independent @ald/verifier', () => { + it('produces a bundle whose proofs verify clean, including the checkpoint-0 auxiliary-tree gap', async () => { + // Mirrors nursery-runtime.ts#createRun: the config actually used to + // register the run binds `promptBundleHash` to the real learner contract + // bundle, not the genesis placeholder, so the exported run-config.json + // and the learnerContracts written to prompts/ agree. + const contract = loadLearnerContract('no-learning'); + const context = await createContext({ + babyA: { track: 'no-learning' }, + babyB: { track: 'no-learning' }, + learningSignal: 'none', + promptBundleHash: promptBundleHash([contract]), + }); + + // Checkpoint 0, created at run initialization before any turn exists: + // per bundle format §6 it legitimately omits `turns` (and `affect`, + // `audit`) because those streams hold no events yet. + await context.service.createCheckpoint(context.runId, 'run-initialized'); + expect( + context.writer.readCheckpoints(context.runId)[0]?.auxiliaryTrees, + ).toEqual({}); + + for (let turn = 1; turn <= 3; turn += 1) { + await commitTurns(context, 1, turn); + await context.service.createCheckpoint(context.runId, 'event-interval'); + } + await context.service.createCheckpoint(context.runId, 'run-sealed'); + + const bundleDir = join(await temporaryDirectory(), 'bundle'); + await exportRunBundle(context.writer, context.runId, bundleDir, { + softwareCommit: 'git:test-commit', + learnerContracts: [ + { track: contract.track, version: contract.version, text: contract.text }, + ], + }); + + const proofCounts = await context.service.writeProofFiles( + context.runId, + bundleDir, + ); + expect(proofCounts.inclusionFiles).toBeGreaterThan(0); + expect(proofCounts.consistencyFiles).toBeGreaterThan(0); + // The exact files the finding reproduced: a consistency proof from + // checkpoint 0 (which does not declare `turns`) to checkpoint 1 (which + // does). + const consistencyFiles = await readdir( + join(bundleDir, 'proofs', 'consistency'), + ); + expect(consistencyFiles).toContain('turns-0-1.json'); + + const report = await verifyBundle(bundleDir, { + verifierVersion: 'test', + now: () => new Date().toISOString(), + allowUnanchored: true, + writeReport: false, + }); + + // Assert the proof- and checkpoint-rebuild checks the finding is about. + // Other checks may legitimately fail for reasons unrelated to proofs + // (for example an unattached anchor chain reader); those are out of + // scope for this regression and are not asserted here. + expect(report.checks.inclusionProofsValid).toBe(true); + expect(report.checks.consistencyProofsValid).toBe(true); + expect(report.checks.merkleRootsRebuilt).toBe(true); + expect(report.checks.checkpointHashesRebuilt).toBe(true); + expect( + report.gaps.filter( + (gap) => + gap.includes('consistency-proof') || gap.includes('inclusion-proof'), + ), + ).toEqual([]); + // With allowUnanchored, the only remaining gaps are the expected + // unanchored-tail notes; the bundle is otherwise clean, so exitCode is 0. + expect(report.exitCode).toBe(0); + + context.close(); + }); +}); diff --git a/packages/checkpoint/__tests__/proof-files.test.ts b/packages/checkpoint/__tests__/proof-files.test.ts new file mode 100644 index 0000000..f0faf7b --- /dev/null +++ b/packages/checkpoint/__tests__/proof-files.test.ts @@ -0,0 +1,286 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { exportRunBundle } from '@ald/evidence'; +import { parseCanonicalJson } from '@ald/hashing'; +import { verifyConsistency, verifyInclusion } from '@ald/merkle'; +import { + ConsistencyProofSchema, + InclusionProofSchema, + type ConsistencyProof, + type InclusionProof, +} from '@ald/types'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + CheckpointNotFoundError, + InvalidCheckpointRequestError, +} from '../src/errors.js'; +import { + cleanupTemporaryDirectories, + commitTurns, + createContext, + temporaryDirectory, + type CheckpointContext, +} from './support.js'; + +afterEach(cleanupTemporaryDirectories); + +/** Three checkpoints over trees of size 0, 3, and 5. */ +async function threeCheckpoints(): Promise { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 3); + await context.service.createCheckpoint(context.runId, 'pause'); + await commitTurns(context, 2, 4); + await context.service.createCheckpoint(context.runId, 'run-sealed'); + return context; +} + +async function readProof( + directory: string, + file: string, +): Promise<{ text: string; value: T }> { + const text = await readFile(join(directory, file), 'utf8'); + expect(text.endsWith('\n')).toBe(true); + return { text, value: parseCanonicalJson(text.slice(0, -1)) }; +} + +describe('EvidenceCheckpointService.writeProofFiles', () => { + it('writes verifiable inclusion and consistency proofs under the documented names', async () => { + const context = await threeCheckpoints(); + const bundleDir = await temporaryDirectory(); + + const counts = await context.service.writeProofFiles( + context.runId, + bundleDir, + ); + + const inclusionDir = join(bundleDir, 'proofs', 'inclusion'); + const consistencyDir = join(bundleDir, 'proofs', 'consistency'); + const inclusionFiles = (await readdir(inclusionDir)).sort(); + const consistencyFiles = (await readdir(consistencyDir)).sort(); + + // Four non-empty trees (babyA, babyB, channel, turns) at sizes 3 and 5. + expect(counts).toEqual({ inclusionFiles: 32, consistencyFiles: 12 }); + expect(inclusionFiles).toHaveLength(counts.inclusionFiles); + expect(consistencyFiles).toHaveLength(counts.consistencyFiles); + expect(inclusionFiles).toContain('channel-1-at-1.json'); + expect(inclusionFiles).toContain('channel-3-at-1.json'); + expect(inclusionFiles).toContain('turns-5-at-2.json'); + expect(inclusionFiles).toContain('babyB-1-at-2.json'); + expect(consistencyFiles).toEqual([ + 'babyA-0-1.json', + 'babyA-0-2.json', + 'babyA-1-2.json', + 'babyB-0-1.json', + 'babyB-0-2.json', + 'babyB-1-2.json', + 'channel-0-1.json', + 'channel-0-2.json', + 'channel-1-2.json', + 'turns-0-1.json', + 'turns-0-2.json', + 'turns-1-2.json', + ]); + // Nothing is written for a stream with no events. + expect( + inclusionFiles.filter((file) => file.startsWith('affect')), + ).toEqual([]); + expect( + consistencyFiles.filter((file) => file.startsWith('audit')), + ).toEqual([]); + // Checkpoint 0 committed empty trees, so it has no inclusion proofs. + expect(inclusionFiles.filter((file) => file.endsWith('-at-0.json'))).toEqual( + [], + ); + + const checkpoints = context.writer.readCheckpoints(context.runId); + for (const file of inclusionFiles) { + const { value } = await readProof(inclusionDir, file); + expect(InclusionProofSchema.parse(value)).toEqual(value); + expect(verifyInclusion(value)).toBe(true); + const manifest = checkpoints[value.checkpointSequence]; + expect(manifest).toBeDefined(); + expect(file).toBe( + `${value.treeName}-${String(value.sequence)}-at-${String(value.checkpointSequence)}.json`, + ); + } + + for (const file of consistencyFiles) { + const { value } = await readProof( + consistencyDir, + file, + ); + expect(ConsistencyProofSchema.parse(value)).toEqual(value); + expect(verifyConsistency(value)).toBe(true); + expect(file).toBe( + `${value.treeName}-${String(value.fromCheckpointSequence)}-${String(value.toCheckpointSequence)}.json`, + ); + } + + context.close(); + }); + + it('is byte-identical when run twice with no intervening writes', async () => { + const context = await threeCheckpoints(); + const bundleDir = await temporaryDirectory(); + + await context.service.writeProofFiles(context.runId, bundleDir); + const first = await readProof( + join(bundleDir, 'proofs', 'inclusion'), + 'channel-3-at-1.json', + ); + await context.service.writeProofFiles(context.runId, bundleDir); + const second = await readProof( + join(bundleDir, 'proofs', 'inclusion'), + 'channel-3-at-1.json', + ); + + expect(second.text).toBe(first.text); + + context.close(); + }); + + it('caps inclusion proofs at the first and last sequence for a large tree', async () => { + const context = await threeCheckpoints(); + const bundleDir = await temporaryDirectory(); + + const counts = await context.service.writeProofFiles( + context.runId, + bundleDir, + { maxInclusionPerTree: 2 }, + ); + + const inclusionFiles = ( + await readdir(join(bundleDir, 'proofs', 'inclusion')) + ).sort(); + // Trees of size 3 and 5 both exceed the cap: two proofs each, four trees. + expect(counts.inclusionFiles).toBe(16); + expect(inclusionFiles).toContain('channel-1-at-1.json'); + expect(inclusionFiles).toContain('channel-3-at-1.json'); + expect(inclusionFiles).not.toContain('channel-2-at-1.json'); + expect(inclusionFiles).toContain('channel-5-at-2.json'); + + context.close(); + }); + + it('writes no consistency file for a checkpoint pair in which the tree did not grow', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 2); + await context.service.createCheckpoint(context.runId, 'pause'); + // No new events: the trees are identical across checkpoints 1 and 2. + await context.service.createCheckpoint(context.runId, 'run-sealed'); + const bundleDir = await temporaryDirectory(); + + const counts = await context.service.writeProofFiles( + context.runId, + bundleDir, + ); + + const consistencyFiles = ( + await readdir(join(bundleDir, 'proofs', 'consistency')) + ).sort(); + expect(consistencyFiles).toEqual([ + 'babyA-0-1.json', + 'babyA-0-2.json', + 'babyB-0-1.json', + 'babyB-0-2.json', + 'channel-0-1.json', + 'channel-0-2.json', + 'turns-0-1.json', + 'turns-0-2.json', + ]); + expect(counts.consistencyFiles).toBe(consistencyFiles.length); + + context.close(); + }); + + it('writes only the single consecutive pair when a run has two checkpoints', async () => { + const context = await createContext(); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await commitTurns(context, 2); + await context.service.createCheckpoint(context.runId, 'run-sealed'); + const bundleDir = await temporaryDirectory(); + + await context.service.writeProofFiles(context.runId, bundleDir); + + const consistencyFiles = ( + await readdir(join(bundleDir, 'proofs', 'consistency')) + ).sort(); + expect(consistencyFiles).toEqual([ + 'babyA-0-1.json', + 'babyB-0-1.json', + 'channel-0-1.json', + 'turns-0-1.json', + ]); + + context.close(); + }); + + it('fills the proof directories the bundle exporter leaves empty', async () => { + const context = await threeCheckpoints(); + const bundleDir = await temporaryDirectory(); + + const runManifest = await exportRunBundle( + context.writer, + context.runId, + join(bundleDir, 'bundle'), + { + softwareCommit: 'git:test-commit', + learnerContracts: [ + { + track: context.config.babyA.track, + version: 'v1', + text: 'test learner contract', + }, + ], + }, + ); + const target = join(bundleDir, 'bundle'); + expect(await readdir(join(target, 'proofs', 'inclusion'))).toEqual([]); + + const counts = await context.service.writeProofFiles( + context.runId, + target, + ); + + expect(counts.inclusionFiles).toBeGreaterThan(0); + expect(await readdir(join(target, 'checkpoints'))).toHaveLength(3); + const declaredTrees = new Set( + runManifest.streams + .map((stream) => stream.treeName) + .filter((treeName): treeName is string => treeName !== undefined), + ); + for (const file of await readdir(join(target, 'proofs', 'inclusion'))) { + const { value } = await readProof( + join(target, 'proofs', 'inclusion'), + file, + ); + // Bundle format §6: every committed tree name is declared in the run manifest. + expect(declaredTrees.has(value.treeName)).toBe(true); + expect(verifyInclusion(value)).toBe(true); + } + + context.close(); + }); + + it('refuses a run with no checkpoints and an unusable cap', async () => { + const context = await createContext(); + const bundleDir = await temporaryDirectory(); + + await expect( + context.service.writeProofFiles(context.runId, bundleDir), + ).rejects.toThrow(CheckpointNotFoundError); + + await context.service.createCheckpoint(context.runId, 'run-initialized'); + await expect( + context.service.writeProofFiles(context.runId, bundleDir, { + maxInclusionPerTree: 1, + }), + ).rejects.toThrow(InvalidCheckpointRequestError); + + context.close(); + }); +}); diff --git a/packages/checkpoint/__tests__/scheduler.test.ts b/packages/checkpoint/__tests__/scheduler.test.ts new file mode 100644 index 0000000..b224572 --- /dev/null +++ b/packages/checkpoint/__tests__/scheduler.test.ts @@ -0,0 +1,493 @@ +import type { CheckpointManifest, CheckpointReason } from '@ald/types'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { + CheckpointCreationResult, + CheckpointCreator, +} from '../src/checkpoint-service.js'; +import { InvalidCheckpointRequestError } from '../src/errors.js'; +import { + CheckpointScheduler, + type SchedulerTimer, + type SchedulerTimerHandle, +} from '../src/scheduler.js'; +import { + ManualClock, + cleanupTemporaryDirectories, + commitTurns, + createContext, +} from './support.js'; + +afterEach(cleanupTemporaryDirectories); + +/** Timer whose callback only runs when a test fires it. */ +class FakeTimer implements SchedulerTimer { + callbacks: (() => void)[] = []; + intervals: number[] = []; + cleared = 0; + + setInterval(callback: () => void, ms: number): SchedulerTimerHandle { + this.callbacks.push(callback); + this.intervals.push(ms); + return this.callbacks.length - 1; + } + + clearInterval(): void { + this.cleared += 1; + this.callbacks = []; + } + + fire(): void { + for (const callback of [...this.callbacks]) { + callback(); + } + } +} + +/** Records every checkpoint request; never touches an Evidence Store. */ +class RecordingCreator implements CheckpointCreator { + readonly calls: CheckpointReason[] = []; + + constructor(private readonly failWith?: Error) {} + + createCheckpointIfChanged( + _runId: string, + reason: CheckpointReason, + ): Promise { + this.calls.push(reason); + if (this.failWith !== undefined) { + return Promise.reject(this.failWith); + } + return Promise.resolve({ + created: true, + reason, + manifest: { + checkpointSequence: this.calls.length - 1, + } as unknown as CheckpointManifest, + }); + } +} + +describe('CheckpointScheduler event trigger', () => { + it('fires exactly once the configured event interval is reached', async () => { + const creator = new RecordingCreator(); + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 4, + timeIntervalMs: 60_000, + clock: new ManualClock(), + }); + + scheduler.recordAcceptedEvents(1); + scheduler.recordAcceptedEvents(2); + expect(creator.calls).toEqual([]); + expect(scheduler.pendingEventCount).toBe(3); + + scheduler.recordAcceptedEvents(1); + // The window is consumed synchronously; the checkpoint itself is queued. + expect(scheduler.pendingEventCount).toBe(0); + await scheduler.pending; + expect(creator.calls).toEqual(['event-interval']); + + scheduler.recordAcceptedEvents(3); + await scheduler.pending; + expect(creator.calls).toHaveLength(1); + scheduler.recordAcceptedEvents(1); + await scheduler.pending; + expect(creator.calls).toEqual(['event-interval', 'event-interval']); + }); + + it('fires once for a burst larger than the interval and starts a fresh window', async () => { + const creator = new RecordingCreator(); + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 4, + timeIntervalMs: 60_000, + clock: new ManualClock(), + }); + + scheduler.recordAcceptedEvents(10); + await scheduler.pending; + + expect(creator.calls).toEqual(['event-interval']); + expect(scheduler.pendingEventCount).toBe(0); + }); + + it('reports a non-integer event count instead of counting it', () => { + const scheduler = new CheckpointScheduler({ + service: new RecordingCreator(), + runId: 'run-a', + eventInterval: 4, + timeIntervalMs: 60_000, + clock: new ManualClock(), + }); + + expect(() => scheduler.recordAcceptedEvents(-1)).toThrow( + InvalidCheckpointRequestError, + ); + expect(() => scheduler.recordAcceptedEvents(1.5)).toThrow( + InvalidCheckpointRequestError, + ); + expect(scheduler.pendingEventCount).toBe(0); + }); + + it('rejects a non-positive interval at construction', () => { + expect( + () => + new CheckpointScheduler({ + service: new RecordingCreator(), + runId: 'run-a', + eventInterval: 0, + timeIntervalMs: 60_000, + clock: new ManualClock(), + }), + ).toThrow(InvalidCheckpointRequestError); + }); +}); + +describe('CheckpointScheduler time trigger', () => { + it('fires once the time interval has passed and events are pending', async () => { + const creator = new RecordingCreator(); + const clock = new ManualClock(); + const timer = new FakeTimer(); + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 1_000, + timeIntervalMs: 5_000, + clock, + timer, + }); + scheduler.start(); + // Default tickIntervalMs is max(1000, floor(timeIntervalMs / 4)). + expect(timer.intervals).toEqual([1_250]); + + // No events yet: an idle run never checkpoints on time alone. + clock.advance(10_000); + timer.fire(); + await scheduler.pending; + expect(creator.calls).toEqual([]); + + // Events pending and the interval already elapsed: the trigger fires. + scheduler.recordAcceptedEvents(2); + timer.fire(); + await scheduler.pending; + expect(creator.calls).toEqual(['time-interval']); + expect(scheduler.pendingEventCount).toBe(0); + + // The window restarts from the completed checkpoint: too soon to fire. + scheduler.recordAcceptedEvents(1); + timer.fire(); + await scheduler.pending; + expect(creator.calls).toEqual(['time-interval']); + + clock.advance(5_000); + timer.fire(); + await scheduler.pending; + expect(creator.calls).toEqual(['time-interval', 'time-interval']); + + scheduler.stop(); + expect(timer.cleared).toBe(1); + expect(scheduler.running).toBe(false); + }); + + it('start and stop are idempotent', () => { + const timer = new FakeTimer(); + const scheduler = new CheckpointScheduler({ + service: new RecordingCreator(), + runId: 'run-a', + eventInterval: 4, + timeIntervalMs: 5_000, + clock: new ManualClock(), + timer, + }); + + scheduler.start(); + scheduler.start(); + expect(timer.callbacks).toHaveLength(1); + expect(scheduler.running).toBe(true); + scheduler.stop(); + scheduler.stop(); + expect(timer.cleared).toBe(1); + }); +}); + +describe('CheckpointScheduler time-interval cadence bound', () => { + it('fires within timeIntervalMs + tickIntervalMs of the last checkpoint, even off-cycle', async () => { + const timeIntervalMs = 300_000; + const creator = new RecordingCreator(); + const clock = new ManualClock(0); + const timer = new FakeTimer(); + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 2, + timeIntervalMs, + clock, + timer, + }); + scheduler.start(); + const tickIntervalMs = timer.intervals[0]; + expect(tickIntervalMs).toBe(75_000); // max(1000, floor(300_000 / 4)) + + // An event-interval checkpoint lands off the tick cycle, at t = 301_000 + // (the reporter's exact reproduction scenario). + clock.advance(301_000); + scheduler.recordAcceptedEvents(2); + await scheduler.pending; + expect(creator.calls).toEqual(['event-interval']); + const lastCheckpointAtMs = 301_000; + + // One further event arrives, then nothing else. + clock.advance(1_000); // t = 302_000 + scheduler.recordAcceptedEvents(1); + + // Fire ticks on the real fixed phase (multiples of tickIntervalMs from + // start) until the time trigger fires; earlier ticks in this window + // correctly skip because timeIntervalMs has not elapsed since 301_000. + let elapsed = 302_000; + let fired = false; + for (let tick = 5; tick <= 20 && !fired; tick += 1) { + const target = tick * tickIntervalMs; + clock.advance(target - elapsed); + elapsed = target; + timer.fire(); + await scheduler.pending; + if (creator.calls.includes('time-interval')) { + fired = true; + const gap = elapsed - lastCheckpointAtMs; + // This is the bound the fix establishes: not 2 * timeIntervalMs. + expect(gap).toBeLessThanOrEqual(timeIntervalMs + tickIntervalMs); + expect(gap).toBeGreaterThanOrEqual(timeIntervalMs); + } + } + expect(fired).toBe(true); + + scheduler.stop(); + }); +}); + +describe('CheckpointScheduler failure handling', () => { + it('routes a checkpoint failure to onError and keeps the events pending', async () => { + const failure = new Error('sqlite is busy'); + const creator = new RecordingCreator(failure); + const errors: unknown[] = []; + const timer = new FakeTimer(); + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 2, + timeIntervalMs: 5_000, + clock: new ManualClock(), + timer, + onError: (error) => errors.push(error), + }); + scheduler.start(); + + scheduler.recordAcceptedEvents(2); + await scheduler.pending; + + expect(errors).toEqual([failure]); + expect(scheduler.pendingEventCount).toBe(2); + // The chain survived the rejection, so the next trigger still runs. + scheduler.recordAcceptedEvents(1); + await scheduler.pending; + expect(creator.calls).toEqual(['event-interval', 'event-interval']); + expect(errors).toHaveLength(2); + + scheduler.stop(); + }); + + it('never lets a timer callback throw out of the timer', async () => { + const errors: unknown[] = []; + const timer = new FakeTimer(); + let readings = 0; + const creator = new RecordingCreator(); + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 2, + timeIntervalMs: 1_000, + clock: { + now: () => { + readings += 1; + return readings === 1 + ? new Date(Date.UTC(2026, 0, 1)).toISOString() + : 'not-a-timestamp'; + }, + }, + timer, + onError: (error) => errors.push(error), + }); + scheduler.start(); + scheduler.recordAcceptedEvents(1); + + expect(() => timer.fire()).not.toThrow(); + await scheduler.pending; + + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(InvalidCheckpointRequestError); + expect(creator.calls).toEqual([]); + + scheduler.stop(); + }); + + it('recovers when onError itself throws: the chain never stays rejected', async () => { + const failure = new Error('sqlite is busy'); + const creator = new RecordingCreator(failure); + const errors: unknown[] = []; + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 2, + timeIntervalMs: 5_000, + clock: new ManualClock(), + onError: (error) => { + errors.push(error); + throw new Error('logger exploded'); + }, + }); + + scheduler.recordAcceptedEvents(2); + // Before the fix this rejected forever, so a bare `await` here would + // throw "logger exploded" instead of resolving. + await expect(scheduler.pending).resolves.toBeUndefined(); + expect(errors).toEqual([failure]); + + // A later window must still reach the service: the chain was not + // permanently poisoned by the throwing observer. + scheduler.recordAcceptedEvents(2); + await expect(scheduler.pending).resolves.toBeUndefined(); + expect(creator.calls).toEqual(['event-interval', 'event-interval']); + expect(errors).toHaveLength(2); + }); + + it('does not re-add already-checkpointed events when onCheckpoint throws', async () => { + const creator = new RecordingCreator(); + const errors: unknown[] = []; + const scheduler = new CheckpointScheduler({ + service: creator, + runId: 'run-a', + eventInterval: 4, + timeIntervalMs: 60_000, + clock: new ManualClock(), + onCheckpoint: () => { + throw new Error('subscriber blew up'); + }, + onError: (error) => errors.push(error), + }); + + scheduler.recordAcceptedEvents(4); + await expect(scheduler.pending).resolves.toBeUndefined(); + + // The checkpoint was created; only the subscriber failed. + expect(creator.calls).toEqual(['event-interval']); + expect(errors).toHaveLength(1); + // Criterion 2: a successfully checkpointed window must not be restored, + // or the next single event would immediately re-trigger and the + // checkpointed range would be re-attempted. + expect(scheduler.pendingEventCount).toBe(0); + + scheduler.recordAcceptedEvents(1); + await scheduler.pending; + expect(creator.calls).toEqual(['event-interval']); + expect(scheduler.pendingEventCount).toBe(1); + + // The cadence is otherwise unaffected: completing the window still + // fires exactly one more checkpoint, not one per event. + scheduler.recordAcceptedEvents(3); + await scheduler.pending; + expect(creator.calls).toEqual(['event-interval', 'event-interval']); + }); +}); + +describe('CheckpointScheduler against a real Evidence Store', () => { + it('writes non-overlapping checkpoints and skips when nothing changed', async () => { + const context = await createContext({ checkpointEventInterval: 2 }); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + const results: CheckpointCreationResult[] = []; + const timer = new FakeTimer(); + const clock = new ManualClock(); + const scheduler = new CheckpointScheduler({ + service: context.service, + runId: context.runId, + eventInterval: context.config.checkpointEventInterval, + timeIntervalMs: context.config.checkpointTimeIntervalMs, + clock, + timer, + onCheckpoint: (_manifest, result) => results.push(result), + }); + scheduler.start(); + + await commitTurns(context, 1); + scheduler.recordAcceptedEvents(2); + await scheduler.pending; + + await commitTurns(context, 1, 2); + scheduler.recordAcceptedEvents(2); + await scheduler.pending; + + // A third trigger with nothing appended in between is skipped. + scheduler.recordAcceptedEvents(2); + await scheduler.pending; + + scheduler.stop(); + + expect(results.map((result) => result.created)).toEqual([ + true, + true, + false, + ]); + const checkpoints = context.writer.readCheckpoints(context.runId); + expect(checkpoints.map((entry) => entry.checkpointSequence)).toEqual([ + 0, 1, 2, + ]); + // ALD-014 criterion 2: tree sizes never overlap or go backwards. + const sizes = checkpoints.map((entry) => entry.channel.treeSize); + expect(sizes).toEqual([0, 1, 2]); + for (let index = 1; index < checkpoints.length; index += 1) { + const previous = checkpoints[index - 1]; + const current = checkpoints[index]; + expect(current?.previousCheckpointHash).toBe(previous?.checkpointHash); + expect(current?.babyA.treeSize).toBeGreaterThanOrEqual( + previous?.babyA.treeSize ?? 0, + ); + } + + context.close(); + }); + + it('serializes concurrent triggers so no two checkpoints cover the same events', async () => { + const context = await createContext({ checkpointEventInterval: 1 }); + await context.service.createCheckpoint(context.runId, 'run-initialized'); + const timer = new FakeTimer(); + const clock = new ManualClock(); + const scheduler = new CheckpointScheduler({ + service: context.service, + runId: context.runId, + eventInterval: 1, + timeIntervalMs: 1_000, + clock, + timer, + }); + scheduler.start(); + + await commitTurns(context, 2); + // Two triggers back to back: event interval and, in the same tick, time. + scheduler.recordAcceptedEvents(1); + clock.advance(5_000); + scheduler.recordAcceptedEvents(1); + timer.fire(); + await scheduler.pending; + scheduler.stop(); + + const checkpoints = context.writer.readCheckpoints(context.runId); + const sequences = checkpoints.map((entry) => entry.checkpointSequence); + expect(sequences).toEqual([...sequences].sort((a, b) => a - b)); + expect(new Set(sequences).size).toBe(sequences.length); + + context.close(); + }); +}); diff --git a/packages/checkpoint/__tests__/support.ts b/packages/checkpoint/__tests__/support.ts new file mode 100644 index 0000000..7c588b1 --- /dev/null +++ b/packages/checkpoint/__tests__/support.ts @@ -0,0 +1,222 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { openEvidenceDatabase, SqliteEvidenceWriter } from '@ald/evidence'; +import { InMemorySignerRegistry } from '@ald/hashing'; +import { buildRunConfig } from '@ald/lifecycle'; +import type { + CheckpointManifest, + Clock, + EventRange, + EventStream, + RunConfig, + RunMetadataRecord, + StoredEvent, +} from '@ald/types'; + +import { + EvidenceCheckpointService, + type CheckpointEvidenceStore, +} from '../src/checkpoint-service.js'; + +const temporaryDirectories: string[] = []; + +export async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'ald-checkpoint-')); + temporaryDirectories.push(directory); + return directory; +} + +export async function cleanupTemporaryDirectories(): Promise { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +} + +export function hash(seed: string): string { + return `sha256:${seed.repeat(64).slice(0, 64)}`; +} + +/** Deterministic clock advancing a fixed step per reading. */ +export class StepClock implements Clock { + private current: number; + + constructor( + startMs = Date.UTC(2026, 0, 1, 0, 0, 0), + private readonly stepMs = 1_000, + ) { + this.current = startMs; + } + + now(): string { + const value = new Date(this.current).toISOString(); + this.current += this.stepMs; + return value; + } + + /** Moves the clock forward without consuming a reading. */ + advance(ms: number): void { + this.current += ms; + } +} + +/** Clock that only moves when a test moves it. */ +export class ManualClock implements Clock { + constructor(private current = Date.UTC(2026, 0, 1, 0, 0, 0)) {} + + now(): string { + return new Date(this.current).toISOString(); + } + + advance(ms: number): void { + this.current += ms; + } +} + +export interface CheckpointContext { + runId: string; + config: RunConfig; + writer: SqliteEvidenceWriter; + signers: InMemorySignerRegistry; + service: EvidenceCheckpointService; + clock: StepClock; + close(): void; +} + +export async function createContext( + overrides: Partial[0]> = {}, +): Promise { + const runId = overrides.runId ?? 'run-checkpoint-001'; + const config = buildRunConfig({ + runId, + experimentId: 'E00', + randomSeed: 'seed-checkpoint', + promptBundleHash: hash('b'), + ...overrides, + }); + const directory = await temporaryDirectory(); + const database = openEvidenceDatabase(join(directory, 'evidence.sqlite')); + const signers = InMemorySignerRegistry.generate(runId); + const clock = new StepClock(); + const writer = new SqliteEvidenceWriter({ + database, + signers, + clock, + softwareCommit: 'git:test-commit', + }); + writer.registerRun(config); + const service = new EvidenceCheckpointService({ + evidence: writer, + signers, + clock, + softwareCommit: 'git:test-commit', + }); + return { + runId, + config, + writer, + signers, + service, + clock, + close: () => database.close(), + }; +} + +/** + * Commits `count` complete turns: one sender intention plus channel event + * (`commitTurn`), one receiver interpretation bound to that channel event, + * and one Nursery turn record. Leaves `affect` and `audit` empty. + */ +export async function commitTurns( + context: CheckpointContext, + count: number, + startTurn = 1, +): Promise { + for (let index = 0; index < count; index += 1) { + const turn = startTurn + index; + const result = await context.writer.commitTurn({ + runId: context.runId, + turn, + sender: 'baby-a', + recipient: 'baby-b', + carrier: 'fixed-token', + communicationCondition: 'normal', + proposal: { kind: 'emit_symbols', publicArtifact: { symbols: ['S01'] } }, + intentionDraft: { + eventType: 'intention.recorded', + contentSchema: 'agent-native-ledger', + subjectId: hash('d'), + content: { artifactRef: `artifact-${String(turn)}` }, + blindingNonce: `nonce-a-${String(turn)}`, + evidenceRefs: [], + }, + deliveredArtifact: { symbols: ['S01'] }, + }); + await context.writer.appendLedgerEvent({ + runId: context.runId, + babyId: 'B', + turn, + draft: { + eventType: 'interpretation.recorded', + contentSchema: 'agent-native-ledger', + subjectId: hash('e'), + content: { artifactRef: `artifact-${String(turn)}` }, + blindingNonce: `nonce-b-${String(turn)}`, + evidenceRefs: [], + }, + channelEventHash: result.channelEvent.entryHash, + }); + await context.writer.appendTurnRecord({ + runId: context.runId, + turn, + phase: 'running', + roles: { sender: 'baby-a', receiver: 'baby-b' }, + communicationCondition: 'normal', + scenarioRef: `scenario-${String(turn)}`, + scenarioStateHash: hash('1'), + observationHashes: { babyA: hash('2'), babyB: hash('3') }, + babyProposalHash: hash('4'), + deliveredArtifactHash: result.channelEvent.publicArtifactHash, + channelEventHash: result.channelEvent.entryHash, + actionHash: hash('5'), + outcomeHash: hash('6'), + outcome: { success: true, turn }, + }); + } +} + +/** + * Read-through store that rewrites one stream's events, so a checkpoint's + * committed root no longer matches what the store reports (LEDGER §17). + */ +export class RewritingEvidenceStore implements CheckpointEvidenceStore { + constructor( + private readonly inner: CheckpointEvidenceStore, + private readonly stream: EventStream, + private readonly rewrite: (events: StoredEvent[]) => StoredEvent[], + ) {} + + readRunMetadata(runId: string): RunMetadataRecord | undefined { + return this.inner.readRunMetadata(runId); + } + + readCheckpoints(runId: string): CheckpointManifest[] { + return this.inner.readCheckpoints(runId); + } + + readEvents( + runId: string, + stream: EventStream, + range?: EventRange, + ): StoredEvent[] { + const events = this.inner.readEvents(runId, stream, range); + return stream === this.stream ? this.rewrite(events) : events; + } + + insertCheckpointManifest(manifest: CheckpointManifest): void { + this.inner.insertCheckpointManifest(manifest); + } +} diff --git a/packages/checkpoint/package.json b/packages/checkpoint/package.json new file mode 100644 index 0000000..c42b858 --- /dev/null +++ b/packages/checkpoint/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ald/checkpoint", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/merkle": "*", + "@ald/evidence": "*", + "@ald/lifecycle": "*" + }, + "devDependencies": { + "@ald/learners": "*", + "@ald/verifier": "*" + } +} diff --git a/packages/checkpoint/src/checkpoint-service.ts b/packages/checkpoint/src/checkpoint-service.ts new file mode 100644 index 0000000..fad209e --- /dev/null +++ b/packages/checkpoint/src/checkpoint-service.ts @@ -0,0 +1,558 @@ +/** + * ALD-013 — checkpoint manifest generation, and the proof files the evidence + * bundle carries (LEDGER-INTEGRITY-DESIGN.md §7, §8, §9; SPECIFICATION.md + * §11.7, §13.3; docs/evidence-bundle-format.md §5, §6). + * + * A checkpoint is a signed statement about every hash-chained stream of one + * run at one instant: for each stream its tree size, its RFC 6962 root, and + * its last entry hash, chained to the previous checkpoint's hash so the + * checkpoint series is itself append-only. Nothing here writes events; the + * Evidence Writer stays the only component that may append to an event table + * (SPEC §4.1 item 7) and also owns `insertCheckpointManifest`. + */ +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { UnknownRunError } from '@ald/evidence'; +import { + canonicalJson, + hashCanonical, + hashRunId, + parseCanonicalJson, +} from '@ald/hashing'; +import { + buildConsistencyProofRecord, + buildInclusionProofRecord, +} from '@ald/merkle'; +import { + CheckpointManifestSchema, + GENESIS_HASH, + HASH_DOMAINS, + RunConfigSchema, + UnsignedCheckpointManifestSchema, + type CheckpointManifest, + type CheckpointReason, + type CheckpointService, + type Clock, + type ConsistencyProof, + type EventRange, + type EventStream, + type InclusionProof, + type RunConfig, + type RunMetadataRecord, + type SignerRegistry, + type StoredEvent, + type TreeReference, + type UnsignedCheckpointManifest, +} from '@ald/types'; + +import { + CheckpointIntegrityError, + CheckpointNotFoundError, + CheckpointProofRangeError, + InvalidCheckpointRequestError, +} from './errors.js'; +import { + CHECKPOINT_STREAMS, + assertCheckpointStream, + assertManifestTrees, + assertPrefixUnchanged, + auxiliaryTreesFrom, + mandatoryTreesFrom, + referenceFor, + snapshotFromEvents, + treeNameFor, + type CheckpointStream, + type TreeSnapshot, +} from './trees.js'; + +/** + * LEDGER §9 triggers that fire on a schedule rather than on a lifecycle + * event. Only these two are skipped when nothing has been appended since the + * previous checkpoint, which is what keeps checkpoint event ranges from + * overlapping (ALD-014 criterion 2). + */ +const INTERVAL_REASONS: readonly CheckpointReason[] = [ + 'event-interval', + 'time-interval', +]; + +/** + * The slice of the Evidence Store a checkpoint needs: the run's + * configuration, its checkpoint chain, its events, and the writer-owned + * insert. `SqliteEvidenceWriter` satisfies it structurally — it is both the + * reader the roots are recomputed from and the owner of the checkpoint table + * (SPEC §4.1 item 7). Narrowing it this way also lets a test substitute a + * reader that reports different events, which is how the rewritten-prefix + * detector of LEDGER §17 is exercised. + */ +export interface CheckpointEvidenceStore { + readRunMetadata(runId: string): RunMetadataRecord | undefined; + readCheckpoints(runId: string): CheckpointManifest[]; + readEvents( + runId: string, + stream: EventStream, + range?: EventRange, + ): StoredEvent[]; + insertCheckpointManifest(manifest: CheckpointManifest): void; +} + +export interface EvidenceCheckpointServiceOptions { + evidence: CheckpointEvidenceStore; + /** Per-run registry; the `witness` domain signs every checkpoint (LEDGER §11). */ + signers: SignerRegistry; + clock: Clock; + /** Recorded verbatim in `softwareCommit` (LEDGER §8). */ + softwareCommit: string; +} + +/** Result of a possibly-skipped checkpoint attempt (ALD-014). */ +export interface CheckpointCreationResult { + /** `false` when an interval trigger found no new events and reused the previous manifest. */ + created: boolean; + manifest: CheckpointManifest; + reason: CheckpointReason; +} + +export interface WriteProofFilesOptions { + /** + * Trees at or below this size get an inclusion proof for every event; + * larger trees get the first and last sequence only, so a long run's bundle + * stays a bounded size while still proving both ends of every tree. + */ + maxInclusionPerTree?: number; +} + +export interface WriteProofFilesResult { + inclusionFiles: number; + consistencyFiles: number; +} + +/** The scheduler only needs this much of the service (ALD-014). */ +export interface CheckpointCreator { + createCheckpointIfChanged( + runId: string, + reason: CheckpointReason, + ): Promise; +} + +const DEFAULT_MAX_INCLUSION_PER_TREE = 64; + +/** Normalizes a `RunConfig` hash field to the strict manifest encoding. */ +function toStrictHash(field: string, value: string): string { + const hex = value.startsWith('sha256:') ? value.slice('sha256:'.length) : value; + const lower = hex.toLowerCase(); + if (!/^[0-9a-f]{64}$/u.test(lower)) { + throw new InvalidCheckpointRequestError( + `${field} ${value} is not a sha256 hash`, + ); + } + return `sha256:${lower}`; +} + +function isIntervalReason(reason: CheckpointReason): boolean { + return INTERVAL_REASONS.includes(reason); +} + +function manifestAt( + runId: string, + checkpoints: readonly CheckpointManifest[], + checkpointSequence: number, +): CheckpointManifest { + const manifest = checkpoints.find( + (entry) => entry.checkpointSequence === checkpointSequence, + ); + if (manifest === undefined) { + throw new CheckpointNotFoundError(runId, checkpointSequence); + } + return manifest; +} + +export class EvidenceCheckpointService + implements CheckpointService, CheckpointCreator +{ + private readonly evidence: CheckpointEvidenceStore; + private readonly signers: SignerRegistry; + private readonly clock: Clock; + private readonly softwareCommit: string; + + constructor(options: EvidenceCheckpointServiceOptions) { + this.evidence = options.evidence; + this.signers = options.signers; + this.clock = options.clock; + this.softwareCommit = options.softwareCommit; + } + + // ------------------------------------------------------------------------- + // ALD-013 — manifest generation + // ------------------------------------------------------------------------- + + /** + * Creates and stores the next checkpoint for `runId`. Interval triggers + * that find no new events reuse the previous manifest instead of writing a + * duplicate; every lifecycle reason (`run-initialized`, `pause`, + * `intervention`, `recovery`, `policy-checkpoint`, `run-sealed`, + * `run-aborted`) always writes (LEDGER §9). + */ + async createCheckpoint( + runId: string, + reason: CheckpointReason, + ): Promise { + return (await this.createCheckpointIfChanged(runId, reason)).manifest; + } + + /** {@link createCheckpoint} with the skip decision exposed (ALD-014). */ + async createCheckpointIfChanged( + runId: string, + reason: CheckpointReason, + ): Promise { + const metadata = this.evidence.readRunMetadata(runId); + if (metadata === undefined) { + throw new UnknownRunError(runId); + } + + const previous = this.evidence.readCheckpoints(runId).at(-1); + const snapshots = this.snapshots(runId); + if (previous !== undefined) { + assertPrefixUnchanged(previous, snapshots); + } + + if ( + previous !== undefined && + isIntervalReason(reason) && + !hasGrown(previous, snapshots) + ) { + return { created: false, manifest: previous, reason }; + } + + const config = this.runConfig(metadata.configurationJson); + const witness = this.signers.signer('witness'); + const unsigned: UnsignedCheckpointManifest = + UnsignedCheckpointManifestSchema.parse({ + version: 1, + runIdHash: hashRunId(runId), + checkpointSequence: + previous === undefined ? 0 : previous.checkpointSequence + 1, + previousCheckpointHash: previous?.checkpointHash ?? GENESIS_HASH, + ...mandatoryTreesFrom(snapshots), + auxiliaryTrees: auxiliaryTreesFrom(snapshots), + runConfigurationHash: metadata.configurationHash, + promptBundleHash: toStrictHash( + 'promptBundleHash', + config.promptBundleHash, + ), + softwareCommit: this.softwareCommit, + createdAt: this.clock.now(), + witnessKeyId: witness.keyId, + reason, + } satisfies UnsignedCheckpointManifest); + + // ALD-013 criterion 3: refuse to sign a manifest whose trees are not + // exactly the trees the Evidence Store holds. + assertManifestTrees(unsigned, snapshots); + + const checkpointHash = hashCanonical(HASH_DOMAINS.checkpoint, unsigned); + const manifest = CheckpointManifestSchema.parse({ + ...unsigned, + checkpointHash, + witnessSignature: await witness.sign(checkpointHash), + }); + + this.evidence.insertCheckpointManifest(manifest); + return { created: true, manifest, reason }; + } + + /** + * Recomputes every tree from the Evidence Store and asserts `manifest` + * commits exactly those trees. Called before each manifest is signed and + * usable on its own to re-audit a stored checkpoint. + */ + verifyManifestTrees(runId: string, manifest: CheckpointManifest): void { + assertManifestTrees(manifest, this.snapshots(runId)); + } + + // ------------------------------------------------------------------------- + // Proofs (LEDGER §7) + // ------------------------------------------------------------------------- + + /** + * RFC 6962 inclusion proof for one event against the tree a checkpoint + * committed. Throws if the sequence is outside that committed prefix, and + * if the recomputed root is not the root the checkpoint carries. + */ + inclusionProof( + runId: string, + stream: EventStream, + sequence: number, + checkpointSequence: number, + ): InclusionProof { + const checkpointStream = assertCheckpointStream(stream); + const treeName = treeNameFor(checkpointStream); + const manifest = this.checkpointAt(runId, checkpointSequence); + const reference = referenceFor(manifest, checkpointStream); + if ( + !Number.isSafeInteger(sequence) || + sequence < 1 || + sequence > reference.treeSize + ) { + throw new CheckpointProofRangeError( + `sequence ${String(sequence)} is outside ${treeName} tree of size ${String(reference.treeSize)} at checkpoint ${String(checkpointSequence)}`, + ); + } + + const events = this.prefix(runId, checkpointStream, reference.treeSize); + const target = events[sequence - 1]; + if (target === undefined) { + throw new CheckpointIntegrityError( + `stream ${stream} no longer holds sequence ${String(sequence)} committed by checkpoint ${String(checkpointSequence)}`, + ); + } + const snapshot = snapshotFromEvents(checkpointStream, events); + const record = buildInclusionProofRecord({ + stream, + treeName, + checkpointSequence, + sequence, + entryHash: target.entryHash, + leafHashes: snapshot.leafHashes, + }); + if (record.root !== reference.merkleRoot) { + throw new CheckpointIntegrityError( + `checkpoint ${String(checkpointSequence)} committed ${treeName} root ${reference.merkleRoot}, but the stored prefix of size ${String(reference.treeSize)} yields ${record.root}`, + ); + } + return record; + } + + /** + * RFC 6962 prefix-consistency proof between two checkpoints of the same + * stream. Both endpoints are checked against the roots the checkpoints + * stored, so a rewritten prefix is caught here rather than being papered + * over by a freshly recomputed root (LEDGER §17). + */ + consistencyProof( + runId: string, + stream: EventStream, + fromCheckpointSequence: number, + toCheckpointSequence: number, + ): ConsistencyProof { + const checkpointStream = assertCheckpointStream(stream); + const treeName = treeNameFor(checkpointStream); + if (fromCheckpointSequence > toCheckpointSequence) { + throw new CheckpointProofRangeError( + `checkpoint ${String(fromCheckpointSequence)} is not before ${String(toCheckpointSequence)}`, + ); + } + const fromManifest = this.checkpointAt(runId, fromCheckpointSequence); + const toManifest = this.checkpointAt(runId, toCheckpointSequence); + const fromReference = referenceFor(fromManifest, checkpointStream); + const toReference = referenceFor(toManifest, checkpointStream); + if (fromReference.treeSize > toReference.treeSize) { + throw new CheckpointIntegrityError( + `${treeName} shrank from ${String(fromReference.treeSize)} at checkpoint ${String(fromCheckpointSequence)} to ${String(toReference.treeSize)} at checkpoint ${String(toCheckpointSequence)}`, + ); + } + + const events = this.prefix(runId, checkpointStream, toReference.treeSize); + const snapshot = snapshotFromEvents(checkpointStream, events); + const record = buildConsistencyProofRecord({ + stream, + treeName, + fromCheckpointSequence, + toCheckpointSequence, + fromSize: fromReference.treeSize, + leafHashes: snapshot.leafHashes, + }); + if (record.fromRoot !== fromReference.merkleRoot) { + throw new CheckpointIntegrityError( + `checkpoint ${String(fromCheckpointSequence)} committed ${treeName} root ${fromReference.merkleRoot}, but the stored prefix of size ${String(fromReference.treeSize)} yields ${record.fromRoot}`, + ); + } + if (record.toRoot !== toReference.merkleRoot) { + throw new CheckpointIntegrityError( + `checkpoint ${String(toCheckpointSequence)} committed ${treeName} root ${toReference.merkleRoot}, but the stored prefix of size ${String(toReference.treeSize)} yields ${record.toRoot}`, + ); + } + return record; + } + + // ------------------------------------------------------------------------- + // Bundle proof files (docs/evidence-bundle-format.md §1, §5) + // ------------------------------------------------------------------------- + + /** + * Writes `proofs/inclusion/` and `proofs/consistency/` into an exported + * bundle. `exportRunBundle` creates both directories empty and writes + * `checkpoints/`; this fills the proofs in, so a verifier with no access to + * this process can check both an entry and every checkpoint transition. + * + * Inclusion: first and last sequence of every non-empty tree at every + * checkpoint, plus every sequence when the tree is at most + * `maxInclusionPerTree`. Consistency: each consecutive checkpoint pair in + * which the tree grew, plus `(0, last)` when there are three or more + * checkpoints. + */ + async writeProofFiles( + runId: string, + bundleDir: string, + options: WriteProofFilesOptions = {}, + ): Promise { + const maxInclusionPerTree = + options.maxInclusionPerTree ?? DEFAULT_MAX_INCLUSION_PER_TREE; + if (!Number.isSafeInteger(maxInclusionPerTree) || maxInclusionPerTree < 2) { + throw new InvalidCheckpointRequestError( + `maxInclusionPerTree must be an integer of at least 2, received ${String(maxInclusionPerTree)}`, + ); + } + const checkpoints = this.evidence.readCheckpoints(runId); + if (checkpoints.length === 0) { + throw new CheckpointNotFoundError(runId, 0); + } + + const inclusionDir = join(bundleDir, 'proofs', 'inclusion'); + const consistencyDir = join(bundleDir, 'proofs', 'consistency'); + await mkdir(inclusionDir, { recursive: true }); + await mkdir(consistencyDir, { recursive: true }); + + let inclusionFiles = 0; + let consistencyFiles = 0; + const last = checkpoints.at(-1); + if (last === undefined) { + throw new CheckpointNotFoundError(runId, 0); + } + const pairs: [number, number][] = checkpoints + .slice(1) + .map((to, index): [number, number] => [ + checkpoints[index]?.checkpointSequence ?? 0, + to.checkpointSequence, + ]); + if (checkpoints.length > 2) { + pairs.push([0, last.checkpointSequence]); + } + + for (const stream of CHECKPOINT_STREAMS) { + const treeName = treeNameFor(stream); + + for (const manifest of checkpoints) { + const reference = referenceFor(manifest, stream); + for (const sequence of inclusionSequences( + reference.treeSize, + maxInclusionPerTree, + )) { + const record = this.inclusionProof( + runId, + stream, + sequence, + manifest.checkpointSequence, + ); + await writeCanonicalFile( + join( + inclusionDir, + `${treeName}-${String(sequence)}-at-${String(manifest.checkpointSequence)}.json`, + ), + record, + ); + inclusionFiles += 1; + } + } + + for (const [from, to] of pairs) { + const fromSize = referenceFor( + manifestAt(runId, checkpoints, from), + stream, + ).treeSize; + const toSize = referenceFor( + manifestAt(runId, checkpoints, to), + stream, + ).treeSize; + if (toSize === fromSize) { + // The tree did not grow between these checkpoints: the transition + // carries no information, so no file is written. + continue; + } + const record = this.consistencyProof(runId, stream, from, to); + await writeCanonicalFile( + join( + consistencyDir, + `${treeName}-${String(from)}-${String(to)}.json`, + ), + record, + ); + consistencyFiles += 1; + } + } + + return { inclusionFiles, consistencyFiles }; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private snapshots(runId: string): TreeSnapshot[] { + return CHECKPOINT_STREAMS.map((stream) => + snapshotFromEvents(stream, this.evidence.readEvents(runId, stream)), + ); + } + + private prefix( + runId: string, + stream: CheckpointStream, + treeSize: number, + ): StoredEvent[] { + if (treeSize === 0) { + return []; + } + const events = this.evidence.readEvents(runId, stream, { + fromSequence: 1, + toSequence: treeSize, + }); + if (events.length !== treeSize) { + throw new CheckpointIntegrityError( + `stream ${stream} holds ${String(events.length)} of the ${String(treeSize)} events a checkpoint committed`, + ); + } + return events; + } + + private checkpointAt( + runId: string, + checkpointSequence: number, + ): CheckpointManifest { + return manifestAt( + runId, + this.evidence.readCheckpoints(runId), + checkpointSequence, + ); + } + + private runConfig(configurationJson: string): RunConfig { + return RunConfigSchema.parse(parseCanonicalJson(configurationJson)); + } +} + +/** Sequences that get an inclusion proof for a tree of `treeSize` events. */ +function inclusionSequences(treeSize: number, maxPerTree: number): number[] { + if (treeSize === 0) { + return []; + } + if (treeSize <= maxPerTree) { + return Array.from({ length: treeSize }, (_, index) => index + 1); + } + return [1, treeSize]; +} + +/** True when any tree grew since `previous` (ALD-014 criterion 2). */ +function hasGrown( + previous: CheckpointManifest, + snapshots: readonly TreeSnapshot[], +): boolean { + return snapshots.some((snapshot) => { + const committed: TreeReference = referenceFor(previous, snapshot.stream); + return snapshot.reference.treeSize !== committed.treeSize; + }); +} + +async function writeCanonicalFile(path: string, value: unknown): Promise { + await writeFile(path, `${canonicalJson(value)}\n`, 'utf8'); +} diff --git a/packages/checkpoint/src/errors.ts b/packages/checkpoint/src/errors.ts new file mode 100644 index 0000000..bee09df --- /dev/null +++ b/packages/checkpoint/src/errors.ts @@ -0,0 +1,62 @@ +/** + * Checkpoint service error taxonomy (ALD-013, ALD-014). + * + * Every rule the checkpoint service enforces surfaces as one of these + * classes with a machine-readable `code`, following the Evidence Writer + * convention so the Nursery Controller can map both taxonomies onto one + * error shape. Unregistered runs reuse `UnknownRunError` from `@ald/evidence` + * rather than adding a second name for the same condition. + */ +export type CheckpointErrorCode = + | 'checkpoint-not-found' + | 'checkpoint-integrity' + | 'checkpoint-proof-range' + | 'checkpoint-invalid-request'; + +export class CheckpointServiceError extends Error { + constructor( + readonly code: CheckpointErrorCode, + message: string, + ) { + super(message); + this.name = new.target.name; + } +} + +/** A proof was requested against a checkpoint sequence the run never wrote. */ +export class CheckpointNotFoundError extends CheckpointServiceError { + constructor( + readonly runId: string, + readonly checkpointSequence: number, + ) { + super( + 'checkpoint-not-found', + `Run ${runId} has no checkpoint ${String(checkpointSequence)}`, + ); + } +} + +/** + * LEDGER §17: the evidence in the store no longer reproduces what a + * checkpoint committed — a rewritten prefix, a renumbered event, or a + * shrinking tree. This is the detector, so it never degrades to a warning. + */ +export class CheckpointIntegrityError extends CheckpointServiceError { + constructor(message: string) { + super('checkpoint-integrity', message); + } +} + +/** The requested sequence or checkpoint pair lies outside the committed tree. */ +export class CheckpointProofRangeError extends CheckpointServiceError { + constructor(message: string) { + super('checkpoint-proof-range', message); + } +} + +/** A structurally valid request that the checkpoint policy refuses. */ +export class InvalidCheckpointRequestError extends CheckpointServiceError { + constructor(message: string) { + super('checkpoint-invalid-request', message); + } +} diff --git a/packages/checkpoint/src/index.ts b/packages/checkpoint/src/index.ts new file mode 100644 index 0000000..afa2e23 --- /dev/null +++ b/packages/checkpoint/src/index.ts @@ -0,0 +1,53 @@ +/** + * @ald/checkpoint — ALD-013 checkpoint manifest generation, ALD-014 the + * checkpoint frequency scheduler, and the inclusion/consistency proof files + * an evidence bundle carries (LEDGER-INTEGRITY-DESIGN.md §7, §8, §9; + * SPECIFICATION.md §11.7, §13.3; docs/evidence-bundle-format.md §5, §6). + * + * `EvidenceCheckpointService` is the `CheckpointService` implementation: it + * reads every hash-chained stream through the Evidence Store, recomputes the + * Merkle roots with `@ald/merkle`, has the run's `witness` key sign the + * manifest hash, and hands the manifest to the Evidence Writer, which owns + * the checkpoint chain. `CheckpointScheduler` drives its two periodic + * triggers; lifecycle checkpoints stay with the orchestrator. + */ +export { + EvidenceCheckpointService, + type CheckpointCreationResult, + type CheckpointCreator, + type CheckpointEvidenceStore, + type EvidenceCheckpointServiceOptions, + type WriteProofFilesOptions, + type WriteProofFilesResult, +} from './checkpoint-service.js'; +export { + CheckpointScheduler, + nodeSchedulerTimer, + schedulerIntervalsFromConfig, + type CheckpointSchedulerOptions, + type SchedulerTimer, + type SchedulerTimerHandle, +} from './scheduler.js'; +export { + CheckpointIntegrityError, + CheckpointNotFoundError, + CheckpointProofRangeError, + CheckpointServiceError, + InvalidCheckpointRequestError, + type CheckpointErrorCode, +} from './errors.js'; +export { + CHECKPOINT_STREAMS, + EMPTY_TREE_REFERENCE, + assertCheckpointStream, + assertContiguousPrefix, + assertManifestTrees, + assertPrefixUnchanged, + auxiliaryTreesFrom, + mandatoryTreesFrom, + referenceFor, + snapshotFromEvents, + treeNameFor, + type CheckpointStream, + type TreeSnapshot, +} from './trees.js'; diff --git a/packages/checkpoint/src/scheduler.ts b/packages/checkpoint/src/scheduler.ts new file mode 100644 index 0000000..f247827 --- /dev/null +++ b/packages/checkpoint/src/scheduler.ts @@ -0,0 +1,304 @@ +/** + * ALD-014 — the checkpoint frequency scheduler + * (LEDGER-INTEGRITY-DESIGN.md §9). + * + * LEDGER §9 asks for a checkpoint "every 64 accepted ledger events or five + * minutes, whichever occurs first", with the exact numbers recorded before + * the run (`RunConfig.checkpointEventInterval` / + * `checkpointTimeIntervalMs`). Lifecycle checkpoints — run initialization, + * pause, intervention, recovery, policy checkpoints, seal, abort — are the + * orchestrator's business and go straight to + * {@link EvidenceCheckpointService.createCheckpoint}; this class owns only + * the two periodic triggers. + * + * Two invariants matter more than the cadence: + * + * 1. **No two checkpoints overlap in event range** (criterion 2). Every + * trigger consumes the pending event count synchronously and all + * checkpoint calls run through one promise chain, so two triggers can + * never interleave over the same events. + * 2. **A scheduling failure never crashes the host** (criterion 3). The + * timer callback cannot throw and the internal chain never rejects; every + * failure is handed to `onError`, which the server wires to its logger + * alongside its `uncaughtException` / `unhandledRejection` handlers. A + * throwing `onError` or `onCheckpoint` observer is itself contained the + * same way: it can neither poison the chain nor re-add events for a + * checkpoint that was already written (see {@link trigger}). + * + * **Time-interval cadence bound.** `tick()` only fires a checkpoint when at + * least `tickIntervalMs` has passed since the previous tick *and* at least + * `timeIntervalMs` has passed since the last checkpoint. Because ticks land + * on a fixed phase from `start()`, a tick that arrives even slightly short of + * `timeIntervalMs` is skipped entirely and the next opportunity is a full + * `tickIntervalMs` later. Defaulting `tickIntervalMs` to `timeIntervalMs` + * (as this class used to) therefore lets the worst-case gap between an + * accepted event and its time-interval checkpoint approach + * `2 * timeIntervalMs` instead of the configured value, doubling the + * unanchored-rewrite window LEDGER §9 exists to bound. The default is now + * `max(1000, floor(timeIntervalMs / 4))`, which bounds the worst case at + * `timeIntervalMs + tickIntervalMs` (at most `1.25 * timeIntervalMs` at the + * default ratio): the event can arrive up to one tick after the interval + * last elapsed, and the checkpoint then lands at most one more tick later. + */ +import type { CheckpointManifest, CheckpointReason, Clock } from '@ald/types'; + +import type { CheckpointCreationResult, CheckpointCreator } from './checkpoint-service.js'; +import { InvalidCheckpointRequestError } from './errors.js'; + +/** Opaque handle returned by {@link SchedulerTimer.setInterval}. */ +export type SchedulerTimerHandle = unknown; + +/** The slice of the timer API the scheduler uses; injectable for tests. */ +export interface SchedulerTimer { + setInterval(callback: () => void, ms: number): SchedulerTimerHandle; + clearInterval(handle: SchedulerTimerHandle): void; +} + +/** + * Default timer. The interval is unref'd so a scheduler nobody stopped can + * never be the only reason a process stays alive; the server's own listening + * handles keep it running. + */ +export const nodeSchedulerTimer: SchedulerTimer = { + setInterval(callback, ms) { + const handle = setInterval(callback, ms); + handle.unref(); + return handle; + }, + clearInterval(handle) { + clearInterval(handle as ReturnType); + }, +}; + +export interface CheckpointSchedulerOptions { + service: CheckpointCreator; + runId: string; + /** `RunConfig.checkpointEventInterval` (LEDGER §9 default 64). */ + eventInterval: number; + /** `RunConfig.checkpointTimeIntervalMs` (LEDGER §9 default 300000). */ + timeIntervalMs: number; + clock: Clock; + /** + * How often {@link CheckpointScheduler.tick} runs; defaults to + * `max(1000, floor(timeIntervalMs / 4))` (see the class docstring). + */ + tickIntervalMs?: number; + /** Called after every completed attempt, skipped ones included. */ + onCheckpoint?(manifest: CheckpointManifest, result: CheckpointCreationResult): void; + onError?(error: unknown): void; + timer?: SchedulerTimer; +} + +export class CheckpointScheduler { + private readonly service: CheckpointCreator; + private readonly runId: string; + private readonly eventInterval: number; + private readonly timeIntervalMs: number; + private readonly tickIntervalMs: number; + private readonly clock: Clock; + private readonly timer: SchedulerTimer; + private readonly onCheckpoint?: ( + manifest: CheckpointManifest, + result: CheckpointCreationResult, + ) => void; + private readonly onError?: (error: unknown) => void; + + private eventsSinceCheckpoint = 0; + private lastCheckpointAtMs: number; + private chain: Promise = Promise.resolve(); + private handle: SchedulerTimerHandle | undefined; + + constructor(options: CheckpointSchedulerOptions) { + assertPositiveInteger('eventInterval', options.eventInterval); + assertPositiveInteger('timeIntervalMs', options.timeIntervalMs); + if (options.tickIntervalMs !== undefined) { + assertPositiveInteger('tickIntervalMs', options.tickIntervalMs); + } + this.service = options.service; + this.runId = options.runId; + this.eventInterval = options.eventInterval; + this.timeIntervalMs = options.timeIntervalMs; + this.tickIntervalMs = + options.tickIntervalMs ?? defaultTickIntervalMs(options.timeIntervalMs); + this.clock = options.clock; + this.timer = options.timer ?? nodeSchedulerTimer; + this.onCheckpoint = options.onCheckpoint; + this.onError = options.onError; + this.lastCheckpointAtMs = this.nowMs(); + } + + /** Events counted toward the next event-interval trigger. */ + get pendingEventCount(): number { + return this.eventsSinceCheckpoint; + } + + /** Wall-clock instant the last checkpoint attempt was reserved at. */ + get lastCheckpointAt(): string { + return new Date(this.lastCheckpointAtMs).toISOString(); + } + + /** Whether {@link start} has been called without a matching {@link stop}. */ + get running(): boolean { + return this.handle !== undefined; + } + + /** + * Resolves once every checkpoint this scheduler has triggered so far has + * finished. Tests await it; the orchestrator awaits it before sealing so a + * scheduled checkpoint cannot land after the final one. + */ + get pending(): Promise { + return this.chain; + } + + /** + * Records `count` accepted events. Fires an `event-interval` checkpoint as + * soon as the configured interval is reached, then starts counting again + * from zero, so successive checkpoints cover disjoint event ranges. + */ + recordAcceptedEvents(count: number): void { + if (!Number.isSafeInteger(count) || count < 0) { + throw new InvalidCheckpointRequestError( + `accepted event count must be a non-negative integer, received ${String(count)}`, + ); + } + this.eventsSinceCheckpoint += count; + if (this.eventsSinceCheckpoint >= this.eventInterval) { + this.trigger('event-interval'); + } + } + + /** + * Time trigger. Fires a `time-interval` checkpoint when at least + * `timeIntervalMs` has passed since the last checkpoint and at least one + * event has been recorded since — an idle run appends nothing, so a new + * checkpoint would only restate the previous one. + */ + tick(): void { + if (this.eventsSinceCheckpoint === 0) { + return; + } + if (this.nowMs() - this.lastCheckpointAtMs < this.timeIntervalMs) { + return; + } + this.trigger('time-interval'); + } + + /** Starts the background timer. Idempotent. */ + start(): void { + if (this.handle !== undefined) { + return; + } + this.handle = this.timer.setInterval(() => { + // Criterion 3: nothing thrown inside a timer callback may escape. + try { + this.tick(); + } catch (error) { + this.report(error); + } + }, this.tickIntervalMs); + } + + /** Stops the background timer. Idempotent; in-flight work still settles. */ + stop(): void { + if (this.handle === undefined) { + return; + } + this.timer.clearInterval(this.handle); + this.handle = undefined; + } + + /** + * Runs a checkpoint through the same serialized chain as the triggers. + * + * `onError` and `onCheckpoint` are host-supplied observers and neither may + * be trusted not to throw: a throwing `onCheckpoint` must not be mistaken + * for a failed `createCheckpointIfChanged` (that would re-add `consumed` + * for a checkpoint that was in fact written, corrupting the next window's + * count), and a throwing `onError` must not escape into `this.chain` (that + * would permanently reject it, silently dropping every later trigger — see + * {@link report}). Both observers therefore run in their own try/catch, + * entirely outside the block that decides whether the attempt failed. + */ + private trigger(reason: CheckpointReason): void { + const consumed = this.eventsSinceCheckpoint; + // Reserve the window before awaiting anything: a second synchronous + // trigger must not queue a checkpoint over the same events. + this.eventsSinceCheckpoint = 0; + this.lastCheckpointAtMs = this.nowMs(); + // A prior attempt may have left the chain rejected despite every catch + // below, if something is still capable of throwing to a wider scope than + // an observer — `.catch` here means that history can never keep this + // trigger's attempt from running. + this.chain = this.chain.catch(() => undefined).then(async () => { + let result: CheckpointCreationResult | undefined; + try { + result = await this.service.createCheckpointIfChanged( + this.runId, + reason, + ); + this.lastCheckpointAtMs = this.nowMs(); + } catch (error) { + // The events stay pending so the next trigger retries them rather + // than silently dropping an uncheckpointed range. + this.eventsSinceCheckpoint += consumed; + this.report(error); + return; + } + try { + this.onCheckpoint?.(result.manifest, result); + } catch (error) { + this.report(error); + } + }); + } + + /** Never throws: a misbehaving `onError` must not poison {@link chain}. */ + private report(error: unknown): void { + try { + this.onError?.(error); + } catch { + // The scheduler owes the host a non-throwing timer callback and a + // chain that never rejects (criterion 3); a logger that itself throws + // has nowhere safer to report that than nowhere. + } + } + + private nowMs(): number { + const parsed = Date.parse(this.clock.now()); + if (Number.isNaN(parsed)) { + throw new InvalidCheckpointRequestError( + 'clock.now() must return an ISO-8601 timestamp', + ); + } + return parsed; + } +} + +/** Convenience for callers holding a validated `RunConfig`. */ +export function schedulerIntervalsFromConfig(config: { + checkpointEventInterval: number; + checkpointTimeIntervalMs: number; +}): { eventInterval: number; timeIntervalMs: number } { + return { + eventInterval: config.checkpointEventInterval, + timeIntervalMs: config.checkpointTimeIntervalMs, + }; +} + +/** + * A quarter of `timeIntervalMs`, floored at one second, so tick granularity + * does not itself dominate the configured interval (see the class docstring + * for the resulting worst-case bound). + */ +function defaultTickIntervalMs(timeIntervalMs: number): number { + return Math.max(1_000, Math.floor(timeIntervalMs / 4)); +} + +function assertPositiveInteger(field: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new InvalidCheckpointRequestError( + `${field} must be a positive integer, received ${String(value)}`, + ); + } +} diff --git a/packages/checkpoint/src/trees.ts b/packages/checkpoint/src/trees.ts new file mode 100644 index 0000000..9c7c882 --- /dev/null +++ b/packages/checkpoint/src/trees.ts @@ -0,0 +1,251 @@ +/** + * The mapping between event streams, checkpoint tree names, and the + * `TreeReference` a manifest commits for each one + * (LEDGER-INTEGRITY-DESIGN.md §7, §8; docs/evidence-bundle-format.md §5, §6). + * + * The unsigned `intervention` stream is included: its entries do not carry + * individual signatures, but the witness-signed checkpoint commits its exact + * Merkle prefix like every other auxiliary stream. + */ +import { EMPTY_MERKLE_ROOT, merkleLeafHashes, merkleRoot } from '@ald/merkle'; +import { + AUXILIARY_TREES, + EVENT_STREAMS, + GENESIS_HASH, + MANDATORY_TREES, + type CheckpointManifest, + type CheckpointTreeName, + type EventStream, + type StoredEvent, + type TreeReference, + type UnsignedCheckpointManifest, +} from '@ald/types'; + +import { CheckpointIntegrityError } from './errors.js'; + +/** Streams that carry a Merkle root in a checkpoint manifest. */ +export type CheckpointStream = EventStream; + +/** Every checkpointed stream, in the canonical `EVENT_STREAMS` order. */ +export const CHECKPOINT_STREAMS: readonly CheckpointStream[] = EVENT_STREAMS; + +const MANDATORY_STREAMS = Object.keys( + MANDATORY_TREES, +) as (keyof typeof MANDATORY_TREES)[]; + +const AUXILIARY_STREAMS = Object.keys( + AUXILIARY_TREES, +) as (keyof typeof AUXILIARY_TREES)[]; + +function isMandatory( + stream: CheckpointStream, +): stream is keyof typeof MANDATORY_TREES { + return (MANDATORY_STREAMS as CheckpointStream[]).includes(stream); +} + +/** + * Narrows an arbitrary event stream to a checkpointed one. + */ +export function assertCheckpointStream(stream: EventStream): CheckpointStream { + return stream; +} + +/** The LEDGER §8 tree name a stream is committed under. */ +export function treeNameFor(input: EventStream): CheckpointTreeName { + const stream = assertCheckpointStream(input); + return isMandatory(stream) + ? MANDATORY_TREES[stream] + : AUXILIARY_TREES[stream as keyof typeof AUXILIARY_TREES]; +} + +/** The empty-stream reference: size 0, the empty root, and the genesis hash. */ +export const EMPTY_TREE_REFERENCE: TreeReference = { + treeSize: 0, + merkleRoot: EMPTY_MERKLE_ROOT, + lastEntryHash: GENESIS_HASH, +}; + +/** One stream's committed prefix plus the leaves the root was built from. */ +export interface TreeSnapshot { + stream: CheckpointStream; + treeName: CheckpointTreeName; + reference: TreeReference; + leafHashes: string[]; +} + +/** + * Asserts the stored prefix is exactly `1..events.length` with no gap. A gap + * means the tree size does not describe the committed prefix (LEDGER §7), so + * no checkpoint may be built over it. + */ +export function assertContiguousPrefix( + stream: EventStream, + events: readonly StoredEvent[], +): void { + events.forEach((event, index) => { + if (event.sequence !== index + 1) { + throw new CheckpointIntegrityError( + `stream ${stream} is not a contiguous prefix: expected sequence ${String(index + 1)}, stored ${String(event.sequence)}`, + ); + } + }); +} + +/** Builds the `TreeReference` and leaf hashes for one stream's stored prefix. */ +export function snapshotFromEvents( + stream: CheckpointStream, + events: readonly StoredEvent[], +): TreeSnapshot { + assertContiguousPrefix(stream, events); + const leafHashes = merkleLeafHashes(events); + const last = events.at(-1); + return { + stream, + treeName: treeNameFor(stream), + leafHashes, + reference: { + treeSize: events.length, + merkleRoot: merkleRoot(leafHashes), + lastEntryHash: last?.entryHash ?? GENESIS_HASH, + }, + }; +} + +/** + * The reference a manifest commits for `stream`. An auxiliary stream absent + * from `auxiliaryTrees` is committed as empty (LEDGER §8: auxiliary trees + * appear only once they hold at least one event). + */ +export function referenceFor( + manifest: UnsignedCheckpointManifest, + stream: EventStream, +): TreeReference { + const treeName = treeNameFor(stream); + switch (treeName) { + case 'babyA': + return manifest.babyA; + case 'babyB': + return manifest.babyB; + case 'channel': + return manifest.channel; + default: + return manifest.auxiliaryTrees[treeName] ?? EMPTY_TREE_REFERENCE; + } +} + +/** The auxiliary-tree map for a checkpoint: only non-empty streams appear. */ +export function auxiliaryTreesFrom( + snapshots: readonly TreeSnapshot[], +): Record { + const auxiliary: Record = {}; + for (const stream of AUXILIARY_STREAMS) { + const snapshot = snapshots.find((entry) => entry.stream === stream); + if (snapshot !== undefined && snapshot.reference.treeSize > 0) { + auxiliary[snapshot.treeName] = snapshot.reference; + } + } + return auxiliary; +} + +function mandatorySnapshot( + snapshots: readonly TreeSnapshot[], + stream: keyof typeof MANDATORY_TREES, +): TreeSnapshot { + const snapshot = snapshots.find((entry) => entry.stream === stream); + if (snapshot === undefined) { + throw new CheckpointIntegrityError( + `mandatory checkpoint tree ${MANDATORY_TREES[stream]} is missing from the snapshot`, + ); + } + return snapshot; +} + +/** The three mandatory trees of LEDGER §8, in manifest order. */ +export function mandatoryTreesFrom(snapshots: readonly TreeSnapshot[]): { + babyA: TreeReference; + babyB: TreeReference; + channel: TreeReference; +} { + return { + babyA: mandatorySnapshot(snapshots, 'baby-a-ledger').reference, + babyB: mandatorySnapshot(snapshots, 'baby-b-ledger').reference, + channel: mandatorySnapshot(snapshots, 'channel').reference, + }; +} + +/** + * LEDGER §17: the prefix a previous checkpoint committed must still be in the + * store, unchanged, when the next checkpoint is built. A shrinking tree or a + * prefix whose root no longer matches is a rewrite, and no new checkpoint may + * paper over it with a freshly recomputed root. + */ +export function assertPrefixUnchanged( + previous: CheckpointManifest, + snapshots: readonly TreeSnapshot[], +): void { + for (const snapshot of snapshots) { + const committed = referenceFor(previous, snapshot.stream); + if (committed.treeSize > snapshot.reference.treeSize) { + throw new CheckpointIntegrityError( + `${snapshot.treeName} shrank from ${String(committed.treeSize)} at checkpoint ${String(previous.checkpointSequence)} to ${String(snapshot.reference.treeSize)}`, + ); + } + if (committed.treeSize === 0) { + continue; + } + const prefixRoot = merkleRoot( + snapshot.leafHashes.slice(0, committed.treeSize), + ); + if (prefixRoot !== committed.merkleRoot) { + throw new CheckpointIntegrityError( + `checkpoint ${String(previous.checkpointSequence)} committed ${snapshot.treeName} root ${committed.merkleRoot}, but the stored prefix of size ${String(committed.treeSize)} now yields ${prefixRoot}`, + ); + } + } +} + +/** + * ALD-013 criterion 3: a manifest must declare exactly the trees the Evidence + * Store holds, with exactly the sizes, roots, and last hashes ALD-012 + * recomputes. A missing tree, an extra tree, or any mismatched field is an + * integrity failure rather than a warning. + */ +export function assertManifestTrees( + manifest: UnsignedCheckpointManifest | CheckpointManifest, + snapshots: readonly TreeSnapshot[], +): void { + const expectedAuxiliary = auxiliaryTreesFrom(snapshots); + const declared = Object.keys(manifest.auxiliaryTrees).sort(); + const expected = Object.keys(expectedAuxiliary).sort(); + for (const treeName of declared) { + if (!expected.includes(treeName)) { + throw new CheckpointIntegrityError( + `checkpoint declares auxiliary tree ${treeName}, which the Evidence Store does not hold`, + ); + } + } + for (const treeName of expected) { + if (!declared.includes(treeName)) { + throw new CheckpointIntegrityError( + `checkpoint is missing auxiliary tree ${treeName}, which the Evidence Store holds`, + ); + } + } + for (const snapshot of snapshots) { + const committed = referenceFor(manifest, snapshot.stream); + // An empty stream's snapshot reference is exactly EMPTY_TREE_REFERENCE, + // which is also what `referenceFor` returns for an absent auxiliary tree. + const expectedReference = snapshot.reference; + if ( + committed.treeSize !== expectedReference.treeSize || + committed.merkleRoot !== expectedReference.merkleRoot || + committed.lastEntryHash !== expectedReference.lastEntryHash + ) { + throw new CheckpointIntegrityError( + `checkpoint tree ${snapshot.treeName} commits ` + + `size ${String(committed.treeSize)} root ${committed.merkleRoot}, ` + + `but the Evidence Store yields size ${String(expectedReference.treeSize)} root ${expectedReference.merkleRoot}`, + ); + } + } +} diff --git a/packages/checkpoint/tsconfig.json b/packages/checkpoint/tsconfig.json new file mode 100644 index 0000000..2890d1a --- /dev/null +++ b/packages/checkpoint/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../types" }, + { "path": "../hashing" }, + { "path": "../merkle" }, + { "path": "../evidence" }, + { "path": "../lifecycle" }, + { "path": "../learners" }, + { "path": "../verifier" } + ] +} diff --git a/packages/config/__tests__/environment.test.ts b/packages/config/__tests__/environment.test.ts index 3227df0..c0dd3a9 100644 --- a/packages/config/__tests__/environment.test.ts +++ b/packages/config/__tests__/environment.test.ts @@ -10,20 +10,42 @@ describe('loadRuntimeEnvironment', () => { deploymentMode: 'prototype', evidenceDir: './evidence', databasePath: './evidence/ald.sqlite', - keyDir: './evidence/keys', + signerSeedsFile: undefined, logLevel: 'info', + anchorClass: 'simulated', baseNetwork: 'base-sepolia', - baseRpcUrl: undefined, + baseRpcUrlFile: undefined, anchorKeyFile: undefined, }); }); - it('fails fast when research-grade key isolation is not configured', () => { + it('fails fast when research-grade Fort signer material is not configured', () => { expect(() => loadRuntimeEnvironment({ ALD_DEPLOYMENT_MODE: 'research-grade', }), - ).toThrow('ALD_KEY_DIR is required in research-grade mode'); + ).toThrow( + 'ALD_RUN_SIGNER_SEEDS_JSON_FILE is required in research-grade mode', + ); + }); + + it('accepts only Fort file paths for secret-bearing values', () => { + expect( + loadRuntimeEnvironment({ + ALD_DEPLOYMENT_MODE: 'research-grade', + ALD_RUN_SIGNER_SEEDS_JSON_FILE: '/run/secrets/signers', + ALD_BASE_RPC_URL_FILE: '/run/secrets/rpc-url', + ALD_ANCHOR_KEY_FILE: '/run/secrets/anchor-key', + }), + ).toMatchObject({ + signerSeedsFile: '/run/secrets/signers', + baseRpcUrlFile: '/run/secrets/rpc-url', + anchorKeyFile: '/run/secrets/anchor-key', + }); + + expect(() => + loadRuntimeEnvironment({ ALD_RUN_SIGNER_SEEDS_JSON: 'secret' }), + ).toThrow(/direct secret value/u); }); it('rejects invalid integer values', () => { diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 54f16ac..30d141a 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -7,10 +7,11 @@ export interface RuntimeEnvironment { deploymentMode: DeploymentMode; evidenceDir: string; databasePath: string; - keyDir: string; + signerSeedsFile?: string; logLevel: LogLevel; + anchorClass: 'simulated' | 'public-chain'; baseNetwork: 'base-sepolia' | 'base-mainnet'; - baseRpcUrl?: string; + baseRpcUrlFile?: string; anchorKeyFile?: string; } @@ -59,6 +60,17 @@ function requireValue(source: EnvironmentSource, name: string): string { export function loadRuntimeEnvironment( source: EnvironmentSource = process.env, ): RuntimeEnvironment { + for (const forbidden of [ + 'ALD_RUN_SIGNER_SEEDS_JSON', + 'ALD_BASE_RPC_URL', + 'ALD_ANCHOR_KEY', + ]) { + if (source[forbidden] !== undefined) { + throw new Error( + `${forbidden} is a direct secret value; use si fort --mode files`, + ); + } + } const deploymentMode = parseEnum( source, 'ALD_DEPLOYMENT_MODE', @@ -66,10 +78,10 @@ export function loadRuntimeEnvironment( 'prototype', ); const evidenceDir = source.ALD_EVIDENCE_DIR ?? './evidence'; - const keyDir = + const signerSeedsFile = deploymentMode === 'research-grade' - ? requireValue(source, 'ALD_KEY_DIR') - : (source.ALD_KEY_DIR ?? `${evidenceDir}/keys`); + ? requireValue(source, 'ALD_RUN_SIGNER_SEEDS_JSON_FILE') + : source.ALD_RUN_SIGNER_SEEDS_JSON_FILE; return { dtsfPort: parseInteger(source, 'DTSF_PORT', 8080), @@ -77,20 +89,26 @@ export function loadRuntimeEnvironment( deploymentMode, evidenceDir, databasePath: source.ALD_DATABASE_PATH ?? `${evidenceDir}/ald.sqlite`, - keyDir, + signerSeedsFile, logLevel: parseEnum( source, 'ALD_LOG_LEVEL', ['debug', 'info', 'warn', 'error'] as const, 'info', ), + anchorClass: parseEnum( + source, + 'ALD_ANCHOR_CLASS', + ['simulated', 'public-chain'] as const, + 'simulated', + ), baseNetwork: parseEnum( source, 'ALD_BASE_NETWORK', ['base-sepolia', 'base-mainnet'] as const, 'base-sepolia', ), - baseRpcUrl: source.ALD_BASE_RPC_URL || undefined, + baseRpcUrlFile: source.ALD_BASE_RPC_URL_FILE || undefined, anchorKeyFile: source.ALD_ANCHOR_KEY_FILE || undefined, }; } diff --git a/packages/crypto-research/__tests__/.gitkeep b/packages/crypto-research/__tests__/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/crypto-research/__tests__/boundary.test.ts b/packages/crypto-research/__tests__/boundary.test.ts new file mode 100644 index 0000000..5d3cfd7 --- /dev/null +++ b/packages/crypto-research/__tests__/boundary.test.ts @@ -0,0 +1,54 @@ +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all( + temporary.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +function runBoundary(root: string) { + return spawnSync( + process.execPath, + [join(process.cwd(), 'scripts', 'lint-crypto-boundary.mjs'), root], + { encoding: 'utf8' }, + ); +} + +describe('ALD-070 import boundary', () => { + it('accepts production crypto modules with no research-harness dependency', async () => { + const root = await mkdtemp(join(tmpdir(), 'ald-crypto-boundary-clean-')); + temporary.push(root); + await mkdir(join(root, 'packages', 'hashing', 'src'), { recursive: true }); + await mkdir(join(root, 'packages', 'anchor', 'src'), { recursive: true }); + await writeFile( + join(root, 'packages', 'hashing', 'src', 'sign.ts'), + "import { createHash } from 'node:crypto';\n", + ); + + const result = runBoundary(root); + expect(result.status).toBe(0); + expect(result.stdout).toContain('are isolated'); + }); + + it('fails when hashing or anchoring imports the research harness', async () => { + const root = await mkdtemp(join(tmpdir(), 'ald-crypto-boundary-bad-')); + temporary.push(root); + await mkdir(join(root, 'packages', 'hashing', 'src'), { recursive: true }); + await mkdir(join(root, 'packages', 'anchor', 'src'), { recursive: true }); + await writeFile( + join(root, 'packages', 'anchor', 'src', 'publisher.ts'), + "import { EphemeralEncodingHarness } from '@ald/crypto-research';\n", + ); + + const result = runBoundary(root); + expect(result.status).toBe(1); + expect(result.stderr).toContain('packages/anchor/src/publisher.ts'); + }); +}); diff --git a/packages/crypto-research/__tests__/harness.test.ts b/packages/crypto-research/__tests__/harness.test.ts new file mode 100644 index 0000000..121b622 --- /dev/null +++ b/packages/crypto-research/__tests__/harness.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalJson } from '@ald/hashing'; + +import { + CRYPTOGRAPHIC_SECURITY_STATUS, + ENCODING_ROLES, + EphemeralEncodingHarness, + RESEARCH_ONLY_NOTICE, +} from '../src/index.js'; + +class StepClock { + private tick = 0; + + now(): string { + const value = new Date(Date.UTC(2026, 8, 2, 0, 0, this.tick)).toISOString(); + this.tick += 1; + return value; + } +} + +function harness(priorArtifactHashes: string[] = []): EphemeralEncodingHarness { + return new EphemeralEncodingHarness({ + runId: 'e40-harness-test', + carrierMode: 'generative-canvas', + cipherThreatModel: 'novelty-only', + syntheticMessagesOnly: true, + clock: new StepClock(), + priorArtifactHashes, + }); +} + +describe('E40 ephemeral encoding harness', () => { + it('refuses a non-synthetic run even if an untyped caller bypasses TypeScript', () => { + expect( + () => + new EphemeralEncodingHarness({ + runId: 'unsafe-e40-run', + carrierMode: 'generative-canvas', + cipherThreatModel: 'novelty-only', + syntheticMessagesOnly: false, + clock: new StepClock(), + } as unknown as ConstructorParameters[0]), + ).toThrow(/synthetic messages only/u); + }); + + it('requires both independent nonce commitments and logs every scheme change', () => { + const run = harness(); + expect(run.participants).toEqual(ENCODING_ROLES); + run.commitNonce('baby-a', 'nonce-a'); + expect(() => + run.registerScheme({ + proposedBy: 'baby-a', + canonicalProtocolArtifact: { strokes: [1, 2] }, + publicSalt: 'salt-1', + }), + ).toThrow(/both Baby nonce commitments/u); + run.commitNonce('baby-b', 'nonce-b'); + const first = run.registerScheme({ + proposedBy: 'baby-a', + canonicalProtocolArtifact: { strokes: [1, 2] }, + publicSalt: 'salt-1', + }); + const second = run.registerScheme({ + proposedBy: 'baby-b', + canonicalProtocolArtifact: { strokes: [2, 1] }, + publicSalt: 'salt-2', + }); + + expect(first.sequence).toBe(1); + expect(second.sequence).toBe(2); + expect(second.previousEventHash).toBe(first.eventHash); + expect(second.changedAt).not.toBe(first.changedAt); + expect(() => + run.registerScheme({ + proposedBy: 'baby-b', + canonicalProtocolArtifact: { strokes: [2, 1] }, + publicSalt: 'salt-2', + }), + ).toThrow(/already registered/u); + }); + + it('records eavesdropper recovery without retaining raw synthetic messages', () => { + const run = harness(); + run.commitNonce('baby-a', 'nonce-a'); + run.commitNonce('baby-b', 'nonce-b'); + const scheme = run.registerScheme({ + proposedBy: 'baby-a', + canonicalProtocolArtifact: { transform: [3, 1, 2] }, + publicSalt: 'public-salt', + }); + const failed = run.recordEavesdropperAttempt({ + schemeId: scheme.schemeId, + adversaryId: 'eve-unseen-1', + architectureClass: 'unseen', + messageId: 'synthetic-message-1', + guess: [0, 1], + actual: [1, 0], + }); + const recovered = run.recordEavesdropperAttempt({ + schemeId: scheme.schemeId, + adversaryId: 'eve-history-1', + architectureClass: 'history-trained', + messageId: 'synthetic-message-2', + guess: [1, 0], + actual: [1, 0], + }); + const serialized = canonicalJson(run.report()); + + expect(failed).toMatchObject({ role: 'eavesdropper', recovered: false }); + expect(recovered.recovered).toBe(true); + expect(serialized).not.toContain('synthetic-message-1'); + expect(serialized).not.toContain('synthetic-message-2'); + expect(run.report().eavesdropperAttempts).toHaveLength(2); + }); + + it('reports novelty separately and can never report cryptographic security', () => { + const firstRun = harness(); + firstRun.commitNonce('baby-a', 'nonce-a'); + firstRun.commitNonce('baby-b', 'nonce-b'); + const scheme = firstRun.registerScheme({ + proposedBy: 'baby-a', + canonicalProtocolArtifact: { transform: [1, 2, 3] }, + publicSalt: 'public-salt', + }); + + const compared = harness([scheme.artifactHash]); + compared.commitNonce('baby-a', 'nonce-a'); + compared.commitNonce('baby-b', 'nonce-b'); + compared.registerScheme({ + proposedBy: 'baby-a', + canonicalProtocolArtifact: { transform: [1, 2, 3] }, + publicSalt: 'public-salt', + }); + const report = compared.report(); + + expect(report.uniqueArtifactCount).toBe(0); + expect(report.priorRegistryCollisions).toEqual([scheme.artifactHash]); + expect(report.cryptographicSecurity).toBe(CRYPTOGRAPHIC_SECURITY_STATUS); + expect(report.researchOnlyNotice).toBe(RESEARCH_ONLY_NOTICE); + expect(JSON.stringify(report)).not.toMatch(/production-ready/iu); + }); +}); diff --git a/packages/crypto-research/package.json b/packages/crypto-research/package.json new file mode 100644 index 0000000..4f7bfde --- /dev/null +++ b/packages/crypto-research/package.json @@ -0,0 +1,23 @@ +{ + "name": "@ald/crypto-research", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/analysis": "*", + "zod": "4.4.3" + } +} diff --git a/packages/crypto-research/src/index.ts b/packages/crypto-research/src/index.ts new file mode 100644 index 0000000..733d3fc --- /dev/null +++ b/packages/crypto-research/src/index.ts @@ -0,0 +1,259 @@ +/** + * E40 ephemeral-encoding research instrumentation (ALD-069/070). + * + * This package records scheme changes and eavesdropper attempts over synthetic + * messages. It deliberately provides no encryption API and always reports + * cryptographic security as `not-established`. + */ +import { + canonicalJson, + encodeHash, + hashCanonical, + sha256Bytes, +} from '@ald/hashing'; +import { HASH_DOMAINS, type Clock, type Sha256Hash } from '@ald/types'; + +export const PACKAGE_NAME = '@ald/crypto-research'; +export const ENCODING_HARNESS_VERSION = 'ephemeral-encoding-harness-v1'; +export const CRYPTOGRAPHIC_SECURITY_STATUS = 'not-established' as const; +export const RESEARCH_ONLY_NOTICE = + 'Research instrumentation only. Novelty and adversarial recovery measurements do not establish cryptographic security.'; + +export const ENCODING_ROLES = [ + 'baby-a', + 'baby-b', + 'eavesdropper', +] as const; +export type EncodingRole = (typeof ENCODING_ROLES)[number]; +export type CommunicatingRole = Exclude; +export type CipherThreatModel = + | 'post-run-disclosure' + | 'external-observer-only' + | 'novelty-only'; +export type GenerativeCarrier = + | 'generative-bitmap' + | 'generative-canvas' + | 'generative-tone'; + +export interface NonceCommitment { + role: CommunicatingRole; + commitmentHash: Sha256Hash; + committedAt: string; +} + +export interface EncodingSchemeEvent { + version: 1; + runId: string; + sequence: number; + schemeId: Sha256Hash; + artifactHash: Sha256Hash; + proposedBy: CommunicatingRole; + carrierMode: GenerativeCarrier; + nonceCommitments: Record; + publicSaltHash: Sha256Hash; + previousEventHash: Sha256Hash; + eventHash: Sha256Hash; + changedAt: string; +} + +export interface EavesdropperAttempt { + version: 1; + runId: string; + sequence: number; + role: 'eavesdropper'; + schemeId: Sha256Hash; + adversaryId: string; + architectureClass: 'history-trained' | 'training-time' | 'unseen'; + messageIdHash: Sha256Hash; + guessHash: Sha256Hash; + recovered: boolean; + attemptedAt: string; +} + +export interface EncodingHarnessReport { + kind: 'encoding-events'; + analysisVersion: typeof ENCODING_HARNESS_VERSION; + runId: string; + participants: readonly EncodingRole[]; + carrierMode: GenerativeCarrier; + cipherThreatModel: CipherThreatModel; + syntheticMessagesOnly: true; + nonceCommitments: NonceCommitment[]; + schemeEvents: EncodingSchemeEvent[]; + eavesdropperAttempts: EavesdropperAttempt[]; + uniqueArtifactCount: number; + priorRegistryCollisions: Sha256Hash[]; + cryptographicSecurity: typeof CRYPTOGRAPHIC_SECURITY_STATUS; + researchOnlyNotice: typeof RESEARCH_ONLY_NOTICE; +} + +export interface EphemeralEncodingHarnessOptions { + runId: string; + carrierMode: GenerativeCarrier; + cipherThreatModel: CipherThreatModel; + syntheticMessagesOnly: true; + clock: Clock; + /** Artifact hashes observed before this run; novelty comparison only. */ + priorArtifactHashes?: Iterable; +} + +export interface RegisterSchemeInput { + proposedBy: CommunicatingRole; + /** Canonicalizable public description; never a production secret or key. */ + canonicalProtocolArtifact: unknown; + publicSalt: string; +} + +export interface RecordEavesdropperAttemptInput { + schemeId: Sha256Hash; + adversaryId: string; + architectureClass: EavesdropperAttempt['architectureClass']; + messageId: string; + guess: unknown; + actual: unknown; +} + +const GENESIS_EVENT_HASH = `sha256:${'0'.repeat(64)}`; + +function assertNonEmpty(name: string, value: string): void { + if (value.trim().length === 0) { + throw new Error(`${name} must be non-empty`); + } +} + +function plainTextHash(value: string): Sha256Hash { + return encodeHash(sha256Bytes(Buffer.from(value, 'utf8'))); +} + +/** Append-only, one-run E40 measurement harness. */ +export class EphemeralEncodingHarness { + readonly participants = ENCODING_ROLES; + + private readonly commitments = new Map(); + private readonly schemes: EncodingSchemeEvent[] = []; + private readonly attempts: EavesdropperAttempt[] = []; + private readonly priorArtifacts: Set; + + constructor(private readonly options: EphemeralEncodingHarnessOptions) { + assertNonEmpty('runId', options.runId); + if (options.syntheticMessagesOnly !== true) { + throw new Error('E40 harness accepts synthetic messages only'); + } + this.priorArtifacts = new Set(options.priorArtifactHashes ?? []); + } + + /** Commit a nonce contribution without retaining or exposing the nonce. */ + commitNonce(role: CommunicatingRole, nonce: string): NonceCommitment { + assertNonEmpty('nonce', nonce); + if (this.commitments.has(role)) { + throw new Error(`${role} already committed a nonce`); + } + const commitment: NonceCommitment = { + role, + commitmentHash: hashCanonical(HASH_DOMAINS.nonceCommitment, { + runId: this.options.runId, + role, + nonce, + }), + committedAt: this.options.clock.now(), + }; + this.commitments.set(role, commitment); + return commitment; + } + + /** Record one distinct scheme change after both nonce commitments exist. */ + registerScheme(input: RegisterSchemeInput): EncodingSchemeEvent { + assertNonEmpty('publicSalt', input.publicSalt); + const babyA = this.commitments.get('baby-a'); + const babyB = this.commitments.get('baby-b'); + if (babyA === undefined || babyB === undefined) { + throw new Error('both Baby nonce commitments are required before a scheme'); + } + canonicalJson(input.canonicalProtocolArtifact); + const artifactHash = hashCanonical(HASH_DOMAINS.encodingScheme, { + runId: this.options.runId, + carrierMode: this.options.carrierMode, + nonceCommitments: { + 'baby-a': babyA.commitmentHash, + 'baby-b': babyB.commitmentHash, + }, + publicSalt: input.publicSalt, + protocolArtifact: input.canonicalProtocolArtifact, + }); + if (this.schemes.some((event) => event.artifactHash === artifactHash)) { + throw new Error(`scheme ${artifactHash} is already registered in this run`); + } + const unsigned = { + version: 1 as const, + runId: this.options.runId, + sequence: this.schemes.length + 1, + schemeId: artifactHash, + artifactHash, + proposedBy: input.proposedBy, + carrierMode: this.options.carrierMode, + nonceCommitments: { + 'baby-a': babyA.commitmentHash, + 'baby-b': babyB.commitmentHash, + }, + publicSaltHash: plainTextHash(input.publicSalt), + previousEventHash: this.schemes.at(-1)?.eventHash ?? GENESIS_EVENT_HASH, + changedAt: this.options.clock.now(), + }; + const event: EncodingSchemeEvent = { + ...unsigned, + eventHash: hashCanonical(HASH_DOMAINS.encodingScheme, unsigned), + }; + this.schemes.push(event); + return event; + } + + /** Record Eve's result as hashes and a recovery bit, never raw messages. */ + recordEavesdropperAttempt( + input: RecordEavesdropperAttemptInput, + ): EavesdropperAttempt { + assertNonEmpty('adversaryId', input.adversaryId); + if (!this.schemes.some((event) => event.schemeId === input.schemeId)) { + throw new Error(`unknown scheme ${input.schemeId}`); + } + const guessCanonical = canonicalJson(input.guess); + const actualCanonical = canonicalJson(input.actual); + const attempt: EavesdropperAttempt = { + version: 1, + runId: this.options.runId, + sequence: this.attempts.length + 1, + role: 'eavesdropper', + schemeId: input.schemeId, + adversaryId: input.adversaryId, + architectureClass: input.architectureClass, + messageIdHash: plainTextHash(input.messageId), + guessHash: plainTextHash(guessCanonical), + recovered: guessCanonical === actualCanonical, + attemptedAt: this.options.clock.now(), + }; + this.attempts.push(attempt); + return attempt; + } + + /** Canonicalizable `encoding-events` attachment value. */ + report(): EncodingHarnessReport { + const collisions = this.schemes + .filter((event) => this.priorArtifacts.has(event.artifactHash)) + .map((event) => event.artifactHash); + return { + kind: 'encoding-events', + analysisVersion: ENCODING_HARNESS_VERSION, + runId: this.options.runId, + participants: this.participants, + carrierMode: this.options.carrierMode, + cipherThreatModel: this.options.cipherThreatModel, + syntheticMessagesOnly: true, + nonceCommitments: [...this.commitments.values()], + schemeEvents: [...this.schemes], + eavesdropperAttempts: [...this.attempts], + uniqueArtifactCount: this.schemes.length - collisions.length, + priorRegistryCollisions: collisions, + cryptographicSecurity: CRYPTOGRAPHIC_SECURITY_STATUS, + researchOnlyNotice: RESEARCH_ONLY_NOTICE, + }; + } +} diff --git a/packages/crypto-research/tsconfig.json b/packages/crypto-research/tsconfig.json new file mode 100644 index 0000000..a63da40 --- /dev/null +++ b/packages/crypto-research/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../types" + }, + { + "path": "../hashing" + }, + { + "path": "../analysis" + } + ] +} diff --git a/packages/evidence/__tests__/api.test.ts b/packages/evidence/__tests__/api.test.ts new file mode 100644 index 0000000..dbbaf11 --- /dev/null +++ b/packages/evidence/__tests__/api.test.ts @@ -0,0 +1,66 @@ +/** + * ALD-010 criterion 3 regression guard: the package must not expose any + * function that writes an event table other than `SqliteEvidenceWriter`. + * Every SQL statement against the event tables lives in `src/writer.ts` and + * is private to it; this test fails if a new raw-SQL helper is exported. + */ +import { describe, expect, it } from 'vitest'; + +import * as evidence from '../src/index.js'; + +const EXPECTED_EXPORTS = [ + 'CheckpointChainError', + 'DuplicateEventError', + 'DuplicateRunError', + 'EvidenceWriterError', + 'ExperimentRecordVersionError', + 'ForkDetectedError', + 'IntegrityBlockedError', + 'InterpretationBindingError', + 'InvalidRequestError', + 'LEDGER_EVENT_TYPES', + 'SqliteEvidenceWriter', + 'UnknownRunError', + 'applyMigrations', + 'buildRunManifest', + 'canonicalizeJson', + 'deserializeUnsignedLedgerEvent', + 'exportRunBundle', + 'isLedgerEventType', + 'migrations', + 'openEvidenceDatabase', + 'parseCanonicalJson', + 'serializeUnsignedLedgerEvent', + 'validateLedgerEventDraft', +]; + +describe('public API surface', () => { + it('exports exactly the curated runtime symbols', () => { + expect(Object.keys(evidence).sort()).toEqual(EXPECTED_EXPORTS); + }); + + it('routes every event-table write through the writer class', () => { + const writeMethods = [ + 'registerRun', + 'commitTurn', + 'commitRejection', + 'commitControlArtifact', + 'appendLedgerEvent', + 'appendTurnRecord', + 'appendInterventionEvent', + 'appendAuditLedgerEntry', + 'appendAffectEvent', + 'insertCheckpointManifest', + 'insertAnchorReceipt', + 'appendExperimentRecord', + 'acknowledgeIntegrityReview', + ]; + for (const method of writeMethods) { + expect( + typeof (evidence.SqliteEvidenceWriter.prototype as Record)[ + method + ], + ).toBe('function'); + } + }); +}); diff --git a/packages/evidence/__tests__/crash-safety.test.ts b/packages/evidence/__tests__/crash-safety.test.ts new file mode 100644 index 0000000..4f82ddd --- /dev/null +++ b/packages/evidence/__tests__/crash-safety.test.ts @@ -0,0 +1,138 @@ +/** + * ALD-011: WAL durability and crash safety. + * + * Each trial spawns a child process that commits turns through + * `SqliteEvidenceWriter` as fast as it can, SIGKILLs it at a random point, + * then reopens the store and proves the recovered state is a committed prefix: + * `recover()` reports no violation, and for every turn the sender intention + * event and its channel event are either both present or both absent + * (SPEC §8.2, LEDGER §3, §15). + */ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + cleanupTemporaryDirectories, + createWriter, + temporaryDatabasePath, +} from './fixtures/support.js'; + +afterEach(cleanupTemporaryDirectories); + +const RUN_ID = 'run-test-001'; +const TRIALS = 20; +const CHILD = fileURLToPath(new URL('./fixtures/crash-writer.ts', import.meta.url)); + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function crashAfterRandomDelay(databasePath: string): Promise { + const child = spawn(process.execPath, ['--import', 'tsx', CHILD, databasePath], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const errors: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => errors.push(chunk)); + + const ready = new Promise((resolve, reject) => { + let buffered = ''; + child.stdout.on('data', (chunk: Buffer) => { + buffered += chunk.toString('utf8'); + if (buffered.includes('ready')) { + resolve(); + } + }); + child.once('exit', () => + reject( + new Error( + `crash-writer exited before it was ready: ${Buffer.concat(errors).toString('utf8')}`, + ), + ), + ); + }); + + await ready; + await delay(2 + Math.floor(Math.random() * 30)); + child.kill('SIGKILL'); + await once(child, 'exit'); +} + +describe('WAL crash safety', () => { + it(`recovers a committed prefix after ${TRIALS} randomized kills`, async () => { + const observedTurns: number[] = []; + + for (let trial = 0; trial < TRIALS; trial += 1) { + const databasePath = await temporaryDatabasePath(); + await crashAfterRandomDelay(databasePath); + + const context = await createWriter({ path: databasePath, register: false }); + try { + const report = await context.writer.recover(RUN_ID); + expect(report.chainViolations).toEqual([]); + expect(report.forks).toEqual([]); + expect(report.ok).toBe(true); + + const ledger = context.writer + .readEvents(RUN_ID, 'baby-a-ledger') + .map((event) => JSON.parse(event.canonicalJson) as { + turn: number; + eventType: string; + sequence: number; + }); + const channel = context.writer + .readEvents(RUN_ID, 'channel') + .map((event) => JSON.parse(event.canonicalJson) as { + turn: number; + sequence: number; + senderLedgerSequence?: number; + senderEntryHash?: string; + }); + + // Both-or-neither: one commitTurn writes exactly one intention event + // and one channel event, in one transaction. + const intentionTurns = ledger + .filter((event) => event.eventType === 'intention.recorded') + .map((event) => event.turn); + expect(intentionTurns).toEqual(ledger.map((event) => event.turn)); + expect(channel.map((event) => event.turn)).toEqual(intentionTurns); + + // Sequences are dense and consistent across the two chains. + expect(ledger.map((event) => event.sequence)).toEqual( + ledger.map((_, index) => index + 1), + ); + expect(channel.map((event) => event.sequence)).toEqual( + channel.map((_, index) => index + 1), + ); + expect( + context.writer.chainHead(RUN_ID, 'baby-a-ledger').size, + ).toBe(context.writer.chainHead(RUN_ID, 'channel').size); + + // Every channel event still points at its sender ledger event. + const byLedgerSequence = new Map( + context.writer + .readEvents(RUN_ID, 'baby-a-ledger') + .map((event) => [event.sequence, event.entryHash]), + ); + for (const event of channel) { + expect(byLedgerSequence.get(event.senderLedgerSequence ?? -1)).toBe( + event.senderEntryHash, + ); + } + + observedTurns.push(ledger.length); + } finally { + context.close(); + } + } + + // The harness must actually have committed work before each kill. + expect(observedTurns).toHaveLength(TRIALS); + expect(Math.min(...observedTurns)).toBeGreaterThan(0); + }, 40_000); +}); diff --git a/packages/evidence/__tests__/database.test.ts b/packages/evidence/__tests__/database.test.ts index 789030e..79b574f 100644 --- a/packages/evidence/__tests__/database.test.ts +++ b/packages/evidence/__tests__/database.test.ts @@ -1,10 +1,239 @@ +import { createHash } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; -import { openEvidenceDatabase } from '../src/index.js'; +import { migrations, openEvidenceDatabase } from '../src/index.js'; + +// The applied schema is checksum-verified (see `applyMigrations`), so the SQL +// of a released migration may never change. Pinning the digest here turns an +// accidental edit into a failing test instead of a store that refuses to open. +const APPLIED_MIGRATION_CHECKSUMS: Record = { + 1: '31241366310597875775715ac638810eccd586dd179f0d350b641bf0716290d9', + 2: '3ef7eb58ac9954d2c1ba29b2e0cfd85b8b10ca5b64918eb5420b746288dfed50', + 3: 'f2f2893d29e954fa412bdd1dcd1a3ab8c59975725dc06306b150548e10bf50e3', +}; + +const RUN_ID = 'run-append-only'; +const RECORDED_AT = '1970-01-01T00:00:00.000Z'; +const TRANSACTION_HASH = `0x${'a'.repeat(64)}`; + +function hash(hexDigit: string): string { + return `sha256:${hexDigit.repeat(64)}`; +} + +/** + * One seed row per append-only table (LEDGER §3), in foreign-key order, with + * the non-key column a `REPLACE` would silently rewrite. + */ +interface ProtectedRow { + table: string; + columns: Record; + mutated: string; + /** Replacement value; must satisfy the column CHECK so only the trigger fires. */ + mutatedValue?: string; +} + +const PROTECTED_ROWS: readonly ProtectedRow[] = [ + { + table: 'run_metadata', + columns: { + run_id: RUN_ID, + created_at: RECORDED_AT, + deployment_mode: 'prototype', + configuration_hash: hash('a'), + configuration_json: '{}', + }, + mutated: 'deployment_mode', + mutatedValue: 'research-grade', + }, + { + table: 'ledger_events', + columns: { + run_id: RUN_ID, + baby_id: 'A', + sequence: 1, + turn: 1, + event_type: 'intention.recorded', + content_schema: 'agent-native-ledger', + previous_entry_hash: hash('0'), + entry_hash: hash('1'), + writer_key_id: 'baby-a-ledger-v1', + writer_signature: 'ed25519:AA==', + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'channel_events', + columns: { + run_id: RUN_ID, + sequence: 1, + turn: 1, + logical_sender: 'baby-a', + origin: 'baby', + communication_condition: 'normal', + public_artifact_hash: hash('2'), + previous_channel_hash: hash('0'), + validation_result: 'accepted', + entry_hash: hash('3'), + writer_key_id: 'channel-v1', + writer_signature: 'ed25519:AA==', + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'affect_events', + columns: { + run_id: RUN_ID, + sequence: 1, + turn: 1, + sender: 'baby-a', + display_id: 'A1', + affect_mode: 'declared', + previous_entry_hash: hash('0'), + entry_hash: hash('4'), + writer_key_id: 'affect-v1', + writer_signature: 'ed25519:AA==', + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'audit_ledger_entries', + columns: { + run_id: RUN_ID, + sequence: 1, + baby_id: 'A', + source: 'generated-analysis', + source_entry_hash: hash('1'), + previous_entry_hash: hash('0'), + entry_hash: hash('5'), + writer_key_id: 'audit-v1', + writer_signature: 'ed25519:AA==', + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'turn_records', + columns: { + run_id: RUN_ID, + sequence: 1, + turn: 1, + phase: 'running', + previous_entry_hash: hash('0'), + entry_hash: hash('6'), + writer_key_id: 'nursery-witness-v1', + writer_signature: 'ed25519:AA==', + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'intervention_log', + columns: { + run_id: RUN_ID, + sequence: 1, + event_type: 'annotate', + actor_id: 'researcher-1', + reason_code: 'note', + previous_entry_hash: hash('0'), + entry_hash: hash('7'), + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'checkpoint_manifests', + columns: { + run_id: RUN_ID, + checkpoint_sequence: 0, + checkpoint_hash: hash('8'), + previous_checkpoint_hash: hash('0'), + witness_key_id: 'nursery-witness-v1', + witness_signature: 'ed25519:AA==', + created_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'anchor_receipts', + columns: { + run_id: RUN_ID, + checkpoint_hash: hash('8'), + chain_id: 84_532, + transaction_hash: TRANSACTION_HASH, + block_number: 1, + status: 'submitted', + finality_policy: '1-confirmation', + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'experiment_records', + columns: { + run_id: RUN_ID, + record_version: 1, + experiment_id: 'E00', + disposition: 'valid', + checkpoint_manifest_ref: hash('8'), + anchor_tx_ref: TRANSACTION_HASH, + verifier_report_ref: 'verifier-report-1', + recorded_at: RECORDED_AT, + canonical_json: '{}', + }, + mutated: 'canonical_json', + }, + { + table: 'run_signers', + columns: { + run_id: RUN_ID, + domain: 'witness', + key_id: 'nursery-witness-v1', + public_key: 'ed25519-pub:AA==', + recorded_at: RECORDED_AT, + }, + mutated: 'public_key', + }, + { + table: 'fork_artifacts', + columns: { + run_id: RUN_ID, + stream: 'channel', + sequence: 1, + entry_hash: hash('9'), + canonical_json: '{}', + detected_at: RECORDED_AT, + }, + mutated: 'canonical_json', + }, +]; + +function insertSql(row: ProtectedRow, verb: string): string { + const names = Object.keys(row.columns); + return `${verb} INTO ${row.table} (${names.join(', ')}) + VALUES (${names.map((name) => `@${name}`).join(', ')})`; +} + +function seedProtectedRows(database: Database.Database): void { + for (const row of PROTECTED_ROWS) { + database.prepare(insertSql(row, 'INSERT')).run(row.columns); + } +} const temporaryDirectories: string[] = []; @@ -42,15 +271,19 @@ describe('evidence database migrations', () => { expect(journalMode).toBe('wal'); expect(tables).toEqual([ 'affect_events', + 'analysis_attachments', 'anchor_receipts', 'audit_ledger_entries', 'channel_events', 'checkpoint_manifests', 'experiment_records', + 'fork_artifacts', 'intervention_log', 'ledger_events', 'run_metadata', + 'run_signers', 'schema_migrations', + 'turn_records', ]); evidence.close(); @@ -68,6 +301,9 @@ describe('evidence database migrations', () => { expect(migrations).toEqual([ { version: 1, name: 'initial-evidence-schema' }, + { version: 2, name: 'turn-records-signers-and-fork-artifacts' }, + { version: 3, name: 'fork-artifact-stream-check-and-run-reference' }, + { version: 4, name: 'analysis-attachments' }, ]); second.close(); @@ -117,4 +353,125 @@ describe('evidence database migrations', () => { evidence.close(); }); + + it('never changes the SQL of an already applied migration', () => { + for (const migration of migrations) { + const expected = APPLIED_MIGRATION_CHECKSUMS[migration.version]; + if (expected === undefined) { + continue; + } + expect(createHash('sha256').update(migration.sql).digest('hex')).toBe( + expected, + ); + } + }); + + it('rejects updates and deletes from the migration 2 tables', async () => { + const evidence = openEvidenceDatabase(await temporaryDatabasePath()); + seedProtectedRows(evidence.database); + + expect(() => + evidence.database + .prepare("UPDATE fork_artifacts SET entry_hash = 'sha256:bb'") + .run(), + ).toThrow('append-only table: fork_artifacts'); + expect(() => + evidence.database.prepare('DELETE FROM fork_artifacts').run(), + ).toThrow('append-only table: fork_artifacts'); + + for (const table of ['turn_records', 'run_signers']) { + const triggers = evidence.database + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'trigger' AND tbl_name = ? ORDER BY name`, + ) + .all(table) + .map((row) => (row as { name: string }).name); + expect(triggers).toEqual([ + `${table}_reject_delete`, + `${table}_reject_update`, + ]); + } + + evidence.close(); + }); + it('enables recursive triggers so REPLACE cannot bypass the append-only guards', async () => { + // LEDGER §3: the guard triggers are BEFORE UPDATE / BEFORE DELETE, and + // SQLite routes the implicit row deletion of the REPLACE conflict + // algorithm through BEFORE DELETE triggers only when recursive triggers + // are on. Without the pragma `INSERT OR REPLACE` destroys and rewrites a + // committed event with no error at all. + const evidence = openEvidenceDatabase(await temporaryDatabasePath()); + + expect( + evidence.database.pragma('recursive_triggers', { simple: true }), + ).toBe(1); + + seedProtectedRows(evidence.database); + + for (const row of PROTECTED_ROWS) { + const replacement = { + ...row.columns, + [row.mutated]: row.mutatedValue ?? '{"tampered":true}', + }; + expect(() => + evidence.database + .prepare(insertSql(row, 'INSERT OR REPLACE')) + .run(replacement), + ).toThrow(`append-only table: ${row.table}`); + expect(() => + evidence.database.prepare(insertSql(row, 'REPLACE')).run(replacement), + ).toThrow(`append-only table: ${row.table}`); + + const stored = evidence.database + .prepare(`SELECT ${row.mutated} AS value FROM ${row.table}`) + .all() + .map((stored) => (stored as { value: unknown }).value); + expect(stored).toEqual([row.columns[row.mutated]]); + } + + evidence.close(); + }); + + it('constrains fork_artifacts to known streams and registered runs', async () => { + // Migration 3: `fork_artifacts` is read back on the recovery path + // (LEDGER §15), so an out-of-domain stream name or an artifact for a run + // that was never registered is rejected at the schema, not carried into + // `recover()`. + const evidence = openEvidenceDatabase(await temporaryDatabasePath()); + seedProtectedRows(evidence.database); + + const insert = evidence.database.prepare( + `INSERT INTO fork_artifacts ( + run_id, stream, sequence, entry_hash, canonical_json, detected_at + ) VALUES (@run_id, @stream, @sequence, @entry_hash, @canonical_json, @detected_at)`, + ); + const artifact = { + run_id: RUN_ID, + stream: 'baby-a-ledger', + sequence: 2, + entry_hash: hash('b'), + canonical_json: '{}', + detected_at: RECORDED_AT, + }; + + expect(() => insert.run(artifact)).not.toThrow(); + expect(() => + insert.run({ ...artifact, stream: 'constructor', entry_hash: hash('c') }), + ).toThrow('CHECK constraint failed'); + expect(() => + insert.run({ ...artifact, stream: 'toString', entry_hash: hash('d') }), + ).toThrow('CHECK constraint failed'); + expect(() => + insert.run({ ...artifact, run_id: 'run-never-registered' }), + ).toThrow('FOREIGN KEY constraint failed'); + + expect( + evidence.database + .prepare('SELECT COUNT(*) AS count FROM fork_artifacts') + .get(), + ).toEqual({ count: 2 }); + + evidence.close(); + }); }); diff --git a/packages/evidence/__tests__/export.test.ts b/packages/evidence/__tests__/export.test.ts new file mode 100644 index 0000000..7f75068 --- /dev/null +++ b/packages/evidence/__tests__/export.test.ts @@ -0,0 +1,492 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +import { canonicalJson, domainHash, hashCanonical } from '@ald/hashing'; +import { + CLAIM_BOUNDARY_STATEMENTS, + ClaimBoundaryError, + HASH_DOMAINS, + MODE_R_ONLY_CLAIM_LABELS, + RunManifestSchema, + type AnchorReceipt, + type CheckpointManifest, + type ExperimentRecord, +} from '@ald/types'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { InvalidRequestError } from '../src/errors.js'; +import { exportRunBundle, type LearnerContractText } from '../src/export.js'; +import { + cleanupTemporaryDirectories, + createWriter, + hash, + intentionDraft, + interpretationDraft, + proposal, + runConfig, + temporaryDirectory, + type TestWriter, +} from './fixtures/support.js'; + +afterEach(cleanupTemporaryDirectories); + +const RUN_ID = 'run-test-001'; + +const learnerContracts: LearnerContractText[] = [ + { track: 'no-learning', version: '1.0.0', text: '# no-learning contract\n' }, + { track: 'frozen-llm', version: '2.1.0', text: '# frozen-llm contract\n' }, +]; + +const exportOptions = { softwareCommit: 'software-commit-1', learnerContracts }; + +async function listFiles(root: string, prefix = ''): Promise { + const entries = await readdir(join(root, prefix), { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const path = prefix === '' ? entry.name : join(prefix, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFiles(root, path))); + } else { + files.push(path); + } + } + return files; +} + +async function populate(): Promise { + const config = runConfig({ + babyB: { + track: 'frozen-llm', + modelRef: 'frozen-model', + trainingIsolation: 'independent', + }, + }); + const context = await createWriter({ config }); + + const turn = await context.writer.commitTurn({ + runId: RUN_ID, + turn: 1, + sender: 'baby-a', + recipient: 'baby-b', + carrier: 'fixed-token', + communicationCondition: 'normal', + proposal, + intentionDraft: intentionDraft(), + deliveredArtifact: proposal.publicArtifact, + }); + await context.writer.appendLedgerEvent({ + runId: RUN_ID, + babyId: 'B', + turn: 1, + draft: interpretationDraft(), + channelEventHash: turn.channelEvent.entryHash, + }); + await context.writer.appendTurnRecord({ + runId: RUN_ID, + turn: 1, + phase: 'running', + roles: { sender: 'baby-a', receiver: 'baby-b' }, + communicationCondition: 'normal', + scenarioRef: 'scenario-01', + scenarioStateHash: hash('1'), + observationHashes: { babyA: hash('2'), babyB: hash('3') }, + babyProposalHash: turn.channelEvent.babyProposalHash ?? null, + deliveredArtifactHash: turn.channelEvent.publicArtifactHash, + channelEventHash: turn.channelEvent.entryHash, + actionHash: hash('7'), + outcomeHash: hash('8'), + outcome: { success: true, reward: 1 }, + }); + await context.writer.appendInterventionEvent({ + runId: RUN_ID, + eventType: 'human-view', + actorId: 'researcher-1', + reasonCode: 'dashboard-read', + }); + + const tree = { treeSize: 1, merkleRoot: hash('1'), lastEntryHash: hash('2') }; + const unsigned = { + version: 1 as const, + runIdHash: domainHash(HASH_DOMAINS.runId, RUN_ID), + checkpointSequence: 0, + previousCheckpointHash: `sha256:${'0'.repeat(64)}`, + babyA: tree, + babyB: tree, + channel: tree, + auxiliaryTrees: {}, + runConfigurationHash: hash('3'), + promptBundleHash: hash('4'), + softwareCommit: 'software-commit-1', + createdAt: new Date(0).toISOString(), + witnessKeyId: 'nursery-witness-v1', + reason: 'run-initialized' as const, + }; + const checkpointHash = hashCanonical(HASH_DOMAINS.checkpoint, unsigned); + const manifest: CheckpointManifest = { + ...unsigned, + checkpointHash, + witnessSignature: await context.signers.signer('witness').sign(checkpointHash), + }; + context.writer.insertCheckpointManifest(manifest); + + const receipt: AnchorReceipt = { + version: 1, + runId: RUN_ID, + checkpointSequence: 0, + checkpointHash, + anchorClass: 'simulated', + network: 'base-sepolia', + chainId: 84532, + transactionHash: `0x${'a'.repeat(64)}`, + from: `0x${'b'.repeat(40)}`, + to: `0x${'c'.repeat(40)}`, + inputData: `0x${'d'.repeat(64)}`, + blockNumber: 7, + blockHash: `0x${'e'.repeat(64)}`, + status: 'confirmed', + confirmations: 1, + finalityPolicy: '1-confirmation', + rpcEndpointLabel: 'test-rpc', + recordedAt: new Date(0).toISOString(), + }; + context.writer.insertAnchorReceipt(receipt); + + const record: ExperimentRecord = { + version: 1, + recordVersion: 1, + runId: RUN_ID, + experimentId: 'E00', + deploymentMode: 'prototype', + learnerContractVersion: '1.0.0', + runConfigRef: hash('a'), + protocolGitCommit: 'e2b1c0d4f5a6978877665544332211aabbccddee', + preRegistrationHash: hash('c'), + disposition: 'valid', + checkpointManifestRef: checkpointHash, + anchorTxRef: receipt.transactionHash, + verifierReportRef: 'verification-report.json', + claimBoundaryStatement: CLAIM_BOUNDARY_STATEMENTS.prototype, + deviations: [], + }; + context.writer.appendExperimentRecord(record); + context.writer.appendExperimentRecord({ ...record, recordVersion: 2 }); + + return context; +} + +describe('exportRunBundle', () => { + it.each(MODE_R_ONLY_CLAIM_LABELS)( + 'blocks Prototype Mode from surfacing the %s claim label', + async (label) => { + const context = await populate(); + const directory = await temporaryDirectory(); + await expect( + exportRunBundle(context.writer, RUN_ID, directory, { + ...exportOptions, + claimLabels: [label], + }), + ).rejects.toMatchObject({ + name: ClaimBoundaryError.name, + code: 'claim-boundary-violation', + label, + }); + context.close(); + }, + ); + + it('writes the documented layout and a schema-valid manifest', async () => { + const context = await populate(); + const directory = await temporaryDirectory(); + + const manifest = await exportRunBundle( + context.writer, + RUN_ID, + directory, + exportOptions, + ); + + expect(RunManifestSchema.parse(manifest)).toEqual(manifest); + expect(await listFiles(directory)).toEqual([ + 'analysis/index.json', + 'anchors/base-receipts.json', + 'baby-a-ledger.jsonl', + 'baby-b-ledger.jsonl', + 'channel-transcript.jsonl', + 'checkpoints/000000.json', + 'configuration/run-config.json', + 'experiment-record.json', + 'intervention-log.jsonl', + 'prompts/learner-contract.frozen-llm.v2.1.0.md', + 'prompts/learner-contract.no-learning.v1.0.0.md', + 'run-manifest.json', + 'turn-records.jsonl', + ]); + expect((await stat(join(directory, 'proofs', 'inclusion'))).isDirectory()).toBe( + true, + ); + expect((await stat(join(directory, 'proofs', 'consistency'))).isDirectory()).toBe( + true, + ); + + const written = JSON.parse( + await readFile(join(directory, 'run-manifest.json'), 'utf8'), + ); + expect(RunManifestSchema.parse(written)).toEqual(manifest); + expect(manifest.claimBoundaryStatement).toBe(CLAIM_BOUNDARY_STATEMENTS.prototype); + expect(manifest.runIdHash).toBe(domainHash(HASH_DOMAINS.runId, RUN_ID)); + expect(manifest.configurationHash).toBe( + context.writer.readRunMetadata(RUN_ID)?.configurationHash, + ); + expect(manifest.softwareCommit).toBe('software-commit-1'); + expect(manifest.learnerContractVersions).toEqual({ + babyA: '1.0.0', + babyB: '2.1.0', + }); + expect(manifest.signers).toHaveLength(6); + expect(manifest.streams).toEqual([ + { + stream: 'baby-a-ledger', + file: 'baby-a-ledger.jsonl', + hashDomain: 'dtsf-baby-ledger-entry-v1', + signerDomain: 'baby-a-ledger', + treeName: 'babyA', + }, + { + stream: 'baby-b-ledger', + file: 'baby-b-ledger.jsonl', + hashDomain: 'dtsf-baby-ledger-entry-v1', + signerDomain: 'baby-b-ledger', + treeName: 'babyB', + }, + { + stream: 'channel', + file: 'channel-transcript.jsonl', + hashDomain: 'dtsf-channel-event-v1', + signerDomain: 'channel', + treeName: 'channel', + }, + { + stream: 'turns', + file: 'turn-records.jsonl', + hashDomain: 'dtsf-turn-record-v1', + signerDomain: 'witness', + treeName: 'turns', + }, + { + stream: 'intervention', + file: 'intervention-log.jsonl', + hashDomain: 'dtsf-intervention-event-v1', + treeName: 'intervention', + }, + ]); + + context.close(); + }); + + it('writes JSONL lines that match the stored canonical events', async () => { + const context = await populate(); + const directory = await temporaryDirectory(); + await exportRunBundle(context.writer, RUN_ID, directory, exportOptions); + + for (const [stream, file] of [ + ['baby-a-ledger', 'baby-a-ledger.jsonl'], + ['baby-b-ledger', 'baby-b-ledger.jsonl'], + ['channel', 'channel-transcript.jsonl'], + ['turns', 'turn-records.jsonl'], + ['intervention', 'intervention-log.jsonl'], + ] as const) { + const content = await readFile(join(directory, file), 'utf8'); + const lines = content === '' ? [] : content.slice(0, -1).split('\n'); + const events = context.writer.readEvents(RUN_ID, stream); + expect(lines).toEqual(events.map((event) => event.canonicalJson)); + for (const [index, line] of lines.entries()) { + const parsed: unknown = JSON.parse(line); + expect(canonicalJson(parsed)).toBe(line); + expect((parsed as { sequence: number }).sequence).toBe(index + 1); + } + expect(content.endsWith('\n') || content === '').toBe(true); + } + + const config = JSON.parse( + await readFile(join(directory, 'configuration', 'run-config.json'), 'utf8'), + ); + expect(canonicalJson(config)).toBe( + context.writer.readRunMetadata(RUN_ID)?.configurationJson, + ); + + const experiment = JSON.parse( + await readFile(join(directory, 'experiment-record.json'), 'utf8'), + ); + expect(experiment.history).toHaveLength(2); + expect(experiment.current.recordVersion).toBe(2); + + const anchors = JSON.parse( + await readFile(join(directory, 'anchors', 'base-receipts.json'), 'utf8'), + ); + expect(anchors).toEqual(context.writer.readAnchorReceipts(RUN_ID)); + + expect( + await readFile( + join(directory, 'prompts', 'learner-contract.frozen-llm.v2.1.0.md'), + 'utf8', + ), + ).toBe('# frozen-llm contract\n'); + + context.close(); + }); + + it('produces byte-identical bundles for two exports of the same run', async () => { + const context = await populate(); + const first = await temporaryDirectory(); + const second = await temporaryDirectory(); + + await exportRunBundle(context.writer, RUN_ID, first, exportOptions); + await exportRunBundle(context.writer, RUN_ID, second, exportOptions); + + const files = await listFiles(first); + expect(files).toEqual(await listFiles(second)); + for (const file of files) { + const left = await readFile(join(first, file)); + const right = await readFile(join(second, file)); + expect(left.equals(right)).toBe(true); + } + expect(files).not.toContain('verification-report.json'); + expect(relative(first, join(first, 'run-manifest.json'))).toBe( + 'run-manifest.json', + ); + + context.close(); + }); + + it('omits the affect and audit files until those streams have events', async () => { + const context = await populate(); + const withoutAuxiliary = await temporaryDirectory(); + await exportRunBundle(context.writer, RUN_ID, withoutAuxiliary, exportOptions); + expect(await listFiles(withoutAuxiliary)).not.toContain('affect-transcript.jsonl'); + expect(await listFiles(withoutAuxiliary)).not.toContain('audit-ledger.jsonl'); + + await context.writer.appendAffectEvent({ + runId: RUN_ID, + turn: 1, + windowId: 'window-01', + sender: 'baby-a', + displayId: 'A1', + affectMode: 'declared', + deliveredAt: new Date(0).toISOString(), + }); + const source = context.writer.readEvents(RUN_ID, 'baby-a-ledger')[0]; + expect(source).toBeDefined(); + await context.writer.appendAuditLedgerEntry({ + runId: RUN_ID, + babyId: 'A', + sourceEntryHash: source?.entryHash ?? hash('1'), + interpreterVersion: 'interpreter-v1', + content: { term: 'S01', hypothesis: 'target', evidence: 'turn 1' }, + }); + + const withAuxiliary = await temporaryDirectory(); + const manifest = await exportRunBundle( + context.writer, + RUN_ID, + withAuxiliary, + exportOptions, + ); + const files = await listFiles(withAuxiliary); + expect(files).toContain('affect-transcript.jsonl'); + expect(files).toContain('audit-ledger.jsonl'); + expect(manifest.streams.map((declaration) => declaration.stream)).toEqual([ + 'baby-a-ledger', + 'baby-b-ledger', + 'channel', + 'affect', + 'audit', + 'turns', + 'intervention', + ]); + expect( + manifest.streams.find((declaration) => declaration.stream === 'audit'), + ).toEqual({ + stream: 'audit', + file: 'audit-ledger.jsonl', + hashDomain: 'dtsf-audit-ledger-entry-v1', + signerDomain: 'audit', + treeName: 'audit', + }); + + context.close(); + }); + + it('stores and exports an analysis attachment with its atomic intervention binding', async () => { + const context = await populate(); + const directory = await temporaryDirectory(); + const value = { status: 'software-readiness', passed: true }; + const stored = await context.writer.appendAnalysisAttachment({ + runId: RUN_ID, + path: 'analysis/red-team-observation/e02-readiness.json', + kind: 'red-team-observation', + analysisVersion: 'observation-suite-v1', + value, + actorId: 'researcher:red-team', + reasonCode: 'e02-readiness', + }); + + await exportRunBundle(context.writer, RUN_ID, directory, exportOptions); + + expect( + await readFile( + join(directory, 'analysis', 'red-team-observation', 'e02-readiness.json'), + 'utf8', + ), + ).toBe(`${canonicalJson(value)}\n`); + expect( + JSON.parse(await readFile(join(directory, 'analysis', 'index.json'), 'utf8')), + ).toEqual({ version: 1, runId: RUN_ID, attachments: [stored.descriptor] }); + const binding = context.writer + .readEvents(RUN_ID, 'intervention') + .map((event) => JSON.parse(event.canonicalJson) as Record) + .find((event) => event['entryHash'] === stored.descriptor.boundBy?.entryHash); + expect(binding).toMatchObject({ + eventType: 'analysis-attached', + details: { + path: stored.descriptor.path, + sha256: stored.descriptor.sha256, + kind: stored.descriptor.kind, + analysisVersion: stored.descriptor.analysisVersion, + }, + }); + + context.close(); + }); + + it('refuses a non-empty directory unless overwrite is set', async () => { + const context = await populate(); + const directory = await temporaryDirectory(); + await exportRunBundle(context.writer, RUN_ID, directory, exportOptions); + + await expect( + exportRunBundle(context.writer, RUN_ID, directory, exportOptions), + ).rejects.toThrow(InvalidRequestError); + await expect( + exportRunBundle(context.writer, RUN_ID, directory, { + ...exportOptions, + overwrite: true, + }), + ).resolves.toBeDefined(); + + context.close(); + }); + + it('requires a contract text for every configured track', async () => { + const context = await populate(); + const directory = await temporaryDirectory(); + + await expect( + exportRunBundle(context.writer, RUN_ID, directory, { + softwareCommit: 'software-commit-1', + learnerContracts: [learnerContracts[0]!], + }), + ).rejects.toThrow(InvalidRequestError); + + context.close(); + }); +}); diff --git a/packages/evidence/__tests__/fixtures/crash-writer.ts b/packages/evidence/__tests__/fixtures/crash-writer.ts new file mode 100644 index 0000000..474343d --- /dev/null +++ b/packages/evidence/__tests__/fixtures/crash-writer.ts @@ -0,0 +1,50 @@ +/** + * ALD-011 crash harness child. Registers one run and commits turns in a tight + * loop until the parent SIGKILLs it, so the parent can prove the WAL store + * recovers to a consistent last-committed state with no torn turn. + * + * Run as: node --import tsx crash-writer.ts + */ +import { InMemorySignerRegistry } from '@ald/hashing'; + +import { openEvidenceDatabase } from '../../src/database.js'; +import { SqliteEvidenceWriter } from '../../src/writer.js'; +import { intentionDraft, proposal, runConfig } from './support.js'; + +async function main(): Promise { + const databasePath = process.argv[2]; + if (databasePath === undefined) { + throw new Error('usage: crash-writer.ts '); + } + + const config = runConfig(); + const evidence = openEvidenceDatabase(databasePath); + const writer = new SqliteEvidenceWriter({ + database: evidence, + signers: InMemorySignerRegistry.generate(config.runId), + }); + writer.registerRun(config); + + // Signal readiness only after the first turn is committed, so every trial + // kills the child in the middle of the commit loop rather than before it. + for (let turn = 1; turn <= 10_000; turn += 1) { + await writer.commitTurn({ + runId: config.runId, + turn, + sender: 'baby-a', + recipient: 'baby-b', + carrier: 'fixed-token', + communicationCondition: 'normal', + proposal, + intentionDraft: intentionDraft({ blindingNonce: `nonce-${turn}` }), + deliveredArtifact: proposal.publicArtifact, + }); + if (turn === 1) { + process.stdout.write('ready\n'); + } + } + + evidence.close(); +} + +await main(); diff --git a/packages/evidence/__tests__/fixtures/support.ts b/packages/evidence/__tests__/fixtures/support.ts new file mode 100644 index 0000000..defafa6 --- /dev/null +++ b/packages/evidence/__tests__/fixtures/support.ts @@ -0,0 +1,174 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { InMemorySignerRegistry } from '@ald/hashing'; +import type { + AgentActionProposal, + Clock, + LedgerEventDraft, + RunConfig, +} from '@ald/types'; + +import { openEvidenceDatabase } from '../../src/database.js'; +import { SqliteEvidenceWriter } from '../../src/writer.js'; + +const temporaryDirectories: string[] = []; + +export async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'ald-evidence-')); + temporaryDirectories.push(directory); + return directory; +} + +export async function temporaryDatabasePath(): Promise { + return join(await temporaryDirectory(), 'evidence.sqlite'); +} + +export async function cleanupTemporaryDirectories(): Promise { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +} + +export function hash(seed: string): string { + return `sha256:${seed.repeat(64).slice(0, 64)}`; +} + +/** Deterministic clock: one millisecond per call from a fixed epoch. */ +export class StepClock implements Clock { + private current: number; + + constructor(startMs = Date.UTC(2026, 0, 1, 0, 0, 0), private readonly stepMs = 1) { + this.current = startMs; + } + + now(): string { + const value = new Date(this.current).toISOString(); + this.current += this.stepMs; + return value; + } +} + +export function runConfig(overrides: Partial = {}): RunConfig { + return { + version: 1, + runId: 'run-test-001', + deploymentMode: 'prototype', + babyA: { + track: 'no-learning', + modelRef: 'reference-a', + trainingIsolation: 'independent', + }, + babyB: { + track: 'no-learning', + modelRef: 'reference-b', + trainingIsolation: 'independent', + }, + symmetricTracks: true, + learningSignal: 'none', + communicationCondition: 'normal', + interactionMode: 'cooperative-signaling', + carrierMode: 'fixed-token', + symbolInventorySize: 8, + maxSymbolsPerMessage: 4, + affectMode: 'none', + affectWindowSchedule: 'every-4-turns', + observationEncoding: 'opaque-numeric', + roleReversalPeriod: 2, + turnResponseBudgetMs: 5_000, + maxTurnsPerRun: 20, + maxConsecutiveRejections: 3, + ledgerLagTurns: 0, + curriculumMode: 'fixed-schedule', + cipherThreatModel: 'post-run-disclosure', + interventionSuiteThreshold: 0.5, + evaluationSeeds: 4, + checkpointEventInterval: 25, + checkpointTimeIntervalMs: 60_000, + anchorClass: 'simulated', + anchorNetwork: 'base-sepolia', + finalityPolicy: '1-confirmation', + prototypeRetentionDays: 30, + scenarioBundleHash: hash('a'), + promptBundleHash: hash('b'), + protocolGitCommit: 'e2b1c0d4f5a6978877665544332211aabbccddee', + preRegistrationHash: hash('c'), + randomSeed: 'seed-000', + experimentId: 'E00', + ...overrides, + } as RunConfig; +} + +export const proposal: AgentActionProposal = { + kind: 'emit_symbols', + publicArtifact: { symbols: ['S01', 'S02'] }, +}; + +export function intentionDraft( + overrides: Partial = {}, +): LedgerEventDraft { + return { + eventType: 'intention.recorded', + contentSchema: 'agent-native-ledger', + subjectId: hash('d'), + content: { artifactRef: 'artifact-01' }, + blindingNonce: 'nonce-01', + evidenceRefs: [], + ...overrides, + }; +} + +export function interpretationDraft( + overrides: Partial = {}, +): LedgerEventDraft { + return { + eventType: 'interpretation.recorded', + contentSchema: 'agent-native-ledger', + subjectId: hash('e'), + content: { artifactRef: 'artifact-01' }, + blindingNonce: 'nonce-02', + evidenceRefs: [], + ...overrides, + }; +} + +export interface TestWriter { + writer: SqliteEvidenceWriter; + close(): void; + database: ReturnType['database']; + path: string; + signers: InMemorySignerRegistry; +} + +export async function createWriter(options: { + config?: RunConfig; + path?: string; + clock?: Clock; + register?: boolean; + signers?: InMemorySignerRegistry; +} = {}): Promise { + const config = options.config ?? runConfig(); + const path = options.path ?? (await temporaryDatabasePath()); + const evidence = openEvidenceDatabase(path); + const signers = + options.signers ?? InMemorySignerRegistry.generate(config.runId); + const writer = new SqliteEvidenceWriter({ + database: evidence, + signers, + clock: options.clock ?? new StepClock(), + softwareCommit: 'test-commit', + }); + if (options.register !== false) { + writer.registerRun(config); + } + return { + writer, + signers, + database: evidence.database, + path, + close: () => evidence.close(), + }; +} diff --git a/packages/evidence/__tests__/recovery.test.ts b/packages/evidence/__tests__/recovery.test.ts new file mode 100644 index 0000000..1cf8621 --- /dev/null +++ b/packages/evidence/__tests__/recovery.test.ts @@ -0,0 +1,469 @@ +import { canonicalJson, domainHash, hashCanonical } from '@ald/hashing'; +import { + EVENT_STREAMS, + GENESIS_HASH, + HASH_DOMAINS, + type CheckpointManifest, + type EventStream, + type TreeReference, +} from '@ald/types'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ForkDetectedError, IntegrityBlockedError, UnknownRunError } from '../src/errors.js'; +import { + cleanupTemporaryDirectories, + createWriter, + hash, + intentionDraft, + proposal, + StepClock, +} from './fixtures/support.js'; +import type { TestWriter } from './fixtures/support.js'; + +afterEach(cleanupTemporaryDirectories); + +const RUN_ID = 'run-test-001'; + +/** The tree reference a truthful checkpoint would commit for a stream. */ +function headTree(context: TestWriter, stream: EventStream): TreeReference { + const head = context.writer.chainHead(RUN_ID, stream); + return { + treeSize: head.size, + merkleRoot: hash('1') as TreeReference['merkleRoot'], + lastEntryHash: head.lastEntryHash, + }; +} + +/** + * Inserts a well-formed, witness-signed checkpoint 0 (LEDGER §8) committing + * the current heads of the three mandatory trees plus any auxiliary trees + * asked for, so recovery has real checkpoint hashes to verify against. + */ +async function insertHeadCheckpoint( + context: TestWriter, + overrides: { + babyA?: TreeReference; + auxiliaryTrees?: Record; + } = {}, +): Promise { + const unsigned = { + version: 1 as const, + runIdHash: domainHash(HASH_DOMAINS.runId, RUN_ID), + checkpointSequence: 0, + previousCheckpointHash: GENESIS_HASH, + babyA: overrides.babyA ?? headTree(context, 'baby-a-ledger'), + babyB: headTree(context, 'baby-b-ledger'), + channel: headTree(context, 'channel'), + auxiliaryTrees: overrides.auxiliaryTrees ?? {}, + runConfigurationHash: hash('3'), + promptBundleHash: hash('4'), + softwareCommit: 'test-commit', + createdAt: new Date(0).toISOString(), + witnessKeyId: 'nursery-witness-v1', + reason: 'event-interval' as const, + }; + const checkpointHash = hashCanonical(HASH_DOMAINS.checkpoint, unsigned); + const manifest = { + ...unsigned, + checkpointHash, + witnessSignature: await context.signers.signer('witness').sign(checkpointHash), + } as CheckpointManifest; + context.writer.insertCheckpointManifest(manifest); + return manifest; +} + +/** + * Simulates the LEDGER §3 privileged operator / SPEC §14.4 restore that lost + * the tail: the append-only trigger makes this impossible for application + * code, so the test drops it exactly as the tamper tests above do. + */ +function dropTail(context: TestWriter, table: string, sequence: number): void { + context.database.exec(`DROP TRIGGER ${table}_reject_delete`); + context.database + .prepare(`DELETE FROM ${table} WHERE run_id = ? AND sequence = ?`) + .run(RUN_ID, sequence); +} + +function turnRequest(turn: number) { + return { + runId: RUN_ID, + turn, + sender: 'baby-a' as const, + recipient: 'baby-b' as const, + carrier: 'fixed-token' as const, + communicationCondition: 'normal' as const, + proposal, + intentionDraft: intentionDraft(), + deliveredArtifact: proposal.publicArtifact, + }; +} + +describe('recover', () => { + it('reports a clean run with the correct heads', async () => { + const context = await createWriter(); + const first = await context.writer.commitTurn(turnRequest(1)); + const second = await context.writer.commitTurn(turnRequest(2)); + await context.writer.appendInterventionEvent({ + runId: RUN_ID, + eventType: 'annotate', + actorId: 'researcher-1', + reasonCode: 'note', + }); + + const report = await context.writer.recover(RUN_ID); + + expect(report.ok).toBe(true); + expect(report.chainViolations).toEqual([]); + expect(report.forks).toEqual([]); + expect(report.heads.map((head) => head.stream)).toEqual([...EVENT_STREAMS]); + const heads = new Map(report.heads.map((head) => [head.stream, head])); + expect(heads.get('baby-a-ledger')).toEqual({ + stream: 'baby-a-ledger', + size: 2, + lastEntryHash: second.senderLedgerEvent.entryHash, + }); + expect(heads.get('channel')?.size).toBe(2); + expect(heads.get('baby-b-ledger')).toEqual({ + stream: 'baby-b-ledger', + size: 0, + lastEntryHash: GENESIS_HASH, + }); + expect(heads.get('intervention')?.size).toBe(1); + expect(first.senderLedgerEvent.entryHash).not.toBe( + second.senderLedgerEvent.entryHash, + ); + + context.close(); + }); + + it('throws for an unregistered run', async () => { + const context = await createWriter(); + await expect(context.writer.recover('run-missing')).rejects.toThrow( + UnknownRunError, + ); + context.close(); + }); + + it('detects a tampered canonical row, blocks writes, and clears only after review', async () => { + const context = await createWriter(); + await context.writer.commitTurn(turnRequest(1)); + + // The append-only trigger makes this impossible for application code; a + // privileged operator with direct file access is the threat model here + // (LEDGER §3), so the test drops the trigger to simulate it. + context.database.exec('DROP TRIGGER ledger_events_reject_update'); + const stored = context.writer.readEvents(RUN_ID, 'baby-a-ledger')[0]; + const tampered = JSON.parse(stored?.canonicalJson ?? '{}') as { + content: Record; + }; + tampered.content = { ...tampered.content, artifactRef: 'tampered-artifact' }; + context.database + .prepare('UPDATE ledger_events SET canonical_json = ? WHERE entry_hash = ?') + .run(canonicalJson(tampered), stored?.entryHash); + + const report = await context.writer.recover(RUN_ID); + expect(report.ok).toBe(false); + expect(report.chainViolations).toHaveLength(1); + expect(report.chainViolations[0]).toContain('entry hash mismatch'); + expect(context.writer.integrityFindings(RUN_ID)).toHaveLength(1); + + await expect(context.writer.commitTurn(turnRequest(2))).rejects.toThrow( + IntegrityBlockedError, + ); + await expect( + context.writer.appendInterventionEvent({ + runId: RUN_ID, + eventType: 'annotate', + actorId: 'researcher-1', + reasonCode: 'note', + }), + ).rejects.toThrow(IntegrityBlockedError); + + const decision = await context.writer.acknowledgeIntegrityReview( + RUN_ID, + 'integrity-reviewer-1', + ); + expect(decision.eventType).toBe('governance-decision'); + expect(decision.actorId).toBe('integrity-reviewer-1'); + expect(decision.details.acknowledgedFindings).toHaveLength(1); + expect(context.writer.integrityFindings(RUN_ID)).toEqual([]); + + const resumed = await context.writer.commitTurn(turnRequest(2)); + expect(resumed.senderLedgerEvent.sequence).toBe(2); + + context.close(); + }); + + it('detects a broken previous link and an invalid writer signature', async () => { + const context = await createWriter(); + await context.writer.commitTurn(turnRequest(1)); + await context.writer.commitTurn(turnRequest(2)); + context.database.exec('DROP TRIGGER ledger_events_reject_update'); + + const [, second] = context.writer.readEvents(RUN_ID, 'baby-a-ledger'); + context.database + .prepare('UPDATE ledger_events SET previous_entry_hash = ? WHERE entry_hash = ?') + .run(GENESIS_HASH, second?.entryHash); + + const channel = context.writer.readEvents(RUN_ID, 'channel')[0]; + const forgedSignature = `ed25519:${Buffer.alloc(64, 7).toString('base64')}`; + const forged = { + ...(JSON.parse(channel?.canonicalJson ?? '{}') as Record), + writerSignature: forgedSignature, + }; + context.database.exec('DROP TRIGGER channel_events_reject_update'); + context.database + .prepare('UPDATE channel_events SET canonical_json = ? WHERE entry_hash = ?') + .run(canonicalJson(forged), channel?.entryHash); + + const report = await context.writer.recover(RUN_ID); + + expect(report.ok).toBe(false); + expect( + report.chainViolations.some((violation) => + violation.includes('does not chain to'), + ), + ).toBe(true); + expect( + report.chainViolations.some((violation) => + violation.includes('writer signature does not verify'), + ), + ).toBe(true); + + context.close(); + }); + + it('preserves both artifacts and blocks the run when two writers fork a sequence', async () => { + const first = await createWriter({ clock: new StepClock(Date.UTC(2026, 0, 1)) }); + const second = await createWriter({ + path: first.path, + register: false, + signers: first.signers, + clock: new StepClock(Date.UTC(2026, 5, 1)), + }); + + const results = await Promise.allSettled([ + first.writer.commitTurn(turnRequest(1)), + second.writer.commitTurn(turnRequest(1)), + ]); + + const rejected = results.filter((result) => result.status === 'rejected'); + expect(rejected).toHaveLength(1); + const reason = (rejected[0] as PromiseRejectedResult).reason; + expect(reason).toBeInstanceOf(ForkDetectedError); + expect((reason as ForkDetectedError).stream).toBe('baby-a-ledger'); + expect((reason as ForkDetectedError).sequence).toBe(1); + expect((reason as ForkDetectedError).entryHashes).toHaveLength(2); + + const artifacts = first.writer.readForkArtifacts(RUN_ID); + expect(artifacts).toHaveLength(2); + expect(artifacts.map((artifact) => artifact.stream)).toEqual([ + 'baby-a-ledger', + 'baby-a-ledger', + ]); + expect(new Set(artifacts.map((artifact) => artifact.entryHash))).toEqual( + new Set((reason as ForkDetectedError).entryHashes), + ); + // Only one of the two competing events was committed. + expect(first.writer.readEvents(RUN_ID, 'baby-a-ledger')).toHaveLength(1); + expect(first.writer.readEvents(RUN_ID, 'channel')).toHaveLength(1); + + // The forking writer refuses further writes for that run. + await expect(second.writer.commitTurn(turnRequest(2))).rejects.toThrow( + IntegrityBlockedError, + ); + + // recover() surfaces the preserved fork for any writer on the store. + const report = await first.writer.recover(RUN_ID); + expect(report.ok).toBe(false); + expect(report.forks).toHaveLength(1); + expect(report.forks[0]?.stream).toBe('baby-a-ledger'); + expect(report.forks[0]?.entryHashes).toHaveLength(2); + expect(report.chainViolations).toEqual([]); + await expect(first.writer.commitTurn(turnRequest(2))).rejects.toThrow( + IntegrityBlockedError, + ); + + second.close(); + first.close(); + }); + it('blocks a run whose committed prefix is shorter than its last checkpoint', async () => { + // LEDGER §15: "load the last valid entry **and checkpoint hashes**; + // verify the committed prefix before accepting new writes; ... never + // truncate or reuse a sequence number". A lost tail is invisible to a + // walk of the surviving rows — they still chain and verify perfectly — + // so only the checkpoint can see it. + const context = await createWriter(); + await context.writer.commitTurn(turnRequest(1)); + await context.writer.commitTurn(turnRequest(2)); + const third = await context.writer.commitTurn(turnRequest(3)); + await insertHeadCheckpoint(context); + + const clean = await context.writer.recover(RUN_ID); + expect(clean.ok).toBe(true); + + dropTail(context, 'ledger_events', 3); + dropTail(context, 'channel_events', 3); + + const report = await context.writer.recover(RUN_ID); + + expect(report.ok).toBe(false); + expect(report.forks).toEqual([]); + expect(report.heads.map((head) => head.stream)).toEqual([...EVENT_STREAMS]); + expect( + report.chainViolations.some( + (violation) => + violation.includes('checkpoint #0') && + violation.includes('babyA commits treeSize 3') && + violation.includes('baby-a-ledger now holds 2 entries'), + ), + ).toBe(true); + expect( + report.chainViolations.some( + (violation) => + violation.includes('channel commits treeSize 3') && + violation.includes('channel now holds 2 entries'), + ), + ).toBe(true); + expect(context.writer.integrityFindings(RUN_ID)).not.toEqual([]); + + // The checkpointed sequence 3 can never be handed out a second time. + await expect(context.writer.commitTurn(turnRequest(4))).rejects.toThrow( + IntegrityBlockedError, + ); + expect(context.writer.readEvents(RUN_ID, 'baby-a-ledger')).toHaveLength(2); + expect(third.senderLedgerEvent.sequence).toBe(3); + + context.close(); + }); + + it('blocks a run whose checkpointed entry hash no longer matches the stored entry', async () => { + // SPEC §14.4: a snapshot restore that brings back a different tail of the + // same length leaves every link intact, so the equal-size case has to be + // caught by comparing the entry at `treeSize` with the committed + // `lastEntryHash`. + const context = await createWriter(); + await context.writer.commitTurn(turnRequest(1)); + await context.writer.commitTurn(turnRequest(2)); + const head = context.writer.chainHead(RUN_ID, 'baby-a-ledger'); + await insertHeadCheckpoint(context, { + babyA: { + treeSize: head.size, + merkleRoot: hash('1') as TreeReference['merkleRoot'], + lastEntryHash: hash('e') as TreeReference['lastEntryHash'], + }, + }); + + const report = await context.writer.recover(RUN_ID); + + expect(report.ok).toBe(false); + expect( + report.chainViolations.some( + (violation) => + violation.includes(`babyA commits lastEntryHash ${hash('e')}`) && + violation.includes('baby-a-ledger#2') && + violation.includes(head.lastEntryHash), + ), + ).toBe(true); + + context.close(); + }); + + it('verifies auxiliary trees and reports an auxiliary tree it cannot map', async () => { + const context = await createWriter(); + await context.writer.commitTurn(turnRequest(1)); + await context.writer.appendInterventionEvent({ + runId: RUN_ID, + eventType: 'annotate', + actorId: 'researcher-1', + reasonCode: 'note', + }); + await insertHeadCheckpoint(context, { + auxiliaryTrees: { + turns: { + treeSize: 1, + merkleRoot: hash('1') as TreeReference['merkleRoot'], + lastEntryHash: hash('f') as TreeReference['lastEntryHash'], + }, + nonsense: { + treeSize: 1, + merkleRoot: hash('1') as TreeReference['merkleRoot'], + lastEntryHash: hash('f') as TreeReference['lastEntryHash'], + }, + }, + }); + + const report = await context.writer.recover(RUN_ID); + + expect(report.ok).toBe(false); + // `turns` is committed at size 1 but no turn record was ever appended. + expect( + report.chainViolations.some( + (violation) => + violation.includes('turns commits treeSize 1') && + violation.includes('turns now holds 0 entries'), + ), + ).toBe(true); + expect( + report.chainViolations.some((violation) => + violation.includes('commits unknown auxiliary tree nonsense'), + ), + ).toBe(true); + + context.close(); + }); + + it('reports a fork artifact naming an unknown stream instead of throwing', async () => { + // `'constructor' in STREAM_TABLES` is true for any object literal, so the + // old `in` guard let a prototype key through and `recover()` raised + // `no such table: undefined`. Recovery reports malformed input, never + // raises on it (LEDGER §15). + const context = await createWriter(); + await context.writer.commitTurn(turnRequest(1)); + + // Migration 3 constrains `stream`, so the poison row needs the same + // privileged file access the tamper tests above assume. + context.database.exec(` + DROP TRIGGER fork_artifacts_reject_update; + DROP TRIGGER fork_artifacts_reject_delete; + ALTER TABLE fork_artifacts RENAME TO fork_artifacts_constrained; + CREATE TABLE fork_artifacts ( + run_id TEXT NOT NULL, + stream TEXT NOT NULL, + sequence INTEGER NOT NULL, + entry_hash TEXT NOT NULL, + canonical_json TEXT NOT NULL, + detected_at TEXT NOT NULL, + PRIMARY KEY (run_id, stream, sequence, entry_hash) + ) STRICT; + `); + const poison = context.database.prepare( + `INSERT INTO fork_artifacts ( + run_id, stream, sequence, entry_hash, canonical_json, detected_at + ) VALUES (?, ?, 1, ?, '{}', '1970-01-01T00:00:00.000Z')`, + ); + poison.run(RUN_ID, 'constructor', hash('a')); + poison.run(RUN_ID, 'toString', hash('b')); + + const report = await context.writer.recover(RUN_ID); + + expect(report.heads.map((head) => head.stream)).toEqual([...EVENT_STREAMS]); + expect(report.forks).toEqual([]); + expect(report.ok).toBe(false); + for (const stream of ['constructor', 'toString']) { + expect( + report.chainViolations.some( + (violation) => + violation.includes(`fork artifact ${stream}#1`) && + violation.includes('is not a known event stream'), + ), + ).toBe(true); + } + // A poisoned artifact leaves the run integrity-blocked, not writable. + await expect(context.writer.commitTurn(turnRequest(2))).rejects.toThrow( + IntegrityBlockedError, + ); + + context.close(); + }); +}); diff --git a/packages/evidence/__tests__/writer.test.ts b/packages/evidence/__tests__/writer.test.ts new file mode 100644 index 0000000..315b618 --- /dev/null +++ b/packages/evidence/__tests__/writer.test.ts @@ -0,0 +1,668 @@ +import { + canonicalJson, + computeEntryHash, + domainHash, + hashCanonical, + hashCarrierMark, + verifyHashSignature, +} from '@ald/hashing'; +import { + ChannelEventSchema, + HASH_DOMAINS, + LedgerEventSchema, + type AnchorReceipt, + type CheckpointManifest, + type ExperimentRecord, +} from '@ald/types'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + CheckpointChainError, + DuplicateRunError, + ExperimentRecordVersionError, + InterpretationBindingError, + InvalidRequestError, + UnknownRunError, +} from '../src/errors.js'; +import { + cleanupTemporaryDirectories, + createWriter, + hash, + intentionDraft, + interpretationDraft, + proposal, + runConfig, +} from './fixtures/support.js'; + +afterEach(cleanupTemporaryDirectories); + +function turnRequest(overrides: Record = {}) { + return { + runId: 'run-test-001', + turn: 1, + sender: 'baby-a' as const, + recipient: 'baby-b' as const, + carrier: 'fixed-token' as const, + communicationCondition: 'normal' as const, + proposal, + intentionDraft: intentionDraft(), + deliveredArtifact: proposal.publicArtifact, + ...overrides, + }; +} + +describe('registerRun', () => { + it('records the configuration hash and the run public keys', async () => { + const context = await createWriter({ register: false }); + const config = runConfig(); + + const { configurationHash } = context.writer.registerRun(config); + + expect(configurationHash).toBe( + hashCanonical(HASH_DOMAINS.runConfig, config), + ); + const metadata = context.writer.readRunMetadata(config.runId); + expect(metadata?.configurationJson).toBe(canonicalJson(config)); + expect(metadata?.configurationHash).toBe(configurationHash); + expect(context.writer.listRuns()).toEqual([config.runId]); + expect(context.writer.readRunSigners(config.runId).map((s) => s.domain)).toEqual([ + 'affect', + 'audit', + 'baby-a-ledger', + 'baby-b-ledger', + 'channel', + 'witness', + ]); + for (const signer of context.writer.readRunSigners(config.runId)) { + expect(signer.publicKey.startsWith('ed25519-pub:')).toBe(true); + } + + context.close(); + }); + + it('refuses a duplicate run id', async () => { + const context = await createWriter(); + expect(() => context.writer.registerRun(runConfig())).toThrow( + DuplicateRunError, + ); + context.close(); + }); + + it('refuses a run the signer registry is not bound to', async () => { + const context = await createWriter({ register: false }); + expect(() => + context.writer.registerRun(runConfig({ runId: 'run-other' })), + ).toThrow(InvalidRequestError); + context.close(); + }); +}); + +describe('commitTurn', () => { + it('commits the sender intention and channel event atomically with correct bindings', async () => { + const context = await createWriter(); + + const result = await context.writer.commitTurn(turnRequest()); + + const ledgerEvents = context.writer.readEvents('run-test-001', 'baby-a-ledger'); + const channelEvents = context.writer.readEvents('run-test-001', 'channel'); + expect(ledgerEvents).toHaveLength(1); + expect(channelEvents).toHaveLength(1); + expect(ledgerEvents[0]?.sequence).toBe(1); + expect(channelEvents[0]?.sequence).toBe(1); + expect(context.writer.readEvents('run-test-001', 'baby-b-ledger')).toHaveLength(0); + + // Cross-bindings, LEDGER §6. + expect(result.channelEvent.senderLedgerSequence).toBe( + result.senderLedgerEvent.sequence, + ); + expect(result.channelEvent.senderEntryHash).toBe( + result.senderLedgerEvent.entryHash, + ); + expect(result.channelEvent.babyProposalHash).toBe( + hashCanonical(HASH_DOMAINS.babyProposal, proposal), + ); + expect(result.channelEvent.publicArtifactHash).toBe( + hashCarrierMark('fixed-token', proposal.publicArtifact), + ); + expect(result.channelEvent.deliveryReceipt).toEqual({ + recipient: 'baby-b', + deliveredArtifactHash: result.channelEvent.publicArtifactHash, + deliveredAt: expect.any(String), + }); + expect(result.delivery).toEqual({ + runId: 'run-test-001', + turn: 1, + logicalSender: 'baby-a', + carrier: 'fixed-token', + publicArtifact: proposal.publicArtifact, + channelEventHash: result.channelEvent.entryHash, + }); + + // Genesis links. + expect(result.senderLedgerEvent.previousEntryHash).toBe( + `sha256:${'0'.repeat(64)}`, + ); + expect(result.channelEvent.previousChannelHash).toBe( + `sha256:${'0'.repeat(64)}`, + ); + + // Stored canonical JSON rehashes to the stored entry hash. + const storedLedger = JSON.parse(ledgerEvents[0]?.canonicalJson ?? '{}'); + const storedChannel = JSON.parse(channelEvents[0]?.canonicalJson ?? '{}'); + expect(LedgerEventSchema.parse(storedLedger)).toEqual(result.senderLedgerEvent); + expect(ChannelEventSchema.parse(storedChannel)).toEqual(result.channelEvent); + expect(computeEntryHash('baby-a-ledger', storedLedger)).toBe( + result.senderLedgerEvent.entryHash, + ); + expect(computeEntryHash('channel', storedChannel)).toBe( + result.channelEvent.entryHash, + ); + + // Signatures verify under the public keys recorded in run_signers. + const keys = new Map( + context.writer + .readRunSigners('run-test-001') + .map((signer) => [signer.domain, signer]), + ); + expect( + verifyHashSignature( + result.senderLedgerEvent.entryHash, + result.senderLedgerEvent.writerSignature, + keys.get('baby-a-ledger')?.publicKey ?? '', + ), + ).toBe(true); + expect( + verifyHashSignature( + result.channelEvent.entryHash, + result.channelEvent.writerSignature, + keys.get('channel')?.publicKey ?? '', + ), + ).toBe(true); + expect(result.senderLedgerEvent.writerKeyId).toBe('baby-a-ledger-writer-v1'); + expect(result.channelEvent.writerKeyId).toBe('channel-writer-v1'); + + context.close(); + }); + + it('chains successive turns and both ledgers independently', async () => { + const context = await createWriter(); + + await context.writer.commitTurn(turnRequest()); + const second = await context.writer.commitTurn( + turnRequest({ turn: 2, sender: 'baby-b', recipient: 'baby-a' }), + ); + const third = await context.writer.commitTurn(turnRequest({ turn: 3 })); + + expect(second.senderLedgerEvent.sequence).toBe(1); + expect(second.channelEvent.sequence).toBe(2); + expect(third.senderLedgerEvent.sequence).toBe(2); + expect(third.channelEvent.sequence).toBe(3); + expect(third.channelEvent.previousChannelHash).toBe(second.channelEvent.entryHash); + expect(context.writer.chainHead('run-test-001', 'channel')).toEqual({ + stream: 'channel', + size: 3, + lastEntryHash: third.channelEvent.entryHash, + }); + expect( + context.writer.readEvents('run-test-001', 'channel', { + fromSequence: 2, + toSequence: 2, + }), + ).toHaveLength(1); + + context.close(); + }); + + it('rolls the whole turn back when the channel insert fails', async () => { + const context = await createWriter(); + context.database.exec( + `CREATE TRIGGER channel_events_force_failure + BEFORE INSERT ON channel_events + BEGIN + SELECT RAISE(ABORT, 'induced channel insert failure'); + END;`, + ); + + await expect(context.writer.commitTurn(turnRequest())).rejects.toThrow( + 'induced channel insert failure', + ); + + expect(context.writer.readEvents('run-test-001', 'baby-a-ledger')).toHaveLength(0); + expect(context.writer.readEvents('run-test-001', 'channel')).toHaveLength(0); + expect( + context.database + .prepare('SELECT COUNT(*) AS count FROM ledger_events') + .get(), + ).toEqual({ count: 0 }); + expect( + context.database + .prepare('SELECT COUNT(*) AS count FROM channel_events') + .get(), + ).toEqual({ count: 0 }); + + // The writer still assigns sequence 1 after the rolled-back attempt. + context.database.exec('DROP TRIGGER channel_events_force_failure'); + const recovered = await context.writer.commitTurn(turnRequest()); + expect(recovered.senderLedgerEvent.sequence).toBe(1); + expect(recovered.channelEvent.sequence).toBe(1); + + context.close(); + }); + + it('omits the delivery receipt under the disabled condition', async () => { + const context = await createWriter(); + + const result = await context.writer.commitTurn( + turnRequest({ communicationCondition: 'disabled', deliveredArtifact: null }), + ); + + expect(result.delivery).toBeNull(); + expect(result.channelEvent.deliveryReceipt).toBeUndefined(); + expect(result.channelEvent.publicArtifactHash).toBe( + hashCarrierMark('fixed-token', null), + ); + expect(result.channelEvent.babyProposalHash).toBe( + hashCanonical(HASH_DOMAINS.babyProposal, proposal), + ); + const stored = context.writer.readEvents('run-test-001', 'channel')[0]; + expect( + ChannelEventSchema.safeParse(JSON.parse(stored?.canonicalJson ?? '{}')).success, + ).toBe(true); + expect(JSON.parse(stored?.canonicalJson ?? '{}')).not.toHaveProperty( + 'deliveryReceipt', + ); + + context.close(); + }); + + it('copies non-empty evidence refs into the ledger content', async () => { + const context = await createWriter(); + + const result = await context.writer.commitTurn( + turnRequest({ + intentionDraft: intentionDraft({ evidenceRefs: ['channel:1', 'outcome:1'] }), + }), + ); + + expect(result.senderLedgerEvent.content).toEqual({ + artifactRef: 'artifact-01', + evidenceRefs: ['channel:1', 'outcome:1'], + }); + + context.close(); + }); + + it('rejects a draft that is not an intention event and an unknown run', async () => { + const context = await createWriter(); + + await expect( + context.writer.commitTurn( + turnRequest({ intentionDraft: interpretationDraft() }), + ), + ).rejects.toThrow(InvalidRequestError); + await expect( + context.writer.commitTurn(turnRequest({ runId: 'run-missing' })), + ).rejects.toThrow(UnknownRunError); + + context.close(); + }); +}); + +describe('commitRejection and commitControlArtifact', () => { + it('writes a rejected channel event with no proposal or ledger bindings', async () => { + const context = await createWriter(); + + const event = await context.writer.commitRejection({ + runId: 'run-test-001', + turn: 4, + sender: 'baby-a', + carrier: 'fixed-token', + communicationCondition: 'normal', + reasonCode: 'symbol-not-in-inventory', + rejectedPayloadHash: hash('f'), + }); + + expect(event.gatewayValidationResult).toBe('rejected'); + expect(event.reasonCode).toBe('symbol-not-in-inventory'); + expect(event.publicArtifactHash).toBe(hash('f')); + expect(event.babyProposalHash).toBeUndefined(); + expect(event.senderLedgerSequence).toBeUndefined(); + expect(event.senderEntryHash).toBeUndefined(); + expect(event.deliveryReceipt).toBeUndefined(); + expect(context.writer.readEvents('run-test-001', 'baby-a-ledger')).toHaveLength(0); + + const stored = context.writer.readEvents('run-test-001', 'channel')[0]; + expect(ChannelEventSchema.parse(JSON.parse(stored?.canonicalJson ?? '{}'))).toEqual( + event, + ); + expect(computeEntryHash('channel', JSON.parse(stored?.canonicalJson ?? '{}'))).toBe( + event.entryHash, + ); + + context.close(); + }); + + it('writes an oracle control artifact with origin gateway-control', async () => { + const context = await createWriter(); + + const { channelEvent, delivery } = await context.writer.commitControlArtifact({ + runId: 'run-test-001', + turn: 5, + logicalSender: 'baby-a', + recipient: 'baby-b', + carrier: 'fixed-token', + deliveredArtifact: { symbols: ['S03'] }, + }); + + expect(channelEvent.origin).toBe('gateway-control'); + expect(channelEvent.communicationCondition).toBe('oracle'); + expect(channelEvent.babyProposalHash).toBeUndefined(); + expect(channelEvent.senderEntryHash).toBeUndefined(); + expect(channelEvent.publicArtifactHash).toBe( + hashCarrierMark('fixed-token', { symbols: ['S03'] }), + ); + expect(channelEvent.deliveryReceipt?.recipient).toBe('baby-b'); + expect(delivery.channelEventHash).toBe(channelEvent.entryHash); + + const stored = context.writer.readEvents('run-test-001', 'channel')[0]; + expect( + ChannelEventSchema.safeParse(JSON.parse(stored?.canonicalJson ?? '{}')).success, + ).toBe(true); + + context.close(); + }); +}); + +describe('appendLedgerEvent', () => { + it('binds an interpretation to a delivery addressed to the same Baby', async () => { + const context = await createWriter(); + const turn = await context.writer.commitTurn(turnRequest()); + + const event = await context.writer.appendLedgerEvent({ + runId: 'run-test-001', + babyId: 'B', + turn: 1, + draft: interpretationDraft(), + channelEventHash: turn.channelEvent.entryHash, + }); + + expect(event.channelEventHash).toBe(turn.channelEvent.entryHash); + expect(event.sequence).toBe(1); + expect(context.writer.readEvents('run-test-001', 'baby-b-ledger')).toHaveLength(1); + + context.close(); + }); + + it('refuses an interpretation bound to the other Baby delivery and writes nothing', async () => { + const context = await createWriter(); + const turn = await context.writer.commitTurn(turnRequest()); + + await expect( + context.writer.appendLedgerEvent({ + runId: 'run-test-001', + babyId: 'A', + turn: 1, + draft: interpretationDraft(), + channelEventHash: turn.channelEvent.entryHash, + }), + ).rejects.toThrow(InterpretationBindingError); + + expect(context.writer.readEvents('run-test-001', 'baby-a-ledger')).toHaveLength(1); + expect(context.writer.readEvents('run-test-001', 'baby-b-ledger')).toHaveLength(0); + + context.close(); + }); + + it('requires a channel event hash on interpretation drafts', async () => { + const context = await createWriter(); + await expect( + context.writer.appendLedgerEvent({ + runId: 'run-test-001', + babyId: 'B', + turn: 1, + draft: interpretationDraft(), + }), + ).rejects.toThrow(InterpretationBindingError); + context.close(); + }); + + it('refuses an unknown channel event hash', async () => { + const context = await createWriter(); + await expect( + context.writer.appendLedgerEvent({ + runId: 'run-test-001', + babyId: 'B', + turn: 1, + draft: interpretationDraft(), + channelEventHash: hash('9'), + }), + ).rejects.toThrow(InterpretationBindingError); + context.close(); + }); +}); + +describe('auxiliary streams', () => { + it('appends signed turn records, audit entries, affect events and unsigned interventions', async () => { + const context = await createWriter(); + const keys = new Map( + context.writer + .readRunSigners('run-test-001') + .map((signer) => [signer.domain, signer.publicKey]), + ); + + const record = await context.writer.appendTurnRecord({ + runId: 'run-test-001', + turn: 1, + phase: 'running', + roles: { sender: 'baby-a', receiver: 'baby-b' }, + communicationCondition: 'normal', + scenarioRef: 'scenario-01', + scenarioStateHash: hash('1'), + observationHashes: { babyA: hash('2'), babyB: hash('3') }, + babyProposalHash: hash('4'), + deliveredArtifactHash: hash('5'), + channelEventHash: hash('6'), + actionHash: hash('7'), + outcomeHash: hash('8'), + outcome: { success: true, reward: 1 }, + }); + expect(record.writerKeyId).toBe('nursery-witness-v1'); + expect( + verifyHashSignature( + record.entryHash, + record.writerSignature, + keys.get('witness') ?? '', + ), + ).toBe(true); + + const auditSource = await context.writer.appendLedgerEvent({ + runId: 'run-test-001', + babyId: 'A', + turn: 1, + draft: intentionDraft(), + }); + const audit = await context.writer.appendAuditLedgerEntry({ + runId: 'run-test-001', + babyId: 'A', + sourceEntryHash: auditSource.entryHash, + interpreterVersion: 'interpreter-v1', + content: { + term: 'S01', + hypothesis: 'S01 marks the target', + confidence: 0.5, + evidence: 'turn 1', + }, + }); + expect(audit.source).toBe('generated-analysis'); + expect( + verifyHashSignature( + audit.entryHash, + audit.writerSignature, + keys.get('audit') ?? '', + ), + ).toBe(true); + + await expect( + context.writer.appendAuditLedgerEntry({ + runId: 'run-test-001', + babyId: 'B', + sourceEntryHash: auditSource.entryHash, + interpreterVersion: 'interpreter-v1', + content: { term: 'S01', hypothesis: 'wrong Baby', evidence: 'turn 1' }, + }), + ).rejects.toThrow(InterpretationBindingError); + + const affect = await context.writer.appendAffectEvent({ + runId: 'run-test-001', + turn: 1, + windowId: 'window-01', + sender: 'baby-a', + displayId: 'A3', + affectMode: 'declared', + deliveredAt: new Date(0).toISOString(), + }); + expect( + verifyHashSignature( + affect.entryHash, + affect.writerSignature, + keys.get('affect') ?? '', + ), + ).toBe(true); + + const intervention = await context.writer.appendInterventionEvent({ + runId: 'run-test-001', + eventType: 'pause', + actorId: 'researcher-1', + reasonCode: 'manual-pause', + }); + expect(intervention.details).toEqual({}); + expect(intervention.sequence).toBe(1); + expect(intervention).not.toHaveProperty('writerSignature'); + + for (const stream of ['turns', 'audit', 'affect', 'intervention'] as const) { + const stored = context.writer.readEvents('run-test-001', stream); + expect(stored).toHaveLength(1); + expect(stored[0]?.previousEntryHash).toBe(`sha256:${'0'.repeat(64)}`); + expect( + computeEntryHash(stream, JSON.parse(stored[0]?.canonicalJson ?? '{}')), + ).toBe(stored[0]?.entryHash); + } + + context.close(); + }); +}); + +describe('checkpoints, anchors and experiment records', () => { + it('inserts checkpoints, anchor receipts and versioned experiment records', async () => { + const context = await createWriter(); + const tree = { treeSize: 1, merkleRoot: hash('1'), lastEntryHash: hash('2') }; + const buildManifest = async ( + sequence: number, + previous: string, + ): Promise => { + const unsigned = { + version: 1 as const, + runIdHash: domainHash(HASH_DOMAINS.runId, 'run-test-001'), + checkpointSequence: sequence, + previousCheckpointHash: previous, + babyA: tree, + babyB: tree, + channel: tree, + auxiliaryTrees: {}, + runConfigurationHash: hash('3'), + promptBundleHash: hash('4'), + softwareCommit: 'test-commit', + createdAt: new Date(sequence).toISOString(), + witnessKeyId: 'nursery-witness-v1', + reason: 'event-interval' as const, + }; + const checkpointHash = hashCanonical(HASH_DOMAINS.checkpoint, unsigned); + return { + ...unsigned, + checkpointHash, + witnessSignature: await context.signers + .signer('witness') + .sign(checkpointHash), + }; + }; + + const first = await buildManifest(0, `sha256:${'0'.repeat(64)}`); + context.writer.insertCheckpointManifest(first); + const second = await buildManifest(1, first.checkpointHash); + context.writer.insertCheckpointManifest(second); + + expect(context.writer.readCheckpoints('run-test-001')).toEqual([first, second]); + + // A manifest whose hash does not cover its content is refused (LEDGER §8). + expect(() => + context.writer.insertCheckpointManifest({ + ...second, + checkpointSequence: 2, + }), + ).toThrow(CheckpointChainError); + // So is a correctly hashed manifest that does not link to the last one. + const forked = await buildManifest(2, `sha256:${'0'.repeat(64)}`); + expect(() => context.writer.insertCheckpointManifest(forked)).toThrow( + CheckpointChainError, + ); + + const receipt: AnchorReceipt = { + version: 1, + runId: 'run-test-001', + checkpointSequence: 1, + checkpointHash: second.checkpointHash, + anchorClass: 'simulated', + network: 'base-sepolia', + chainId: 84532, + transactionHash: `0x${'a'.repeat(64)}`, + from: `0x${'b'.repeat(40)}`, + to: `0x${'c'.repeat(40)}`, + inputData: `0x${'d'.repeat(64)}`, + blockNumber: 42, + blockHash: `0x${'e'.repeat(64)}`, + status: 'confirmed', + confirmations: 3, + finalityPolicy: '1-confirmation', + rpcEndpointLabel: 'test-rpc', + recordedAt: new Date(0).toISOString(), + }; + context.writer.insertAnchorReceipt(receipt); + expect(context.writer.readAnchorReceipts('run-test-001')).toEqual([receipt]); + // The anchor receipt must reference a stored checkpoint (foreign key). + expect(() => + context.writer.insertAnchorReceipt({ + ...receipt, + checkpointHash: hash('9'), + transactionHash: `0x${'f'.repeat(64)}`, + }), + ).toThrow(); + + const record: ExperimentRecord = { + version: 1, + recordVersion: 1, + runId: 'run-test-001', + experimentId: 'E00', + deploymentMode: 'prototype', + learnerContractVersion: '1', + runConfigRef: hash('a'), + protocolGitCommit: 'e2b1c0d4f5a6978877665544332211aabbccddee', + preRegistrationHash: hash('c'), + disposition: 'valid', + checkpointManifestRef: second.checkpointHash, + anchorTxRef: receipt.transactionHash, + verifierReportRef: 'verification-report.json', + claimBoundaryStatement: 'prototype boundary', + deviations: [], + }; + context.writer.appendExperimentRecord(record); + expect(() => + context.writer.appendExperimentRecord({ ...record, recordVersion: 3 }), + ).toThrow(ExperimentRecordVersionError); + context.writer.appendExperimentRecord({ ...record, recordVersion: 2 }); + expect( + context.writer.readExperimentRecords('run-test-001').map((r) => r.recordVersion), + ).toEqual([1, 2]); + + context.close(); + }); +}); diff --git a/packages/evidence/package.json b/packages/evidence/package.json index 723c4b2..2965864 100644 --- a/packages/evidence/package.json +++ b/packages/evidence/package.json @@ -17,10 +17,11 @@ "dependencies": { "@ald/config": "*", "@ald/types": "*", - "better-sqlite3": "^12.4.1", - "canonicalize": "^4.0.0" + "better-sqlite3": "12.11.1", + "canonicalize": "4.0.0", + "@ald/hashing": "*" }, "devDependencies": { - "@types/better-sqlite3": "^9.6.0" + "@types/better-sqlite3": "9.6.0" } } diff --git a/packages/evidence/src/database.ts b/packages/evidence/src/database.ts new file mode 100644 index 0000000..cb6d4e0 --- /dev/null +++ b/packages/evidence/src/database.ts @@ -0,0 +1,50 @@ +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import Database from 'better-sqlite3'; + +import { applyMigrations } from './migrations.js'; + +export interface EvidenceDatabase { + database: Database.Database; + path: string; + close(): void; +} + +/** + * Opens (creating if needed) the authoritative local evidence store + * (LEDGER §3): WAL journal mode, `synchronous = FULL` so a killed process can + * never expose a torn transaction (ALD-011), foreign keys enforced, + * `recursive_triggers` enabled, and every pending migration applied. + * + * `recursive_triggers = ON` is load-bearing, not a tuning knob: every + * append-only guard in {@link applyMigrations} is a `BEFORE UPDATE` / + * `BEFORE DELETE` trigger, and SQLite fires the implicit row deletion of the + * `REPLACE` conflict-resolution algorithm through `BEFORE DELETE` triggers + * only when recursive triggers are enabled. With the pragma at its default + * (OFF) an `INSERT OR REPLACE` / `REPLACE INTO` would destroy and rewrite a + * committed event without raising `append-only table: `, defeating the + * accident-prevention control LEDGER §3 requires ("Database permissions and + * triggers should reject UPDATE and DELETE operations on event tables"). + */ +export function openEvidenceDatabase(databasePath: string): EvidenceDatabase { + const resolvedPath = + databasePath === ':memory:' ? databasePath : resolve(databasePath); + + if (resolvedPath !== ':memory:') { + mkdirSync(dirname(resolvedPath), { recursive: true }); + } + + const database = new Database(resolvedPath); + database.pragma('foreign_keys = ON'); + database.pragma('recursive_triggers = ON'); + database.pragma('synchronous = FULL'); + database.pragma('journal_mode = WAL'); + applyMigrations(database); + + return { + database, + path: resolvedPath, + close: () => database.close(), + }; +} diff --git a/packages/evidence/src/errors.ts b/packages/evidence/src/errors.ts new file mode 100644 index 0000000..573d931 --- /dev/null +++ b/packages/evidence/src/errors.ts @@ -0,0 +1,133 @@ +/** + * Evidence Writer error taxonomy. + * + * The writer is the only component allowed to write event tables + * (SPECIFICATION.md §4.1 item 7, ALD-010 criterion 3), so every rule it + * enforces surfaces as one of these classes rather than as a raw SQLite or + * zod error. `code` is machine-readable and is what the Gateway and the + * Nursery Controller map onto their own error shapes. + */ +import type { EventStream } from '@ald/types'; + +export type EvidenceErrorCode = + | 'duplicate-run' + | 'unknown-run' + | 'duplicate-event' + | 'fork-detected' + | 'interpretation-binding' + | 'integrity-blocked' + | 'checkpoint-chain' + | 'experiment-record-version' + | 'analysis-attachment' + | 'invalid-request'; + +export class EvidenceWriterError extends Error { + constructor( + readonly code: EvidenceErrorCode, + message: string, + ) { + super(message); + this.name = new.target.name; + } +} + +/** A run may be registered exactly once (409-style conflict). */ +export class DuplicateRunError extends EvidenceWriterError { + constructor(readonly runId: string) { + super('duplicate-run', `Run ${runId} is already registered`); + } +} + +export class UnknownRunError extends EvidenceWriterError { + constructor(readonly runId: string) { + super('unknown-run', `Run ${runId} is not registered`); + } +} + +/** + * The exact same event (identical entry hash) was submitted twice. The store + * is append-only, so the second submission is refused rather than merged + * (LEDGER §15: never reuse a sequence number). + */ +export class DuplicateEventError extends EvidenceWriterError { + constructor( + readonly stream: EventStream, + readonly sequence: number, + readonly entryHash: string, + ) { + super( + 'duplicate-event', + `Event ${stream}#${sequence} with hash ${entryHash} is already committed`, + ); + } +} + +/** + * LEDGER §15: two entries claim the same stream and sequence with different + * hashes. Both artifacts are preserved in `fork_artifacts`, the run is + * blocked, and the evidence stays invalid until a research-integrity review + * acknowledges it. + */ +export class ForkDetectedError extends EvidenceWriterError { + constructor( + readonly stream: EventStream, + readonly sequence: number, + readonly entryHashes: string[], + ) { + super( + 'fork-detected', + `Fork detected on ${stream}#${sequence}: ${entryHashes.join(' vs ')}`, + ); + } +} + +/** + * SPEC §11.3: the receiver MUST echo the `channelEventHash` of a delivery + * addressed to it. A hash that names another Baby's delivery, or no delivery + * at all, is refused before anything is written. + */ +export class InterpretationBindingError extends EvidenceWriterError { + constructor(message: string) { + super('interpretation-binding', message); + } +} + +/** LEDGER §15: no new writes for a run with an unreviewed integrity failure. */ +export class IntegrityBlockedError extends EvidenceWriterError { + constructor( + readonly runId: string, + readonly reasons: string[], + ) { + super( + 'integrity-blocked', + `Run ${runId} is blocked pending integrity review: ${reasons.join('; ')}`, + ); + } +} + +/** LEDGER §8: checkpoint manifests form their own hash chain. */ +export class CheckpointChainError extends EvidenceWriterError { + constructor(message: string) { + super('checkpoint-chain', message); + } +} + +/** SPEC §11.9: experiment records are versioned append-only, `last + 1`. */ +export class ExperimentRecordVersionError extends EvidenceWriterError { + constructor( + readonly expected: number, + readonly received: number, + ) { + super( + 'experiment-record-version', + `Experiment record version must be ${expected}, received ${received}`, + ); + } +} + +/** A structurally valid request that violates a writer-enforced protocol rule. */ +export class InvalidRequestError extends EvidenceWriterError { + constructor(message: string) { + super('invalid-request', message); + } +} diff --git a/packages/evidence/src/export.ts b/packages/evidence/src/export.ts new file mode 100644 index 0000000..ef226b5 --- /dev/null +++ b/packages/evidence/src/export.ts @@ -0,0 +1,399 @@ +/** + * Evidence bundle export (ALD-016). + * + * Writes the byte-level layout fixed by `docs/evidence-bundle-format.md` + * (LEDGER-INTEGRITY-DESIGN.md §13, SPECIFICATION.md §13.2) from the + * authoritative SQLite store, using nothing but the reader side of the + * Evidence Writer. Every `*.json` file is RFC 8785 canonical JSON plus a + * single newline and every `*.jsonl` line is the exact `canonical_json` + * column stored at commit time, so exporting the same run twice without + * intervening writes is byte-identical. + * + * The exporter never writes `verification-report.json` — that file belongs to + * the independent verifier (ALD-015/ALD-017) — and never writes proof files; + * it only creates the `proofs/` directories the Checkpoint Service fills in. + */ +import { mkdir, readdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { + AUXILIARY_TREES, + assertClaimLabelsAllowed, + BundleAttachmentIndexSchema, + CLAIM_BOUNDARY_STATEMENTS, + EVENT_STREAMS, + HASH_DOMAINS, + MANDATORY_TREES, + RunConfigSchema, + RunManifestSchema, + STREAM_HASH_DOMAIN, + STREAM_SIGNER, + type EventStream, + type EvidenceReader, + type RunConfig, + type RunManifest, + type ModeROnlyClaimLabel, + type PreRegistrationBinding, + type RunMetadataRecord, + type Sha256Hash, + type SignerPublicKey, + type StreamDeclaration, +} from '@ald/types'; +import { + canonicalJson, + domainHash, + encodeHash, + parseCanonicalJson, + sha256Bytes, +} from '@ald/hashing'; + +import { InvalidRequestError, UnknownRunError } from './errors.js'; + +/** File name of each exported stream (`docs/evidence-bundle-format.md` §1). */ +const STREAM_FILES: Record = { + 'baby-a-ledger': 'baby-a-ledger.jsonl', + 'baby-b-ledger': 'baby-b-ledger.jsonl', + channel: 'channel-transcript.jsonl', + affect: 'affect-transcript.jsonl', + audit: 'audit-ledger.jsonl', + turns: 'turn-records.jsonl', + intervention: 'intervention-log.jsonl', +}; + +/** Streams exported for every run, empty file included. */ +const ALWAYS_EXPORTED: readonly EventStream[] = [ + 'baby-a-ledger', + 'baby-b-ledger', + 'channel', + 'turns', + 'intervention', +]; + +/** Streams exported only when they carry at least one event. */ +const CONDITIONAL_STREAMS: readonly EventStream[] = ['affect', 'audit']; + +export interface LearnerContractText { + /** `RunConfig.babyA.track` / `babyB.track` this text was used for. */ + track: string; + version: string; + /** Exact contract text, written verbatim to `prompts/`. */ + text: string; +} + +export interface ExportBundleOptions { + /** Commit of the exporting software, recorded in the run manifest. */ + softwareCommit: string; + /** Contract texts for the tracks named by the run configuration. */ + learnerContracts: LearnerContractText[]; + /** Canonical policy artifacts keyed by their single-segment file name. */ + policyFiles?: Readonly>; + /** Optional Research-Grade claims requested by a report/export surface. */ + claimLabels?: readonly ModeROnlyClaimLabel[]; + preRegistration?: PreRegistrationBinding; + /** Allow writing into a directory that already contains files. */ + overwrite?: boolean; + /** Reserved: the experiment record is never synthesized by the exporter. */ + experimentRecordFallback?: never; +} + +/** Read surface the exporter needs; `SqliteEvidenceWriter` satisfies it. */ +export type BundleReader = EvidenceReader & { + readRunSigners(runId: string): SignerPublicKey[]; +}; + +export interface RunManifestInput { + metadata: RunMetadataRecord; + config: RunConfig; + signers: SignerPublicKey[]; + /** Streams actually written into the bundle, in export order. */ + streams: readonly EventStream[]; + softwareCommit: string; + learnerContracts: LearnerContractText[]; + claimLabels?: readonly ModeROnlyClaimLabel[]; + preRegistration?: PreRegistrationBinding; +} + +/** `sha256:`-prefixed lowercase form; `RunConfig` allows the bare hex form. */ +function strictHash(value: string, field: string): Sha256Hash { + const normalized = value.toLowerCase(); + const withPrefix = normalized.startsWith('sha256:') + ? normalized + : `sha256:${normalized}`; + if (!/^sha256:[a-f0-9]{64}$/u.test(withPrefix)) { + throw new InvalidRequestError(`${field} is not a SHA-256 hash: ${value}`); + } + return withPrefix; +} + +function treeNameFor(stream: EventStream): string | undefined { + if (stream === 'baby-a-ledger' || stream === 'baby-b-ledger' || stream === 'channel') { + return MANDATORY_TREES[stream]; + } + if ( + stream === 'affect' || + stream === 'audit' || + stream === 'turns' || + stream === 'intervention' + ) { + return AUXILIARY_TREES[stream]; + } + return undefined; +} + +function streamDeclaration(stream: EventStream): StreamDeclaration { + const treeName = treeNameFor(stream); + return { + stream, + file: STREAM_FILES[stream], + hashDomain: STREAM_HASH_DOMAIN[stream], + ...(stream === 'intervention' ? {} : { signerDomain: STREAM_SIGNER[stream] }), + ...(treeName === undefined ? {} : { treeName }), + }; +} + +function contractFor( + contracts: LearnerContractText[], + track: string, +): LearnerContractText { + const contract = contracts.find((candidate) => candidate.track === track); + if (!contract) { + throw new InvalidRequestError( + `No learner contract text supplied for track ${track}`, + ); + } + return contract; +} + +/** + * Pure builder for `run-manifest.json` (`docs/evidence-bundle-format.md` §7). + * Exposed separately so the verifier tests and the checkpoint service can + * rebuild a manifest without touching the filesystem. + */ +export function buildRunManifest(input: RunManifestInput): RunManifest { + const { config, metadata } = input; + assertClaimLabelsAllowed( + metadata.deploymentMode, + input.claimLabels ?? [], + ); + const manifest: RunManifest = { + version: 1, + runId: metadata.runId, + runIdHash: domainHash(HASH_DOMAINS.runId, metadata.runId), + experimentId: config.experimentId, + deploymentMode: metadata.deploymentMode, + claimBoundaryStatement: CLAIM_BOUNDARY_STATEMENTS[metadata.deploymentMode], + ...(input.claimLabels === undefined || input.claimLabels.length === 0 + ? {} + : { claimLabels: [...input.claimLabels] }), + configurationHash: strictHash(metadata.configurationHash, 'configurationHash'), + scenarioBundleHash: strictHash(config.scenarioBundleHash, 'scenarioBundleHash'), + promptBundleHash: strictHash(config.promptBundleHash, 'promptBundleHash'), + protocolGitCommit: config.protocolGitCommit, + preRegistrationHash: strictHash( + config.preRegistrationHash, + 'preRegistrationHash', + ), + ...(input.preRegistration === undefined + ? {} + : { preRegistration: input.preRegistration }), + softwareCommit: input.softwareCommit, + createdAt: metadata.createdAt, + ...(metadata.parentRunId === null ? {} : { parentRunId: metadata.parentRunId }), + ...(metadata.derivedFromCheckpointHash === null + ? {} + : { + derivedFromCheckpointHash: strictHash( + metadata.derivedFromCheckpointHash, + 'derivedFromCheckpointHash', + ), + }), + ...(config.babyA.initialPolicyRef === undefined || + config.babyB.initialPolicyRef === undefined + ? {} + : { + initialPolicyRefs: { + babyA: config.babyA.initialPolicyRef, + babyB: config.babyB.initialPolicyRef, + }, + }), + learnerContractVersions: { + babyA: contractFor(input.learnerContracts, config.babyA.track).version, + babyB: contractFor(input.learnerContracts, config.babyB.track).version, + }, + signers: input.signers.map((signer) => ({ + domain: signer.domain, + keyId: signer.keyId, + publicKey: signer.publicKey, + })), + streams: input.streams.map(streamDeclaration), + }; + + return RunManifestSchema.parse(manifest); +} + +async function writeCanonical(path: string, value: unknown): Promise { + await writeFile(path, `${canonicalJson(value)}\n`, 'utf8'); +} + +async function assertWritableDirectory( + outputDir: string, + overwrite: boolean, +): Promise { + let entries: string[]; + try { + entries = await readdir(outputDir); + } catch { + return; + } + if (entries.length > 0 && !overwrite) { + throw new InvalidRequestError( + `Export directory ${outputDir} is not empty; pass overwrite to reuse it`, + ); + } +} + +/** + * Exports one run into `outputDir`, returning the manifest that was written. + */ +export async function exportRunBundle( + reader: BundleReader, + runId: string, + outputDir: string, + options: ExportBundleOptions, +): Promise { + const metadata = reader.readRunMetadata(runId); + if (!metadata) { + throw new UnknownRunError(runId); + } + await assertWritableDirectory(outputDir, options.overwrite === true); + + const config = RunConfigSchema.parse(JSON.parse(metadata.configurationJson)); + const events = new Map( + EVENT_STREAMS.map((stream) => [stream, reader.readEvents(runId, stream)]), + ); + const exported = EVENT_STREAMS.filter( + (stream) => + ALWAYS_EXPORTED.includes(stream) || + (CONDITIONAL_STREAMS.includes(stream) && + (events.get(stream)?.length ?? 0) > 0), + ); + + const manifest = buildRunManifest({ + metadata, + config, + signers: reader.readRunSigners(runId), + streams: exported, + softwareCommit: options.softwareCommit, + learnerContracts: options.learnerContracts, + claimLabels: options.claimLabels, + preRegistration: options.preRegistration, + }); + + for (const directory of [ + outputDir, + join(outputDir, 'checkpoints'), + join(outputDir, 'proofs', 'inclusion'), + join(outputDir, 'proofs', 'consistency'), + join(outputDir, 'anchors'), + join(outputDir, 'configuration'), + join(outputDir, 'prompts'), + ...(options.policyFiles === undefined ? [] : [join(outputDir, 'policies')]), + join(outputDir, 'analysis'), + ]) { + await mkdir(directory, { recursive: true }); + } + + await writeCanonical(join(outputDir, 'run-manifest.json'), manifest); + + for (const stream of exported) { + const lines = (events.get(stream) ?? []).map((event) => event.canonicalJson); + await writeFile( + join(outputDir, STREAM_FILES[stream]), + lines.length === 0 ? '' : `${lines.join('\n')}\n`, + 'utf8', + ); + } + + for (const checkpoint of reader.readCheckpoints(runId)) { + await writeCanonical( + join( + outputDir, + 'checkpoints', + `${String(checkpoint.checkpointSequence).padStart(6, '0')}.json`, + ), + checkpoint, + ); + } + + await writeCanonical( + join(outputDir, 'anchors', 'base-receipts.json'), + reader.readAnchorReceipts(runId), + ); + await writeCanonical( + join(outputDir, 'configuration', 'run-config.json'), + config, + ); + + for (const track of [config.babyA.track, config.babyB.track]) { + const contract = contractFor(options.learnerContracts, track); + await writeFile( + join( + outputDir, + 'prompts', + `learner-contract.${contract.track}.v${contract.version}.md`, + ), + contract.text, + 'utf8', + ); + } + + for (const [file, policy] of Object.entries(options.policyFiles ?? {}).sort( + ([left], [right]) => left.localeCompare(right), + )) { + if ( + file.length === 0 || + file.includes('/') || + file.includes('\\') || + !file.endsWith('.json') + ) { + throw new InvalidRequestError( + `Policy artifact name must be a single JSON file segment: ${file}`, + ); + } + await writeCanonical(join(outputDir, 'policies', file), policy); + } + + const attachments = reader.readAnalysisAttachments(runId); + for (const attachment of attachments) { + parseCanonicalJson(attachment.canonicalJson); + const bytes = `${attachment.canonicalJson}\n`; + const actualHash = encodeHash(sha256Bytes(Buffer.from(bytes, 'utf8'))); + if (actualHash !== attachment.descriptor.sha256) { + throw new InvalidRequestError( + `Analysis attachment ${attachment.descriptor.path} hashes to ${actualHash}, stored descriptor declares ${attachment.descriptor.sha256}`, + ); + } + const path = join(outputDir, attachment.descriptor.path); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes, 'utf8'); + } + await writeCanonical( + join(outputDir, 'analysis', 'index.json'), + BundleAttachmentIndexSchema.parse({ + version: 1, + runId, + attachments: attachments.map((attachment) => attachment.descriptor), + }), + ); + + const records = reader.readExperimentRecords(runId); + const current = records.at(-1); + if (current) { + await writeCanonical(join(outputDir, 'experiment-record.json'), { + current, + history: records, + }); + } + + return manifest; +} diff --git a/packages/evidence/src/index.ts b/packages/evidence/src/index.ts index 92df9cb..177bb38 100644 --- a/packages/evidence/src/index.ts +++ b/packages/evidence/src/index.ts @@ -1,37 +1,15 @@ -import { mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; - -import Database from 'better-sqlite3'; - -import { applyMigrations } from './migrations.js'; - -export interface EvidenceDatabase { - database: Database.Database; - path: string; - close(): void; -} - -export function openEvidenceDatabase(databasePath: string): EvidenceDatabase { - const resolvedPath = - databasePath === ':memory:' ? databasePath : resolve(databasePath); - - if (resolvedPath !== ':memory:') { - mkdirSync(dirname(resolvedPath), { recursive: true }); - } - - const database = new Database(resolvedPath); - database.pragma('foreign_keys = ON'); - database.pragma('synchronous = FULL'); - database.pragma('journal_mode = WAL'); - applyMigrations(database); - - return { - database, - path: resolvedPath, - close: () => database.close(), - }; -} - +/** + * @ald/evidence — the authoritative local evidence store (LEDGER §3) and the + * single Evidence Writer service (SPEC §4.1 item 7, §8.2). + * + * The only way to append to an event table is `SqliteEvidenceWriter` + * (ALD-010 criterion 3): every SQL statement against `ledger_events`, + * `channel_events`, `affect_events`, `audit_ledger_entries`, `turn_records`, + * `intervention_log`, `checkpoint_manifests`, `anchor_receipts`, + * `experiment_records`, `analysis_attachments`, `run_metadata`, `run_signers`, + * and `fork_artifacts` is private to `writer.ts`. + */ +export { openEvidenceDatabase, type EvidenceDatabase } from './database.js'; export { applyMigrations, migrations } from './migrations.js'; export { canonicalizeJson, @@ -45,3 +23,30 @@ export { validateLedgerEventDraft, type LedgerEventType, } from './event-types.js'; +export { + CheckpointChainError, + DuplicateEventError, + DuplicateRunError, + EvidenceWriterError, + ExperimentRecordVersionError, + ForkDetectedError, + IntegrityBlockedError, + InterpretationBindingError, + InvalidRequestError, + UnknownRunError, + type EvidenceErrorCode, +} from './errors.js'; +export { + SqliteEvidenceWriter, + type AffectAppendRequest, + type ForkArtifactRecord, + type SqliteEvidenceWriterOptions, +} from './writer.js'; +export { + buildRunManifest, + exportRunBundle, + type BundleReader, + type ExportBundleOptions, + type LearnerContractText, + type RunManifestInput, +} from './export.js'; diff --git a/packages/evidence/src/migrations.ts b/packages/evidence/src/migrations.ts index e380aa3..3d1d1e7 100644 --- a/packages/evidence/src/migrations.ts +++ b/packages/evidence/src/migrations.ts @@ -20,7 +20,11 @@ const protectedTables = [ 'experiment_records', ] as const; -function appendOnlyTriggers(table: (typeof protectedTables)[number]): string { +/** + * Append-only guard triggers (LEDGER §3). The emitted SQL is part of the + * checksum of every migration that uses it, so its text MUST NOT change. + */ +function appendOnlyTriggers(table: string): string { return ` CREATE TRIGGER ${table}_reject_update BEFORE UPDATE ON ${table} @@ -188,12 +192,161 @@ const initialSchema = ` ON experiment_records(run_id, record_version DESC); `; +/** + * Tables added by migration 2. `turn_records` is the implementation-defined + * `turns` stream (SPEC §14.3 replay tuples), `run_signers` binds a run to the + * public halves of its per-run keys (LEDGER §11: public keys only, never + * seeds), and `fork_artifacts` preserves both sides of a detected fork + * (LEDGER §15). + */ +const turnRecordAndForkSchema = ` + CREATE TABLE turn_records ( + run_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + turn INTEGER NOT NULL CHECK (turn >= 0), + phase TEXT NOT NULL CHECK (phase IN ('running', 'evaluating')), + previous_entry_hash TEXT NOT NULL, + entry_hash TEXT NOT NULL UNIQUE, + writer_key_id TEXT NOT NULL, + writer_signature TEXT NOT NULL, + recorded_at TEXT NOT NULL, + canonical_json TEXT NOT NULL, + PRIMARY KEY (run_id, sequence), + FOREIGN KEY (run_id) REFERENCES run_metadata(run_id) + ) STRICT; + + CREATE TABLE run_signers ( + run_id TEXT NOT NULL, + domain TEXT NOT NULL CHECK (domain IN ( + 'baby-a-ledger', 'baby-b-ledger', 'channel', 'affect', 'audit', 'witness' + )), + key_id TEXT NOT NULL, + public_key TEXT NOT NULL, + recorded_at TEXT NOT NULL, + PRIMARY KEY (run_id, domain), + FOREIGN KEY (run_id) REFERENCES run_metadata(run_id) + ) STRICT; + + CREATE TABLE fork_artifacts ( + run_id TEXT NOT NULL, + stream TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + entry_hash TEXT NOT NULL, + canonical_json TEXT NOT NULL, + detected_at TEXT NOT NULL, + PRIMARY KEY (run_id, stream, sequence, entry_hash) + ) STRICT; + + ${['turn_records', 'run_signers', 'fork_artifacts'] + .map(appendOnlyTriggers) + .join('\n')} + + CREATE INDEX turn_records_run_turn_idx + ON turn_records(run_id, turn); + CREATE INDEX fork_artifacts_run_idx + ON fork_artifacts(run_id, stream, sequence); +`; + +/** + * Migration 3 constrains `fork_artifacts`, the one event table migration 2 + * left without a domain check or a run reference. A fork artifact is read + * back during recovery (LEDGER §15) and its `stream` column is used to pick + * the physical table to compare against, so an out-of-domain value is not + * inert data: it is a poison row on the integrity-verification path that the + * append-only triggers make unremovable. The `CHECK` keeps the column inside + * `EVENT_STREAMS` and the `FOREIGN KEY` keeps an artifact bound to a + * registered run, matching every other event table in migration 1. + * + * SQLite cannot add a `CHECK` or a `FOREIGN KEY` with `ALTER TABLE`, so this + * is the documented table rebuild: drop the guard triggers, copy every row + * into the constrained table, drop the old one, rename, then reinstate the + * triggers and the index. The stream list is spelled out rather than + * interpolated from `EVENT_STREAMS` because migration SQL is checksummed and + * must never change once applied. + */ +const forkArtifactConstraintsSchema = ` + DROP TRIGGER fork_artifacts_reject_update; + DROP TRIGGER fork_artifacts_reject_delete; + + CREATE TABLE fork_artifacts_v3 ( + run_id TEXT NOT NULL, + stream TEXT NOT NULL CHECK (stream IN ( + 'baby-a-ledger', 'baby-b-ledger', 'channel', 'affect', 'audit', + 'turns', 'intervention' + )), + sequence INTEGER NOT NULL CHECK (sequence > 0), + entry_hash TEXT NOT NULL, + canonical_json TEXT NOT NULL, + detected_at TEXT NOT NULL, + PRIMARY KEY (run_id, stream, sequence, entry_hash), + FOREIGN KEY (run_id) REFERENCES run_metadata(run_id) + ) STRICT; + + INSERT INTO fork_artifacts_v3 ( + run_id, stream, sequence, entry_hash, canonical_json, detected_at + ) + SELECT run_id, stream, sequence, entry_hash, canonical_json, detected_at + FROM fork_artifacts; + + DROP TABLE fork_artifacts; + + ALTER TABLE fork_artifacts_v3 RENAME TO fork_artifacts; + + ${appendOnlyTriggers('fork_artifacts')} + + CREATE INDEX fork_artifacts_run_idx + ON fork_artifacts(run_id, stream, sequence); +`; + +/** + * Runtime-produced analysis artifacts. Content and descriptor are append-only; + * `bound_entry_hash` points at the intervention event created in the same + * transaction, whose Merkle prefix is witness-committed by checkpoints. + */ +const analysisAttachmentSchema = ` + CREATE TABLE analysis_attachments ( + run_id TEXT NOT NULL, + path TEXT NOT NULL, + sha256 TEXT NOT NULL, + kind TEXT NOT NULL, + analysis_version TEXT NOT NULL, + produced_at TEXT NOT NULL, + bound_stream TEXT NOT NULL CHECK (bound_stream = 'intervention'), + bound_entry_hash TEXT NOT NULL UNIQUE, + descriptor_json TEXT NOT NULL, + canonical_json TEXT NOT NULL, + PRIMARY KEY (run_id, path), + FOREIGN KEY (run_id) REFERENCES run_metadata(run_id), + FOREIGN KEY (bound_entry_hash) REFERENCES intervention_log(entry_hash) + ) STRICT; + + ${appendOnlyTriggers('analysis_attachments')} + + CREATE INDEX analysis_attachments_run_hash_idx + ON analysis_attachments(run_id, sha256); +`; + export const migrations: readonly Migration[] = [ { version: 1, name: 'initial-evidence-schema', sql: initialSchema, }, + { + version: 2, + name: 'turn-records-signers-and-fork-artifacts', + sql: turnRecordAndForkSchema, + }, + { + version: 3, + name: 'fork-artifact-stream-check-and-run-reference', + sql: forkArtifactConstraintsSchema, + }, + { + version: 4, + name: 'analysis-attachments', + sql: analysisAttachmentSchema, + }, ]; function checksum(migration: Migration): string { diff --git a/packages/evidence/src/mutex.ts b/packages/evidence/src/mutex.ts new file mode 100644 index 0000000..11ba3b1 --- /dev/null +++ b/packages/evidence/src/mutex.ts @@ -0,0 +1,22 @@ +/** + * Minimal FIFO async mutex. + * + * LEDGER §3 requires every write to be serialized through one evidence-writer + * service: sequence assignment reads the chain head and must not observe a + * head another write is about to advance. Signatures are awaited inside the + * critical section, so the lock has to survive `await` points — a synchronous + * SQLite transaction alone is not enough. + */ +export class AsyncMutex { + private tail: Promise = Promise.resolve(); + + /** Runs `task` once every previously queued task has settled. */ + run(task: () => Promise): Promise { + const result = this.tail.then(task); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/packages/evidence/src/writer.ts b/packages/evidence/src/writer.ts new file mode 100644 index 0000000..ff6059c --- /dev/null +++ b/packages/evidence/src/writer.ts @@ -0,0 +1,1923 @@ +/** + * The single Evidence Writer service (SPECIFICATION.md §4.1 item 7, §8.2, + * §12.7; LEDGER-INTEGRITY-DESIGN.md §3, §4, §6, §15). + * + * Every write in the system goes through one instance of this class. It is + * the only place that holds SQL against the event tables (ALD-010 criterion + * 3): no other module — Gateway, Controller, Checkpoint Service — is given a + * function that can append to `ledger_events`, `channel_events`, + * `affect_events`, `audit_ledger_entries`, `turn_records`, or + * `intervention_log`. + * + * Ordering rules implemented here: + * - one async mutex serializes all writes of an instance, so the chain-head + * read that assigns a sequence cannot race a concurrent append; + * - signatures are awaited *before* the synchronous better-sqlite3 + * transaction opens, so a signing failure rolls nothing back and a commit + * never waits on a remote signer (SPEC §8.2); + * - all rows of one logical commit are inserted in a single transaction — for + * an accepted turn that is the sender intention event plus the channel + * event, or neither. + */ +import { + AffectEventSchema, + AnchorReceiptSchema, + AuditLedgerEntrySchema, + AUXILIARY_TREES, + babyIdForRole, + BundleAttachmentSchema, + ChannelEventSchema, + CheckpointManifestSchema, + EVENT_STREAMS, + ExperimentRecordSchema, + GENESIS_HASH, + HASH_DOMAINS, + InterventionEventSchema, + LedgerEventSchema, + MANDATORY_TREES, + MANIFEST_SIGNATURE_FIELDS, + roleForBabyId, + RunConfigSchema, + STREAM_SIGNER, + TurnRecordSchema, + type AffectEvent, + type AnalysisAttachmentAppendRequest, + type AnchorReceipt, + type AuditLedgerEntry, + type AuditLedgerAppendRequest, + type BabyRole, + type ChainHead, + type ChannelEvent, + type CheckpointManifest, + type Clock, + type ControlArtifactCommitRequest, + type DeliveredChannelArtifact, + type EventRange, + type EventStream, + type EvidenceWriter, + type ExperimentRecord, + type ForkReport, + type InterventionAppendRequest, + type InterventionEvent, + type LedgerAppendRequest, + type LedgerEvent, + type LedgerEventDraft, + type RecoveryReport, + type RejectionCommitRequest, + type RunConfig, + type RunMetadataRecord, + type Sha256Hash, + type SignerDomain, + type SignerPublicKey, + type SignerRegistry, + type StoredEvent, + type StoredAnalysisAttachment, + type TreeReference, + type TurnCommitRequest, + type TurnCommitResult, + type TurnRecord, + type TurnRecordAppendRequest, +} from '@ald/types'; +import { + canonicalJson, + computeEntryHash, + domainHash, + encodeHash, + hashCanonical, + hashCarrierMark, + sha256Bytes, + verifyHashSignature, +} from '@ald/hashing'; + +import type { EvidenceDatabase } from './database.js'; +import { + CheckpointChainError, + DuplicateEventError, + DuplicateRunError, + EvidenceWriterError, + ExperimentRecordVersionError, + ForkDetectedError, + IntegrityBlockedError, + InterpretationBindingError, + InvalidRequestError, + UnknownRunError, +} from './errors.js'; +import { validateLedgerEventDraft } from './event-types.js'; +import { AsyncMutex } from './mutex.js'; + +// --------------------------------------------------------------------------- +// Private table mapping. Raw SQL never leaves this module. +// --------------------------------------------------------------------------- + +interface StreamTable { + /** Physical table backing the stream. */ + readonly table: string; + /** Column holding the previous entry hash of the chain. */ + readonly previousColumn: string; + /** Set for the two per-Baby ledger streams sharing `ledger_events`. */ + readonly babyId?: 'A' | 'B'; + /** False only for `intervention`, which the v1 schema stores unsigned. */ + readonly signed: boolean; +} + +const STREAM_TABLES: Record = { + 'baby-a-ledger': { + table: 'ledger_events', + previousColumn: 'previous_entry_hash', + babyId: 'A', + signed: true, + }, + 'baby-b-ledger': { + table: 'ledger_events', + previousColumn: 'previous_entry_hash', + babyId: 'B', + signed: true, + }, + channel: { + table: 'channel_events', + previousColumn: 'previous_channel_hash', + signed: true, + }, + affect: { + table: 'affect_events', + previousColumn: 'previous_entry_hash', + signed: true, + }, + audit: { + table: 'audit_ledger_entries', + previousColumn: 'previous_entry_hash', + signed: true, + }, + turns: { + table: 'turn_records', + previousColumn: 'previous_entry_hash', + signed: true, + }, + intervention: { + table: 'intervention_log', + previousColumn: 'previous_entry_hash', + signed: false, + }, +}; + +/** Narrowed form of `ledgerStreamForRole`, which returns the wide `EventStream`. */ +function ledgerStreamFor(role: BabyRole): 'baby-a-ledger' | 'baby-b-ledger' { + return role === 'baby-a' ? 'baby-a-ledger' : 'baby-b-ledger'; +} + +function streamFilter(stream: EventStream): string { + return STREAM_TABLES[stream].babyId === undefined + ? 'run_id = ?' + : 'run_id = ? AND baby_id = ?'; +} + +function streamKey(runId: string, stream: EventStream): unknown[] { + const babyId = STREAM_TABLES[stream].babyId; + return babyId === undefined ? [runId] : [runId, babyId]; +} + +interface StreamRow { + sequence: number; + entry_hash: string; + previous_hash: string; + recorded_at: string; + canonical_json: string; +} + +/** One row of a pending logical commit, used for fork classification. */ +interface PendingRow { + stream: EventStream; + sequence: number; + entryHash: Sha256Hash; + canonicalJson: string; +} + +const SQLITE_CONFLICT_CODES = new Set([ + 'SQLITE_CONSTRAINT_PRIMARYKEY', + 'SQLITE_CONSTRAINT_UNIQUE', +]); + +/** + * Domain check for a stream name read back out of the store. It tests the + * `EVENT_STREAMS` list rather than writing `value in STREAM_TABLES`, because + * `in` is true for every inherited `Object.prototype` key (`constructor`, + * `toString`, …) of an object literal — the same trap `isSignedStream` in + * `@ald/hashing` avoids with `Object.prototype.hasOwnProperty.call`. + */ +function isEventStream(value: string): value is EventStream { + return (EVENT_STREAMS as readonly string[]).includes(value); +} + +/** Checkpoint tree name (LEDGER §8) → the stream it commits, both ways. */ +const STREAM_FOR_AUXILIARY_TREE = new Map( + Object.entries(AUXILIARY_TREES).map(([stream, tree]) => [ + tree, + stream as EventStream, + ]), +); + +const MANDATORY_TREE_STREAMS = Object.entries(MANDATORY_TREES) as [ + EventStream, + (typeof MANDATORY_TREES)[keyof typeof MANDATORY_TREES], +][]; + +function isUniquenessConflict(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + typeof (error as { code?: unknown }).code === 'string' && + SQLITE_CONFLICT_CODES.has((error as { code: string }).code) + ); +} + +// --------------------------------------------------------------------------- +// Package-local request types for streams the contract has no writer for +// --------------------------------------------------------------------------- + +/** + * Affect append request. `EvidenceWriter` in `@ald/types` declares no affect + * method even though `affect_events` is a mandatory auxiliary tree + * (SPEC §11.6, §13.3), so this extension keeps the stream writable by the + * single writer instead of by the Gateway. + */ +export interface AffectAppendRequest { + runId: string; + turn: number; + windowId: string; + sender: BabyRole; + displayId: AffectEvent['displayId']; + affectMode: AffectEvent['affectMode']; + deliveredAt: string; +} + +export interface ForkArtifactRecord { + runId: string; + stream: string; + sequence: number; + entryHash: Sha256Hash; + canonicalJson: string; + detectedAt: string; +} + +export interface SqliteEvidenceWriterOptions { + /** Open store from `openEvidenceDatabase`. */ + database: EvidenceDatabase; + /** Per-run signer registry; its `runId` must match every signed write. */ + signers: SignerRegistry; + /** Injected clock so tests are deterministic (defaults to real UTC time). */ + clock?: Clock; + /** Recorded in exported bundles and checkpoints; not part of any hash here. */ + softwareCommit?: string; +} + +const realClock: Clock = { now: () => new Date().toISOString() }; + +export class SqliteEvidenceWriter implements EvidenceWriter { + private readonly db: EvidenceDatabase['database']; + private readonly signers: SignerRegistry; + private readonly clock: Clock; + private readonly mutex = new AsyncMutex(); + /** + * LEDGER §15: a run whose last `recover()` found violations or forks + * accepts no further writes until a research-integrity review is recorded. + */ + private readonly integrityBlocks = new Map(); + + readonly softwareCommit: string; + + constructor(options: SqliteEvidenceWriterOptions) { + this.db = options.database.database; + this.signers = options.signers; + this.clock = options.clock ?? realClock; + this.softwareCommit = options.softwareCommit ?? 'unknown'; + } + + // ------------------------------------------------------------------------- + // Read side (EvidenceReader) + // ------------------------------------------------------------------------- + + listRuns(): string[] { + return this.db + .prepare<[], { run_id: string }>( + 'SELECT run_id FROM run_metadata ORDER BY run_id', + ) + .all() + .map((row) => row.run_id); + } + + readRunMetadata(runId: string): RunMetadataRecord | undefined { + const row = this.db + .prepare< + [string], + { + run_id: string; + created_at: string; + deployment_mode: string; + configuration_hash: string; + configuration_json: string; + parent_run_id: string | null; + derived_from_checkpoint_hash: string | null; + } + >('SELECT * FROM run_metadata WHERE run_id = ?') + .get(runId); + + if (!row) { + return undefined; + } + + return { + runId: row.run_id, + createdAt: row.created_at, + deploymentMode: row.deployment_mode as RunConfig['deploymentMode'], + configurationHash: row.configuration_hash, + configurationJson: row.configuration_json, + parentRunId: row.parent_run_id, + derivedFromCheckpointHash: row.derived_from_checkpoint_hash, + }; + } + + chainHead(runId: string, stream: EventStream): ChainHead { + const spec = STREAM_TABLES[stream]; + const row = this.db + .prepare( + `SELECT COUNT(*) AS size, + (SELECT entry_hash FROM ${spec.table} + WHERE ${streamFilter(stream)} + ORDER BY sequence DESC LIMIT 1) AS last_entry_hash + FROM ${spec.table} + WHERE ${streamFilter(stream)}`, + ) + .get(...streamKey(runId, stream), ...streamKey(runId, stream)); + + const size = row?.size ?? 0; + return { + stream, + size, + lastEntryHash: size === 0 ? GENESIS_HASH : (row?.last_entry_hash ?? GENESIS_HASH), + }; + } + + readEvents( + runId: string, + stream: EventStream, + range?: EventRange, + ): StoredEvent[] { + return this.streamRows(runId, stream, range).map((row) => ({ + stream, + sequence: row.sequence, + entryHash: row.entry_hash, + previousEntryHash: row.previous_hash, + recordedAt: row.recorded_at, + canonicalJson: row.canonical_json, + })); + } + + readCheckpoints(runId: string): CheckpointManifest[] { + return this.db + .prepare<[string], { canonical_json: string }>( + `SELECT canonical_json FROM checkpoint_manifests + WHERE run_id = ? ORDER BY checkpoint_sequence`, + ) + .all(runId) + .map((row) => + CheckpointManifestSchema.parse(JSON.parse(row.canonical_json)), + ); + } + + readAnchorReceipts(runId: string): AnchorReceipt[] { + return this.db + .prepare<[string], { canonical_json: string }>( + `SELECT canonical_json FROM anchor_receipts + WHERE run_id = ? + ORDER BY recorded_at, chain_id, transaction_hash`, + ) + .all(runId) + .map((row) => AnchorReceiptSchema.parse(JSON.parse(row.canonical_json))); + } + + readExperimentRecords(runId: string): ExperimentRecord[] { + return this.db + .prepare<[string], { canonical_json: string }>( + `SELECT canonical_json FROM experiment_records + WHERE run_id = ? ORDER BY record_version`, + ) + .all(runId) + .map((row) => ExperimentRecordSchema.parse(JSON.parse(row.canonical_json))); + } + + readAnalysisAttachments(runId: string): StoredAnalysisAttachment[] { + return this.db + .prepare< + [string], + { descriptor_json: string; canonical_json: string } + >( + `SELECT descriptor_json, canonical_json FROM analysis_attachments + WHERE run_id = ? ORDER BY path`, + ) + .all(runId) + .map((row) => ({ + descriptor: BundleAttachmentSchema.parse(JSON.parse(row.descriptor_json)), + canonicalJson: row.canonical_json, + })); + } + + /** LEDGER §11: the public halves of this run's per-run keys, never seeds. */ + readRunSigners(runId: string): SignerPublicKey[] { + return this.db + .prepare<[string], { domain: string; key_id: string; public_key: string }>( + `SELECT domain, key_id, public_key FROM run_signers + WHERE run_id = ? ORDER BY domain`, + ) + .all(runId) + .map((row) => ({ + domain: row.domain as SignerDomain, + keyId: row.key_id, + publicKey: row.public_key, + })); + } + + /** LEDGER §15: both sides of every detected fork, preserved for review. */ + readForkArtifacts(runId: string): ForkArtifactRecord[] { + return this.db + .prepare< + [string], + { + run_id: string; + stream: string; + sequence: number; + entry_hash: string; + canonical_json: string; + detected_at: string; + } + >( + `SELECT * FROM fork_artifacts + WHERE run_id = ? ORDER BY stream, sequence, entry_hash`, + ) + .all(runId) + .map((row) => ({ + runId: row.run_id, + stream: row.stream, + sequence: row.sequence, + entryHash: row.entry_hash, + canonicalJson: row.canonical_json, + detectedAt: row.detected_at, + })); + } + + // ------------------------------------------------------------------------- + // Run registration + // ------------------------------------------------------------------------- + + registerRun(config: RunConfig): { configurationHash: Sha256Hash } { + const parsed = RunConfigSchema.parse(config); + if (parsed.runId !== this.signers.runId) { + throw new InvalidRequestError( + `Signer registry is bound to run ${this.signers.runId}, not ${parsed.runId}`, + ); + } + if (this.readRunMetadata(parsed.runId)) { + throw new DuplicateRunError(parsed.runId); + } + + const configurationHash = hashCanonical(HASH_DOMAINS.runConfig, parsed); + const recordedAt = this.clock.now(); + const signerRows = this.signers.publicKeys(); + + this.db.transaction(() => { + this.db + .prepare( + `INSERT INTO run_metadata ( + run_id, created_at, deployment_mode, configuration_hash, + configuration_json, parent_run_id, derived_from_checkpoint_hash + ) VALUES ( + @runId, @createdAt, @deploymentMode, @configurationHash, + @configurationJson, @parentRunId, @derivedFromCheckpointHash + )`, + ) + .run({ + runId: parsed.runId, + createdAt: recordedAt, + deploymentMode: parsed.deploymentMode, + configurationHash, + configurationJson: canonicalJson(parsed), + parentRunId: parsed.parentRunId ?? null, + derivedFromCheckpointHash: parsed.derivedFromCheckpointHash ?? null, + }); + + const insertSigner = this.db.prepare( + `INSERT INTO run_signers (run_id, domain, key_id, public_key, recorded_at) + VALUES (@runId, @domain, @keyId, @publicKey, @recordedAt)`, + ); + for (const signer of signerRows) { + insertSigner.run({ + runId: parsed.runId, + domain: signer.domain, + keyId: signer.keyId, + publicKey: signer.publicKey, + recordedAt: recordedAt, + }); + } + })(); + + return { configurationHash }; + } + + // ------------------------------------------------------------------------- + // Atomic turn commit (SPEC §8.2, LEDGER §6) + // ------------------------------------------------------------------------- + + async commitTurn(request: TurnCommitRequest): Promise { + const draft = validateLedgerEventDraft(request.intentionDraft); + if (draft.eventType !== 'intention.recorded') { + throw new InvalidRequestError( + `A turn commit requires an intention.recorded draft, received ${draft.eventType}`, + ); + } + + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + + const ledgerStream = ledgerStreamFor(request.sender); + const ledgerHead = this.chainHead(request.runId, ledgerStream); + const senderLedgerEvent = await this.signLedgerEvent({ + runId: request.runId, + stream: ledgerStream, + babyId: babyIdForRole(request.sender), + sequence: ledgerHead.size + 1, + turn: request.turn, + draft, + previousEntryHash: ledgerHead.lastEntryHash, + }); + + const channelHead = this.chainHead(request.runId, 'channel'); + const publicArtifactHash = hashCarrierMark( + request.carrier, + request.deliveredArtifact, + ); + const recordedAt = this.clock.now(); + const channelEvent = await this.signChannelEvent({ + version: 1, + runId: request.runId, + sequence: channelHead.size + 1, + turn: request.turn, + logicalSender: request.sender, + origin: 'baby', + carrier: request.carrier, + communicationCondition: request.communicationCondition, + babyProposalHash: hashCanonical( + HASH_DOMAINS.babyProposal, + request.proposal, + ), + senderLedgerSequence: senderLedgerEvent.sequence, + senderEntryHash: senderLedgerEvent.entryHash, + publicArtifactHash, + previousChannelHash: channelHead.lastEntryHash, + gatewayValidationResult: 'accepted', + ...(request.deliveredArtifact !== null + ? { + deliveryReceipt: { + recipient: request.recipient, + deliveredArtifactHash: publicArtifactHash, + deliveredAt: this.clock.now(), + }, + } + : {}), + recordedAt, + }); + + this.commitRows( + request.runId, + [ + this.pendingLedgerRow(ledgerStream, senderLedgerEvent), + this.pendingChannelRow(channelEvent), + ], + () => { + this.insertLedgerRow(senderLedgerEvent); + this.insertChannelRow(channelEvent); + }, + ); + + const delivery: DeliveredChannelArtifact | null = + request.deliveredArtifact === null + ? null + : { + runId: request.runId, + turn: request.turn, + logicalSender: request.sender, + carrier: request.carrier, + publicArtifact: request.deliveredArtifact, + channelEventHash: channelEvent.entryHash, + }; + + return { senderLedgerEvent, channelEvent, delivery }; + }); + } + + commitRejection(request: RejectionCommitRequest): Promise { + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + + const head = this.chainHead(request.runId, 'channel'); + const channelEvent = await this.signChannelEvent({ + version: 1, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + logicalSender: request.sender, + origin: 'baby', + carrier: request.carrier, + communicationCondition: request.communicationCondition, + publicArtifactHash: request.rejectedPayloadHash, + previousChannelHash: head.lastEntryHash, + gatewayValidationResult: 'rejected', + reasonCode: request.reasonCode, + recordedAt: this.clock.now(), + }); + + this.commitRows( + request.runId, + [this.pendingChannelRow(channelEvent)], + () => { + this.insertChannelRow(channelEvent); + }, + ); + + return channelEvent; + }); + } + + commitControlArtifact( + request: ControlArtifactCommitRequest, + ): Promise<{ channelEvent: ChannelEvent; delivery: DeliveredChannelArtifact }> { + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + + const head = this.chainHead(request.runId, 'channel'); + const publicArtifactHash = hashCarrierMark( + request.carrier, + request.deliveredArtifact, + ); + const channelEvent = await this.signChannelEvent({ + version: 1, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + logicalSender: request.logicalSender, + origin: 'gateway-control', + carrier: request.carrier, + communicationCondition: 'oracle', + publicArtifactHash, + previousChannelHash: head.lastEntryHash, + gatewayValidationResult: 'accepted', + deliveryReceipt: { + recipient: request.recipient, + deliveredArtifactHash: publicArtifactHash, + deliveredAt: this.clock.now(), + }, + recordedAt: this.clock.now(), + }); + + this.commitRows( + request.runId, + [this.pendingChannelRow(channelEvent)], + () => { + this.insertChannelRow(channelEvent); + }, + ); + + return { + channelEvent, + delivery: { + runId: request.runId, + turn: request.turn, + logicalSender: request.logicalSender, + carrier: request.carrier, + publicArtifact: request.deliveredArtifact, + channelEventHash: channelEvent.entryHash, + }, + }; + }); + } + + // ------------------------------------------------------------------------- + // Single-stream appends + // ------------------------------------------------------------------------- + + async appendLedgerEvent(request: LedgerAppendRequest): Promise { + const draft = validateLedgerEventDraft(request.draft); + if ( + draft.eventType === 'interpretation.recorded' && + request.channelEventHash === undefined + ) { + throw new InterpretationBindingError( + 'interpretation.recorded events must reference the delivered channelEventHash', + ); + } + + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + if (request.channelEventHash !== undefined) { + this.assertDeliveryBinding( + request.runId, + request.babyId, + request.channelEventHash, + ); + } + + const stream = ledgerStreamFor(roleForBabyId(request.babyId)); + const head = this.chainHead(request.runId, stream); + const event = await this.signLedgerEvent({ + runId: request.runId, + stream, + babyId: request.babyId, + sequence: head.size + 1, + turn: request.turn, + draft, + previousEntryHash: head.lastEntryHash, + channelEventHash: request.channelEventHash, + }); + + this.commitRows( + request.runId, + [this.pendingLedgerRow(stream, event)], + () => { + this.insertLedgerRow(event); + }, + ); + + return event; + }); + } + + appendTurnRecord(request: TurnRecordAppendRequest): Promise { + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + + const head = this.chainHead(request.runId, 'turns'); + const signer = this.requireSigner('turns'); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + phase: request.phase, + roles: request.roles, + communicationCondition: request.communicationCondition, + scenarioRef: request.scenarioRef, + scenarioStateHash: request.scenarioStateHash, + observationHashes: request.observationHashes, + ...(request.repairAttempt === undefined + ? {} + : { repairAttempt: request.repairAttempt }), + ...(request.probeHash === undefined + ? {} + : { probeHash: request.probeHash }), + babyProposalHash: request.babyProposalHash, + deliveredArtifactHash: request.deliveredArtifactHash, + channelEventHash: request.channelEventHash, + actionHash: request.actionHash, + outcomeHash: request.outcomeHash, + outcome: request.outcome, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash('turns', unsigned); + const record = TurnRecordSchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + + const canonical = canonicalJson(record); + this.commitRows( + request.runId, + [ + { + stream: 'turns', + sequence: record.sequence, + entryHash: record.entryHash, + canonicalJson: canonical, + }, + ], + () => { + this.db + .prepare( + `INSERT INTO turn_records ( + run_id, sequence, turn, phase, previous_entry_hash, entry_hash, + writer_key_id, writer_signature, recorded_at, canonical_json + ) VALUES ( + @runId, @sequence, @turn, @phase, @previousEntryHash, @entryHash, + @writerKeyId, @writerSignature, @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: record.runId, + sequence: record.sequence, + turn: record.turn, + phase: record.phase, + previousEntryHash: record.previousEntryHash, + entryHash: record.entryHash, + writerKeyId: record.writerKeyId, + writerSignature: record.writerSignature, + recordedAt: record.recordedAt, + canonicalJson: canonical, + }); + }, + ); + + return record; + }); + } + + appendInterventionEvent( + request: InterventionAppendRequest, + ): Promise { + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + return this.insertInterventionEvent(request); + }); + } + + appendAnalysisAttachment( + request: AnalysisAttachmentAppendRequest, + ): Promise { + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + const canonical = canonicalJson(request.value); + const sha256 = encodeHash( + sha256Bytes(Buffer.from(`${canonical}\n`, 'utf8')), + ); + const producedAt = this.clock.now(); + let stored: StoredAnalysisAttachment | undefined; + + await this.insertInterventionEvent( + { + runId: request.runId, + eventType: 'analysis-attached', + actorId: request.actorId, + reasonCode: request.reasonCode, + details: { + path: request.path, + sha256, + kind: request.kind, + analysisVersion: request.analysisVersion, + }, + }, + (event) => { + const descriptor = BundleAttachmentSchema.parse({ + path: request.path, + sha256, + kind: request.kind, + analysisVersion: request.analysisVersion, + producedAt, + boundBy: { stream: 'intervention', entryHash: event.entryHash }, + }); + this.db + .prepare( + `INSERT INTO analysis_attachments ( + run_id, path, sha256, kind, analysis_version, produced_at, + bound_stream, bound_entry_hash, descriptor_json, canonical_json + ) VALUES ( + @runId, @path, @sha256, @kind, @analysisVersion, @producedAt, + @boundStream, @boundEntryHash, @descriptorJson, @canonicalJson + )`, + ) + .run({ + runId: request.runId, + path: descriptor.path, + sha256: descriptor.sha256, + kind: descriptor.kind, + analysisVersion: descriptor.analysisVersion, + producedAt: descriptor.producedAt, + boundStream: descriptor.boundBy?.stream, + boundEntryHash: descriptor.boundBy?.entryHash, + descriptorJson: canonicalJson(descriptor), + canonicalJson: canonical, + }); + stored = { descriptor, canonicalJson: canonical }; + }, + ); + + if (stored === undefined) { + throw new EvidenceWriterError( + 'analysis-attachment', + `Analysis attachment ${request.path} was not stored`, + ); + } + return stored; + }); + } + + appendAuditLedgerEntry( + request: AuditLedgerAppendRequest, + ): Promise { + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + + const sourceStream = + request.babyId === 'A' ? 'baby-a-ledger' : 'baby-b-ledger'; + const source = this.readEvents(request.runId, sourceStream).find( + (event) => event.entryHash === request.sourceEntryHash, + ); + if (source === undefined) { + throw new InterpretationBindingError( + `Audit interpretation source ${request.sourceEntryHash} is not an event in ${sourceStream}`, + ); + } + const nativeEvent = LedgerEventSchema.parse(JSON.parse(source.canonicalJson)); + if (nativeEvent.contentSchema !== 'agent-native-ledger') { + throw new InterpretationBindingError( + `Audit interpretation source ${request.sourceEntryHash} is not agent-native ledger state`, + ); + } + + const head = this.chainHead(request.runId, 'audit'); + const signer = this.requireSigner('audit'); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + babyId: request.babyId, + source: 'generated-analysis' as const, + sourceEntryHash: request.sourceEntryHash, + interpreterVersion: request.interpreterVersion, + content: request.content, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash('audit', unsigned); + const entry = AuditLedgerEntrySchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + + const canonical = canonicalJson(entry); + this.commitRows( + request.runId, + [ + { + stream: 'audit', + sequence: entry.sequence, + entryHash: entry.entryHash, + canonicalJson: canonical, + }, + ], + () => { + this.db + .prepare( + `INSERT INTO audit_ledger_entries ( + run_id, sequence, baby_id, source, source_entry_hash, + previous_entry_hash, entry_hash, writer_key_id, + writer_signature, recorded_at, canonical_json + ) VALUES ( + @runId, @sequence, @babyId, @source, @sourceEntryHash, + @previousEntryHash, @entryHash, @writerKeyId, + @writerSignature, @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: entry.runId, + sequence: entry.sequence, + babyId: entry.babyId, + source: entry.source, + sourceEntryHash: entry.sourceEntryHash, + previousEntryHash: entry.previousEntryHash, + entryHash: entry.entryHash, + writerKeyId: entry.writerKeyId, + writerSignature: entry.writerSignature, + recordedAt: entry.recordedAt, + canonicalJson: canonical, + }); + }, + ); + + return entry; + }); + } + + /** Extension beyond `EvidenceWriter`; see {@link AffectAppendRequest}. */ + appendAffectEvent(request: AffectAppendRequest): Promise { + return this.mutex.run(async () => { + this.assertWritableRun(request.runId); + + const head = this.chainHead(request.runId, 'affect'); + const signer = this.requireSigner('affect'); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + windowId: request.windowId, + sender: request.sender, + displayId: request.displayId, + affectMode: request.affectMode, + deliveredAt: request.deliveredAt, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash('affect', unsigned); + const event = AffectEventSchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + + const canonical = canonicalJson(event); + this.commitRows( + request.runId, + [ + { + stream: 'affect', + sequence: event.sequence, + entryHash: event.entryHash, + canonicalJson: canonical, + }, + ], + () => { + this.db + .prepare( + `INSERT INTO affect_events ( + run_id, sequence, turn, sender, display_id, affect_mode, + previous_entry_hash, entry_hash, writer_key_id, + writer_signature, recorded_at, canonical_json + ) VALUES ( + @runId, @sequence, @turn, @sender, @displayId, @affectMode, + @previousEntryHash, @entryHash, @writerKeyId, + @writerSignature, @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: event.runId, + sequence: event.sequence, + turn: event.turn, + sender: event.sender, + displayId: event.displayId, + affectMode: event.affectMode, + previousEntryHash: event.previousEntryHash, + entryHash: event.entryHash, + writerKeyId: event.writerKeyId, + writerSignature: event.writerSignature, + recordedAt: event.recordedAt, + canonicalJson: canonical, + }); + }, + ); + + return event; + }); + } + + // ------------------------------------------------------------------------- + // Checkpoints, anchors, experiment records + // ------------------------------------------------------------------------- + + insertCheckpointManifest(manifest: CheckpointManifest): void { + const parsed = CheckpointManifestSchema.parse(manifest); + const runId = this.runIdForHash(parsed.runIdHash); + this.assertWritableRun(runId); + + const expectedHash = hashCanonical( + HASH_DOMAINS.checkpoint, + omit(parsed, MANIFEST_SIGNATURE_FIELDS), + ); + if (expectedHash !== parsed.checkpointHash) { + throw new CheckpointChainError( + `checkpointHash ${parsed.checkpointHash} does not match the manifest content (${expectedHash})`, + ); + } + + const previous = this.db + .prepare<[string], { checkpoint_hash: string; checkpoint_sequence: number }>( + `SELECT checkpoint_hash, checkpoint_sequence FROM checkpoint_manifests + WHERE run_id = ? ORDER BY checkpoint_sequence DESC LIMIT 1`, + ) + .get(runId); + const expectedPrevious = previous?.checkpoint_hash ?? GENESIS_HASH; + if (parsed.previousCheckpointHash !== expectedPrevious) { + throw new CheckpointChainError( + `previousCheckpointHash must be ${expectedPrevious}, received ${parsed.previousCheckpointHash}`, + ); + } + const expectedSequence = previous === undefined ? 0 : previous.checkpoint_sequence + 1; + if (parsed.checkpointSequence !== expectedSequence) { + throw new CheckpointChainError( + `checkpointSequence must be ${expectedSequence}, received ${parsed.checkpointSequence}`, + ); + } + + this.db + .prepare( + `INSERT INTO checkpoint_manifests ( + run_id, checkpoint_sequence, checkpoint_hash, previous_checkpoint_hash, + witness_key_id, witness_signature, created_at, canonical_json + ) VALUES ( + @runId, @checkpointSequence, @checkpointHash, @previousCheckpointHash, + @witnessKeyId, @witnessSignature, @createdAt, @canonicalJson + )`, + ) + .run({ + runId, + checkpointSequence: parsed.checkpointSequence, + checkpointHash: parsed.checkpointHash, + previousCheckpointHash: parsed.previousCheckpointHash, + witnessKeyId: parsed.witnessKeyId, + witnessSignature: parsed.witnessSignature, + createdAt: parsed.createdAt, + canonicalJson: canonicalJson(parsed), + }); + } + + insertAnchorReceipt(receipt: AnchorReceipt): void { + const parsed = AnchorReceiptSchema.parse(receipt); + this.assertWritableRun(parsed.runId); + + const chainId = parsed.chainId; + this.db + .prepare( + `INSERT INTO anchor_receipts ( + run_id, checkpoint_hash, chain_id, transaction_hash, block_number, + status, finality_policy, recorded_at, canonical_json + ) VALUES ( + @runId, @checkpointHash, @chainId, @transactionHash, @blockNumber, + @status, @finalityPolicy, @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: parsed.runId, + checkpointHash: parsed.checkpointHash, + chainId, + transactionHash: parsed.transactionHash, + blockNumber: parsed.blockNumber, + status: parsed.status, + finalityPolicy: parsed.finalityPolicy, + recordedAt: parsed.recordedAt, + canonicalJson: canonicalJson(parsed), + }); + } + + appendExperimentRecord(record: ExperimentRecord): void { + const parsed = ExperimentRecordSchema.parse(record); + this.assertWritableRun(parsed.runId); + + const last = this.db + .prepare<[string], { record_version: number }>( + `SELECT record_version FROM experiment_records + WHERE run_id = ? ORDER BY record_version DESC LIMIT 1`, + ) + .get(parsed.runId); + const expected = (last?.record_version ?? 0) + 1; + if (parsed.recordVersion !== expected) { + throw new ExperimentRecordVersionError(expected, parsed.recordVersion); + } + + this.db + .prepare( + `INSERT INTO experiment_records ( + run_id, record_version, experiment_id, disposition, + checkpoint_manifest_ref, anchor_tx_ref, verifier_report_ref, + recorded_at, canonical_json + ) VALUES ( + @runId, @recordVersion, @experimentId, @disposition, + @checkpointManifestRef, @anchorTxRef, @verifierReportRef, + @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: parsed.runId, + recordVersion: parsed.recordVersion, + experimentId: parsed.experimentId, + disposition: parsed.disposition, + checkpointManifestRef: parsed.checkpointManifestRef, + anchorTxRef: parsed.anchorTxRef, + verifierReportRef: parsed.verifierReportRef, + recordedAt: this.clock.now(), + canonicalJson: canonicalJson(parsed), + }); + } + + // ------------------------------------------------------------------------- + // Recovery and fork handling (LEDGER §15) + // ------------------------------------------------------------------------- + + /** + * Re-verifies the whole committed prefix of every stream of a run without + * touching a single row: recomputes each entry hash from the stored + * canonical JSON, walks the previous-hash links from genesis, verifies each + * writer signature against the public key recorded in `run_signers`, + * cross-checks every stream against the last checkpoint manifest, and + * reports every preserved fork. A run with any finding is blocked for + * writing until {@link acknowledgeIntegrityReview}. + * + * LEDGER §15 ("load the last valid entry **and checkpoint hashes**; verify + * the committed prefix before accepting new writes; … never truncate or + * reuse a sequence number"): a walk of the surviving rows alone cannot see + * a lost tail, because the shortened prefix is internally perfect — + * sequence density, previous-hash links and signatures all still hold. + * Only the checkpoint the store already holds knows how long the committed + * prefix was, so {@link checkpointPrefixViolations} is what stops the + * writer from re-issuing a sequence number a checkpoint already bound to a + * different entry hash. + */ + async recover(runId: string): Promise { + if (!this.readRunMetadata(runId)) { + throw new UnknownRunError(runId); + } + + const chainViolations: string[] = []; + const heads: ChainHead[] = []; + const rowsByStream = new Map(); + const publicKeys = new Map( + this.readRunSigners(runId).map((signer) => [signer.domain, signer]), + ); + + for (const stream of EVENT_STREAMS) { + const rows = this.streamRows(runId, stream); + rowsByStream.set(stream, rows); + heads.push(this.chainHead(runId, stream)); + let previous = GENESIS_HASH; + + for (const [index, row] of rows.entries()) { + const label = `${stream}#${row.sequence}`; + if (row.sequence !== index + 1) { + chainViolations.push( + `${label}: expected sequence ${index + 1}, stored ${row.sequence}`, + ); + } + + let event: Record; + try { + event = JSON.parse(row.canonical_json) as Record; + } catch { + chainViolations.push(`${label}: canonical_json is not valid JSON`); + previous = row.entry_hash; + continue; + } + + if (canonicalJson(event) !== row.canonical_json) { + chainViolations.push(`${label}: canonical_json is not RFC 8785 canonical`); + } + + const recomputed = computeEntryHash(stream, event); + if (recomputed !== row.entry_hash) { + chainViolations.push( + `${label}: entry hash mismatch (stored ${row.entry_hash}, recomputed ${recomputed})`, + ); + } + if (event.entryHash !== row.entry_hash) { + chainViolations.push(`${label}: stored entryHash disagrees with the row`); + } + if (row.previous_hash !== previous) { + chainViolations.push( + `${label}: previous hash ${row.previous_hash} does not chain to ${previous}`, + ); + } + + if (STREAM_TABLES[stream].signed) { + const domain = STREAM_SIGNER[stream as Exclude]; + const signer = publicKeys.get(domain); + const signature = event.writerSignature; + if (!signer) { + chainViolations.push( + `${label}: no public key recorded for signer domain ${domain}`, + ); + } else if (typeof signature !== 'string') { + chainViolations.push(`${label}: missing writerSignature`); + } else if ( + !verifyHashSignature(row.entry_hash, signature, signer.publicKey) + ) { + chainViolations.push( + `${label}: writer signature does not verify under ${signer.keyId}`, + ); + } else if (event.writerKeyId !== signer.keyId) { + chainViolations.push( + `${label}: writerKeyId ${String(event.writerKeyId)} is not ${signer.keyId}`, + ); + } + } + + previous = row.entry_hash; + } + } + + chainViolations.push(...this.checkpointPrefixViolations(runId, rowsByStream)); + + const { forks, violations } = this.collectForks(runId); + chainViolations.push(...violations); + const ok = chainViolations.length === 0 && forks.length === 0; + + if (ok) { + this.integrityBlocks.delete(runId); + } else { + this.integrityBlocks.set(runId, [ + ...chainViolations, + ...forks.map( + (fork) => `fork ${fork.stream}#${fork.sequence}: ${fork.entryHashes.join(' vs ')}`, + ), + ]); + } + + return { runId, ok, heads, forks, chainViolations }; + } + + /** + * LEDGER §15: the only way to clear a fork/violation block. The reviewer is + * recorded as a `governance-decision` intervention event so the decision is + * itself part of the evidence. + */ + async acknowledgeIntegrityReview( + runId: string, + reviewer: string, + ): Promise { + if (!this.readRunMetadata(runId)) { + throw new UnknownRunError(runId); + } + if (reviewer.trim().length === 0) { + throw new InvalidRequestError('An integrity review requires a reviewer id'); + } + + const reasons = this.integrityBlocks.get(runId) ?? []; + return this.mutex.run(async () => { + this.integrityBlocks.delete(runId); + return this.insertInterventionEvent({ + runId, + eventType: 'governance-decision', + actorId: reviewer, + reasonCode: 'integrity-review-acknowledged', + details: { reviewer, acknowledgedFindings: reasons }, + }); + }); + } + + /** Findings of the last {@link recover} call, empty when the run is writable. */ + integrityFindings(runId: string): string[] { + return [...(this.integrityBlocks.get(runId) ?? [])]; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private requireSigner(stream: Exclude) { + return this.signers.signer(STREAM_SIGNER[stream]); + } + + private assertWritableRun(runId: string): void { + if (!this.readRunMetadata(runId)) { + throw new UnknownRunError(runId); + } + const reasons = this.integrityBlocks.get(runId); + if (reasons && reasons.length > 0) { + throw new IntegrityBlockedError(runId, reasons); + } + if (runId !== this.signers.runId) { + throw new InvalidRequestError( + `Signer registry is bound to run ${this.signers.runId}, not ${runId}`, + ); + } + } + + /** SPEC §11.3: the echoed hash must name a delivery addressed to this Baby. */ + private assertDeliveryBinding( + runId: string, + babyId: 'A' | 'B', + channelEventHash: Sha256Hash, + ): void { + const row = this.db + .prepare<[string, string], { canonical_json: string }>( + 'SELECT canonical_json FROM channel_events WHERE run_id = ? AND entry_hash = ?', + ) + .get(runId, channelEventHash); + if (!row) { + throw new InterpretationBindingError( + `No channel event ${channelEventHash} is committed for run ${runId}`, + ); + } + + const event = ChannelEventSchema.parse(JSON.parse(row.canonical_json)); + const expected = roleForBabyId(babyId); + if (event.deliveryReceipt?.recipient !== expected) { + throw new InterpretationBindingError( + `Channel event ${channelEventHash} was not delivered to ${expected}`, + ); + } + } + + private buildContent(draft: LedgerEventDraft): Record { + return draft.evidenceRefs.length > 0 + ? { ...draft.content, evidenceRefs: draft.evidenceRefs } + : { ...draft.content }; + } + + private async signLedgerEvent(input: { + runId: string; + stream: Exclude; + babyId: 'A' | 'B'; + sequence: number; + turn: number; + draft: LedgerEventDraft; + previousEntryHash: Sha256Hash; + channelEventHash?: Sha256Hash; + }): Promise { + const signer = this.requireSigner(input.stream); + const unsigned = { + version: 1 as const, + runId: input.runId, + babyId: input.babyId, + sequence: input.sequence, + turn: input.turn, + eventType: input.draft.eventType, + contentSchema: input.draft.contentSchema, + subjectId: input.draft.subjectId, + content: this.buildContent(input.draft), + blindingNonce: input.draft.blindingNonce, + previousEntryHash: input.previousEntryHash, + ...(input.channelEventHash === undefined + ? {} + : { channelEventHash: input.channelEventHash }), + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash(input.stream, unsigned); + return LedgerEventSchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + } + + private async signChannelEvent( + unsigned: Omit, + ): Promise { + const signer = this.requireSigner('channel'); + const withKey = { ...unsigned, writerKeyId: signer.keyId }; + const entryHash = computeEntryHash('channel', withKey); + return ChannelEventSchema.parse({ + ...withKey, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + } + + private async insertInterventionEvent( + request: InterventionAppendRequest, + insertAfter?: (event: InterventionEvent) => void, + ): Promise { + const head = this.chainHead(request.runId, 'intervention'); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + eventType: request.eventType, + actorId: request.actorId, + reasonCode: request.reasonCode, + details: request.details ?? {}, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + }; + const event = InterventionEventSchema.parse({ + ...unsigned, + entryHash: computeEntryHash('intervention', unsigned), + }); + + const canonical = canonicalJson(event); + this.commitRows( + request.runId, + [ + { + stream: 'intervention', + sequence: event.sequence, + entryHash: event.entryHash, + canonicalJson: canonical, + }, + ], + () => { + this.db + .prepare( + `INSERT INTO intervention_log ( + run_id, sequence, event_type, actor_id, reason_code, + previous_entry_hash, entry_hash, recorded_at, canonical_json + ) VALUES ( + @runId, @sequence, @eventType, @actorId, @reasonCode, + @previousEntryHash, @entryHash, @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: event.runId, + sequence: event.sequence, + eventType: event.eventType, + actorId: event.actorId, + reasonCode: event.reasonCode, + previousEntryHash: event.previousEntryHash, + entryHash: event.entryHash, + recordedAt: event.recordedAt, + canonicalJson: canonical, + }); + insertAfter?.(event); + }, + ); + + return event; + } + + private insertLedgerRow(event: LedgerEvent): void { + this.db + .prepare( + `INSERT INTO ledger_events ( + run_id, baby_id, sequence, turn, event_type, content_schema, + previous_entry_hash, channel_event_hash, entry_hash, writer_key_id, + writer_signature, recorded_at, canonical_json + ) VALUES ( + @runId, @babyId, @sequence, @turn, @eventType, @contentSchema, + @previousEntryHash, @channelEventHash, @entryHash, @writerKeyId, + @writerSignature, @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: event.runId, + babyId: event.babyId, + sequence: event.sequence, + turn: event.turn, + eventType: event.eventType, + contentSchema: event.contentSchema, + previousEntryHash: event.previousEntryHash, + channelEventHash: event.channelEventHash ?? null, + entryHash: event.entryHash, + writerKeyId: event.writerKeyId, + writerSignature: event.writerSignature, + recordedAt: event.recordedAt, + canonicalJson: canonicalJson(event), + }); + } + + private insertChannelRow(event: ChannelEvent): void { + this.db + .prepare( + `INSERT INTO channel_events ( + run_id, sequence, turn, logical_sender, origin, + communication_condition, baby_proposal_hash, sender_ledger_sequence, + sender_entry_hash, public_artifact_hash, previous_channel_hash, + validation_result, entry_hash, writer_key_id, writer_signature, + recorded_at, canonical_json + ) VALUES ( + @runId, @sequence, @turn, @logicalSender, @origin, + @communicationCondition, @babyProposalHash, @senderLedgerSequence, + @senderEntryHash, @publicArtifactHash, @previousChannelHash, + @validationResult, @entryHash, @writerKeyId, @writerSignature, + @recordedAt, @canonicalJson + )`, + ) + .run({ + runId: event.runId, + sequence: event.sequence, + turn: event.turn, + logicalSender: event.logicalSender, + origin: event.origin, + communicationCondition: event.communicationCondition, + babyProposalHash: event.babyProposalHash ?? null, + senderLedgerSequence: event.senderLedgerSequence ?? null, + senderEntryHash: event.senderEntryHash ?? null, + publicArtifactHash: event.publicArtifactHash, + previousChannelHash: event.previousChannelHash, + validationResult: event.gatewayValidationResult, + entryHash: event.entryHash, + writerKeyId: event.writerKeyId, + writerSignature: event.writerSignature, + recordedAt: event.recordedAt, + canonicalJson: canonicalJson(event), + }); + } + + private pendingLedgerRow( + stream: EventStream, + event: LedgerEvent, + ): PendingRow { + return { + stream, + sequence: event.sequence, + entryHash: event.entryHash, + canonicalJson: canonicalJson(event), + }; + } + + private pendingChannelRow(event: ChannelEvent): PendingRow { + return { + stream: 'channel', + sequence: event.sequence, + entryHash: event.entryHash, + canonicalJson: canonicalJson(event), + }; + } + + /** + * Runs all inserts of one logical commit in a single SQLite transaction and + * turns a uniqueness conflict into either an idempotent-duplicate error or + * a fork (LEDGER §15), preserving both artifacts. + */ + private commitRows( + runId: string, + rows: PendingRow[], + insert: () => void, + ): void { + try { + this.db.transaction(insert)(); + } catch (error) { + if (error instanceof EvidenceWriterError || !isUniquenessConflict(error)) { + throw error; + } + this.classifyConflict(runId, rows); + throw error; + } + } + + private classifyConflict(runId: string, rows: PendingRow[]): void { + const duplicates: PendingRow[] = []; + + for (const row of rows) { + const spec = STREAM_TABLES[row.stream]; + const existing = this.db + .prepare( + `SELECT entry_hash, canonical_json FROM ${spec.table} + WHERE ${streamFilter(row.stream)} AND sequence = ?`, + ) + .get(...streamKey(runId, row.stream), row.sequence); + + if (existing && existing.entry_hash !== row.entryHash) { + this.preserveFork(runId, row, existing); + const entryHashes = [existing.entry_hash, row.entryHash]; + this.integrityBlocks.set(runId, [ + `fork ${row.stream}#${row.sequence}: ${entryHashes.join(' vs ')}`, + ]); + throw new ForkDetectedError(row.stream, row.sequence, entryHashes); + } + + if (existing) { + duplicates.push(row); + continue; + } + + const sameHash = this.db + .prepare<[string], { sequence: number }>( + `SELECT sequence FROM ${spec.table} WHERE entry_hash = ?`, + ) + .get(row.entryHash); + if (sameHash) { + duplicates.push(row); + } + } + + const duplicate = duplicates[0]; + if (duplicate) { + throw new DuplicateEventError( + duplicate.stream, + duplicate.sequence, + duplicate.entryHash, + ); + } + } + + private preserveFork( + runId: string, + row: PendingRow, + existing: { entry_hash: string; canonical_json: string }, + ): void { + const detectedAt = this.clock.now(); + const insert = this.db.prepare( + `INSERT OR IGNORE INTO fork_artifacts ( + run_id, stream, sequence, entry_hash, canonical_json, detected_at + ) VALUES (@runId, @stream, @sequence, @entryHash, @canonicalJson, @detectedAt)`, + ); + this.db.transaction(() => { + insert.run({ + runId, + stream: row.stream, + sequence: row.sequence, + entryHash: existing.entry_hash, + canonicalJson: existing.canonical_json, + detectedAt, + }); + insert.run({ + runId, + stream: row.stream, + sequence: row.sequence, + entryHash: row.entryHash, + canonicalJson: row.canonicalJson, + detectedAt, + }); + })(); + } + + /** + * Verifies every stream against the highest checkpoint manifest the store + * holds for the run (LEDGER §15; SPEC §7.3 "on restart, the runtime MUST + * load the last valid entry/checkpoint hashes, verify the committed prefix, + * continue with the next sequence number … sequence numbers are never + * reused or truncated"). + * + * For each mandatory tree (`babyA`, `babyB`, `channel`) and each auxiliary + * tree the manifest actually carries, the current stream must be at least + * `treeSize` entries long and must still store the committed + * `lastEntryHash` at sequence `treeSize`. A shortfall means the checkpointed + * prefix no longer exists, and a mismatch at that sequence means the + * prefix was rewritten; both are chain violations, so the run is + * integrity-blocked and `assertWritableRun` refuses every further append + * until a research-integrity review is recorded. That is what keeps a + * checkpointed sequence number from being handed out a second time. + * + * A manifest committing an empty tree (`treeSize === 0`) constrains + * nothing and is skipped. Recovery reports, never raises: a manifest row + * that no longer parses becomes a violation rather than an exception. + */ + private checkpointPrefixViolations( + runId: string, + rowsByStream: ReadonlyMap, + ): string[] { + const row = this.db + .prepare< + [string], + { checkpoint_sequence: number; canonical_json: string } + >( + `SELECT checkpoint_sequence, canonical_json FROM checkpoint_manifests + WHERE run_id = ? ORDER BY checkpoint_sequence DESC LIMIT 1`, + ) + .get(runId); + + if (!row) { + return []; + } + + const violations: string[] = []; + let manifest: CheckpointManifest; + try { + manifest = CheckpointManifestSchema.parse(JSON.parse(row.canonical_json)); + } catch { + violations.push( + `checkpoint #${row.checkpoint_sequence}: canonical_json is not a valid checkpoint manifest`, + ); + return violations; + } + + const label = `checkpoint #${manifest.checkpointSequence}`; + const check = ( + stream: EventStream, + treeName: string, + reference: TreeReference, + ): void => { + if (reference.treeSize === 0) { + return; + } + const rows = rowsByStream.get(stream) ?? []; + if (rows.length < reference.treeSize) { + violations.push( + `${label}: ${treeName} commits treeSize ${reference.treeSize} but ${stream} now holds ${rows.length} entries`, + ); + return; + } + const committed = rows.find( + (candidate) => candidate.sequence === reference.treeSize, + ); + if (!committed) { + violations.push( + `${label}: ${treeName} commits ${stream}#${reference.treeSize}, which is missing`, + ); + return; + } + if (committed.entry_hash !== reference.lastEntryHash) { + violations.push( + `${label}: ${treeName} commits lastEntryHash ${reference.lastEntryHash} at ${stream}#${reference.treeSize}, which now stores ${committed.entry_hash}`, + ); + } + }; + + for (const [stream, treeName] of MANDATORY_TREE_STREAMS) { + check(stream, treeName, manifest[treeName]); + } + + for (const [treeName, reference] of Object.entries( + manifest.auxiliaryTrees, + )) { + const stream = STREAM_FOR_AUXILIARY_TREE.get(treeName); + if (stream === undefined) { + violations.push(`${label}: commits unknown auxiliary tree ${treeName}`); + continue; + } + check(stream, treeName, reference); + } + + return violations; + } + + /** + * Groups the preserved fork artifacts of a run into one report per forked + * sequence (LEDGER §15). + * + * The stream guard uses {@link isEventStream} rather than the `in` + * operator: `'constructor' in STREAM_TABLES` is true for any object + * literal, so an `in` check admits every `Object.prototype` key, and + * `STREAM_TABLES['constructor']` is `undefined`, which used to be + * interpolated straight into `FROM undefined` and made `recover()` raise a + * `SqliteError` instead of returning a report. Recovery reports malformed + * input, it never raises on it, so an out-of-domain `stream` becomes a + * chain violation (which blocks the run) and the artifact is skipped. + */ + private collectForks(runId: string): { + forks: ForkReport[]; + violations: string[]; + } { + const grouped = new Map(); + const violations: string[] = []; + + for (const artifact of this.readForkArtifacts(runId)) { + if (!isEventStream(artifact.stream)) { + violations.push( + `fork artifact ${artifact.stream}#${artifact.sequence}: ${artifact.stream} is not a known event stream`, + ); + continue; + } + const stream: EventStream = artifact.stream; + const key = `${artifact.stream}#${artifact.sequence}`; + const report = grouped.get(key) ?? { + stream, + sequence: artifact.sequence, + entryHashes: [], + }; + if (!report.entryHashes.includes(artifact.entryHash)) { + report.entryHashes.push(artifact.entryHash); + } + grouped.set(key, report); + } + + for (const report of grouped.values()) { + const spec = STREAM_TABLES[report.stream]; + const committed = this.db + .prepare( + `SELECT entry_hash FROM ${spec.table} + WHERE ${streamFilter(report.stream)} AND sequence = ?`, + ) + .get(...streamKey(runId, report.stream), report.sequence); + if (committed && !report.entryHashes.includes(committed.entry_hash)) { + report.entryHashes.push(committed.entry_hash); + } + report.entryHashes.sort(); + } + + const forks = [...grouped.values()].sort((left, right) => + left.stream === right.stream + ? left.sequence - right.sequence + : left.stream.localeCompare(right.stream), + ); + return { forks, violations }; + } + + private streamRows( + runId: string, + stream: EventStream, + range?: EventRange, + ): StreamRow[] { + const spec = STREAM_TABLES[stream]; + const parameters: unknown[] = streamKey(runId, stream); + let sql = `SELECT sequence, entry_hash, ${spec.previousColumn} AS previous_hash, + recorded_at, canonical_json + FROM ${spec.table} + WHERE ${streamFilter(stream)}`; + + if (range?.fromSequence !== undefined) { + sql += ' AND sequence >= ?'; + parameters.push(range.fromSequence); + } + if (range?.toSequence !== undefined) { + sql += ' AND sequence <= ?'; + parameters.push(range.toSequence); + } + sql += ' ORDER BY sequence'; + + return this.db.prepare(sql).all(...parameters); + } + + /** + * `CheckpointManifest` binds a run by `runIdHash` only (LEDGER §8), so the + * writer maps it back to the local run id it was registered under. + */ + private runIdForHash(runIdHash: Sha256Hash): string { + for (const runId of this.listRuns()) { + if (domainHash(HASH_DOMAINS.runId, runId) === runIdHash) { + return runId; + } + } + throw new UnknownRunError(runIdHash); + } +} + +function omit( + record: Record, + fields: readonly string[], +): Record { + const copy: Record = { ...record }; + for (const field of fields) { + delete copy[field]; + } + return copy; +} diff --git a/packages/evidence/tsconfig.json b/packages/evidence/tsconfig.json index 1d8431a..e3188d1 100644 --- a/packages/evidence/tsconfig.json +++ b/packages/evidence/tsconfig.json @@ -6,9 +6,18 @@ "rootDir": "./src", "tsBuildInfoFile": "./dist/.tsbuildinfo" }, - "include": ["src/**/*.ts"], + "include": [ + "src/**/*.ts" + ], "references": [ - { "path": "../config" }, - { "path": "../types" } + { + "path": "../config" + }, + { + "path": "../types" + }, + { + "path": "../hashing" + } ] } diff --git a/packages/gateway/__tests__/affect-conformance.test.ts b/packages/gateway/__tests__/affect-conformance.test.ts new file mode 100644 index 0000000..cf5dd0c --- /dev/null +++ b/packages/gateway/__tests__/affect-conformance.test.ts @@ -0,0 +1,163 @@ +/** + * ALD-033 criterion 3 / ALD-036 — the affect half of the conformance suite. + * + * Every vector in `affect-vectors.ts` runs against a real `SymbolGatewayImpl` + * over a real `EvidenceWriter`, with no learner adapter involved. An accepted + * vector must produce exactly one schema-valid `AffectEvent`; a rejected + * vector must produce exactly one `channel.rejected` event with reason + * `affect-violation`, a payload hash, and no `AffectEvent` at all. + */ +import { describe, expect, it } from 'vitest'; + +import { + AFFECT_DISPLAY_IDS, + AffectEventSchema, + type AffectSubmitResult, + type BabyRole, + type RunConfig, +} from '@ald/types'; + +import { AFFECT_VIOLATION_REASON } from '../src/affect.js'; +import { createAffectWindow } from '../src/affect-windows.js'; +import { + AFFECT_NO_SUBMISSION_VECTORS, + AFFECT_SUBMISSION_MODES, + AFFECT_SUBMISSION_VECTORS, + AFFECT_VECTOR_MODES, + affectVectorsFor, + assertEveryAffectModeHasVectors, + type AffectVector, + type AffectVectorMode, +} from '../src/affect-vectors.js'; +import { isGatewayReasonCode } from '../src/reason-codes.js'; +import { harness } from './support.js'; + +function affectHarness(affectMode: RunConfig['affectMode']) { + return harness({ affectMode, affectWindowSchedule: 'every-turn' }); +} + +/** + * Runs one vector in one mode. `outOfWindow` and `fromRecipient` vectors + * exercise SPEC §9.3 rules 2 and 5; every other vector is submitted by the + * window's own sender inside its open window. + */ +async function runVector( + mode: AffectVectorMode, + vector: AffectVector, +): Promise<{ + result: AffectSubmitResult; + harness: ReturnType; +}> { + const h = affectHarness(mode); + const window = createAffectWindow({ + turn: 1, + sender: 'baby-a', + recipient: 'baby-b', + }); + if (vector.outOfWindow !== 'never-opened') { + h.gateway.affect.openWindow(window); + } + if (vector.outOfWindow === 'already-answered') { + await h.gateway.affect.submitAffect(window, { + kind: 'submit_affect', + publicArtifact: { displayId: 'A1' }, + }); + } + const sender: BabyRole = + vector.fromRecipient === true ? window.recipient : window.sender; + const result = await h.gateway.affect.submitAffectFrom( + sender, + window, + vector.submission, + ); + return { result, harness: h }; +} + +describe('ALD-033 criterion 3: affect conformance vectors', () => { + it('every enabled affect mode contributes accept/reject vectors', () => { + expect(() => assertEveryAffectModeHasVectors()).not.toThrow(); + for (const mode of AFFECT_VECTOR_MODES) { + expect(affectVectorsFor(mode)?.length ?? 0).toBeGreaterThan(0); + } + expect(() => + assertEveryAffectModeHasVectors([ + 'declared', + 'sideways' as AffectVectorMode, + ]), + ).toThrow(/contribute no conformance vectors/u); + }); + + it('covers every allowlisted display with an acceptance vector', () => { + for (const displayId of AFFECT_DISPLAY_IDS) { + expect( + AFFECT_SUBMISSION_VECTORS.some( + (vector) => + vector.expect === 'accepted' && + JSON.stringify(vector.submission).includes(`"${displayId}"`), + ), + ).toBe(true); + } + }); + + for (const mode of AFFECT_SUBMISSION_MODES) { + describe(`${mode} mode`, () => { + for (const vector of AFFECT_SUBMISSION_VECTORS) { + it(vector.name, async () => { + const { result, harness: h } = await runVector(mode, vector); + const affectRecords = h.evidence.readEvents(h.context.runId, 'affect'); + const channelRecords = h.evidence.channelEvents(h.context.runId); + + if (vector.expect === 'accepted') { + expect(result.kind).toBe('accepted'); + expect(affectRecords).toHaveLength(1); + const event = AffectEventSchema.parse( + JSON.parse(affectRecords[0]?.canonicalJson ?? '{}'), + ); + expect(event.affectMode).toBe(mode); + expect(AFFECT_DISPLAY_IDS).toContain(event.displayId); + expect(channelRecords).toHaveLength(0); + return; + } + + if (result.kind !== 'rejected') { + throw new Error(`${vector.name} should have been rejected`); + } + expect(result.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(isGatewayReasonCode(result.reasonCode)).toBe(true); + expect(result.rejectedPayloadHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + // An `already-answered` vector legitimately has one earlier event. + expect(affectRecords).toHaveLength( + vector.outOfWindow === 'already-answered' ? 1 : 0, + ); + const rejections = channelRecords.filter( + (event) => event.gatewayValidationResult === 'rejected', + ); + expect(rejections).toHaveLength(1); + expect(rejections[0]?.reasonCode).toBe(AFFECT_VIOLATION_REASON); + // SPEC §9.4: the committed event carries a hash, never content. + expect(affectRecords.length + rejections.length).toBeGreaterThan(0); + expect( + JSON.stringify(rejections[0]), + ).not.toContain('smuggled'); + }); + } + }); + } + + for (const mode of ['derived', 'emergent'] as const) { + describe(`${mode} mode has no submit_affect surface`, () => { + for (const vector of AFFECT_NO_SUBMISSION_VECTORS) { + it(vector.name, async () => { + const { result, harness: h } = await runVector(mode, vector); + if (result.kind !== 'rejected') { + throw new Error(`${vector.name} should have been rejected`); + } + expect(result.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(h.evidence.readEvents(h.context.runId, 'affect')).toHaveLength( + 0, + ); + }); + } + }); + } +}); diff --git a/packages/gateway/__tests__/affect.test.ts b/packages/gateway/__tests__/affect.test.ts new file mode 100644 index 0000000..d6a40ca --- /dev/null +++ b/packages/gateway/__tests__/affect.test.ts @@ -0,0 +1,860 @@ +/** + * ALD-033 — the six-display affect protocol (SPECIFICATION.md §9.3, §11.6). + * + * Every test runs against a real `SymbolGatewayImpl` over a real + * `EvidenceWriter`, with no learner adapter involved, so affect rejections go + * through the one ALD-034 rejection framework (shared counter, shared pause + * policy, payload hash only). + */ +import { describe, expect, it } from 'vitest'; + +import { + AFFECT_DISPLAY_IDS, + AffectEventSchema, + GENESIS_HASH, + type AffectDisplayId, + type AffectEvent, + type AffectStateMeasurement, + type AffectSubmitResult, + type BabyRole, + type RunConfig, +} from '@ald/types'; +import { + canonicalJson, + computeEntryHash, + omitFields, + verifyHashSignature, +} from '@ald/hashing'; + +import { + AFFECT_ACCEPTED_RESULT_KEYS, + AFFECT_MEASUREMENT_HASH_DOMAIN, + AFFECT_REJECTED_RESULT_KEYS, + AFFECT_VIOLATION_REASON, + AffectProtocol, + EMERGENT_AFFECT_ANALYSIS_TAG, + assertAffectConfiguration, + normalizedAffectSubmission, + producesAffectEvents, + tagEmergentAffect, + type DerivedAffectResult, +} from '../src/affect.js'; +import { + AffectDisabledError, + AffectModeMismatchError, + AffectWindowNotOpenError, + InvalidAffectWindowError, + InvalidAffectWindowScheduleError, + UnknownAffectDerivedMappingError, +} from '../src/affect-errors.js'; +import { + DEFAULT_AFFECT_DERIVED_MAPPING, + argmaxV1Mapping, + registeredAffectDerivedMappings, + resolveAffectDerivedMapping, +} from '../src/affect-mapping.js'; +import { + RECOGNISED_AFFECT_WINDOW_SCHEDULES, + affectActionAvailable, + affectWindowDue, + affectWindowFor, + affectWindowId, + createAffectWindow, + parseAffectWindowSchedule, +} from '../src/affect-windows.js'; +import { isGatewayReasonCode } from '../src/reason-codes.js'; +import { StepClock } from './fake-evidence-writer.js'; +import { harness, intentionDraft, symbolEnvelope, turn } from './support.js'; + +type AffectHarness = ReturnType; + +/** A run with the affect channel enabled and a window after every turn. */ +function affectHarness( + affectMode: RunConfig['affectMode'], + overrides: Partial = {}, +): AffectHarness { + return harness({ + affectMode, + affectWindowSchedule: 'every-turn', + ...overrides, + }); +} + +/** Opens the scheduled window for one turn and returns it. */ +function open( + h: AffectHarness, + turnIndex: number, + sender: BabyRole = 'baby-a', +): ReturnType { + const recipient: BabyRole = sender === 'baby-a' ? 'baby-b' : 'baby-a'; + return h.gateway.affect.openWindow( + createAffectWindow({ turn: turnIndex, sender, recipient }), + ); +} + +function affectEvents(h: AffectHarness): AffectEvent[] { + return h.evidence + .readEvents(h.context.runId, 'affect') + .map((record) => AffectEventSchema.parse(JSON.parse(record.canonicalJson))); +} + +function accepted( + result: AffectSubmitResult, +): Extract { + if (result.kind !== 'accepted') { + throw new Error(`expected an accepted affect result, got ${result.kind}`); + } + return result; +} + +function rejected( + result: AffectSubmitResult, +): Extract { + if (result.kind !== 'rejected') { + throw new Error(`expected a rejected affect result, got ${result.kind}`); + } + return result; +} + +function measurement(scores: number[]): AffectStateMeasurement { + return { + measurementVersion: 'v1', + scores: scores as unknown as AffectStateMeasurement['scores'], + }; +} + +// --------------------------------------------------------------------------- +// ALD-033 criterion 1 — declared / permuted / opaque emit only A1-A6 +// --------------------------------------------------------------------------- + +describe('ALD-033 criterion 1: declared, permuted, and opaque submissions', () => { + for (const mode of ['declared', 'permuted', 'opaque'] as const) { + it(`${mode} mode accepts every allowlisted display and records one normalized AffectEvent per window`, async () => { + const h = affectHarness(mode); + const keys = h.evidence.signerRegistry.publicKeys(); + const affectKey = keys.find((key) => key.domain === 'affect'); + + for (const [index, displayId] of AFFECT_DISPLAY_IDS.entries()) { + const turnIndex = index + 1; + const window = open(h, turnIndex); + const result = accepted( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission(displayId), + ), + ); + + // SPEC §11.6: the event shape is the schema's, nothing more. + const event = AffectEventSchema.parse(result.affectEvent); + expect(event.runId).toBe(h.context.runId); + expect(event.sequence).toBe(turnIndex); + expect(event.turn).toBe(turnIndex); + expect(event.windowId).toBe(affectWindowId(turnIndex, 'baby-a')); + expect(event.sender).toBe('baby-a'); + expect(event.affectMode).toBe(mode); + expect(AFFECT_DISPLAY_IDS).toContain(event.displayId); + expect(Object.keys(result).sort()).toEqual([ + ...AFFECT_ACCEPTED_RESULT_KEYS, + ]); + + // Real entry hash over the unsigned event, real signature. + const unsigned = omitFields(event, ['entryHash', 'writerSignature']); + expect(computeEntryHash('affect', unsigned)).toBe(event.entryHash); + expect( + verifyHashSignature( + event.entryHash, + event.writerSignature, + affectKey?.publicKey ?? '', + ), + ).toBe(true); + } + + // LEDGER §4: the affect stream is hash-chained like every other stream. + const events = affectEvents(h); + expect(events).toHaveLength(AFFECT_DISPLAY_IDS.length); + expect(events[0]?.previousEntryHash).toBe(GENESIS_HASH); + for (let index = 1; index < events.length; index += 1) { + expect(events[index]?.previousEntryHash).toBe( + events[index - 1]?.entryHash, + ); + } + }); + } + + it('records the sender chosen display for every display in declared mode', async () => { + const h = affectHarness('declared'); + for (const [index, displayId] of AFFECT_DISPLAY_IDS.entries()) { + const window = open(h, index + 1); + const result = accepted( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission(displayId), + ), + ); + expect(result.affectEvent.displayId).toBe(displayId); + expect(result.deliveredDisplayId).toBe(displayId); + } + }); + + it('rejects an out-of-window submission as affect-violation and writes no AffectEvent', async () => { + const h = affectHarness('declared'); + // No window was opened for turn 1. + const result = rejected( + await h.gateway.affect.submitAffect( + createAffectWindow({ turn: 1, sender: 'baby-a', recipient: 'baby-b' }), + normalizedAffectSubmission('A1'), + ), + ); + expect(result.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(isGatewayReasonCode(result.reasonCode)).toBe(true); + expect(affectEvents(h)).toHaveLength(0); + expect(h.evidence.channelEvents(h.context.runId)).toHaveLength(1); + expect( + h.evidence.channelEvents(h.context.runId)[0]?.gatewayValidationResult, + ).toBe('rejected'); + }); + + it('consumes the window after one submission, so there is no variable retry count', async () => { + const h = affectHarness('declared'); + const window = open(h, 1); + accepted( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission('A1'), + ), + ); + const second = rejected( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission('A2'), + ), + ); + expect(second.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(affectEvents(h)).toHaveLength(1); + }); + + it('spends the window on a rejected submission too, so a malformed try is not a free retry', async () => { + const h = affectHarness('declared'); + const window = open(h, 1); + rejected( + await h.gateway.affect.submitAffectFrom(window.sender, window, { + kind: 'submit_affect', + publicArtifact: { displayId: 'A9' }, + }), + ); + const retry = rejected( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission('A1'), + ), + ); + expect(retry.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(affectEvents(h)).toHaveLength(0); + }); + + it('rejects a submission by the window recipient (SPEC §9.3 rule 5)', async () => { + const h = affectHarness('declared'); + const window = open(h, 1); + const result = rejected( + await h.gateway.affect.submitAffectFrom( + window.recipient, + window, + normalizedAffectSubmission('A1'), + ), + ); + expect(result.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(affectEvents(h)).toHaveLength(0); + }); + + it('returns a constant-shape rejection that echoes no part of the payload', async () => { + const h = affectHarness('declared'); + const secret = 'smuggled-instruction-please-pick-red'; + const window = open(h, 1); + const result = rejected( + await h.gateway.affect.submitAffectFrom(window.sender, window, { + kind: 'submit_affect', + publicArtifact: { displayId: 'A1', note: secret }, + }), + ); + expect(Object.keys(result).sort()).toEqual([ + ...AFFECT_REJECTED_RESULT_KEYS, + ]); + expect(JSON.stringify(result)).not.toContain(secret); + const stored = h.evidence + .readEvents(h.context.runId, 'channel') + .map((record) => record.canonicalJson) + .join(''); + expect(stored).not.toContain(secret); + }); + + it('shares the ALD-034 counter and pause policy with every other channel violation', async () => { + const h = affectHarness('declared'); + const ceiling = h.context.config.maxConsecutiveRejections; + let last = undefined as + | Extract + | undefined; + for (let index = 1; index <= ceiling; index += 1) { + const window = open(h, index); + last = rejected( + await h.gateway.affect.submitAffectFrom(window.sender, window, { + kind: 'submit_affect', + publicArtifact: { displayId: 'A7' }, + }), + ); + expect(last.consecutiveRejections).toBe(index); + } + expect(last?.pauseRequested).toBe(true); + const safety = h.evidence + .interventionEvents(h.context.runId) + .filter((event) => event.eventType === 'safety-trigger'); + expect(safety).toHaveLength(1); + expect(safety[0]?.reasonCode).toBe('max-consecutive-rejections'); + }); + + it('resets the shared rejection counter when an ordinary proposal is accepted', async () => { + const h = affectHarness('declared'); + const window = open(h, 1); + rejected( + await h.gateway.affect.submitAffectFrom(window.sender, window, 'A1'), + ); + expect(h.gateway.consecutiveRejections()).toBe(1); + const proposal = await h.gateway.submitProposal( + turn({ turn: 2 }), + symbolEnvelope(['S01'], intentionDraft()), + ); + expect(proposal.kind).toBe('accepted'); + expect(h.gateway.consecutiveRejections()).toBe(0); + }); + + it('delegates through the SymbolGateway contract methods', async () => { + const h = affectHarness('declared'); + const window = open(h, 1); + const result = accepted( + await h.gateway.submitAffect(window, normalizedAffectSubmission('A4')), + ); + expect(result.affectEvent.displayId).toBe('A4'); + }); +}); + +// --------------------------------------------------------------------------- +// permuted mode +// --------------------------------------------------------------------------- + +describe('ALD-033 criterion 1: permuted mode', () => { + it('delivers a permuted display while the AffectEvent records the sender choice', async () => { + const h = affectHarness('permuted'); + const permutation = h.gateway.affect.researcherOnlyPermutation(); + expect(permutation).toBeDefined(); + + for (const [index, displayId] of AFFECT_DISPLAY_IDS.entries()) { + const window = open(h, index + 1); + const result = accepted( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission(displayId), + ), + ); + expect(result.affectEvent.displayId).toBe(displayId); + const expected = AFFECT_DISPLAY_IDS[ + (permutation as readonly number[])[index] as number + ] as AffectDisplayId; + expect(result.deliveredDisplayId).toBe(expected); + // Deranged: no display is delivered as itself, so `permuted` is never + // accidentally `declared`. + expect(result.deliveredDisplayId).not.toBe(displayId); + } + }); + + it('derives the permutation from the run seed: reproducible, and different across seeds', () => { + const first = affectHarness('permuted').gateway.affect.researcherOnlyPermutation(); + const again = affectHarness('permuted').gateway.affect.researcherOnlyPermutation(); + expect(again).toEqual(first); + + const other = harness( + { affectMode: 'permuted', affectWindowSchedule: 'every-turn' }, + {}, + { seed: 'seed-gateway-999' }, + ).gateway.affect.researcherOnlyPermutation(); + expect(other).not.toEqual(first); + expect([...(other as readonly number[])].sort()).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it('keeps the permutation out of every Baby-reachable value', async () => { + const h = affectHarness('permuted'); + const window = open(h, 1); + const result = accepted( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission('A1'), + ), + ); + // The only surface carrying the mapping is the researcher-only accessor. + expect(Object.keys(result).sort()).toEqual([...AFFECT_ACCEPTED_RESULT_KEYS]); + const serialized = JSON.stringify(result); + const permutation = h.gateway.affect.researcherOnlyPermutation(); + expect(serialized).not.toContain(JSON.stringify(permutation)); + // A single accepted window reveals exactly one (chosen, delivered) pair. + expect(Object.keys(result.affectEvent).sort()).toEqual( + Object.keys(AffectEventSchema.parse(result.affectEvent)).sort(), + ); + }); + + it('returns a defensive copy of the researcher-only permutation', () => { + const h = affectHarness('permuted'); + const first = h.gateway.affect.researcherOnlyPermutation() as number[]; + first[0] = 99; + expect(h.gateway.affect.researcherOnlyPermutation()?.[0]).not.toBe(99); + }); + + it('exposes no permutation in declared or opaque mode', () => { + expect( + affectHarness('declared').gateway.affect.researcherOnlyPermutation(), + ).toBeUndefined(); + expect( + affectHarness('opaque').gateway.affect.researcherOnlyPermutation(), + ).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// opaque mode +// --------------------------------------------------------------------------- + +describe('ALD-033 criterion 1: opaque mode', () => { + it('behaves identically to declared on the wire, differing only in the recorded affectMode', async () => { + async function run(mode: RunConfig['affectMode']): Promise { + const h = affectHarness(mode); + for (const [index, displayId] of AFFECT_DISPLAY_IDS.entries()) { + const window = open(h, index + 1); + accepted( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission(displayId), + ), + ); + } + return affectEvents(h); + } + + const declared = await run('declared'); + const opaque = await run('opaque'); + expect(opaque).toHaveLength(declared.length); + for (let index = 0; index < declared.length; index += 1) { + const a = declared[index] as AffectEvent; + const b = opaque[index] as AffectEvent; + expect(b.affectMode).toBe('opaque'); + expect(a.affectMode).toBe('declared'); + // Everything the recipient could observe is identical; only the recorded + // mode label (and therefore the entry hash and signature) differs. + expect(b.displayId).toBe(a.displayId); + expect(b.windowId).toBe(a.windowId); + expect(b.sender).toBe(a.sender); + expect(b.turn).toBe(a.turn); + expect(b.sequence).toBe(a.sequence); + } + }); +}); + +// --------------------------------------------------------------------------- +// ALD-033 criterion 2 — derived mode +// --------------------------------------------------------------------------- + +describe('ALD-033 criterion 2: derived mode', () => { + it('disables submit_affect and rejects even a well-formed submission', async () => { + const h = affectHarness('derived'); + const window = open(h, 1); + expect(h.gateway.affect.affectActionAvailable()).toBe(false); + const result = rejected( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission('A1'), + ), + ); + expect(result.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(affectEvents(h)).toHaveLength(0); + // The rogue submission does not spend the Gateway's own derived window. + const derived = (await h.gateway.recordDerivedAffect( + window, + measurement([0, 0, 1, 0, 0, 0]), + )) as DerivedAffectResult; + expect(derived.kind).toBe('accepted'); + }); + + it('records the complete private measurement and applies the pre-registered mapping', async () => { + const h = affectHarness('derived'); + const window = open(h, 1); + const scores = [0.1, 0.2, 0.9, 0.4, 0.5, 0.6]; + const result = (await h.gateway.recordDerivedAffect( + window, + measurement(scores), + )) as DerivedAffectResult; + const ok = accepted(result); + expect(ok.affectEvent.affectMode).toBe('derived'); + expect(ok.affectEvent.displayId).toBe('A3'); + expect(ok.deliveredDisplayId).toBe('A3'); + + const record = result.privateMeasurement; + expect(record?.mapping).toBe(DEFAULT_AFFECT_DERIVED_MAPPING); + expect(record?.measurement.scores).toEqual(scores); + expect(record?.measurementHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(h.gateway.affect.privateMeasurementFor(window.windowId)).toEqual( + record, + ); + + // The measurement never reaches the public event or the affect stream. + const stored = h.evidence + .readEvents(h.context.runId, 'affect') + .map((record_) => JSON.parse(record_.canonicalJson)); + // Compare the structured event rather than searching serialized text for + // a score such as `0.9`, which can occur coincidentally in timestamps or + // signatures without disclosing the measurement. + expect(stored).toEqual([ok.affectEvent]); + expect(stored[0]).not.toHaveProperty('scores'); + expect(JSON.stringify(ok.affectEvent)).not.toContain('scores'); + }); + + it('resolves argmax-v1 ties to the lowest display index', () => { + expect(argmaxV1Mapping.map(measurement([1, 1, 1, 1, 1, 1]))).toBe('A1'); + expect(argmaxV1Mapping.map(measurement([0, 2, 2, 0, 0, 0]))).toBe('A2'); + expect(argmaxV1Mapping.map(measurement([-3, -3, -3, -3, -3, -1]))).toBe('A6'); + expect(argmaxV1Mapping.map(measurement([0, 0, 0, 0, 0, 0]))).toBe('A1'); + }); + + it('drains retained private measurements for the runtime to persist', async () => { + const h = affectHarness('derived'); + for (const index of [1, 2]) { + const window = open(h, index); + await h.gateway.recordDerivedAffect(window, measurement([index, 0, 0, 0, 0, 0])); + } + const drained = h.gateway.affect.takePrivateMeasurements(); + expect(drained.map((record) => record.windowId)).toEqual([ + affectWindowId(1, 'baby-a'), + affectWindowId(2, 'baby-a'), + ]); + expect(h.gateway.affect.takePrivateMeasurements()).toEqual([]); + }); + + it('refuses a mapping name that was never pre-registered', () => { + const h = affectHarness('derived', { affectDerivedMapping: 'argmax-v99' }); + expect(() => h.gateway.affect).toThrow(UnknownAffectDerivedMappingError); + expect(() => assertAffectConfiguration(h.context.config)).toThrow( + UnknownAffectDerivedMappingError, + ); + expect(registeredAffectDerivedMappings()).toEqual(['argmax-v1']); + expect(resolveAffectDerivedMapping(undefined).name).toBe('argmax-v1'); + }); + + it('treats a malformed adapter measurement as an affect-violation carrying only a hash', async () => { + const cases: unknown[] = [ + { measurementVersion: 'v1', scores: [1, 2, 3] }, + { measurementVersion: 'v1', scores: [1, 2, 3, 4, 5, 6, 7] }, + { measurementVersion: 'v2', scores: [1, 2, 3, 4, 5, 6] }, + { measurementVersion: 'v1', scores: [1, 2, 3, 4, 5, Number.NaN] }, + { measurementVersion: 'v1', scores: [1, 2, 3, 4, 5, Number.POSITIVE_INFINITY] }, + { measurementVersion: 'v1', scores: ['1', 2, 3, 4, 5, 6] }, + { measurementVersion: 'v1', scores: [1, 2, 3, 4, 5, 6], note: 'pick red' }, + { scores: [1, 2, 3, 4, 5, 6] }, + 'A1', + null, + ]; + for (const [index, value] of cases.entries()) { + const h = affectHarness('derived'); + const window = open(h, index + 1); + const result = rejected( + (await h.gateway.recordDerivedAffect( + window, + value as AffectStateMeasurement, + )) as AffectSubmitResult, + ); + expect(result.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(Object.keys(result).sort()).toEqual([ + ...AFFECT_REJECTED_RESULT_KEYS, + ]); + expect(affectEvents(h)).toHaveLength(0); + expect(JSON.stringify(result)).not.toContain('pick red'); + } + }); + + it('refuses a derived measurement on a non-derived run and on a closed window', async () => { + const declared = affectHarness('declared'); + const declaredWindow = open(declared, 1); + await expect( + declared.gateway.recordDerivedAffect( + declaredWindow, + measurement([1, 0, 0, 0, 0, 0]), + ), + ).rejects.toBeInstanceOf(AffectModeMismatchError); + + const derived = affectHarness('derived'); + const window = createAffectWindow({ + turn: 1, + sender: 'baby-a', + recipient: 'baby-b', + }); + await expect( + derived.gateway.recordDerivedAffect(window, measurement([1, 0, 0, 0, 0, 0])), + ).rejects.toBeInstanceOf(AffectWindowNotOpenError); + }); + + it('hashes the measurement under the documented domain separator', async () => { + const h = affectHarness('derived'); + const window = open(h, 1); + const value = measurement([0, 0, 0, 0, 0, 1]); + const result = (await h.gateway.recordDerivedAffect( + window, + value, + )) as DerivedAffectResult; + const { hashCanonical } = await import('@ald/hashing'); + expect(result.privateMeasurement?.measurementHash).toBe( + hashCanonical(AFFECT_MEASUREMENT_HASH_DOMAIN, value), + ); + }); +}); + +// --------------------------------------------------------------------------- +// ALD-033 criterion 2 — emergent mode +// --------------------------------------------------------------------------- + +describe('ALD-033 criterion 2: emergent mode', () => { + it('produces no AffectEvent and records the display as an ordinary ChannelEvent', async () => { + const h = affectHarness('emergent'); + expect(producesAffectEvents('emergent')).toBe(false); + expect(producesAffectEvents('none')).toBe(false); + + const window = open(h, 1); + const result = rejected( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission('A1'), + ), + ); + expect(result.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(affectEvents(h)).toHaveLength(0); + + // The carrier channel event is the only record; the tag is analysis-side. + const submitted = await h.gateway.submitProposal( + turn({ turn: 2 }), + symbolEnvelope(['S05']), + ); + if (submitted.kind !== 'accepted') { + throw new Error('expected the carrier proposal to be accepted'); + } + const tag = tagEmergentAffect(submitted.channelEvent); + expect(tag).toEqual({ + analysisTag: EMERGENT_AFFECT_ANALYSIS_TAG, + channelEventHash: submitted.channelEvent.entryHash, + turn: 2, + sender: 'baby-a', + }); + // Tagging never touches the signed event. + expect(Object.keys(submitted.channelEvent)).not.toContain('analysisTag'); + expect(affectEvents(h)).toHaveLength(0); + }); + + it('refuses a derived measurement in emergent mode', async () => { + const h = affectHarness('emergent'); + const window = open(h, 1); + await expect( + h.gateway.recordDerivedAffect(window, measurement([1, 0, 0, 0, 0, 0])), + ).rejects.toBeInstanceOf(AffectModeMismatchError); + }); +}); + +// --------------------------------------------------------------------------- +// Window schedule and availability (SPEC §9.3 rules 2 and 5, §6.3) +// --------------------------------------------------------------------------- + +describe('ALD-033: fixed window schedule', () => { + it('parses the recognised schedules and refuses everything else', () => { + expect(parseAffectWindowSchedule('every-turn').everyTurns).toBe(1); + expect(parseAffectWindowSchedule('every-4-turns').everyTurns).toBe(4); + expect(parseAffectWindowSchedule('never').everyTurns).toBeUndefined(); + for (const bad of [ + 'every-0-turns', + 'every-4-turn', + 'sometimes', + '', + 'EVERY-TURN', + 'every--turns', + 'every-4-turns ', + ]) { + expect(() => parseAffectWindowSchedule(bad)).toThrow( + InvalidAffectWindowScheduleError, + ); + } + expect(RECOGNISED_AFFECT_WINDOW_SCHEDULES).toContain('every--turns'); + }); + + it('opens a window only on scheduled turns', () => { + const schedule = parseAffectWindowSchedule('every-4-turns'); + expect(affectWindowDue(schedule, 0)).toBe(false); + expect(affectWindowDue(schedule, 3)).toBe(false); + expect(affectWindowDue(schedule, 4)).toBe(true); + expect(affectWindowDue(schedule, 8)).toBe(true); + expect(affectWindowDue(parseAffectWindowSchedule('never'), 4)).toBe(false); + expect( + affectWindowFor({ + schedule, + turn: 3, + sender: 'baby-a', + recipient: 'baby-b', + }), + ).toBeUndefined(); + expect( + affectWindowFor({ + schedule, + turn: 4, + sender: 'baby-b', + recipient: 'baby-a', + }), + ).toEqual({ + windowId: 'w-4-baby-b', + turn: 4, + sender: 'baby-b', + recipient: 'baby-a', + opensAfter: 'outcome', + }); + }); + + it('refuses an extra window the schedule does not allow, and a reopened window', () => { + const h = affectHarness('declared', { affectWindowSchedule: 'every-4-turns' }); + expect(() => open(h, 3)).toThrow(InvalidAffectWindowError); + open(h, 4); + expect(() => open(h, 4)).toThrow(InvalidAffectWindowError); + }); + + it('closes the previous window when the next one opens', async () => { + const h = affectHarness('declared'); + const first = open(h, 1); + open(h, 2); + const late = rejected( + await h.gateway.affect.submitAffect( + first, + normalizedAffectSubmission('A1'), + ), + ); + expect(late.reasonCode).toBe(AFFECT_VIOLATION_REASON); + expect(h.gateway.affect.openWindowState()?.turn).toBe(2); + }); + + it('rejects a malformed window outright rather than committing a rejection', () => { + for (const bad of [ + { windowId: '', turn: 1, sender: 'baby-a', recipient: 'baby-b', opensAfter: 'outcome' }, + { windowId: 'w-1-baby-a', turn: -1, sender: 'baby-a', recipient: 'baby-b', opensAfter: 'outcome' }, + { windowId: 'w-1-baby-a', turn: 1, sender: 'baby-a', recipient: 'baby-a', opensAfter: 'outcome' }, + { windowId: 'w-1-baby-a', turn: 1, sender: 'baby-a', recipient: 'baby-b', opensAfter: 'action' }, + { windowId: 'w-9-baby-a', turn: 1, sender: 'baby-a', recipient: 'baby-b', opensAfter: 'outcome' }, + ]) { + const h = affectHarness('declared'); + expect(() => + h.gateway.affect.openWindow( + bad as unknown as ReturnType, + ), + ).toThrow(InvalidAffectWindowError); + } + }); + + it('offers submit_affect only in the three submission modes and only inside a window', () => { + for (const mode of ['declared', 'permuted', 'opaque'] as const) { + expect(affectActionAvailable(mode, true)).toBe(true); + expect(affectActionAvailable(mode, false)).toBe(false); + } + for (const mode of ['none', 'derived', 'emergent'] as const) { + expect(affectActionAvailable(mode, true)).toBe(false); + expect(affectActionAvailable(mode, false)).toBe(false); + } + + const h = affectHarness('declared'); + expect(h.gateway.affect.affectActionAvailable()).toBe(false); + open(h, 1); + expect(h.gateway.affect.affectActionAvailable()).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// ALD-033 criterion 3 — normalized envelope behaviour, disabled runs +// --------------------------------------------------------------------------- + +describe('ALD-033 criterion 3: normalized envelope and configuration', () => { + it('gives every one of the six submissions the same canonical byte length', () => { + const lengths = new Set( + AFFECT_DISPLAY_IDS.map( + (displayId) => + Buffer.byteLength( + canonicalJson(normalizedAffectSubmission(displayId)), + 'utf8', + ), + ), + ); + expect(lengths.size).toBe(1); + }); + + it('keeps the accepted and rejected result key sets constant across modes and violations', async () => { + const violations: unknown[] = [ + 'A1', + { kind: 'submit_affect', publicArtifact: { displayId: ['A1', 'A1'] } }, + { kind: 'submit_affect', publicArtifact: { displayId: 'A9' } }, + { kind: 'emit_symbols', publicArtifact: { symbols: ['S01'] } }, + ]; + for (const mode of ['declared', 'permuted', 'opaque'] as const) { + for (const [index, payload] of violations.entries()) { + const h = affectHarness(mode); + const window = open(h, index + 1); + const result = rejected( + await h.gateway.affect.submitAffectFrom(window.sender, window, payload), + ); + expect(Object.keys(result).sort()).toEqual([ + ...AFFECT_REJECTED_RESULT_KEYS, + ]); + } + const h = affectHarness(mode); + const window = open(h, 1); + const ok = accepted( + await h.gateway.affect.submitAffect( + window, + normalizedAffectSubmission('A1'), + ), + ); + expect(Object.keys(ok).sort()).toEqual([...AFFECT_ACCEPTED_RESULT_KEYS]); + } + }); + + it('has no affect surface at all when the channel is disabled', () => { + const h = harness({ affectMode: 'none' }); + expect(() => h.gateway.affect).toThrow(AffectDisabledError); + expect(() => assertAffectConfiguration(h.context.config)).not.toThrow(); + expect(producesAffectEvents('none')).toBe(false); + }); + + it('validates the whole affect configuration before turn 1', () => { + const h = affectHarness('declared', { affectWindowSchedule: 'occasionally' }); + expect(() => assertAffectConfiguration(h.context.config)).toThrow( + InvalidAffectWindowScheduleError, + ); + expect(() => + assertAffectConfiguration( + affectHarness('derived').context.config, + ), + ).not.toThrow(); + }); + + it('takes the AffectEvent timestamp from the injected clock', async () => { + const h = affectHarness('declared'); + const clock = new StepClock(Date.UTC(2026, 5, 1, 12, 0, 0), 1000); + const protocol = new AffectProtocol({ + runContext: h.context, + evidence: h.evidence, + commitAffectRejection: () => { + throw new Error('this test exercises the accepted path only'); + }, + now: () => clock.now(), + }); + const window = protocol.openWindow( + createAffectWindow({ turn: 1, sender: 'baby-a', recipient: 'baby-b' }), + ); + const result = accepted( + await protocol.submitAffect(window, normalizedAffectSubmission('A2')), + ); + expect(result.affectEvent.deliveredAt).toBe('2026-06-01T12:00:00.000Z'); + }); +}); diff --git a/packages/gateway/__tests__/carrier-registry.test.ts b/packages/gateway/__tests__/carrier-registry.test.ts new file mode 100644 index 0000000..0129a0c --- /dev/null +++ b/packages/gateway/__tests__/carrier-registry.test.ts @@ -0,0 +1,295 @@ +/** + * ALD-034 criterion 3 and ALD-036 criterion 3: a second protocol module can + * register later without changing the rejection event shape or the pause + * policy, and the consolidated suite requires every registered module to + * contribute accept/reject vectors. + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ChannelEventSchema } from '@ald/types'; +import type { GatewaySubmitResult } from '@ald/types'; + +import { + carrierModule, + registerCarrierModule, + registeredCarriers, + resetCarrierModules, + type CarrierModule, +} from '../src/carrier-modules.js'; +import { + assertEveryCarrierHasVectors, + conformanceVectorsFor, + FIXED_TOKEN_VECTORS, + registerConformanceVectors, + resetConformanceVectors, + type ConformanceVector, +} from '../src/conformance-vectors.js'; +import { UnsupportedCarrierError } from '../src/errors.js'; +import { SymbolGatewayImpl } from '../src/symbol-gateway.js'; +import { asEnvelope, harness, intentionDraft, runContext, symbolEnvelope, turn } from './support.js'; +import { FakeEvidenceWriter, StepClock } from './fake-evidence-writer.js'; + +/** + * A minimal stand-in for the ALD-031 `generative-canvas` module: enough of a + * grammar to accept a bounded stroke list and reject everything else. + */ +const stubCanvasModule: CarrierModule = { + carrier: 'generative-canvas', + allowedKinds: ['emit_canvas'], + validate(proposal, context) { + const artifact = (proposal as { publicArtifact?: unknown }).publicArtifact; + if (typeof artifact !== 'object' || artifact === null) { + return { ok: false, reasonCode: 'invalid-envelope', detail: 'no artifact' }; + } + const keys = Object.keys(artifact); + if (keys.length !== 1 || keys[0] !== 'strokes') { + return { + ok: false, + reasonCode: 'unexpected-artifact-field', + detail: `artifact carries ${keys.length} field(s)`, + }; + } + const strokes = (artifact as { strokes: unknown }).strokes; + if (!Array.isArray(strokes)) { + return { ok: false, reasonCode: 'invalid-envelope', detail: 'not an array' }; + } + if (strokes.length === 0) { + return { ok: false, reasonCode: 'empty-message', detail: 'no strokes' }; + } + const max = context.runContext.config.maxStrokes ?? 8; + if (strokes.length > max) { + return { + ok: false, + reasonCode: 'message-too-long', + detail: `${strokes.length} strokes exceed ${max}`, + }; + } + return { ok: true, artifact: { strokes: strokes as never } }; + }, + randomArtifact() { + return { strokes: [{ startX: 0, startY: 0, endX: 1, endY: 1, width: 1 }] }; + }, + constantArtifact() { + return { strokes: [{ startX: 2, startY: 2, endX: 3, endY: 3, width: 2 }] }; + }, +}; + +const stroke = { startX: 0, startY: 0, endX: 4, endY: 4, width: 1 } as const; + +function canvasEnvelope(count: number): unknown { + return { + proposal: { + kind: 'emit_canvas', + publicArtifact: { + strokes: Array.from({ length: count }, () => ({ ...stroke })), + }, + }, + privateLedgerDraft: intentionDraft(), + }; +} + +const STUB_VECTORS: readonly ConformanceVector[] = [ + { name: 'accepts one stroke', envelope: canvasEnvelope(1), expect: 'accepted' }, + { + name: 'rejects nine strokes', + envelope: canvasEnvelope(9), + expect: 'message-too-long', + }, +]; + +function canvasHarness() { + const context = runContext({ carrierMode: 'generative-canvas', maxStrokes: 8 }); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + return { context, evidence, gateway: new SymbolGatewayImpl(context, evidence) }; +} + +afterEach(() => { + resetCarrierModules(); + resetConformanceVectors(); +}); + +describe('protocol module registration (ALD-034, ALD-036)', () => { + it('registers only the fixed-token module by default', () => { + expect(registeredCarriers()).toEqual(['fixed-token']); + expect(carrierModule('fixed-token').allowedKinds).toEqual(['emit_symbols']); + }); + + it('refuses to construct a Gateway for an unregistered carrier', () => { + const context = runContext({ carrierMode: 'generative-bitmap' }); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + let caught: unknown; + try { + new SymbolGatewayImpl(context, evidence); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(UnsupportedCarrierError); + expect((caught as UnsupportedCarrierError).details?.backlogRefs).toEqual([ + 'ALD-031', + 'ALD-033', + ]); + }); + + it('routes a later-registered module without touching Gateway code', async () => { + registerCarrierModule(stubCanvasModule); + expect(registeredCarriers()).toEqual(['fixed-token', 'generative-canvas']); + + const { gateway, context, evidence } = canvasHarness(); + const result: GatewaySubmitResult = await gateway.submitProposal( + turn(), + asEnvelope(canvasEnvelope(2)), + ); + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + const event = ChannelEventSchema.parse(result.channelEvent); + expect(event.carrier).toBe('generative-canvas'); + expect(event.deliveryReceipt?.deliveredArtifactHash).toBe( + result.deliveredArtifactHash, + ); + expect(evidence.ledgerEvents(context.runId, 'A')).toHaveLength(1); + + // Fixed-token proposals are not routable on a canvas run. + const mismatch = await gateway.submitProposal( + turn({ turn: 2 }), + symbolEnvelope(['S01']), + ); + expect(mismatch.kind === 'rejected' && mismatch.reasonCode).toBe( + 'carrier-mismatch', + ); + }); + + it('gives a second module the identical rejection shape and pause policy', async () => { + registerCarrierModule(stubCanvasModule); + const canvas = canvasHarness(); + const tokens = harness(); + + const canvasRejection = await canvas.gateway.submitProposal( + turn(), + asEnvelope(canvasEnvelope(9)), + ); + const tokenRejection = await tokens.gateway.submitProposal( + turn(), + symbolEnvelope(['S01', 'S02', 'S03', 'S04', 'S05']), + ); + + expect(canvasRejection.kind).toBe('rejected'); + expect(tokenRejection.kind).toBe('rejected'); + if (canvasRejection.kind !== 'rejected' || tokenRejection.kind !== 'rejected') { + return; + } + expect(Object.keys(canvasRejection).sort()).toEqual( + Object.keys(tokenRejection).sort(), + ); + expect(canvasRejection.reasonCode).toBe(tokenRejection.reasonCode); + + const canvasEvent = ChannelEventSchema.parse(canvasRejection.channelEvent); + const tokenEvent = ChannelEventSchema.parse(tokenRejection.channelEvent); + expect(Object.keys(canvasEvent).sort()).toEqual( + Object.keys(tokenEvent).sort(), + ); + expect(canvasEvent.gatewayValidationResult).toBe('rejected'); + expect(canvasEvent.deliveryReceipt).toBeUndefined(); + + // Identical pause policy: the fifth consecutive rejection trips it. + for (let attempt = 2; attempt <= 4; attempt += 1) { + await canvas.gateway.submitProposal( + turn({ turn: attempt }), + asEnvelope(canvasEnvelope(9)), + ); + } + const fifth = await canvas.gateway.submitProposal( + turn({ turn: 5 }), + asEnvelope(canvasEnvelope(9)), + ); + expect(fifth.kind === 'rejected' && fifth.pauseRequested).toBe(true); + const interventions = canvas.evidence.interventionEvents( + canvas.context.runId, + ); + expect(interventions).toHaveLength(1); + expect(interventions[0]?.eventType).toBe('safety-trigger'); + }); + + it('applies the §9.6 conditions to a second module unchanged', async () => { + registerCarrierModule(stubCanvasModule); + const context = runContext({ + carrierMode: 'generative-canvas', + maxStrokes: 8, + communicationCondition: 'constant', + }); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + const gateway = new SymbolGatewayImpl(context, evidence); + + const result = await gateway.submitProposal( + turn(), + asEnvelope(canvasEnvelope(3)), + ); + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + expect(result.delivery?.publicArtifact).toEqual( + stubCanvasModule.constantArtifact(gateway.carrierContext), + ); + }); + + it('requires every registered module to contribute accept and reject vectors', () => { + expect(conformanceVectorsFor('fixed-token')).toBe(FIXED_TOKEN_VECTORS); + expect(() => + assertEveryCarrierHasVectors(registeredCarriers()), + ).not.toThrow(); + + registerCarrierModule(stubCanvasModule); + expect(() => assertEveryCarrierHasVectors(registeredCarriers())).toThrow( + /generative-canvas/u, + ); + + registerConformanceVectors('generative-canvas', STUB_VECTORS); + expect(() => + assertEveryCarrierHasVectors(registeredCarriers()), + ).not.toThrow(); + }); + + it('rejects a vector set that never exercises a rejection', () => { + expect(() => + registerConformanceVectors('generative-canvas', [ + { name: 'accepts one stroke', envelope: canvasEnvelope(1), expect: 'accepted' }, + ]), + ).toThrow(/at least one rejection/u); + expect(() => + registerConformanceVectors('generative-canvas', [ + { + name: 'rejects nine strokes', + envelope: canvasEnvelope(9), + expect: 'message-too-long', + }, + ]), + ).toThrow(/at least one acceptance/u); + }); + + it('runs a second module vectors through the same suite driver', async () => { + registerCarrierModule(stubCanvasModule); + registerConformanceVectors('generative-canvas', STUB_VECTORS); + const vectors = conformanceVectorsFor('generative-canvas') ?? []; + expect(vectors).toHaveLength(2); + + for (const vector of vectors) { + const { gateway } = canvasHarness(); + const result = await gateway.submitProposal( + turn(), + asEnvelope(vector.envelope), + ); + if (vector.expect === 'accepted') { + expect(result.kind).toBe('accepted'); + } else { + expect(result.kind === 'rejected' && result.reasonCode).toBe( + vector.expect, + ); + } + } + }); +}); diff --git a/packages/gateway/__tests__/carriers.test.ts b/packages/gateway/__tests__/carriers.test.ts new file mode 100644 index 0000000..74dd44c --- /dev/null +++ b/packages/gateway/__tests__/carriers.test.ts @@ -0,0 +1,628 @@ +/** + * ALD-031 — SPEC §9.2 alternate neutral carrier protocols. + * + * Every vector runs through a real `SymbolGatewayImpl` over a real + * `EvidenceWriter` with no learner adapter involved (ALD-036 criterion 2). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + ChannelEventSchema, + HASH_DOMAINS, + LedgerEventSchema, + type GatewaySubmitResult, + type RunConfig, +} from '@ald/types'; +import { SeededPrng, computeEntryHash, hashCanonical, hashCarrierMark } from '@ald/hashing'; + +import { + ALL_CARRIER_ACTION_KINDS, + CARRIER_ACTION_KINDS, + InvalidCarrierFamilyError, + assertSingleCarrierFamily, + availableCarrierActions, + carrierModule, + isCarrierActionKind, + registerCarrierModule, + registeredCarriers, + resetCarrierModules, + type CarrierModule, +} from '../src/carrier-modules.js'; +import { + ALTERNATE_CARRIER_VECTORS, + FIXED_TOKEN_VECTORS, + assertEveryCarrierHasVectors, + conformanceVectorsFor, + resetConformanceVectors, + type ConformanceVector, +} from '../src/conformance-vectors.js'; +import { + ALTERNATE_CARRIERS, + CARRIER_MARK_HASH_VECTORS, + MissingGlyphBundleHashError, + carrierInventory, + carrierMarkHash, + fixedGlyphModule, + generateGlyphBundle, + glyphInventory, + hashGlyphBundle, + registerAlternateCarriers, +} from '../src/carriers/index.js'; +import { GATEWAY_REASON_CODES, isGatewayReasonCode } from '../src/reason-codes.js'; +import { SymbolGatewayImpl } from '../src/symbol-gateway.js'; +import { FakeEvidenceWriter, StepClock } from './fake-evidence-writer.js'; +import { asEnvelope, intentionDraft, runContext, turn } from './support.js'; + +const GLYPH_BUNDLE_HASH = hashGlyphBundle( + generateGlyphBundle({ seed: 'ald-carriers-test-bundle', size: 32 }), +); + +/** A registered run over `carrier`, with the §9.2 config a run needs. */ +function carrierHarness( + carrier: RunConfig['carrierMode'], + overrides: Partial = {}, +): { + gateway: SymbolGatewayImpl; + evidence: FakeEvidenceWriter; + context: ReturnType; +} { + const inventory = carrierInventory({ + carrierMode: carrier, + symbolInventorySize: 32, + }); + const context = runContext( + { + carrierMode: carrier, + ...(carrier === 'fixed-glyph' ? { glyphBundleHash: GLYPH_BUNDLE_HASH } : {}), + ...overrides, + }, + // A generative carrier declares no inventory (SPEC §9.2), but the Gateway + // still requires a non-degenerate `symbolInventory` for the §9.1 checks it + // shares, so the fixed-token default stands in there. + inventory.length >= 2 ? { symbolInventory: inventory } : {}, + ); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + return { + gateway: new SymbolGatewayImpl(context, evidence), + evidence, + context, + }; +} + +/** A minimal valid proposal for each carrier. */ +const VALID_PROPOSALS: Readonly< + Record +> = { + 'fixed-token': { kind: 'emit_symbols', publicArtifact: { symbols: ['S01'] } }, + 'fixed-glyph': { kind: 'emit_glyphs', publicArtifact: { glyphs: ['G01'] } }, + 'generative-bitmap': { + kind: 'emit_bitmap', + publicArtifact: { bitmap: { bits: new Array<0 | 1>(256).fill(0) } }, + }, + 'generative-canvas': { + kind: 'emit_canvas', + publicArtifact: { + strokes: [{ startX: 0, startY: 0, endX: 15, endY: 15, width: 1 }], + }, + }, + 'generative-tone': { + kind: 'emit_tones', + publicArtifact: { tones: { tones: [{ pitchBin: 2, durationBin: 3 }] } }, + }, +}; + +const ALL_CARRIERS = Object.keys(VALID_PROPOSALS) as RunConfig['carrierMode'][]; + +function envelopeFor(carrier: RunConfig['carrierMode']): unknown { + return { + proposal: VALID_PROPOSALS[carrier], + privateLedgerDraft: intentionDraft(), + }; +} + +/** Every non-empty string reachable from a submission's public artifact. */ +function artifactStrings(envelopeValue: unknown): string[] { + const found: string[] = []; + const walk = (value: unknown): void => { + if (typeof value === 'string') { + found.push(value); + return; + } + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + if (typeof value === 'object' && value !== null) { + Object.values(value).forEach(walk); + } + }; + const proposal = (envelopeValue as { proposal?: { publicArtifact?: unknown } }) + ?.proposal; + walk(proposal?.publicArtifact); + // Strings shorter than three characters are dropped: a rejection result is + // mostly hex digests, so `"1"` or `"C4"` appears inside a hash by chance + // and would make the leak assertion meaningless rather than strict. + return found.filter((value) => value.length >= 3); +} + +beforeEach(() => { + registerAlternateCarriers(); +}); + +afterEach(() => { + resetCarrierModules(); + resetConformanceVectors(); +}); + +describe('alternate carrier registration (ALD-031 criterion 3)', () => { + it('ALD-031: registers all four §9.2 modules and their ALD-036 vectors', () => { + expect(registeredCarriers()).toEqual([ + 'fixed-glyph', + 'fixed-token', + 'generative-bitmap', + 'generative-canvas', + 'generative-tone', + ]); + for (const carrier of ALTERNATE_CARRIERS) { + expect(conformanceVectorsFor(carrier)).toBe( + ALTERNATE_CARRIER_VECTORS.get(carrier), + ); + } + expect(conformanceVectorsFor('fixed-token')).toBe(FIXED_TOKEN_VECTORS); + expect(() => + assertEveryCarrierHasVectors(registeredCarriers()), + ).not.toThrow(); + }); + + it('ALD-031: is idempotent, so a second bootstrap changes nothing', () => { + registerAlternateCarriers(); + registerAlternateCarriers(); + expect(registeredCarriers()).toHaveLength(5); + }); + + it('ALD-031: leaves fixed-token alone after a reset (never the default)', () => { + resetCarrierModules(); + resetConformanceVectors(); + expect(registeredCarriers()).toEqual(['fixed-token']); + expect(() => + assertEveryCarrierHasVectors(registeredCarriers()), + ).not.toThrow(); + }); + + it('ALD-031: every carrier owns exactly one tool family', () => { + for (const carrier of ALL_CARRIERS) { + expect(CARRIER_ACTION_KINDS[carrier]).toHaveLength(1); + expect(availableCarrierActions({ carrierMode: carrier })).toEqual( + carrierModule(carrier).allowedKinds, + ); + expect(() => + assertSingleCarrierFamily({ carrierMode: carrier }), + ).not.toThrow(); + } + expect(new Set(ALL_CARRIER_ACTION_KINDS).size).toBe(5); + }); + + it('ALD-031: refuses a module whose family disagrees with its carrier', () => { + const impostor: CarrierModule = { + ...fixedGlyphModule, + carrier: 'generative-tone', + }; + registerCarrierModule(impostor); + let caught: unknown; + try { + assertSingleCarrierFamily({ carrierMode: 'generative-tone' }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(InvalidCarrierFamilyError); + expect((caught as InvalidCarrierFamilyError).actualKinds).toEqual([ + 'emit_glyphs', + ]); + }); +}); + +describe('exactly one carrier family is available in a run (ALD-031 criterion 3)', () => { + for (const carrier of ALL_CARRIERS) { + for (const submitted of ALL_CARRIERS) { + const expected = carrier === submitted ? 'accepted' : 'carrier-mismatch'; + it(`ALD-031: a ${submitted} proposal on a ${carrier} run is ${expected}`, async () => { + const { gateway } = carrierHarness(carrier); + const result = await gateway.submitProposal( + turn(), + asEnvelope(envelopeFor(submitted)), + ); + if (expected === 'accepted') { + expect(result.kind).toBe('accepted'); + } else { + expect(result.kind === 'rejected' && result.reasonCode).toBe( + 'carrier-mismatch', + ); + } + }); + } + } + + it('ALD-031: no carrier offers another carrier’s emit tool', () => { + for (const carrier of ALL_CARRIERS) { + for (const kind of ALL_CARRIER_ACTION_KINDS) { + expect(isCarrierActionKind(carrier, kind)).toBe( + (CARRIER_ACTION_KINDS[carrier] as readonly string[]).includes(kind), + ); + } + } + }); +}); + +describe('carrier-qualified markHash (ALD-031 criterion 1)', () => { + it('ALD-031: reproduces the published §9.2 hash vectors', () => { + for (const vector of CARRIER_MARK_HASH_VECTORS) { + expect(carrierMarkHash(vector.carrier, vector.artifact), vector.name).toBe( + vector.markHash, + ); + expect(hashCarrierMark(vector.carrier, vector.artifact)).toBe( + vector.markHash, + ); + } + }); + + it('ALD-031: the same canonical artifact hashes differently per carrier', () => { + const artifact = { glyphs: ['G01', 'G02'] }; + const asGlyph = carrierMarkHash('fixed-glyph', artifact); + const asBitmap = carrierMarkHash( + 'generative-bitmap', + artifact as unknown as never, + ); + expect(asGlyph).not.toBe(asBitmap); + expect(new Set(CARRIER_MARK_HASH_VECTORS.map((v) => v.markHash)).size).toBe( + CARRIER_MARK_HASH_VECTORS.length, + ); + }); + + it('ALD-031: is insensitive to key order but sensitive to mark order', () => { + const ordered = { strokes: [{ startX: 1, startY: 2, endX: 3, endY: 4, width: 1 }] }; + const shuffledKeys = { + strokes: [{ width: 1, endY: 4, endX: 3, startY: 2, startX: 1 }], + }; + expect(carrierMarkHash('generative-canvas', ordered)).toBe( + carrierMarkHash('generative-canvas', shuffledKeys), + ); + expect(carrierMarkHash('generative-tone', { + tones: { tones: [{ pitchBin: 1, durationBin: 1 }, { pitchBin: 2, durationBin: 1 }] }, + })).not.toBe( + carrierMarkHash('generative-tone', { + tones: { tones: [{ pitchBin: 2, durationBin: 1 }, { pitchBin: 1, durationBin: 1 }] }, + }), + ); + }); + + for (const carrier of ALTERNATE_CARRIERS) { + it(`ALD-031: a ${carrier} delivery records the markHash as its artifact hash`, async () => { + const first = carrierHarness(carrier); + const result: GatewaySubmitResult = await first.gateway.submitProposal( + turn(), + asEnvelope(envelopeFor(carrier)), + ); + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + const proposal = VALID_PROPOSALS[carrier]; + const expected = carrierMarkHash( + carrier, + proposal.publicArtifact as never, + ); + const channelEvent = ChannelEventSchema.parse(result.channelEvent); + const ledgerEvent = LedgerEventSchema.parse(result.senderLedgerEvent); + expect(result.deliveredArtifactHash).toBe(expected); + expect(channelEvent.publicArtifactHash).toBe(expected); + expect(channelEvent.carrier).toBe(carrier); + expect(channelEvent.senderEntryHash).toBe(ledgerEvent.entryHash); + expect(channelEvent.entryHash).toBe( + computeEntryHash('channel', channelEvent), + ); + expect(result.babyProposalHash).toBe( + hashCanonical(HASH_DOMAINS.babyProposal, proposal), + ); + expect(result.delivery?.publicArtifact).toEqual(proposal.publicArtifact); + + // A second, independent Gateway over a different run reproduces the + // identical mark hash: content addressing is process-independent. + const second = carrierHarness(carrier, { runId: 'run-gateway-002' }); + const again = await second.gateway.submitProposal( + turn({ turn: 7, sender: 'baby-b', recipient: 'baby-a' }), + asEnvelope(envelopeFor(carrier)), + ); + expect(again.kind === 'accepted' && again.deliveredArtifactHash).toBe( + expected, + ); + }); + } +}); + +describe('alternate carrier conformance vectors (ALD-031, ALD-036)', () => { + it('covers the E13 dimension, sample-rate, compression, and container side features', () => { + const names = [...ALTERNATE_CARRIER_VECTORS.values()] + .flatMap((vectors) => vectors.map((vector) => vector.name)); + for (const required of [ + 'bitmap width side feature', + 'bitmap height side feature', + 'canvas width side feature', + 'canvas height side feature', + 'tone sample-rate side feature', + 'bitmap compression side feature', + 'canvas compression side feature', + 'tone compression side feature', + 'bitmap container side feature', + 'canvas container side feature', + 'tone container side feature', + ]) { + expect(names.some((name) => name.includes(required)), required).toBe(true); + } + }); + + it('ALD-031: covers every reason code the §9.2 modules can produce', () => { + const covered = new Set(); + for (const vectors of ALTERNATE_CARRIER_VECTORS.values()) { + for (const vector of vectors) { + if (vector.expect !== 'accepted') { + covered.add(vector.expect); + } + } + } + for (const reasonCode of [ + 'glyph-not-in-inventory', + 'bitmap-size-invalid', + 'bitmap-value-invalid', + 'too-many-strokes', + 'stroke-out-of-range', + 'stroke-width-invalid', + 'too-many-tones', + 'tone-out-of-range', + 'empty-message', + 'message-too-long', + 'symbol-repeat-limit', + 'free-text-present', + 'unexpected-artifact-field', + 'carrier-mismatch', + 'invalid-envelope', + 'trusted-metadata-present', + 'missing-intention', + ]) { + expect(covered, reasonCode).toContain(reasonCode); + } + for (const code of covered) { + expect(isGatewayReasonCode(code)).toBe(true); + expect(GATEWAY_REASON_CODES).toContain(code); + } + }); + + for (const [carrier, vectors] of ALTERNATE_CARRIER_VECTORS) { + for (const vector of vectors as readonly ConformanceVector[]) { + const label = + vector.expect === 'accepted' ? 'accepts' : `rejects (${vector.expect})`; + it(`ALD-031 ${carrier} ${label}: ${vector.name}`, async () => { + const { gateway } = carrierHarness(carrier); + const result = await gateway.submitProposal( + turn(), + asEnvelope(vector.envelope), + ); + + if (vector.expect === 'accepted') { + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + const channelEvent = ChannelEventSchema.parse(result.channelEvent); + expect(channelEvent.gatewayValidationResult).toBe('accepted'); + expect(channelEvent.deliveryReceipt?.recipient).toBe('baby-b'); + return; + } + + expect(result.kind).toBe('rejected'); + if (result.kind !== 'rejected') { + return; + } + expect(result.reasonCode).toBe(vector.expect); + + // ALD-034: the shared rejection framework, unchanged by the new + // modules — a committed `channel.rejected` event carrying a reason + // code and a payload hash and no part of the attempted content. + const channelEvent = ChannelEventSchema.parse(result.channelEvent); + expect(channelEvent.gatewayValidationResult).toBe('rejected'); + expect(channelEvent.reasonCode).toBe(vector.expect); + expect(channelEvent.deliveryReceipt).toBeUndefined(); + expect(channelEvent.publicArtifactHash).toBe(result.rejectedPayloadHash); + const serialized = JSON.stringify(result); + for (const leaked of artifactStrings(vector.envelope)) { + expect(serialized).not.toContain(leaked); + } + }); + } + } + + it('ALD-031: commits exactly one channel event per submission, per carrier', async () => { + for (const [carrier, vectors] of ALTERNATE_CARRIER_VECTORS) { + const { gateway, evidence, context } = carrierHarness(carrier); + for (const vector of vectors) { + gateway.resetRejectionCounter(); + await gateway.submitProposal(turn(), asEnvelope(vector.envelope)); + } + expect(evidence.channelEvents(context.runId), carrier).toHaveLength( + vectors.length, + ); + } + }); +}); + +describe('§9.6 communication controls over the §9.2 carriers (ALD-029, ALD-031)', () => { + for (const carrier of ALTERNATE_CARRIERS) { + it(`ALD-031: ${carrier} constant and random artifacts are themselves valid`, () => { + const { gateway } = carrierHarness(carrier); + const module = carrierModule(carrier); + const context = gateway.carrierContext; + const constant = module.constantArtifact(context); + const validation = module.validate( + { kind: module.allowedKinds[0], publicArtifact: constant }, + context, + ); + expect(validation.ok, carrier).toBe(true); + + for (let draw = 0; draw < 12; draw += 1) { + const artifact = module.randomArtifact( + new SeededPrng(`carrier-random/${carrier}/${String(draw)}`), + context, + ); + const check = module.validate( + { kind: module.allowedKinds[0], publicArtifact: artifact }, + context, + ); + expect(check.ok, `${carrier} draw ${String(draw)}`).toBe(true); + } + }); + + it(`ALD-031: ${carrier} delivers the pre-registered constant under \`constant\``, async () => { + const { gateway } = carrierHarness(carrier, { + communicationCondition: 'constant', + }); + const result = await gateway.submitProposal( + turn(), + asEnvelope(envelopeFor(carrier)), + ); + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + expect(result.delivery?.publicArtifact).toEqual( + carrierModule(carrier).constantArtifact(gateway.carrierContext), + ); + }); + + it(`ALD-031: ${carrier} \`random\` replays from the run seed`, async () => { + const deliveries = await Promise.all( + [0, 1].map(async () => { + const { gateway } = carrierHarness(carrier, { + communicationCondition: 'random', + }); + const result = await gateway.submitProposal( + turn(), + asEnvelope(envelopeFor(carrier)), + ); + return result.kind === 'accepted' ? result.deliveredArtifactHash : ''; + }), + ); + expect(deliveries[0]).not.toBe(''); + expect(deliveries[0]).toBe(deliveries[1]); + }); + + it(`ALD-031: ${carrier} delivers nothing under \`disabled\``, async () => { + const { gateway } = carrierHarness(carrier, { + communicationCondition: 'disabled', + }); + const result = await gateway.submitProposal( + turn(), + asEnvelope(envelopeFor(carrier)), + ); + expect(result.kind === 'accepted' && result.delivery).toBeNull(); + }); + } +}); + +describe('fixed-glyph run configuration (ALD-031, SPEC §9.2)', () => { + it('ALD-031: refuses to construct a glyph Gateway with no frozen bundle hash', () => { + const context = runContext( + { carrierMode: 'fixed-glyph' }, + { symbolInventory: glyphInventory(32) }, + ); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + let caught: unknown; + try { + new SymbolGatewayImpl(context, evidence); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(MissingGlyphBundleHashError); + expect((caught as MissingGlyphBundleHashError).details?.field).toBe( + 'glyphBundleHash', + ); + }); + + it('ALD-031: validates glyph ids against the declared inventory size', async () => { + const { gateway } = carrierHarness('fixed-glyph', { + symbolInventorySize: 4, + }); + const inside = await gateway.submitProposal(turn(), asEnvelope({ + proposal: { kind: 'emit_glyphs', publicArtifact: { glyphs: ['G04'] } }, + privateLedgerDraft: intentionDraft(), + })); + expect(inside.kind).toBe('accepted'); + + const outside = await gateway.submitProposal(turn({ turn: 2 }), asEnvelope({ + proposal: { kind: 'emit_glyphs', publicArtifact: { glyphs: ['G05'] } }, + privateLedgerDraft: intentionDraft(), + })); + expect(outside.kind === 'rejected' && outside.reasonCode).toBe( + 'glyph-not-in-inventory', + ); + }); + + it('ALD-031: declares no inventory for the generative carriers', () => { + expect(carrierInventory({ carrierMode: 'fixed-token' })).toHaveLength(32); + expect(carrierInventory({ carrierMode: 'fixed-glyph' })[0]).toBe('G01'); + for (const carrier of [ + 'generative-bitmap', + 'generative-canvas', + 'generative-tone', + ] as const) { + expect(carrierInventory({ carrierMode: carrier })).toEqual([]); + } + }); +}); + +describe('canvas stroke bound is configurable per run (SPEC §9.2, §18)', () => { + it('ALD-031: honours maxStrokes without a code change', async () => { + const { gateway } = carrierHarness('generative-canvas', { maxStrokes: 2 }); + const stroke = { startX: 0, startY: 0, endX: 1, endY: 1, width: 1 }; + const withinCap = await gateway.submitProposal(turn(), asEnvelope({ + proposal: { kind: 'emit_canvas', publicArtifact: { strokes: [stroke, stroke] } }, + privateLedgerDraft: intentionDraft(), + })); + expect(withinCap.kind).toBe('accepted'); + + const overCap = await gateway.submitProposal(turn({ turn: 2 }), asEnvelope({ + proposal: { + kind: 'emit_canvas', + publicArtifact: { strokes: [stroke, stroke, stroke] }, + }, + privateLedgerDraft: intentionDraft(), + })); + expect(overCap.kind === 'rejected' && overCap.reasonCode).toBe( + 'too-many-strokes', + ); + }); + + it('ALD-031: clamps a configured cap to the §9.2 ceiling of 64', async () => { + const { gateway } = carrierHarness('generative-canvas', { maxStrokes: 64 }); + const stroke = { startX: 0, startY: 0, endX: 1, endY: 1, width: 1 }; + const atCeiling = await gateway.submitProposal(turn(), asEnvelope({ + proposal: { + kind: 'emit_canvas', + publicArtifact: { strokes: Array.from({ length: 64 }, () => stroke) }, + }, + privateLedgerDraft: intentionDraft(), + })); + expect(atCeiling.kind).toBe('accepted'); + + const overCeiling = await gateway.submitProposal(turn({ turn: 2 }), asEnvelope({ + proposal: { + kind: 'emit_canvas', + publicArtifact: { strokes: Array.from({ length: 65 }, () => stroke) }, + }, + privateLedgerDraft: intentionDraft(), + })); + expect(overCeiling.kind === 'rejected' && overCeiling.reasonCode).toBe( + 'too-many-strokes', + ); + }); +}); diff --git a/packages/gateway/__tests__/conditions.test.ts b/packages/gateway/__tests__/conditions.test.ts new file mode 100644 index 0000000..4a4cee1 --- /dev/null +++ b/packages/gateway/__tests__/conditions.test.ts @@ -0,0 +1,419 @@ +/** + * ALD-029 criterion 2: the six SPEC §9.6 communication-control conditions + * produce their exact documented behaviour from the same proposals and the + * same code, and criterion 3: every turn records the Baby-proposal hash when + * one exists plus the exact delivered-artifact hash. + */ +import { describe, expect, it } from 'vitest'; + +import { ChannelEventSchema, HASH_DOMAINS } from '@ald/types'; +import type { AgentActionProposal, GatewaySubmitResult } from '@ald/types'; +import { hashCanonical, hashCarrierMark } from '@ald/hashing'; + +import { + ControlArtifactNotPermittedError, + OracleRequiresControlArtifactError, + ShuffledBatchRequiredError, +} from '../src/errors.js'; +import { harness, symbolEnvelope, turn } from './support.js'; + +const PROPOSED = ['S13', 'S04']; + +function accepted( + result: GatewaySubmitResult, +): Extract { + if (result.kind !== 'accepted') { + throw new Error(`expected an accepted result, got ${result.reasonCode}`); + } + return result; +} + +/** Reads the `symbols` field of a delivered or proposed artifact. */ +function symbolsOf(artifact: unknown): string[] { + return (artifact as { symbols?: string[] } | undefined)?.symbols ?? []; +} + +describe('SPEC §9.6 communication-control conditions', () => { + it('normal delivers the validated Baby proposal unchanged', async () => { + const { gateway, context } = harness({ communicationCondition: 'normal' }); + const result = accepted( + await gateway.submitProposal(turn(), symbolEnvelope(PROPOSED)), + ); + + expect(symbolsOf(result.delivery?.publicArtifact)).toEqual(PROPOSED); + expect(result.deliveredArtifactHash).toBe( + hashCarrierMark(context.config.carrierMode, { symbols: PROPOSED }), + ); + expect(result.channelEvent.communicationCondition).toBe('normal'); + expect(result.channelEvent.babyProposalHash).toBe(result.babyProposalHash); + }); + + it('applies live ablation and substitution probes after validation', async () => { + const ablationRun = harness({ communicationCondition: 'normal' }); + const ablation = accepted( + await ablationRun.gateway.submitProposal( + turn({ + probe: { + probeId: 'probe:t1:ablation', + kind: 'ablation', + position: 1, + hypothesisRef: 'ledger:h1', + }, + }), + symbolEnvelope(PROPOSED), + ), + ); + expect(symbolsOf(ablation.delivery?.publicArtifact)).toEqual(['S13']); + expect(ablation.probeApplication).toMatchObject({ + status: 'applied', + artifactBefore: { symbols: PROPOSED }, + artifactAfter: { symbols: ['S13'] }, + }); + expect(ablation.probeApplication?.artifactHashAfter).toBe( + ablation.deliveredArtifactHash, + ); + + const substitutionRun = harness({ communicationCondition: 'normal' }); + const substitution = accepted( + await substitutionRun.gateway.submitProposal( + turn({ + probe: { + probeId: 'probe:t1:substitution', + kind: 'substitution', + position: 0, + substitute: 'S09', + hypothesisRef: 'ledger:h2', + }, + }), + symbolEnvelope(PROPOSED), + ), + ); + expect(symbolsOf(substitution.delivery?.publicArtifact)).toEqual([ + 'S09', + 'S04', + ]); + expect(substitution.probeApplication).toMatchObject({ + status: 'applied', + artifactBefore: { symbols: PROPOSED }, + artifactAfter: { symbols: ['S09', 'S04'] }, + }); + }); + + it('reports a live-probe shortfall without changing the delivery', async () => { + const { gateway } = harness({ communicationCondition: 'normal' }); + const result = accepted( + await gateway.submitProposal( + turn({ + probe: { + probeId: 'probe:t1:ablation', + kind: 'ablation', + position: 0, + hypothesisRef: 'ledger:h1', + }, + }), + symbolEnvelope(['S13']), + ), + ); + expect(symbolsOf(result.delivery?.publicArtifact)).toEqual(['S13']); + expect(result.probeApplication).toMatchObject({ + status: 'skipped', + reasonCode: 'would-empty-artifact', + artifactBefore: { symbols: ['S13'] }, + artifactAfter: { symbols: ['S13'] }, + }); + }); + + it('disabled delivers no artifact but still records both hashes', async () => { + const { gateway, context } = harness({ communicationCondition: 'disabled' }); + const result = accepted( + await gateway.submitProposal(turn(), symbolEnvelope(PROPOSED)), + ); + + expect(result.delivery).toBeNull(); + expect(result.channelEvent.deliveryReceipt).toBeUndefined(); + expect(result.babyProposalHash).toBe( + hashCanonical(HASH_DOMAINS.babyProposal, { + kind: 'emit_symbols', + publicArtifact: { symbols: PROPOSED }, + }), + ); + // SPEC §11.5: for disabled, publicArtifactHash is the hash of canonical null. + expect(result.deliveredArtifactHash).toBe( + hashCarrierMark(context.config.carrierMode, null), + ); + expect(result.deliveredArtifactHash).not.toBe( + hashCarrierMark(context.config.carrierMode, { symbols: PROPOSED }), + ); + // No delivery is recorded, so no interpretation can bind to this turn. + expect(gateway.deliveryFor(1, 'baby-b')).toBeUndefined(); + }); + + it('constant replaces every proposal with the module default artifact', async () => { + const { gateway, context } = harness({ communicationCondition: 'constant' }); + const first = accepted( + await gateway.submitProposal(turn(), symbolEnvelope(PROPOSED)), + ); + const second = accepted( + await gateway.submitProposal( + turn({ turn: 2 }), + symbolEnvelope(['S21', 'S22', 'S23']), + ), + ); + + expect(symbolsOf(first.delivery?.publicArtifact)).toEqual([ + context.symbolInventory[0], + ]); + expect(first.deliveredArtifactHash).toBe(second.deliveredArtifactHash); + expect(first.babyProposalHash).not.toBe(second.babyProposalHash); + expect(first.deliveredArtifactHash).not.toBe( + hashCarrierMark(context.config.carrierMode, { symbols: PROPOSED }), + ); + }); + + it('constant honours a pre-registered artifact and validates it', () => { + const { gateway } = harness( + { communicationCondition: 'constant' }, + { constantArtifact: { symbols: ['S09', 'S09'] } }, + ); + expect(gateway.condition).toBe('constant'); + + expect(() => + harness( + { communicationCondition: 'constant' }, + { constantArtifact: { symbols: ['S99'] } }, + ), + ).toThrow(/symbol-not-in-inventory/u); + }); + + it('random delivers a seeded valid artifact that is never the proposal', async () => { + const { gateway, context } = harness({ communicationCondition: 'random' }); + const result = accepted( + await gateway.submitProposal(turn(), symbolEnvelope(PROPOSED)), + ); + + const delivered = symbolsOf(result.delivery?.publicArtifact); + expect(delivered.length).toBeGreaterThanOrEqual(1); + expect(delivered.length).toBeLessThanOrEqual(4); + for (const symbol of delivered) { + expect(context.symbolInventory).toContain(symbol); + } + expect(result.deliveredArtifactHash).toBe( + hashCarrierMark(context.config.carrierMode, { symbols: delivered }), + ); + expect(result.babyProposalHash).toBeDefined(); + }); + + it('random is reproducible from the run seed and varies per turn', async () => { + const first = harness({ communicationCondition: 'random' }); + const second = harness({ communicationCondition: 'random' }); + + const deliveries: string[][][] = [[], []]; + for (const [index, run] of [first, second].entries()) { + for (const turnNumber of [1, 2, 3, 4, 5]) { + const result = accepted( + await run.gateway.submitProposal( + turn({ turn: turnNumber }), + symbolEnvelope(PROPOSED), + ), + ); + (deliveries[index] as string[][]).push( + symbolsOf(result.delivery?.publicArtifact), + ); + } + } + + expect(deliveries[0]).toEqual(deliveries[1]); + const distinct = new Set( + (deliveries[0] as string[][]).map((symbols) => symbols.join(',')), + ); + expect(distinct.size).toBeGreaterThan(1); + }); + + it('random draws only artifacts its own module accepts, across seeds', async () => { + for (const seedIndex of [0, 1, 2, 3, 4, 5, 6, 7]) { + const run = harness( + { communicationCondition: 'random', symbolInventorySize: 2, maxSymbolsPerMessage: 8 }, + { maxSymbolRepeats: 1 }, + { seed: `seed-random-${seedIndex}` }, + ); + for (const turnNumber of [1, 2, 3]) { + const result = accepted( + await run.gateway.submitProposal( + turn({ turn: turnNumber }), + symbolEnvelope(['S01']), + ), + ); + const delivered = symbolsOf(result.delivery?.publicArtifact); + expect(delivered.length).toBeGreaterThanOrEqual(1); + expect(delivered.length).toBeLessThanOrEqual(8); + for (let index = 1; index < delivered.length; index += 1) { + expect(delivered[index]).not.toBe(delivered[index - 1]); + } + } + } + }); + + it('shuffled delivers another episode of the batch under a seeded derangement', async () => { + const batch: AgentActionProposal['publicArtifact'][] = [ + { symbols: ['S01'] }, + { symbols: ['S02'] }, + { symbols: ['S03'] }, + { symbols: ['S04'] }, + ]; + + const deliveredPerIndex: string[][] = []; + for (let index = 0; index < batch.length; index += 1) { + const { gateway } = harness({ communicationCondition: 'shuffled' }); + const result = accepted( + await gateway.submitProposal( + turn({ turn: index + 1, batchArtifacts: batch, batchIndex: index }), + symbolEnvelope(symbolsOf(batch[index])), + ), + ); + const delivered = symbolsOf(result.delivery?.publicArtifact); + deliveredPerIndex.push(delivered); + // Never this episode's own artifact. + expect(delivered).not.toEqual(symbolsOf(batch[index])); + expect(result.deliveredArtifactHash).not.toBe( + hashCarrierMark('fixed-token', batch[index]), + ); + expect(result.babyProposalHash).toBeDefined(); + } + + // A permutation, so every batch artifact is delivered exactly once. + expect( + new Set(deliveredPerIndex.map((symbols) => symbols.join(','))).size, + ).toBe(batch.length); + }); + + it('shuffled is reproducible from the run seed', async () => { + const batch: AgentActionProposal['publicArtifact'][] = [ + { symbols: ['S01'] }, + { symbols: ['S02'] }, + { symbols: ['S03'] }, + ]; + const runs = [harness({ communicationCondition: 'shuffled' }), harness({ communicationCondition: 'shuffled' })]; + const observed: string[][] = []; + for (const run of runs) { + const result = accepted( + await run.gateway.submitProposal( + turn({ batchArtifacts: batch, batchIndex: 2 }), + symbolEnvelope(['S03']), + ), + ); + observed.push(symbolsOf(result.delivery?.publicArtifact)); + } + expect(observed[0]).toEqual(observed[1]); + }); + + it('shuffled requires the batch and rejects an out-of-range index', async () => { + const { gateway } = harness({ communicationCondition: 'shuffled' }); + await expect( + gateway.submitProposal(turn(), symbolEnvelope(['S01'])), + ).rejects.toBeInstanceOf(ShuffledBatchRequiredError); + + await expect( + gateway.submitProposal( + turn({ batchArtifacts: [{ symbols: ['S01'] }], batchIndex: 4 }), + symbolEnvelope(['S01']), + ), + ).rejects.toBeInstanceOf(ShuffledBatchRequiredError); + }); + + it('shuffled with a single-episode batch can only deliver that episode', async () => { + const { gateway } = harness({ communicationCondition: 'shuffled' }); + const result = accepted( + await gateway.submitProposal( + turn({ batchArtifacts: [{ symbols: ['S05'] }], batchIndex: 0 }), + symbolEnvelope(['S05']), + ), + ); + expect(symbolsOf(result.delivery?.publicArtifact)).toEqual(['S05']); + }); + + it('oracle refuses learner proposals and commits gateway-control artifacts', async () => { + const { gateway, evidence, context } = harness({ + communicationCondition: 'oracle', + experimentId: 'E03', + }); + + await expect( + gateway.submitProposal(turn(), symbolEnvelope(PROPOSED)), + ).rejects.toBeInstanceOf(OracleRequiresControlArtifactError); + + const { channelEvent, delivery } = await gateway.submitControlArtifact( + turn(), + { symbols: ['S11', 'S12'] }, + ); + const parsed = ChannelEventSchema.parse(channelEvent); + expect(parsed.origin).toBe('gateway-control'); + expect(parsed.communicationCondition).toBe('oracle'); + expect(parsed.babyProposalHash).toBeUndefined(); + expect(parsed.senderLedgerSequence).toBeUndefined(); + expect(parsed.publicArtifactHash).toBe( + hashCarrierMark(context.config.carrierMode, { symbols: ['S11', 'S12'] }), + ); + expect(parsed.deliveryReceipt?.recipient).toBe('baby-b'); + expect(symbolsOf(delivery.publicArtifact)).toEqual(['S11', 'S12']); + expect(gateway.deliveryFor(1, 'baby-b')?.channelEventHash).toBe( + parsed.entryHash, + ); + // No sender ledger event exists for an oracle turn. + expect(evidence.ledgerEvents(context.runId, 'A')).toHaveLength(0); + }); + + it('oracle validates the Scenario Engine artifact against the carrier', async () => { + const { gateway } = harness({ + communicationCondition: 'oracle', + experimentId: 'E03', + }); + await expect( + gateway.submitControlArtifact(turn(), { symbols: ['not a symbol'] }), + ).rejects.toThrow(/free-text-present/u); + }); + + it('rejects control artifacts under the other five conditions', async () => { + for (const condition of [ + 'normal', + 'disabled', + 'constant', + 'random', + 'shuffled', + ] as const) { + const { gateway } = harness({ communicationCondition: condition }); + await expect( + gateway.submitControlArtifact(turn(), { symbols: ['S01'] }), + ).rejects.toBeInstanceOf(ControlArtifactNotPermittedError); + } + }); + + it('records both hashes for every Baby-originated condition', async () => { + for (const condition of [ + 'normal', + 'disabled', + 'constant', + 'random', + 'shuffled', + ] as const) { + const { gateway } = harness({ communicationCondition: condition }); + const result = accepted( + await gateway.submitProposal( + turn({ + batchArtifacts: [{ symbols: ['S01'] }, { symbols: ['S02'] }], + batchIndex: 0, + }), + symbolEnvelope(PROPOSED), + ), + ); + expect(result.babyProposalHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(result.deliveredArtifactHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(result.channelEvent.babyProposalHash).toBe(result.babyProposalHash); + expect(result.channelEvent.publicArtifactHash).toBe( + result.deliveredArtifactHash, + ); + // The two hashes use different domains, so they are never equal even + // under `normal`; comparability is via the carrier-mark hash instead. + expect(result.babyProposalHash).not.toBe(result.deliveredArtifactHash); + } + }); +}); diff --git a/packages/gateway/__tests__/conformance.test.ts b/packages/gateway/__tests__/conformance.test.ts new file mode 100644 index 0000000..a0f5f24 --- /dev/null +++ b/packages/gateway/__tests__/conformance.test.ts @@ -0,0 +1,223 @@ +/** + * ALD-036: the fixed-token conformance suite. Every vector runs through a + * real `SymbolGatewayImpl` over a real `EvidenceWriter`, with no learner + * adapter involved. + */ +import { describe, expect, it } from 'vitest'; + +import { + ChannelEventSchema, + LedgerEventSchema, + HASH_DOMAINS, + type GatewaySubmitResult, +} from '@ald/types'; +import { + computeEntryHash, + hashCanonical, + hashCarrierMark, + verifyHashSignature, +} from '@ald/hashing'; + +import { + CONFORMANCE_INVENTORY, + CONFORMANCE_MAX_SYMBOL_REPEATS, + CONFORMANCE_MAX_SYMBOLS, + FIXED_TOKEN_VECTORS, +} from '../src/conformance-vectors.js'; +import { isGatewayReasonCode } from '../src/reason-codes.js'; +import { asEnvelope, harness, turn } from './support.js'; + +/** Every string reachable from the offending public artifact. */ +function artifactStrings(envelopeValue: unknown): string[] { + const found: string[] = []; + const walk = (value: unknown): void => { + if (typeof value === 'string') { + found.push(value); + return; + } + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + if (typeof value === 'object' && value !== null) { + Object.values(value).forEach(walk); + } + }; + const proposal = (envelopeValue as { proposal?: { publicArtifact?: unknown } }) + ?.proposal; + walk(proposal?.publicArtifact); + return found.filter((value) => value.length > 0); +} + +/** + * Plaintext-bearing strings from a result. Cryptographic commitments are + * intentionally opaque: a short token such as `S01` can occur by chance in + * a hash or Ed25519 signature without disclosing the rejected artifact. + */ +function nonCryptographicStrings(value: unknown): string[] { + const found: string[] = []; + const walk = (candidate: unknown, key = ''): void => { + if (/hash|signature/iu.test(key)) { + return; + } + if (typeof candidate === 'string') { + found.push(candidate); + return; + } + if (Array.isArray(candidate)) { + candidate.forEach((entry) => walk(entry)); + return; + } + if (typeof candidate === 'object' && candidate !== null) { + Object.entries(candidate).forEach(([entryKey, entry]) => + walk(entry, entryKey), + ); + } + }; + walk(value); + return found; +} + +describe('fixed-token conformance vectors (ALD-036)', () => { + it('declares a 32-symbol inventory and the SPEC §9.1 defaults', () => { + expect(CONFORMANCE_INVENTORY).toHaveLength(32); + expect(CONFORMANCE_INVENTORY[0]).toBe('S01'); + expect(CONFORMANCE_INVENTORY[31]).toBe('S32'); + expect(CONFORMANCE_MAX_SYMBOLS).toBe(4); + expect(CONFORMANCE_MAX_SYMBOL_REPEATS).toBe(3); + }); + + it('covers every reason code the fixed-token module can produce', () => { + const covered = new Set( + FIXED_TOKEN_VECTORS.filter((vector) => vector.expect !== 'accepted').map( + (vector) => vector.expect, + ), + ); + for (const reasonCode of [ + 'invalid-envelope', + 'trusted-metadata-present', + 'missing-intention', + 'carrier-mismatch', + 'unexpected-artifact-field', + 'free-text-present', + 'symbol-not-in-inventory', + 'empty-message', + 'message-too-long', + 'symbol-repeat-limit', + ]) { + expect(covered).toContain(reasonCode); + } + expect( + FIXED_TOKEN_VECTORS.some((vector) => vector.expect === 'accepted'), + ).toBe(true); + }); + + for (const vector of FIXED_TOKEN_VECTORS) { + it(`${vector.expect === 'accepted' ? 'accepts' : `rejects (${vector.expect})`}: ${vector.name}`, async () => { + const { gateway, evidence, context } = harness(); + const result: GatewaySubmitResult = await gateway.submitProposal( + turn(), + asEnvelope(vector.envelope), + ); + + if (vector.expect === 'accepted') { + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + + // ALD-035 criterion 2: the committed events are schema-valid, chained, + // and signed by the channel/ledger writer keys. + const channelEvent = ChannelEventSchema.parse(result.channelEvent); + const ledgerEvent = LedgerEventSchema.parse(result.senderLedgerEvent); + expect(channelEvent.gatewayValidationResult).toBe('accepted'); + expect(channelEvent.origin).toBe('baby'); + expect(channelEvent.senderLedgerSequence).toBe(ledgerEvent.sequence); + expect(channelEvent.senderEntryHash).toBe(ledgerEvent.entryHash); + expect(channelEvent.deliveryReceipt?.recipient).toBe('baby-b'); + expect(channelEvent.entryHash).toBe( + computeEntryHash('channel', channelEvent), + ); + expect(ledgerEvent.entryHash).toBe( + computeEntryHash('baby-a-ledger', ledgerEvent), + ); + + const keys = evidence.signerRegistry.publicKeys(); + const channelKey = keys.find((key) => key.domain === 'channel'); + const ledgerKey = keys.find((key) => key.domain === 'baby-a-ledger'); + expect( + verifyHashSignature( + channelEvent.entryHash, + channelEvent.writerSignature, + channelKey?.publicKey ?? '', + ), + ).toBe(true); + expect( + verifyHashSignature( + ledgerEvent.entryHash, + ledgerEvent.writerSignature, + ledgerKey?.publicKey ?? '', + ), + ).toBe(true); + + // ALD-029 criterion 3: both hashes are recorded and match. + const proposal = ( + vector.envelope as { proposal: { kind: string; publicArtifact: unknown } } + ).proposal; + expect(result.babyProposalHash).toBe( + hashCanonical(HASH_DOMAINS.babyProposal, proposal), + ); + expect(result.babyProposalHash).toBe(channelEvent.babyProposalHash); + expect(result.deliveredArtifactHash).toBe( + channelEvent.publicArtifactHash, + ); + expect(result.deliveredArtifactHash).toBe( + hashCarrierMark(context.config.carrierMode, proposal.publicArtifact), + ); + expect(result.delivery?.publicArtifact).toEqual(proposal.publicArtifact); + expect(result.delivery?.channelEventHash).toBe(channelEvent.entryHash); + // SPEC §4.2: the sender's private ledger content never reaches the + // receiver's delivery envelope. + expect(JSON.stringify(result.delivery)).not.toContain('artifact-1'); + return; + } + + expect(result.kind).toBe('rejected'); + if (result.kind !== 'rejected') { + return; + } + expect(result.reasonCode).toBe(vector.expect); + expect(isGatewayReasonCode(result.reasonCode)).toBe(true); + + // ALD-034 criterion 1: an append-only channel.rejected event with a + // reason code and payload hash, and no raw content anywhere. + const channelEvent = ChannelEventSchema.parse(result.channelEvent); + expect(channelEvent.gatewayValidationResult).toBe('rejected'); + expect(channelEvent.reasonCode).toBe(vector.expect); + expect(channelEvent.deliveryReceipt).toBeUndefined(); + expect(channelEvent.publicArtifactHash).toBe(result.rejectedPayloadHash); + expect(channelEvent.entryHash).toBe( + computeEntryHash('channel', channelEvent), + ); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('symbols'); + for (const leaked of artifactStrings(vector.envelope)) { + for (const visible of nonCryptographicStrings(result)) { + expect(visible).not.toContain(leaked); + } + } + }); + } + + it('commits exactly one channel event per submission', async () => { + const { gateway, evidence, context } = harness(); + for (const vector of FIXED_TOKEN_VECTORS) { + gateway.resetRejectionCounter(); + await gateway.submitProposal(turn(), asEnvelope(vector.envelope)); + } + expect(evidence.channelEvents(context.runId)).toHaveLength( + FIXED_TOKEN_VECTORS.length, + ); + }); +}); diff --git a/packages/gateway/__tests__/fake-evidence-writer.ts b/packages/gateway/__tests__/fake-evidence-writer.ts new file mode 100644 index 0000000..eeae4b9 --- /dev/null +++ b/packages/gateway/__tests__/fake-evidence-writer.ts @@ -0,0 +1,13 @@ +/** + * The Gateway's test double for the Evidence Writer. + * + * The implementation lives in `src/testing.ts` so the Nursery Controller's + * tests can reuse the exact same writer (exported as + * `InMemoryEvidenceWriter` from `@ald/gateway`). It is re-exported here under + * the `Fake…` name the Gateway tests read with. + */ +export { + InMemoryEvidenceError as FakeEvidenceError, + InMemoryEvidenceWriter as FakeEvidenceWriter, + StepClock, +} from '../src/testing.js'; diff --git a/packages/gateway/__tests__/gateway-api.test.ts b/packages/gateway/__tests__/gateway-api.test.ts new file mode 100644 index 0000000..24594be --- /dev/null +++ b/packages/gateway/__tests__/gateway-api.test.ts @@ -0,0 +1,308 @@ +/** + * SPEC §12.3 response mapping, §8.3 turn deadlines, §8.2 Evidence Writer + * boundary, and the fidelity of the in-memory writer the suite runs on. + */ +import { describe, expect, it } from 'vitest'; + +import { + ChannelEventSchema, + LedgerEventSchema, + RunConfigSchema, + type TurnCommitRequest, +} from '@ald/types'; +import { computeEntryHash, verifyHashSignature } from '@ald/hashing'; + +import { + ControlArtifactNotPermittedError, + InvalidControlArtifactError, + InvalidSymbolInventoryError, + isGatewayError, + OracleRequiresControlArtifactError, + ShuffledBatchRequiredError, + toGatewayError, + toGatewayRejectionResponse, + TurnDeadlineExceededError, + UnsupportedCarrierError, +} from '../src/errors.js'; +import { SymbolGatewayImpl, withTurnDeadline } from '../src/symbol-gateway.js'; +import { FakeEvidenceWriter, StepClock } from './fake-evidence-writer.js'; +import { harness, runContext, symbolEnvelope, turn } from './support.js'; + +describe('SPEC §12.3 response shape', () => { + it('maps every Gateway error class to its code and status', () => { + const cases: Array<[Error, number, string]> = [ + [new UnsupportedCarrierError('generative-tone'), 400, 'INVALID_REQUEST'], + [new ShuffledBatchRequiredError(3), 400, 'INVALID_REQUEST'], + [ + new InvalidControlArtifactError('free-text-present', 'oracle', 'detail'), + 400, + 'INVALID_REQUEST', + ], + [new InvalidSymbolInventoryError('too small'), 400, 'INVALID_REQUEST'], + [new OracleRequiresControlArtifactError(), 403, 'FORBIDDEN'], + [new ControlArtifactNotPermittedError('normal'), 403, 'FORBIDDEN'], + [new TurnDeadlineExceededError(30_000), 422, 'CHANNEL_REJECTED'], + ]; + + for (const [error, status, code] of cases) { + expect(isGatewayError(error)).toBe(true); + const response = toGatewayError(error); + expect(response.status).toBe(status); + expect(response.error.code).toBe(code); + expect(response.error.message.length).toBeGreaterThan(0); + } + }); + + it('maps a schema failure to INVALID_REQUEST without echoing the payload', () => { + let caught: unknown; + try { + RunConfigSchema.parse({ version: 1, runId: 'secret-run-name' }); + } catch (error) { + caught = error; + } + const response = toGatewayError(caught); + expect(response.status).toBe(400); + expect(response.error.code).toBe('INVALID_REQUEST'); + expect(JSON.stringify(response)).not.toContain('secret-run-name'); + }); + + it('maps an Evidence Writer error code to a conflict', async () => { + const context = runContext(); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + // The run was never registered, so the writer refuses the commit. + const gateway = new SymbolGatewayImpl(context, evidence); + let caught: unknown; + try { + await gateway.submitProposal(turn(), symbolEnvelope(['S01'])); + } catch (error) { + caught = error; + } + const response = toGatewayError(caught); + expect(response.status).toBe(409); + expect(response.error.code).toBe('CONFLICT'); + expect(response.error.details).toMatchObject({ evidenceCode: 'unknown-run' }); + }); + + it('maps an unmapped failure to a conflict rather than a request error', () => { + const response = toGatewayError(new Error('disk on fire')); + expect(response.status).toBe(409); + expect(response.error.details).toMatchObject({ unmapped: true }); + expect(response.error.message).not.toContain('disk on fire'); + }); + + it('renders a committed rejection as CHANNEL_REJECTED with no content', async () => { + const { gateway } = harness(); + const result = await gateway.submitProposal( + turn(), + symbolEnvelope(['not a symbol']), + ); + expect(result.kind).toBe('rejected'); + if (result.kind !== 'rejected') { + return; + } + const response = toGatewayRejectionResponse(result); + expect(response.status).toBe(422); + expect(response.error.code).toBe('CHANNEL_REJECTED'); + expect(response.error.details).toMatchObject({ + reasonCode: 'free-text-present', + pauseRequested: false, + }); + expect(JSON.stringify(response)).not.toContain('not a symbol'); + }); +}); + +describe('SPEC §8.3 turn deadlines', () => { + it('resolves a promise that beats the budget', async () => { + await expect(withTurnDeadline(Promise.resolve('ok'), 1_000)).resolves.toBe( + 'ok', + ); + }); + + it('rejects when the budget elapses first', async () => { + const never = new Promise(() => undefined); + await expect(withTurnDeadline(never, 5)).rejects.toBeInstanceOf( + TurnDeadlineExceededError, + ); + }); + + it('propagates the wrapped failure unchanged', async () => { + const failure = new Error('adapter failed'); + await expect(withTurnDeadline(Promise.reject(failure), 1_000)).rejects.toBe( + failure, + ); + }); + + it('rejects a non-positive budget immediately', async () => { + await expect( + withTurnDeadline(Promise.resolve('ok'), 0), + ).rejects.toBeInstanceOf(TurnDeadlineExceededError); + }); + + it('defaults to the run turnResponseBudgetMs and commits the forfeited turn', async () => { + const { gateway, evidence, context } = harness({ + turnResponseBudgetMs: 1_000, + }); + await expect( + gateway.withTurnDeadline(new Promise(() => undefined), 5), + ).rejects.toBeInstanceOf(TurnDeadlineExceededError); + + const rejection = await gateway.rejectForTimeout(turn({ turn: 4 }), 'baby-a'); + expect(rejection.reasonCode).toBe('timeout'); + const events = evidence.channelEvents(context.runId); + expect(events).toHaveLength(1); + expect(events[0]?.reasonCode).toBe('timeout'); + }); +}); + +describe('Evidence Writer boundary (SPEC §4.2, §8.2)', () => { + it('submits one normalized TurnCommitRequest per accepted proposal', async () => { + const { gateway, evidence, context } = harness(); + const seen: TurnCommitRequest[] = []; + const original = evidence.commitTurn.bind(evidence); + evidence.commitTurn = (request: TurnCommitRequest) => { + seen.push(request); + return original(request); + }; + + await gateway.submitProposal( + turn({ turn: 6 }), + symbolEnvelope(['S03', 'S03']), + ); + + expect(seen).toHaveLength(1); + const request = seen[0] as TurnCommitRequest; + expect(request.runId).toBe(context.runId); + expect(request.turn).toBe(6); + expect(request.sender).toBe('baby-a'); + expect(request.recipient).toBe('baby-b'); + expect(request.carrier).toBe('fixed-token'); + expect(request.communicationCondition).toBe('normal'); + expect(Object.keys(request.proposal).sort()).toEqual([ + 'kind', + 'publicArtifact', + ]); + expect(request.proposal.publicArtifact).toEqual({ symbols: ['S03', 'S03'] }); + expect(request.intentionDraft.eventType).toBe('intention.recorded'); + expect(request.deliveredArtifact).toEqual({ symbols: ['S03', 'S03'] }); + }); + + it('produces schema-valid, chained, verifiable events', async () => { + const { gateway, evidence, context } = harness(); + for (const turnNumber of [1, 2, 3]) { + await gateway.submitProposal( + turn({ turn: turnNumber }), + symbolEnvelope(['S01']), + ); + } + + const channel = evidence.channelEvents(context.runId); + const ledger = evidence.ledgerEvents(context.runId, 'A'); + expect(channel).toHaveLength(3); + expect(ledger).toHaveLength(3); + + const keys = evidence.signerRegistry.publicKeys(); + const channelKey = keys.find((key) => key.domain === 'channel')?.publicKey ?? ''; + const ledgerKey = + keys.find((key) => key.domain === 'baby-a-ledger')?.publicKey ?? ''; + + channel.forEach((event, index) => { + const parsed = ChannelEventSchema.parse(event); + expect(parsed.sequence).toBe(index + 1); + expect(parsed.entryHash).toBe(computeEntryHash('channel', parsed)); + expect( + verifyHashSignature(parsed.entryHash, parsed.writerSignature, channelKey), + ).toBe(true); + }); + ledger.forEach((event, index) => { + const parsed = LedgerEventSchema.parse(event); + expect(parsed.sequence).toBe(index + 1); + expect(parsed.entryHash).toBe(computeEntryHash('baby-a-ledger', parsed)); + expect( + verifyHashSignature(parsed.entryHash, parsed.writerSignature, ledgerKey), + ).toBe(true); + }); + + const recovery = await evidence.recover(context.runId); + expect(recovery.ok).toBe(true); + expect(recovery.chainViolations).toEqual([]); + expect(recovery.forks).toEqual([]); + expect(evidence.chainHead(context.runId, 'channel').size).toBe(3); + }); + + it('registers a run once and reports its configuration hash', () => { + const context = runContext(); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + const { configurationHash } = evidence.registerRun(context.config); + expect(configurationHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(evidence.listRuns()).toEqual([context.runId]); + expect(() => evidence.registerRun(context.config)).toThrow( + /already registered/u, + ); + }); + + it('refuses an interpretation ledger append with no delivery binding', () => { + const { evidence, context } = harness(); + expect(() => + evidence.appendLedgerEvent({ + runId: context.runId, + babyId: 'B', + turn: 1, + draft: { + eventType: 'interpretation.recorded', + contentSchema: 'agent-native-ledger', + subjectId: 'subject', + content: { artifactRef: 'a' }, + blindingNonce: 'nonce', + evidenceRefs: [], + }, + }), + ).toThrow(/channelEventHash/u); + }); +}); + +describe('symbol inventory validation (SPEC §9.1)', () => { + it('refuses an inventory the protocol cannot use', () => { + const context = runContext(); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + + for (const inventory of [['S01'], ['S01', 'S01'], ['S01', 'S 02'], ['S01', '']]) { + expect( + () => + new SymbolGatewayImpl( + { ...context, symbolInventory: inventory }, + evidence, + ), + ).toThrow(InvalidSymbolInventoryError); + } + }); + + it('refuses a non-positive maxSymbolRepeats', () => { + const context = runContext(); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + expect( + () => new SymbolGatewayImpl(context, evidence, { maxSymbolRepeats: 0 }), + ).toThrow(InvalidSymbolInventoryError); + }); + + it('accepts a run-configured inventory of any size without a code change', async () => { + const { gateway } = harness({ symbolInventorySize: 4, maxSymbolsPerMessage: 2 }); + const accepted = await gateway.submitProposal(turn(), symbolEnvelope(['S04'])); + expect(accepted.kind).toBe('accepted'); + const rejected = await gateway.submitProposal( + turn({ turn: 2 }), + symbolEnvelope(['S05']), + ); + expect(rejected.kind === 'rejected' && rejected.reasonCode).toBe( + 'symbol-not-in-inventory', + ); + const tooLong = await gateway.submitProposal( + turn({ turn: 3 }), + symbolEnvelope(['S01', 'S02', 'S03']), + ); + expect(tooLong.kind === 'rejected' && tooLong.reasonCode).toBe( + 'message-too-long', + ); + }); +}); diff --git a/packages/gateway/__tests__/glyph-bundle.test.ts b/packages/gateway/__tests__/glyph-bundle.test.ts new file mode 100644 index 0000000..c36e342 --- /dev/null +++ b/packages/gateway/__tests__/glyph-bundle.test.ts @@ -0,0 +1,285 @@ +/** + * ALD-031 — the frozen unfamiliar-glyph bundle and its leakage audit + * (SPEC §9.2: "generated and frozen before pre-registration, contain no + * Unicode text labels in Baby-visible data, and pass the OCR/glyph leakage + * audit"). + * + * The audit under test is a documented heuristic screen, not OCR: the + * assertions below check exactly that — that it is deterministic, that it + * keeps letter-shaped and formless bitmaps out of a generated bundle, and + * that a generated bundle passes it. They do not assert that a passing bundle + * is certified text-free; the real detector is ALD-039's. + */ +import { describe, expect, it } from 'vitest'; + +import { RunConfigSchema } from '@ald/types'; + +import { + GLYPH_AUDIT_REASON_CODES, + GLYPH_AUDIT_THRESHOLDS, + GLYPH_AUDIT_VERSION, + GLYPH_BUNDLE_HASH_DOMAIN, + GLYPH_BUNDLE_VERSION, + GlyphBundleAuditFailedError, + GlyphBundleMismatchError, + auditGlyphBitmap, + auditGlyphBundle, + generateGlyphBundle, + glyphBitmapFeatures, + glyphInventory, + hashGlyphBundle, + heuristicGlyphLeakageAudit, + isGlyphBundleHash, + verifyGlyphBundle, + type GlyphBundle, + type GlyphLeakageAudit, +} from '../src/carriers/index.js'; + +const SEED = 'ald-e13-glyph-bundle-v1'; + +/** A 16x16 bitmap whose ink sits in one x-height band, like written text. */ +function letterLikeBitmap(): (0 | 1)[] { + const bits = new Array<0 | 1>(256).fill(0); + // Three vertical strokes and one crossbar between rows 5 and 11 only. + for (const column of [3, 7, 11]) { + for (let row = 5; row < 12; row += 1) { + bits[row * 16 + column] = 1; + } + } + for (let column = 3; column <= 11; column += 1) { + bits[8 * 16 + column] = 1; + } + return bits; +} + +/** A bitmap with a single tiny mark: no reusable form. */ +function singleDotBitmap(): (0 | 1)[] { + const bits = new Array<0 | 1>(256).fill(0); + bits[8 * 16 + 8] = 1; + return bits; +} + +function solidBitmap(): (0 | 1)[] { + return new Array<0 | 1>(256).fill(1); +} + +describe('glyph inventory (SPEC §9.2)', () => { + it('ALD-031: mints opaque G-prefixed identifiers, padded like S-tokens', () => { + expect(glyphInventory(32)).toHaveLength(32); + expect(glyphInventory(32)[0]).toBe('G01'); + expect(glyphInventory(32)[31]).toBe('G32'); + expect(glyphInventory(100)[99]).toBe('G100'); + expect(() => glyphInventory(1)).toThrow(/between 2 and 256/u); + expect(() => glyphInventory(257)).toThrow(/between 2 and 256/u); + }); +}); + +describe('glyph bundle generation (ALD-031, SPEC §9.2)', () => { + const bundle = generateGlyphBundle({ seed: SEED, size: 32 }); + + it('ALD-031: is a pure function of its seed and size', () => { + const again = generateGlyphBundle({ seed: SEED, size: 32 }); + expect(again).toEqual(bundle); + expect(hashGlyphBundle(again)).toBe(hashGlyphBundle(bundle)); + + const otherSeed = generateGlyphBundle({ seed: `${SEED}-b`, size: 32 }); + expect(hashGlyphBundle(otherSeed)).not.toBe(hashGlyphBundle(bundle)); + const otherSize = generateGlyphBundle({ seed: SEED, size: 16 }); + expect(hashGlyphBundle(otherSize)).not.toBe(hashGlyphBundle(bundle)); + }); + + it('ALD-031: renders 32 monochrome 16x16 glyphs with the declared ids', () => { + expect(bundle.version).toBe(GLYPH_BUNDLE_VERSION); + expect(bundle.glyphs).toHaveLength(32); + expect(bundle.glyphs.map((glyph) => glyph.glyphId)).toEqual( + glyphInventory(32), + ); + for (const glyph of bundle.glyphs) { + expect(glyph.width).toBe(16); + expect(glyph.height).toBe(16); + expect(glyph.bits).toHaveLength(256); + expect(glyph.bits.every((bit) => bit === 0 || bit === 1)).toBe(true); + } + }); + + it('ALD-031: carries no Unicode text label in any Baby-visible field', () => { + // The only strings in a bundle are the opaque identifiers and the + // researcher-only seed and version fields. Nothing renderable as text + // reaches the glyph images themselves, which are bits. + const glyphStrings = bundle.glyphs.flatMap((glyph) => + Object.entries(glyph) + .filter(([, value]) => typeof value === 'string') + .map(([key, value]) => `${key}=${String(value)}`), + ); + expect(glyphStrings).toEqual( + glyphInventory(32).map((id) => `glyphId=${id}`), + ); + for (const glyph of bundle.glyphs) { + expect(glyph.glyphId).toMatch(/^G[0-9]{2}$/u); + } + }); + + it('ALD-031: every generated glyph passes the §9.2 leakage audit', () => { + const audit = auditGlyphBundle(bundle); + expect(audit.auditVersion).toBe(GLYPH_AUDIT_VERSION); + expect(audit.glyphCount).toBe(32); + expect(audit.textLikeCount).toBe(0); + expect(audit.pass).toBe(true); + expect(audit.findings.every((finding) => finding.reasonCodes.length === 0)).toBe( + true, + ); + }); + + it('ALD-031: hashes under the documented implementation-defined domain', () => { + expect(GLYPH_BUNDLE_HASH_DOMAIN).toBe('dtsf-glyph-bundle-v1'); + expect(hashGlyphBundle(bundle)).toMatch(/^sha256:[0-9a-f]{64}$/u); + // The hash is accepted by RunConfigSchema's `glyphBundleHash` field. + expect(() => + RunConfigSchema.shape.glyphBundleHash.parse(hashGlyphBundle(bundle)), + ).not.toThrow(); + }); + + it('ALD-031: verification is exact and reports a mismatch rather than passing', () => { + const hash = hashGlyphBundle(bundle); + expect(() => verifyGlyphBundle(bundle, hash)).not.toThrow(); + expect(isGlyphBundleHash(bundle, hash)).toBe(true); + + const tampered: GlyphBundle = { + ...bundle, + glyphs: bundle.glyphs.map((glyph, index) => + index === 0 + ? { ...glyph, bits: glyph.bits.map((bit, cell) => (cell === 0 ? 1 : bit)) } + : glyph, + ), + }; + expect(isGlyphBundleHash(tampered, hash)).toBe(false); + let caught: unknown; + try { + verifyGlyphBundle(tampered, hash); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(GlyphBundleMismatchError); + expect((caught as GlyphBundleMismatchError).expectedHash).toBe(hash); + }); + + it('ALD-031: accepts a pluggable audit and records its version', () => { + const calls: number[] = []; + const permissive: GlyphLeakageAudit = (glyphs) => { + calls.push(glyphs.length); + return { + auditVersion: 'stub-ocr-detector-v0', + glyphCount: glyphs.length, + textLikeCount: 0, + pass: true, + findings: [], + }; + }; + const custom = generateGlyphBundle({ + seed: SEED, + size: 4, + leakageAudit: permissive, + }); + expect(custom.auditVersion).toBe('stub-ocr-detector-v0'); + expect(calls).toHaveLength(4); + }); + + it('ALD-031: reports a parameter fault when no candidate can pass the audit', () => { + const refuseEverything: GlyphLeakageAudit = (glyphs) => ({ + auditVersion: 'always-fails-v0', + glyphCount: glyphs.length, + textLikeCount: glyphs.length, + pass: false, + findings: glyphs.map((glyph, index) => ({ + index, + glyphId: glyph.glyphId, + textLike: true, + score: 1, + reasonCodes: ['ink-too-sparse'], + features: glyphBitmapFeatures(glyph.bits, glyph.width, glyph.height), + })), + }); + let caught: unknown; + try { + generateGlyphBundle({ seed: SEED, size: 2, leakageAudit: refuseEverything }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(GlyphBundleAuditFailedError); + expect((caught as GlyphBundleAuditFailedError).glyphIndex).toBe(0); + expect((caught as GlyphBundleAuditFailedError).reasonCodes).toContain( + 'ink-too-sparse', + ); + }); +}); + +describe('glyph leakage audit heuristic (ALD-031, ALD-039 hook)', () => { + it('ALD-031: flags a letter-shaped bitmap by baseline concentration', () => { + const finding = auditGlyphBitmap( + { glyphId: 'G01', width: 16, height: 16, bits: letterLikeBitmap() }, + 0, + ); + expect(finding.textLike).toBe(true); + expect(finding.reasonCodes).toContain('baseline-band-concentration'); + expect(finding.features.rowBandConcentration).toBeGreaterThanOrEqual( + GLYPH_AUDIT_THRESHOLDS.bandConcentration, + ); + expect(finding.score).toBeGreaterThan(0.9); + }); + + it('ALD-031: flags a single tiny mark and a solid block', () => { + const dot = auditGlyphBitmap( + { glyphId: 'G02', width: 16, height: 16, bits: singleDotBitmap() }, + 1, + ); + expect(dot.textLike).toBe(true); + expect(dot.reasonCodes).toContain('too-few-components'); + expect(dot.reasonCodes).toContain('ink-too-sparse'); + + const solid = auditGlyphBitmap( + { glyphId: 'G03', width: 16, height: 16, bits: solidBitmap() }, + 2, + ); + expect(solid.textLike).toBe(true); + expect(solid.reasonCodes).toContain('ink-too-dense'); + }); + + it('ALD-031: passes a generated abstract glyph', () => { + const bundle = generateGlyphBundle({ seed: SEED, size: 8 }); + for (const [index, glyph] of bundle.glyphs.entries()) { + const finding = auditGlyphBitmap(glyph, index); + expect(finding.textLike, glyph.glyphId).toBe(false); + expect(finding.score).toBe(0); + } + }); + + it('ALD-031: reports only closed-set reason codes', () => { + const result = heuristicGlyphLeakageAudit([ + { glyphId: 'G01', width: 16, height: 16, bits: letterLikeBitmap() }, + { glyphId: 'G02', width: 16, height: 16, bits: singleDotBitmap() }, + ]); + expect(result.pass).toBe(false); + expect(result.textLikeCount).toBe(2); + for (const finding of result.findings) { + for (const code of finding.reasonCodes) { + expect(GLYPH_AUDIT_REASON_CODES).toContain(code); + } + } + }); + + it('ALD-031: counts 4-connected components and ink extents', () => { + const bits = new Array<0 | 1>(256).fill(0); + bits[0] = 1; + bits[2] = 1; + bits[16 * 15 + 15] = 1; + const features = glyphBitmapFeatures(bits, 16, 16); + expect(features.componentCount).toBe(3); + expect(features.inkDensity).toBeCloseTo(3 / 256, 10); + expect(features.verticalExtent).toBeCloseTo(2 / 16, 10); + expect(features.horizontalExtent).toBeCloseTo(3 / 16, 10); + + const empty = glyphBitmapFeatures(new Array<0 | 1>(256).fill(0), 16, 16); + expect(empty.componentCount).toBe(0); + expect(empty.rowBandConcentration).toBe(0); + }); +}); diff --git a/packages/gateway/__tests__/inspect.test.ts b/packages/gateway/__tests__/inspect.test.ts new file mode 100644 index 0000000..54b8f98 --- /dev/null +++ b/packages/gateway/__tests__/inspect.test.ts @@ -0,0 +1,118 @@ +/** + * SPEC §9.4: every recursive inspection helper is bounded by an explicit + * depth and node budget, so a deeply nested or very wide Baby-controlled + * payload produces a decidable outcome instead of exhausting the native + * call stack (regression for a finding against submitProposal/inspect.ts). + */ +import { describe, expect, it } from 'vitest'; + +import { + containsString, + DEFAULT_COMPLEXITY_BUDGET, + findTrustedMetadataKey, + isWithinComplexityBudget, + jsonSafe, + PayloadTooComplexError, +} from '../src/inspect.js'; + +function deeplyNestedArray(depth: number, leaf: unknown = 'leaf'): unknown { + let value: unknown = leaf; + for (let level = 0; level < depth; level += 1) { + value = [value]; + } + return value; +} + +function wideObject(size: number): Record { + const object: Record = {}; + for (let index = 0; index < size; index += 1) { + object[`key-${index}`] = index; + } + return object; +} + +describe('complexity budget (SPEC §9.4)', () => { + it('accepts a value comfortably inside the default budget', () => { + expect(isWithinComplexityBudget({ a: [1, 2, { b: 'c' }] })).toBe(true); + }); + + it('rejects a value nested past the default 32-level depth budget', () => { + expect(isWithinComplexityBudget(deeplyNestedArray(5000))).toBe(false); + }); + + it('rejects a value with more than the default 10,000-node budget', () => { + expect(isWithinComplexityBudget(wideObject(20_000))).toBe(false); + expect(isWithinComplexityBudget(wideObject(100))).toBe(true); + }); + + it('never overflows the call stack on a 5,000-level nested array', () => { + const deep = deeplyNestedArray(5000); + expect(() => isWithinComplexityBudget(deep)).not.toThrow(); + expect(() => findTrustedMetadataKey(deep)).not.toThrow(RangeError); + expect(() => containsString(deep)).not.toThrow(RangeError); + expect(() => jsonSafe(deep)).not.toThrow(); + }); + + it('never overflows the call stack on 20,000 sibling keys', () => { + const wide = wideObject(20_000); + expect(() => isWithinComplexityBudget(wide)).not.toThrow(); + expect(() => findTrustedMetadataKey(wide)).not.toThrow(RangeError); + expect(() => containsString(wide)).not.toThrow(RangeError); + expect(() => jsonSafe(wide)).not.toThrow(); + }); + + it('findTrustedMetadataKey throws PayloadTooComplexError rather than RangeError once over budget', () => { + expect(() => findTrustedMetadataKey(deeplyNestedArray(5000))).toThrow( + PayloadTooComplexError, + ); + }); + + it('containsString throws PayloadTooComplexError rather than RangeError once over budget', () => { + expect(() => containsString(deeplyNestedArray(5000))).toThrow( + PayloadTooComplexError, + ); + }); + + it('jsonSafe never throws and replaces an over-budget payload with a small marker', () => { + const replacement = jsonSafe(deeplyNestedArray(5000)); + expect(replacement).toEqual({ tooComplex: true }); + // The marker itself must stay comfortably inside the budget so a + // downstream canonical hash of it can never repeat the same failure. + expect(isWithinComplexityBudget(replacement)).toBe(true); + }); + + it('jsonSafe still falls back to its unserializable marker for a circular value within budget', () => { + const circular: Record = { a: 1 }; + circular.self = circular; + // A self-reference increases nesting depth without bound, so it is + // correctly classified as exceeding the depth budget too. + expect(jsonSafe(circular)).toEqual({ tooComplex: true }); + }); + + it('still finds a trusted metadata key nested within budget', () => { + expect( + findTrustedMetadataKey({ a: { b: { c: { runId: 'forged' } } } }), + ).toBe('runId'); + expect(findTrustedMetadataKey({ a: [1, 2, { sender: 'baby-a' }] })).toBe( + 'sender', + ); + }); + + it('still detects a string nested within budget', () => { + expect(containsString({ a: [1, 2, { b: 'free text' }] })).toBe(true); + expect(containsString({ a: [1, 2, { b: 3 }] })).toBe(false); + }); + + it('accepts an explicit narrower budget', () => { + expect( + isWithinComplexityBudget([[[1]]], { maxDepth: 2, maxNodes: 10 }), + ).toBe(false); + expect( + isWithinComplexityBudget([[[1]]], { maxDepth: 3, maxNodes: 10 }), + ).toBe(true); + }); + + it('DEFAULT_COMPLEXITY_BUDGET matches the documented 32/10,000 ceiling', () => { + expect(DEFAULT_COMPLEXITY_BUDGET).toEqual({ maxDepth: 32, maxNodes: 10_000 }); + }); +}); diff --git a/packages/gateway/__tests__/interpretation.test.ts b/packages/gateway/__tests__/interpretation.test.ts new file mode 100644 index 0000000..416cb88 --- /dev/null +++ b/packages/gateway/__tests__/interpretation.test.ts @@ -0,0 +1,218 @@ +/** + * SPEC §8.1 step 6, §8.2, §11.3 and ALD-035 criterion 3: the receiver's + * interpretation event must echo the `channelEventHash` of the delivery + * addressed to it, and every schema failure uses the standard Gateway shape. + */ +import { describe, expect, it } from 'vitest'; + +import { LedgerEventSchema } from '@ald/types'; +import type { GatewaySubmitResult, LedgerDraftEnvelope } from '@ald/types'; +import { computeEntryHash } from '@ald/hashing'; + +import { InterpretationRejectedError, toGatewayError } from '../src/errors.js'; +import { + harness, + intentionDraft, + interpretationDraft, + symbolEnvelope, + turn, +} from './support.js'; + +function accepted( + result: GatewaySubmitResult, +): Extract { + if (result.kind !== 'accepted') { + throw new Error(`expected an accepted result, got ${result.reasonCode}`); + } + return result; +} + +async function delivered() { + const run = harness(); + const result = accepted( + await run.gateway.submitProposal(turn(), symbolEnvelope(['S13', 'S04'])), + ); + return { ...run, result }; +} + +const OTHER_HASH = `sha256:${'b'.repeat(64)}`; + +describe('interpretation binding (SPEC §11.3)', () => { + it('accepts the recipient echoing the delivered channel event hash', async () => { + const { gateway, evidence, context, result } = await delivered(); + const envelope: LedgerDraftEnvelope = { + channelEventHash: result.channelEvent.entryHash, + privateLedgerDraft: interpretationDraft(), + }; + + const event = await gateway.submitInterpretation( + turn(), + 'baby-b', + envelope, + ); + const parsed = LedgerEventSchema.parse(event); + expect(parsed.babyId).toBe('B'); + expect(parsed.eventType).toBe('interpretation.recorded'); + expect(parsed.channelEventHash).toBe(result.channelEvent.entryHash); + expect(parsed.entryHash).toBe(computeEntryHash('baby-b-ledger', parsed)); + expect(evidence.ledgerEvents(context.runId, 'B')).toHaveLength(1); + }); + + it('rejects a mismatched channel event hash and commits the rejection', async () => { + const { gateway, evidence, context } = await delivered(); + let caught: unknown; + try { + await gateway.submitInterpretation(turn(), 'baby-b', { + channelEventHash: OTHER_HASH, + privateLedgerDraft: interpretationDraft(), + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(InterpretationRejectedError); + const error = caught as InterpretationRejectedError; + expect(error.reasonCode).toBe('interpretation-hash-mismatch'); + expect(error.channelEvent.gatewayValidationResult).toBe('rejected'); + // SPEC §9.4: the rejection is attributed to the offending recipient. + expect(error.channelEvent.logicalSender).toBe('baby-b'); + expect(error.consecutiveRejections).toBe(1); + expect(error.pauseRequested).toBe(false); + + expect(evidence.ledgerEvents(context.runId, 'B')).toHaveLength(0); + const events = evidence.channelEvents(context.runId); + expect(events).toHaveLength(2); + expect(events[1]?.reasonCode).toBe('interpretation-hash-mismatch'); + + // SPEC §12.3 response shape. + expect(toGatewayError(error)).toMatchObject({ + status: 422, + error: { code: 'CHANNEL_REJECTED' }, + }); + }); + + it('rejects the other Baby echoing a delivery addressed to its twin', async () => { + const { gateway, result } = await delivered(); + await expect( + gateway.submitInterpretation(turn(), 'baby-a', { + channelEventHash: result.channelEvent.entryHash, + privateLedgerDraft: interpretationDraft(), + }), + ).rejects.toBeInstanceOf(InterpretationRejectedError); + }); + + it('rejects an interpretation for a turn with no recorded delivery', async () => { + const { gateway, result } = await delivered(); + await expect( + gateway.submitInterpretation(turn({ turn: 9 }), 'baby-b', { + channelEventHash: result.channelEvent.entryHash, + privateLedgerDraft: interpretationDraft(), + }), + ).rejects.toBeInstanceOf(InterpretationRejectedError); + }); + + it('requires an interpretation.recorded draft', async () => { + const { gateway, result } = await delivered(); + let caught: unknown; + try { + await gateway.submitInterpretation(turn(), 'baby-b', { + channelEventHash: result.channelEvent.entryHash, + privateLedgerDraft: intentionDraft(), + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(InterpretationRejectedError); + expect((caught as InterpretationRejectedError).reasonCode).toBe( + 'missing-interpretation', + ); + }); + + it('rejects a malformed ledger draft envelope', async () => { + const { gateway } = await delivered(); + for (const malformed of [ + { channelEventHash: 'not-a-hash', privateLedgerDraft: interpretationDraft() }, + { privateLedgerDraft: interpretationDraft() }, + { + channelEventHash: OTHER_HASH, + privateLedgerDraft: interpretationDraft(), + note: 'extra', + }, + ]) { + let caught: unknown; + try { + await gateway.submitInterpretation( + turn(), + 'baby-b', + malformed as unknown as LedgerDraftEnvelope, + ); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(InterpretationRejectedError); + expect((caught as InterpretationRejectedError).reasonCode).toBe( + 'invalid-envelope', + ); + } + }); + + it('does not leak the sender intention into the receiver ledger event', async () => { + const { gateway, result } = await delivered(); + const event = await gateway.submitInterpretation(turn(), 'baby-b', { + channelEventHash: result.channelEvent.entryHash, + privateLedgerDraft: interpretationDraft({ + content: { artifactRef: 'receiver-guess' }, + }), + }); + expect(event.content.artifactRef).toBe('receiver-guess'); + expect(JSON.stringify(event)).not.toContain('nonce-intention'); + }); + + it('rejects an interpretation draft whose content exceeds the complexity budget instead of throwing (SPEC §9.4)', async () => { + const { gateway, evidence, context, result } = await delivered(); + let deep: unknown = 'artifact-1'; + for (let level = 0; level < 3000; level += 1) { + deep = [deep]; + } + + // Before the fix, a deep enough `privateLedgerDraft.content` reached + // canonical hashing inside `appendLedgerEvent` and threw + // `RangeError: Maximum call stack size exceeded` with no rejection ever + // committed. + let caught: unknown; + try { + await gateway.submitInterpretation(turn(), 'baby-b', { + channelEventHash: result.channelEvent.entryHash, + privateLedgerDraft: interpretationDraft({ + content: { artifactRef: 'guess', deep }, + }), + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(InterpretationRejectedError); + expect((caught as InterpretationRejectedError).reasonCode).toBe( + 'payload-too-complex', + ); + expect(evidence.ledgerEvents(context.runId, 'B')).toHaveLength(0); + const events = evidence.channelEvents(context.runId); + expect(events).toHaveLength(2); + expect(events[1]?.reasonCode).toBe('payload-too-complex'); + }); + + it('binds an oracle delivery for interpretation too', async () => { + const { gateway } = harness({ + communicationCondition: 'oracle', + experimentId: 'E03', + }); + const { channelEvent } = await gateway.submitControlArtifact(turn(), { + symbols: ['S07'], + }); + const event = await gateway.submitInterpretation(turn(), 'baby-b', { + channelEventHash: channelEvent.entryHash, + privateLedgerDraft: interpretationDraft(), + }); + expect(event.channelEventHash).toBe(channelEvent.entryHash); + }); +}); diff --git a/packages/gateway/__tests__/rejection.test.ts b/packages/gateway/__tests__/rejection.test.ts new file mode 100644 index 0000000..d51bdd8 --- /dev/null +++ b/packages/gateway/__tests__/rejection.test.ts @@ -0,0 +1,256 @@ +/** + * ALD-034: the protocol-independent rejection framework — consistent shape, + * append-only `channel.rejected` events with no raw content, the consecutive + * rejection counter, and the automatic pause at the configured ceiling + * (SPEC §9.4, §14.5). + */ +import { describe, expect, it } from 'vitest'; + +import { ChannelEventSchema } from '@ald/types'; + +import { + GATEWAY_ACTOR_ID, + MAX_CONSECUTIVE_REJECTIONS_REASON, +} from '../src/symbol-gateway.js'; +import { + asEnvelope, + harness, + intentionDraft, + symbolEnvelope, + turn, +} from './support.js'; + +const BAD = ['S99']; + +describe('rejection behaviour (SPEC §9.4, ALD-034)', () => { + it('counts consecutive rejections and requests a pause at the default of five', async () => { + const { gateway, evidence, context } = harness(); + expect(context.config.maxConsecutiveRejections).toBe(5); + + for (let attempt = 1; attempt <= 4; attempt += 1) { + const result = await gateway.submitProposal( + turn({ turn: attempt }), + symbolEnvelope(BAD), + ); + expect(result.kind).toBe('rejected'); + if (result.kind !== 'rejected') { + return; + } + expect(result.consecutiveRejections).toBe(attempt); + expect(result.pauseRequested).toBe(false); + expect(gateway.consecutiveRejections()).toBe(attempt); + } + expect(evidence.interventionEvents(context.runId)).toHaveLength(0); + + const fifth = await gateway.submitProposal( + turn({ turn: 5 }), + symbolEnvelope(BAD), + ); + expect(fifth.kind).toBe('rejected'); + if (fifth.kind !== 'rejected') { + return; + } + expect(fifth.consecutiveRejections).toBe(5); + expect(fifth.pauseRequested).toBe(true); + + const interventions = evidence.interventionEvents(context.runId); + expect(interventions).toHaveLength(1); + expect(interventions[0]?.eventType).toBe('safety-trigger'); + expect(interventions[0]?.actorId).toBe(GATEWAY_ACTOR_ID); + expect(interventions[0]?.reasonCode).toBe( + MAX_CONSECUTIVE_REJECTIONS_REASON, + ); + expect(interventions[0]?.details.consecutiveRejections).toBe(5); + // The audit entry names the reason code, never the attempted content. + expect(JSON.stringify(interventions[0])).not.toContain('S99'); + }); + + it('honours a lower configured ceiling', async () => { + const { gateway } = harness({ maxConsecutiveRejections: 2 }); + const first = await gateway.submitProposal(turn(), symbolEnvelope(BAD)); + const second = await gateway.submitProposal( + turn({ turn: 2 }), + symbolEnvelope(BAD), + ); + expect(first.kind === 'rejected' && first.pauseRequested).toBe(false); + expect(second.kind === 'rejected' && second.pauseRequested).toBe(true); + }); + + it('resets the counter after an accepted proposal', async () => { + const { gateway } = harness(); + await gateway.submitProposal(turn(), symbolEnvelope(BAD)); + await gateway.submitProposal(turn({ turn: 2 }), symbolEnvelope(BAD)); + expect(gateway.consecutiveRejections()).toBe(2); + + const accepted = await gateway.submitProposal( + turn({ turn: 3 }), + symbolEnvelope(['S01']), + ); + expect(accepted.kind).toBe('accepted'); + expect(gateway.consecutiveRejections()).toBe(0); + + const next = await gateway.submitProposal( + turn({ turn: 4 }), + symbolEnvelope(BAD), + ); + expect(next.kind === 'rejected' && next.consecutiveRejections).toBe(1); + }); + + it('resets the counter on request', async () => { + const { gateway } = harness(); + await gateway.submitProposal(turn(), symbolEnvelope(BAD)); + expect(gateway.consecutiveRejections()).toBe(1); + gateway.resetRejectionCounter(); + expect(gateway.consecutiveRejections()).toBe(0); + }); + + it('commits no sender ledger event for a rejected proposal', async () => { + const { gateway, evidence, context } = harness(); + await gateway.submitProposal(turn(), symbolEnvelope(BAD)); + expect(evidence.ledgerEvents(context.runId, 'A')).toHaveLength(0); + expect(evidence.channelEvents(context.runId)).toHaveLength(1); + }); + + it('chains rejected and accepted channel events in one append-only stream', async () => { + const { gateway, evidence, context } = harness(); + await gateway.submitProposal(turn(), symbolEnvelope(BAD)); + await gateway.submitProposal(turn({ turn: 2 }), symbolEnvelope(['S02'])); + await gateway.submitProposal(turn({ turn: 3 }), symbolEnvelope(BAD)); + + const events = evidence.channelEvents(context.runId); + expect(events.map((event) => event.gatewayValidationResult)).toEqual([ + 'rejected', + 'accepted', + 'rejected', + ]); + expect(events.map((event) => event.sequence)).toEqual([1, 2, 3]); + for (let index = 1; index < events.length; index += 1) { + expect(events[index]?.previousChannelHash).toBe( + events[index - 1]?.entryHash, + ); + } + const recovery = await evidence.recover(context.runId); + expect(recovery.ok).toBe(true); + }); + + it('records a timeout as a channel rejection with no payload (SPEC §8.3)', async () => { + const { gateway, evidence, context } = harness(); + const result = await gateway.rejectForTimeout(turn({ turn: 7 }), 'baby-b'); + + expect(result.kind).toBe('rejected'); + expect(result.reasonCode).toBe('timeout'); + const event = ChannelEventSchema.parse(result.channelEvent); + expect(event.reasonCode).toBe('timeout'); + expect(event.logicalSender).toBe('baby-b'); + expect(event.turn).toBe(7); + expect(event.deliveryReceipt).toBeUndefined(); + expect(event.publicArtifactHash).toBe(result.rejectedPayloadHash); + expect(evidence.channelEvents(context.runId)).toHaveLength(1); + }); + + it('never echoes the attempted payload in the rejection result', async () => { + const { gateway } = harness(); + const result = await gateway.submitProposal( + turn(), + asEnvelope({ + proposal: { + kind: 'emit_symbols', + publicArtifact: { symbols: ['S01'], plea: 'the target is the red square' }, + }, + privateLedgerDraft: symbolEnvelope(['S01']).privateLedgerDraft, + }), + ); + + expect(result.kind).toBe('rejected'); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('red square'); + expect(serialized).not.toContain('plea'); + expect(serialized).not.toContain('S01'); + expect(Object.keys(result).sort()).toEqual([ + 'channelEvent', + 'consecutiveRejections', + 'kind', + 'pauseRequested', + 'reasonCode', + 'rejectedPayloadHash', + ]); + }); + + it('hashes the rejected payload deterministically and distinctly', async () => { + const first = harness(); + const second = harness(); + const a = await first.gateway.submitProposal(turn(), symbolEnvelope(BAD)); + const b = await second.gateway.submitProposal(turn(), symbolEnvelope(BAD)); + const c = await second.gateway.submitProposal( + turn({ turn: 2 }), + symbolEnvelope(['S98']), + ); + + expect(a.kind === 'rejected' && a.rejectedPayloadHash).toBe( + b.kind === 'rejected' ? b.rejectedPayloadHash : '', + ); + expect(a.kind === 'rejected' && a.rejectedPayloadHash).not.toBe( + c.kind === 'rejected' ? c.rejectedPayloadHash : '', + ); + }); + + it('commits a rejection instead of throwing on a 5,000-level nested proposal (SPEC §9.4)', async () => { + const { gateway, evidence, context } = harness(); + let deep: unknown = 'S01'; + for (let level = 0; level < 5000; level += 1) { + deep = [deep]; + } + const envelope = asEnvelope({ + proposal: { kind: 'emit_symbols', publicArtifact: { symbols: [deep] } }, + privateLedgerDraft: intentionDraft(), + }); + + // Before the fix this rejected the returned promise with + // `RangeError: Maximum call stack size exceeded`, committing nothing and + // leaving the §9.4 counter untouched — the exact evasion the finding + // describes. + const result = await gateway.submitProposal(turn(), envelope); + + expect(result.kind).toBe('rejected'); + if (result.kind !== 'rejected') { + return; + } + expect(result.reasonCode).toBe('payload-too-complex'); + expect(result.consecutiveRejections).toBe(1); + expect(gateway.consecutiveRejections()).toBe(1); + expect(evidence.channelEvents(context.runId)).toHaveLength(1); + }); + + it('commits a rejection for a private ledger draft with 20,000 sibling keys (SPEC §9.4)', async () => { + const { gateway, evidence, context } = harness(); + const wideContent: Record = {}; + for (let index = 0; index < 20_000; index += 1) { + wideContent[`key-${index}`] = index; + } + const envelope = symbolEnvelope( + ['S01'], + intentionDraft({ content: wideContent }), + ); + + const result = await gateway.submitProposal(turn(), envelope); + + expect(result.kind).toBe('rejected'); + if (result.kind !== 'rejected') { + return; + } + expect(result.reasonCode).toBe('payload-too-complex'); + expect(result.consecutiveRejections).toBe(1); + expect(evidence.channelEvents(context.runId)).toHaveLength(1); + }); + + it('survives an unserializable submission', async () => { + const circular: Record = { kind: 'emit_symbols' }; + circular.self = circular; + const { gateway } = harness(); + const result = await gateway.submitProposal(turn(), asEnvelope(circular)); + expect(result.kind).toBe('rejected'); + expect(result.kind === 'rejected' && result.reasonCode).toBe( + 'invalid-envelope', + ); + }); +}); diff --git a/packages/gateway/__tests__/support.ts b/packages/gateway/__tests__/support.ts new file mode 100644 index 0000000..801b377 --- /dev/null +++ b/packages/gateway/__tests__/support.ts @@ -0,0 +1,157 @@ +import { fixedTokenInventory } from '@ald/types'; +import type { + GatewayRunContext, + GatewayTurnContext, + LedgerEventDraft, + RunConfig, + TurnProposalEnvelope, +} from '@ald/types'; + +import { SymbolGatewayImpl, type SymbolGatewayOptions } from '../src/symbol-gateway.js'; +import { FakeEvidenceWriter, StepClock } from './fake-evidence-writer.js'; + +export const RUN_ID = 'run-gateway-001'; +export const SEED = 'seed-gateway-001'; + +function hash(seed: string): string { + return `sha256:${seed.repeat(64).slice(0, 64)}`; +} + +export function runConfig(overrides: Partial = {}): RunConfig { + return { + version: 1, + runId: RUN_ID, + deploymentMode: 'prototype', + babyA: { + track: 'no-learning', + modelRef: 'reference-a', + trainingIsolation: 'independent', + }, + babyB: { + track: 'no-learning', + modelRef: 'reference-b', + trainingIsolation: 'independent', + }, + symmetricTracks: true, + learningSignal: 'none', + communicationCondition: 'normal', + interactionMode: 'cooperative-signaling', + carrierMode: 'fixed-token', + symbolInventorySize: 32, + maxSymbolsPerMessage: 4, + affectMode: 'none', + affectWindowSchedule: 'every-4-turns', + observationEncoding: 'opaque-numeric', + roleReversalPeriod: 1, + turnResponseBudgetMs: 30_000, + maxTurnsPerRun: 200, + maxConsecutiveRejections: 5, + ledgerLagTurns: 0, + curriculumMode: 'fixed-schedule', + cipherThreatModel: 'post-run-disclosure', + interventionSuiteThreshold: 0.5, + evaluationSeeds: 4, + checkpointEventInterval: 25, + checkpointTimeIntervalMs: 60_000, + anchorNetwork: 'base-sepolia', + finalityPolicy: '1-confirmation', + prototypeRetentionDays: 30, + scenarioBundleHash: hash('a'), + promptBundleHash: hash('b'), + protocolGitCommit: 'e2b1c0d4f5a6978877665544332211aabbccddee', + preRegistrationHash: hash('c'), + randomSeed: SEED, + experimentId: 'E00', + ...overrides, + } as RunConfig; +} + +export function runContext( + overrides: Partial = {}, + contextOverrides: Partial = {}, +): GatewayRunContext { + const config = runConfig(overrides); + return { + runId: config.runId, + config, + symbolInventory: fixedTokenInventory(config.symbolInventorySize ?? 32), + seed: SEED, + ...contextOverrides, + }; +} + +export interface Harness { + gateway: SymbolGatewayImpl; + evidence: FakeEvidenceWriter; + context: GatewayRunContext; +} + +/** A registered run, a fresh in-memory writer, and a Gateway over both. */ +export function harness( + overrides: Partial = {}, + options: SymbolGatewayOptions = {}, + contextOverrides: Partial = {}, +): Harness { + const context = runContext(overrides, contextOverrides); + const evidence = FakeEvidenceWriter.forRun(context.runId, new StepClock()); + evidence.registerRun(context.config); + return { + gateway: new SymbolGatewayImpl(context, evidence, options), + evidence, + context, + }; +} + +export function turn( + overrides: Partial = {}, +): GatewayTurnContext { + return { + turn: 1, + sender: 'baby-a', + recipient: 'baby-b', + ...overrides, + }; +} + +export function intentionDraft( + overrides: Partial = {}, +): LedgerEventDraft { + return { + eventType: 'intention.recorded', + contentSchema: 'agent-native-ledger', + subjectId: 'subject-intention', + content: { artifactRef: 'artifact-1' }, + blindingNonce: 'nonce-intention', + evidenceRefs: [], + ...overrides, + }; +} + +export function interpretationDraft( + overrides: Partial = {}, +): LedgerEventDraft { + return { + eventType: 'interpretation.recorded', + contentSchema: 'agent-native-ledger', + subjectId: 'subject-interpretation', + content: { artifactRef: 'artifact-1' }, + blindingNonce: 'nonce-interpretation', + evidenceRefs: [], + ...overrides, + }; +} + +export function symbolEnvelope( + symbols: string[], + draft: LedgerEventDraft = intentionDraft(), +): TurnProposalEnvelope { + return { + proposal: { kind: 'emit_symbols', publicArtifact: { symbols } }, + privateLedgerDraft: draft, + }; +} + +/** Casts an intentionally malformed submission onto the contract type. */ +export function asEnvelope(value: unknown): TurnProposalEnvelope { + return value as TurnProposalEnvelope; +} diff --git a/packages/gateway/package.json b/packages/gateway/package.json new file mode 100644 index 0000000..652ac4e --- /dev/null +++ b/packages/gateway/package.json @@ -0,0 +1,27 @@ +{ + "name": "@ald/gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/evidence": "*", + "zod": "4.4.3" + } +} diff --git a/packages/gateway/src/affect-errors.ts b/packages/gateway/src/affect-errors.ts new file mode 100644 index 0000000..0be9ef5 --- /dev/null +++ b/packages/gateway/src/affect-errors.ts @@ -0,0 +1,133 @@ +/** + * Operator-facing faults of the six-display affect protocol + * (SPECIFICATION.md §9.3, ALD-033). + * + * The distinction this file draws is the same one `errors.ts` draws for the + * channel: a *Baby* violation is never an error. Every malformed, repeated, + * combined, or out-of-window affect submission is committed as a + * `channel.rejected` event with reason `affect-violation` and returned as an + * `AffectSubmitResult` of kind `rejected` (SPEC §9.3 rule 6), carrying only a + * reason code, a payload hash, and counters. The classes here cover the cases + * where the *runtime or operator* asked for something the protocol may not do + * at all — an affect call on a run that disabled the channel, a `derived` + * mapping name that was never pre-registered, a window the Gateway never + * opened. + * + * None of these messages may reach a Baby context (SPEC §10.3): they are + * raised to the Nursery Controller, which surfaces opaque codes to adapters. + */ +import { GatewayError } from './errors.js'; + +/** Closed set of affect-protocol fault codes. */ +export type AffectErrorCode = + | 'affect-disabled' + | 'affect-mode-mismatch' + | 'unknown-derived-mapping' + | 'invalid-window' + | 'window-not-open' + | 'invalid-window-schedule'; + +/** Base class carrying the closed {@link AffectErrorCode}. */ +export class AffectProtocolError extends GatewayError { + constructor( + readonly affectCode: AffectErrorCode, + code: 'INVALID_REQUEST' | 'FORBIDDEN' | 'CONFLICT', + message: string, + details: Record = {}, + ) { + super(code, message, { ...details, affectCode }); + } +} + +/** + * SPEC §9.3: the affect channel is disabled by default + * (`affectMode: "none"`). A run that never enabled it has no affect surface + * at all, so this is a configuration fault rather than a rejection. + */ +export class AffectDisabledError extends AffectProtocolError { + constructor() { + super( + 'affect-disabled', + 'FORBIDDEN', + 'The affect channel is disabled for this run (affectMode: "none")', + ); + } +} + +/** + * The call does not belong to the run's `affectMode`: `recordDerivedAffect` + * on a `declared`/`permuted`/`opaque` run, or a measurement recorded on an + * `emergent` run (where no Affect Event is ever produced). + */ +export class AffectModeMismatchError extends AffectProtocolError { + constructor( + readonly affectMode: string, + readonly expected: readonly string[], + ) { + super( + 'affect-mode-mismatch', + 'FORBIDDEN', + `This call requires affectMode in [${expected.join(', ')}]; the run is ${affectMode}`, + { affectMode, expected: [...expected] }, + ); + } +} + +/** + * SPEC §9.3 `derived`: the measurement→display mapping is *fixed and + * pre-registered*. An unknown `affectDerivedMapping` name is refused rather + * than defaulted, so a run cannot silently acquire a different mapping than + * the one it registered. + */ +export class UnknownAffectDerivedMappingError extends AffectProtocolError { + constructor( + readonly mapping: string, + readonly registered: readonly string[], + ) { + super( + 'unknown-derived-mapping', + 'INVALID_REQUEST', + `Affect mapping "${mapping}" is not registered; pre-registered mappings: ${registered.join(', ')}`, + { mapping, registered: [...registered] }, + ); + } +} + +/** The `AffectWindow` handed to the protocol is not a well-formed §9.3 window. */ +export class InvalidAffectWindowError extends AffectProtocolError { + constructor(detail: string) { + super('invalid-window', 'INVALID_REQUEST', `Invalid affect window: ${detail}`); + } +} + +/** + * SPEC §9.3 rule 2: a window opens only immediately after a Gateway-defined + * outcome event. `recordDerivedAffect` is a runtime call, so a closed or + * unknown window is a runtime fault; the equivalent *Baby* submission is an + * `affect-violation` rejection instead. + */ +export class AffectWindowNotOpenError extends AffectProtocolError { + constructor(readonly windowId: string) { + super( + 'window-not-open', + 'CONFLICT', + `No affect window with id ${windowId} is open`, + { windowId }, + ); + } +} + +/** `RunConfig.affectWindowSchedule` is not one of the recognised fixed schedules. */ +export class InvalidAffectWindowScheduleError extends AffectProtocolError { + constructor( + readonly schedule: string, + readonly recognised: readonly string[], + ) { + super( + 'invalid-window-schedule', + 'INVALID_REQUEST', + `affectWindowSchedule "${schedule}" is not recognised; expected ${recognised.join(', ')}`, + { schedule, recognised: [...recognised] }, + ); + } +} diff --git a/packages/gateway/src/affect-mapping.ts b/packages/gateway/src/affect-mapping.ts new file mode 100644 index 0000000..e076a52 --- /dev/null +++ b/packages/gateway/src/affect-mapping.ts @@ -0,0 +1,108 @@ +/** + * The fixed, pre-registered measurement→display mappings of + * `affectMode: "derived"` (SPECIFICATION.md §9.3, ALD-033 criterion 2). + * + * > For `affectMode: "derived"`, the Baby cannot choose a `displayId`. After + * > the outcome, the Gateway calls the adapter's `measureAffect()`, records + * > the complete internal measurement privately, and maps it to `A1`-`A6` + * > using a fixed pre-registered mapping. — SPEC §9.3 + * + * "Fixed" is enforced structurally: a mapping is a pure function of the six + * scores, it is looked up by the `RunConfig.affectDerivedMapping` name, and an + * unknown name is refused rather than defaulted + * ({@link UnknownAffectDerivedMappingError}). Nothing here is adaptive, seeded, + * or run-dependent, so replaying a run reproduces the same display from the + * same measurement. + * + * The mapping is deliberately *not* a claim about what a Baby "feels": it is a + * documented projection of six adapter-reported scores onto six display + * identifiers, and the scores themselves are the adapter's self-report + * (SPEC §6.2 `measureAffect`). + */ +import { + AFFECT_DISPLAY_IDS, + type AffectDisplayId, + type AffectStateMeasurement, +} from '@ald/types'; + +import { UnknownAffectDerivedMappingError } from './affect-errors.js'; + +/** SPEC §9.3: the allowlist has exactly six members. */ +export const AFFECT_DISPLAY_COUNT = AFFECT_DISPLAY_IDS.length; + +/** The `AffectStateMeasurement.measurementVersion` this registry understands. */ +export const SUPPORTED_MEASUREMENT_VERSIONS: readonly string[] = ['v1']; + +export interface AffectDerivedMapping { + /** Pre-registered name matched against `RunConfig.affectDerivedMapping`. */ + readonly name: string; + /** Pure projection of the six scores onto exactly one display. */ + map(measurement: AffectStateMeasurement): AffectDisplayId; +} + +/** + * `argmax-v1`: the display whose score is highest; ties resolve to the lowest + * index, so the mapping is total and deterministic for every finite score + * vector (including all-equal vectors, which map to `A1`). + * + * Non-finite scores are not handled here — the protocol rejects a measurement + * that fails `AffectStateMeasurementSchema` plus its finiteness check before + * the mapping is consulted, so `map` is only ever called on a validated + * measurement. + */ +export const argmaxV1Mapping: AffectDerivedMapping = { + name: 'argmax-v1', + map(measurement) { + const scores = measurement.scores; + let best = 0; + for (let index = 1; index < AFFECT_DISPLAY_COUNT; index += 1) { + if ((scores[index] as number) > (scores[best] as number)) { + best = index; + } + } + return AFFECT_DISPLAY_IDS[best] as AffectDisplayId; + }, +}; + +/** The default when a run enables `derived` without naming a mapping. */ +export const DEFAULT_AFFECT_DERIVED_MAPPING = argmaxV1Mapping.name; + +const mappings = new Map([ + [argmaxV1Mapping.name, argmaxV1Mapping], +]); + +/** + * Registers an additional pre-registered mapping. A run may only use a + * mapping that was registered before it started; registration is an operator + * action, never a Baby-reachable one. + */ +export function registerAffectDerivedMapping( + mapping: AffectDerivedMapping, +): void { + mappings.set(mapping.name, mapping); +} + +export function registeredAffectDerivedMappings(): string[] { + return [...mappings.keys()].sort(); +} + +/** The mapping named `name`, or {@link UnknownAffectDerivedMappingError}. */ +export function resolveAffectDerivedMapping( + name: string | undefined, +): AffectDerivedMapping { + const resolved = name ?? DEFAULT_AFFECT_DERIVED_MAPPING; + const mapping = mappings.get(resolved); + if (!mapping) { + throw new UnknownAffectDerivedMappingError( + resolved, + registeredAffectDerivedMappings(), + ); + } + return mapping; +} + +/** Test helper: drop every registration except the built-in `argmax-v1`. */ +export function resetAffectDerivedMappings(): void { + mappings.clear(); + mappings.set(argmaxV1Mapping.name, argmaxV1Mapping); +} diff --git a/packages/gateway/src/affect-vectors.ts b/packages/gateway/src/affect-vectors.ts new file mode 100644 index 0000000..68fdbf5 --- /dev/null +++ b/packages/gateway/src/affect-vectors.ts @@ -0,0 +1,308 @@ +/** + * Affect-protocol conformance vectors (ALD-033 criterion 3, ALD-036). + * + * The vectors are data, not tests: `__tests__/affect-conformance.test.ts` runs + * every one of them against a real {@link AffectProtocol} over a real + * `EvidenceWriter`, with no learner adapter involved — the same shape + * `conformance-vectors.ts` uses for the carriers. + * + * They are kept in their own registry rather than in `conformance-vectors.ts` + * because that registry is keyed by `RunConfig['carrierMode']` and the affect + * channel is not a carrier: a run selects exactly one carrier *and* + * (independently) one `affectMode`. Folding these in requires widening that + * registry's key to a namespace; see the integrator notes for the exact + * change. Until then {@link assertEveryAffectModeHasVectors} is the gate for + * the affect half of ALD-036. + * + * `submission` is deliberately typed `unknown`: most vectors are payloads a + * `submit_affect` proposal could not express, which is exactly what the + * boundary has to survive. + */ +import type { RunConfig } from '@ald/types'; + +/** Modes with a Baby-facing `submit_affect` surface. */ +export const AFFECT_SUBMISSION_MODES = [ + 'declared', + 'permuted', + 'opaque', +] as const satisfies readonly RunConfig['affectMode'][]; + +/** Every enabled mode ALD-033 must cover. */ +export const AFFECT_VECTOR_MODES = [ + 'declared', + 'permuted', + 'opaque', + 'derived', + 'emergent', +] as const satisfies readonly RunConfig['affectMode'][]; + +export type AffectVectorMode = (typeof AFFECT_VECTOR_MODES)[number]; + +export interface AffectVector { + name: string; + /** Raw payload handed to `submitAffectFrom`. */ + submission: unknown; + /** `accepted`, or the single §9.3 rule 6 reason code. */ + expect: 'accepted' | 'affect-violation'; + /** + * When set, the submission is attempted by the window's *recipient* rather + * than its sender (SPEC §9.3 rule 5). + */ + fromRecipient?: true; + /** + * When set, the submission is attempted after the window has already been + * answered once, or on a window the Gateway never opened. + */ + outOfWindow?: 'already-answered' | 'never-opened'; +} + +function submission(displayId: unknown): unknown { + return { kind: 'submit_affect', publicArtifact: { displayId } }; +} + +/** + * Vectors shared by `declared`, `permuted`, and `opaque`: the three modes + * differ only in what the recipient is delivered and in the Baby-facing + * labels, never in what the Gateway accepts (SPEC §9.3). + */ +export const AFFECT_SUBMISSION_VECTORS: readonly AffectVector[] = [ + // --- the allowlist, exhaustively (ALD-033 criterion 1) ------------------ + { name: 'accepts A1', submission: submission('A1'), expect: 'accepted' }, + { name: 'accepts A2', submission: submission('A2'), expect: 'accepted' }, + { name: 'accepts A3', submission: submission('A3'), expect: 'accepted' }, + { name: 'accepts A4', submission: submission('A4'), expect: 'accepted' }, + { name: 'accepts A5', submission: submission('A5'), expect: 'accepted' }, + { name: 'accepts A6', submission: submission('A6'), expect: 'accepted' }, + + // --- non-allowlisted code points (SPEC §9.3 rule 3) --------------------- + { + name: 'rejects a display beyond the allowlist', + submission: submission('A7'), + expect: 'affect-violation', + }, + { + name: 'rejects a zero-indexed display', + submission: submission('A0'), + expect: 'affect-violation', + }, + { + name: 'rejects a lowercase display', + submission: submission('a1'), + expect: 'affect-violation', + }, + { + name: 'rejects a padded display rather than trimming it', + submission: submission(' A1 '), + expect: 'affect-violation', + }, + { + name: 'rejects a Unicode look-alike of an allowlisted display', + submission: submission('A1'), + expect: 'affect-violation', + }, + { + name: 'rejects an empty display', + submission: submission(''), + expect: 'affect-violation', + }, + { + name: 'rejects a numeric display index', + submission: submission(1), + expect: 'affect-violation', + }, + { + name: 'rejects a null display', + submission: submission(null), + expect: 'affect-violation', + }, + + // --- sequences, repetitions, combinations (SPEC §9.3 rule 3) ------------ + { + name: 'rejects a sequence of two displays', + submission: submission(['A1', 'A2']), + expect: 'affect-violation', + }, + { + name: 'rejects a repetition of one display', + submission: submission(['A1', 'A1']), + expect: 'affect-violation', + }, + { + name: 'rejects a single-element sequence', + submission: submission(['A1']), + expect: 'affect-violation', + }, + { + name: 'rejects a combination object', + submission: submission({ primary: 'A1', secondary: 'A2' }), + expect: 'affect-violation', + }, + { + name: 'rejects a concatenated pair of displays', + submission: submission('A1A2'), + expect: 'affect-violation', + }, + { + name: 'rejects a plural displayIds field', + submission: { kind: 'submit_affect', publicArtifact: { displayIds: ['A1'] } }, + expect: 'affect-violation', + }, + + // --- extra fields and free text (SPEC §9.3 rule 1, §9.1) ---------------- + { + name: 'rejects an extra artifact field beside a valid display', + submission: { + kind: 'submit_affect', + publicArtifact: { displayId: 'A1', intensity: 3 }, + }, + expect: 'affect-violation', + }, + { + name: 'rejects free text smuggled beside a valid display', + submission: { + kind: 'submit_affect', + publicArtifact: { displayId: 'A1', note: 'pick the red one' }, + }, + expect: 'affect-violation', + }, + { + name: 'rejects an extra proposal-level field', + submission: { + kind: 'submit_affect', + publicArtifact: { displayId: 'A1' }, + runId: 'run-forged', + }, + expect: 'affect-violation', + }, + { + name: 'rejects a display smuggled into a symbols payload', + submission: { kind: 'emit_symbols', publicArtifact: { symbols: ['A1'] } }, + expect: 'affect-violation', + }, + { + name: 'rejects a bare display string', + submission: 'A1', + expect: 'affect-violation', + }, + { + name: 'rejects a bare artifact with no proposal frame', + submission: { displayId: 'A1' }, + expect: 'affect-violation', + }, + { + name: 'rejects a proposal with no publicArtifact', + submission: { kind: 'submit_affect' }, + expect: 'affect-violation', + }, + { + name: 'rejects an empty publicArtifact', + submission: { kind: 'submit_affect', publicArtifact: {} }, + expect: 'affect-violation', + }, + { + name: 'rejects a null submission', + submission: null, + expect: 'affect-violation', + }, + + // --- window discipline (SPEC §9.3 rules 2 and 5) ------------------------ + { + name: 'rejects a submission by the window recipient', + submission: submission('A1'), + expect: 'affect-violation', + fromRecipient: true, + }, + { + name: 'rejects a second submission in one window', + submission: submission('A2'), + expect: 'affect-violation', + outOfWindow: 'already-answered', + }, + { + name: 'rejects a submission for a window that never opened', + submission: submission('A3'), + expect: 'affect-violation', + outOfWindow: 'never-opened', + }, +]; + +/** + * `derived` and `emergent` expose no `submit_affect` surface at all + * (ALD-033 criterion 2), so *every* submission is a violation — including a + * perfectly well-formed one, which is the property worth pinning. + */ +export const AFFECT_NO_SUBMISSION_VECTORS: readonly AffectVector[] = [ + { + name: 'rejects a well-formed submission where submit_affect is unavailable', + submission: submission('A1'), + expect: 'affect-violation', + }, + { + name: 'rejects a malformed submission where submit_affect is unavailable', + submission: submission('A9'), + expect: 'affect-violation', + }, + { + name: 'rejects a bare submission where submit_affect is unavailable', + submission: 'A1', + expect: 'affect-violation', + }, +]; + +const modeVectors = new Map([ + ['declared', AFFECT_SUBMISSION_VECTORS], + ['permuted', AFFECT_SUBMISSION_VECTORS], + ['opaque', AFFECT_SUBMISSION_VECTORS], + ['derived', AFFECT_NO_SUBMISSION_VECTORS], + ['emergent', AFFECT_NO_SUBMISSION_VECTORS], +]); + +export function affectVectorsFor( + mode: AffectVectorMode, +): readonly AffectVector[] | undefined { + return modeVectors.get(mode); +} + +/** + * Namespace prefix for the day `conformance-vectors.ts` widens its registry + * key from `RunConfig['carrierMode']` to a namespaced string. At that point + * the affect vectors register as `affect:declared`, `affect:permuted`, … and + * `assertEveryCarrierHasVectors` covers them too; until then + * {@link assertEveryAffectModeHasVectors} is the gate. Exported so the + * integrator's registration call needs no string literals of its own. + */ +export const AFFECT_VECTOR_NAMESPACE = 'affect'; + +/** The namespaced registry key for one mode, e.g. `affect:declared`. */ +export function affectVectorKey(mode: AffectVectorMode): string { + return `${AFFECT_VECTOR_NAMESPACE}:${mode}`; +} + +/** + * The ALD-033 criterion 3 gate: every enabled affect mode must contribute + * vectors, and the three submission modes must contribute both an acceptance + * and a rejection, so a mode cannot be declared conformant on happy-path + * coverage alone. + */ +export function assertEveryAffectModeHasVectors( + modes: readonly AffectVectorMode[] = AFFECT_VECTOR_MODES, +): void { + const missing = modes.filter((mode) => !modeVectors.has(mode)); + if (missing.length > 0) { + throw new Error( + `Affect mode(s) contribute no conformance vectors: ${missing.join(', ')}`, + ); + } + for (const mode of modes) { + const vectors = modeVectors.get(mode) as readonly AffectVector[]; + if (!vectors.some((vector) => vector.expect === 'affect-violation')) { + throw new Error(`${mode} vectors must include at least one rejection`); + } + const submissionMode = ( + AFFECT_SUBMISSION_MODES as readonly string[] + ).includes(mode); + if (submissionMode && !vectors.some((vector) => vector.expect === 'accepted')) { + throw new Error(`${mode} vectors must include at least one acceptance`); + } + } +} diff --git a/packages/gateway/src/affect-windows.ts b/packages/gateway/src/affect-windows.ts new file mode 100644 index 0000000..fcdeac2 --- /dev/null +++ b/packages/gateway/src/affect-windows.ts @@ -0,0 +1,191 @@ +/** + * Affect-window construction and the fixed window schedule + * (SPECIFICATION.md §9.3 rules 2 and 5, §18 `affectWindowSchedule`; ALD-033). + * + * > An affect window opens only immediately after a Gateway-defined + * > action/outcome event, never at a Baby-chosen arbitrary point. Window + * > frequency (`affectWindowSchedule`) is fixed before the run. — SPEC §9.3 + * > rule 2 + * + * Two consequences are encoded here: + * + * - the schedule is a pure function of the turn index and the pre-registered + * schedule string, so no Baby action can create, delay, or repeat a window + * (an unrecognised schedule is refused rather than defaulted, which is what + * keeps "fixed before the run" checkable); + * - a window names exactly one permitted `sender`, which is how rule 5 ("the + * receiving Baby cannot reply through the affect channel until the next + * Gateway-defined window") is enforced: the recipient of a window has no + * submission right in it at all. + * + * Window ids are deterministic (`w--`) so a replay from the same + * configuration reconstructs the same window identities (SPEC §14.3). + */ +import type { AffectWindow, BabyRole, RunConfig } from '@ald/types'; + +import { + InvalidAffectWindowError, + InvalidAffectWindowScheduleError, +} from './affect-errors.js'; + +/** SPEC §9.3 rule 2: the only window trigger in v1 is the outcome event. */ +export const AFFECT_WINDOW_TRIGGER = 'outcome' as const; + +/** + * Recognised `affectWindowSchedule` values. + * + * `every-turn` and `every--turns` are the fixed low-frequency schedules + * E20 registers; `never` is the explicit "enabled mode, no windows" schedule + * used by control conditions that keep `affectMode` set for configuration + * symmetry while opening no windows at all. + */ +export const RECOGNISED_AFFECT_WINDOW_SCHEDULES: readonly string[] = [ + 'never', + 'every-turn', + 'every--turns', +]; + +export interface AffectWindowSchedule { + /** The literal configuration string this schedule was parsed from. */ + readonly schedule: string; + /** Turn period; `undefined` when no window ever opens. */ + readonly everyTurns: number | undefined; +} + +const EVERY_N_TURNS = /^every-([1-9][0-9]{0,3})-turns$/u; + +/** + * Parses `RunConfig.affectWindowSchedule`. Unknown schedules raise + * {@link InvalidAffectWindowScheduleError}: a run may not fall back to a + * different window frequency than the one it pre-registered. + */ +export function parseAffectWindowSchedule( + schedule: string, +): AffectWindowSchedule { + if (schedule === 'never') { + return { schedule, everyTurns: undefined }; + } + if (schedule === 'every-turn') { + return { schedule, everyTurns: 1 }; + } + const match = EVERY_N_TURNS.exec(schedule); + if (match) { + return { schedule, everyTurns: Number(match[1]) }; + } + throw new InvalidAffectWindowScheduleError( + schedule, + RECOGNISED_AFFECT_WINDOW_SCHEDULES, + ); +} + +/** True when the fixed schedule opens a window after turn `turn`'s outcome. */ +export function affectWindowDue( + schedule: AffectWindowSchedule, + turn: number, +): boolean { + if (!Number.isInteger(turn) || turn < 0) { + throw new InvalidAffectWindowError('turn must be a non-negative integer'); + } + const period = schedule.everyTurns; + if (period === undefined || turn === 0) { + return false; + } + return turn % period === 0; +} + +/** SPEC §9.3: deterministic window identity, `w--`. */ +export function affectWindowId(turn: number, sender: BabyRole): string { + return `w-${turn}-${sender}`; +} + +/** + * The window that opens after turn `turn`'s outcome, or `undefined` when the + * fixed schedule opens none. The Baby that acted on the turn is the single + * permitted sender; the other Baby may not answer until its own next window + * (SPEC §9.3 rule 5). + */ +export function affectWindowFor(options: { + schedule: AffectWindowSchedule; + turn: number; + sender: BabyRole; + recipient: BabyRole; +}): AffectWindow | undefined { + if (!affectWindowDue(options.schedule, options.turn)) { + return undefined; + } + return createAffectWindow(options); +} + +/** Builds one well-formed window; the schedule check is the caller's. */ +export function createAffectWindow(options: { + turn: number; + sender: BabyRole; + recipient: BabyRole; +}): AffectWindow { + assertWindowRoles(options.sender, options.recipient); + if (!Number.isInteger(options.turn) || options.turn < 0) { + throw new InvalidAffectWindowError('turn must be a non-negative integer'); + } + return { + windowId: affectWindowId(options.turn, options.sender), + turn: options.turn, + sender: options.sender, + recipient: options.recipient, + opensAfter: AFFECT_WINDOW_TRIGGER, + }; +} + +/** Structural validation of a window handed in by the runtime. */ +export function assertValidAffectWindow(window: AffectWindow): void { + if (typeof window.windowId !== 'string' || window.windowId.length === 0) { + throw new InvalidAffectWindowError('windowId must be a non-empty string'); + } + if (!Number.isInteger(window.turn) || window.turn < 0) { + throw new InvalidAffectWindowError('turn must be a non-negative integer'); + } + assertWindowRoles(window.sender, window.recipient); + if (window.opensAfter !== AFFECT_WINDOW_TRIGGER) { + throw new InvalidAffectWindowError( + `opensAfter must be "${AFFECT_WINDOW_TRIGGER}" (SPEC §9.3 rule 2)`, + ); + } + if (window.windowId !== affectWindowId(window.turn, window.sender)) { + throw new InvalidAffectWindowError( + 'windowId must be the deterministic w-- identity', + ); + } +} + +function assertWindowRoles(sender: BabyRole, recipient: BabyRole): void { + if (sender !== 'baby-a' && sender !== 'baby-b') { + throw new InvalidAffectWindowError('sender must be a Baby role'); + } + if (recipient !== 'baby-a' && recipient !== 'baby-b') { + throw new InvalidAffectWindowError('recipient must be a Baby role'); + } + if (sender === recipient) { + throw new InvalidAffectWindowError('sender and recipient must differ'); + } +} + +/** + * SPEC §6.3: `submit_affect` is available only inside an open window, and + * never at all under `derived` (the Gateway maps a private measurement + * instead) or `emergent` (no Affect Event is produced). This is the helper the + * Nursery Controller uses to build `TurnBudget.availableActions`, so the + * "derived mode disables `submit_affect`" rule of ALD-033 criterion 2 has one + * implementation shared by the runtime and the Gateway's own checks. + */ +export function affectActionAvailable( + affectMode: RunConfig['affectMode'], + windowOpen: boolean, +): boolean { + if (!windowOpen) { + return false; + } + return ( + affectMode === 'declared' || + affectMode === 'permuted' || + affectMode === 'opaque' + ); +} diff --git a/packages/gateway/src/affect.ts b/packages/gateway/src/affect.ts new file mode 100644 index 0000000..f12098e --- /dev/null +++ b/packages/gateway/src/affect.ts @@ -0,0 +1,668 @@ +/** + * The six-display affect protocol (SPECIFICATION.md §9.3, §11.6; ALD-033). + * + * All seven §9.3 rules are enforced here, in one place, so `SymbolGatewayImpl` + * only delegates: + * + * 1. the display occupies its own typed field — an `AffectEvent` on the + * `affect` stream — and can never appear inside a `symbols`/`strokes` + * payload: the affect surface is a separate method that accepts exactly + * `{ kind: 'submit_affect', publicArtifact: { displayId } }` and nothing + * else, and the carrier modules never see it; + * 2. a window opens only through {@link AffectProtocol.openWindow}, which the + * runtime calls after a Gateway-defined outcome and which refuses a turn the + * fixed `affectWindowSchedule` does not allow (no Baby-chosen or extra + * windows); + * 3. exactly one allowlisted `A1`-`A6` per open window — sequences, + * repetitions, combinations, custom forms, and extra fields are rejections; + * 4. delivery timing and envelope size normalization is the runtime's and + * `@ald/isolation`'s job (§10.3); what this module contributes is a + * *constant-shape* result and a constant-size submission — + * {@link normalizedAffectSubmission}, {@link AFFECT_ACCEPTED_RESULT_KEYS}, + * {@link AFFECT_REJECTED_RESULT_KEYS} — and nothing more. This module makes + * no timing claim of any kind; + * 5. one submission per window, by the window's single permitted sender: any + * submission (accepted *or* rejected) consumes the window, so there is no + * variable retry count (§10.3) and the recipient cannot answer until its own + * next window; + * 6. every violation is committed as `channel.rejected` with reason + * `affect-violation` through the one ALD-034 rejection framework, so it + * shares the payload-hash-only shape, the consecutive-rejection counter, and + * the automatic-pause policy with every other channel violation; + * 7. the §15.3 leakage estimator is `@ald/analysis`'s `evaluateAffectLeakage`; + * what this module guarantees is that the data it needs — the sender's + * chosen display per window — is recorded for every accepted window. + * + * Mode notes (SPEC §9.3, CONCEPT-IDEA.md §19.1): + * + * - `declared` — the chosen display is delivered as chosen; + * - `permuted` — a per-run seeded permutation of `A1..A6` is applied between + * the sender's choice and the recipient's delivery. The `AffectEvent` + * records the **sender's chosen** display (that is the quantity §9.3 rule 7 + * and E20 analyse, and the Evidence Store is inside the Nursery trust zone, + * never Baby-readable); the delivered value is returned to the runtime only + * and is reproducible from the run seed. The permutation itself is never + * returned to a Baby-facing caller; + * - `opaque` — byte-for-byte identical Gateway behaviour to `declared`; the + * difference is only in the Baby-facing display labels the runtime and the + * learner contract supply, which the Gateway never sees. The tests assert + * this identity rather than asserting a semantic difference; + * - `derived` — `submit_affect` is unavailable ({@link affectActionAvailable} + * returns `false`, and a submission is an `affect-violation`). The runtime + * passes the adapter's private `AffectStateMeasurement` to + * {@link AffectProtocol.recordDerivedAffect}; the complete measurement is + * kept privately and returned to the runtime for a private ledger/audit + * record, and only the fixed pre-registered mapping's single display reaches + * the public `AffectEvent`; + * - `emergent` — no `AffectEvent` is ever produced. The display is an artifact + * of the selected alternate carrier and is an ordinary `ChannelEvent`; + * {@link tagEmergentAffect} produces the analysis-side tag, which lives in + * analysis and never in a signed event. + * + * Research-integrity note: nothing here claims that a Baby has an affective + * state. The protocol constrains a six-valued side channel and records what was + * chosen; `derived` mode records an adapter's self-reported scores. Both are + * software behaviour (Prototype Mode), not a research finding. + */ +import { + AffectStateMeasurementSchema, + AFFECT_DISPLAY_IDS, + HASH_DOMAINS, + type AffectDisplayId, + type AffectEvent, + type AffectStateMeasurement, + type AffectSubmitResult, + type AffectWindow, + type BabyRole, + type ChannelEvent, + type EvidenceWriter, + type GatewayRunContext, + type RunConfig, + type Sha256Hash, +} from '@ald/types'; +import { SeededPrng, hashCanonical } from '@ald/hashing'; + +import { + AffectDisabledError, + AffectModeMismatchError, + AffectWindowNotOpenError, + InvalidAffectWindowError, +} from './affect-errors.js'; +import { + AFFECT_DISPLAY_COUNT, + SUPPORTED_MEASUREMENT_VERSIONS, + resolveAffectDerivedMapping, + type AffectDerivedMapping, +} from './affect-mapping.js'; +import { + affectActionAvailable, + affectWindowDue, + assertValidAffectWindow, + parseAffectWindowSchedule, + type AffectWindowSchedule, +} from './affect-windows.js'; +import { isPlainObject, isWithinComplexityBudget } from './inspect.js'; + +/** + * Domain separator for the private measurement digest of `derived` mode. + * + * The separator is centralized in `HASH_DOMAINS`; this alias remains part of + * the Gateway's public API for callers that need to reproduce the digest. + */ +export const AFFECT_MEASUREMENT_HASH_DOMAIN = HASH_DOMAINS.affectMeasurement; + +/** SPEC §9.3 rule 6: the single reason code for every affect violation. */ +export const AFFECT_VIOLATION_REASON = 'affect-violation' as const; + +/** Exact key set of an accepted `submit_affect` result (constant shape). */ +export const AFFECT_ACCEPTED_RESULT_KEYS: readonly string[] = [ + 'affectEvent', + 'deliveredDisplayId', + 'kind', +]; + +/** Exact key set of a rejected affect result, for every violation alike. */ +export const AFFECT_REJECTED_RESULT_KEYS: readonly string[] = [ + 'channelEvent', + 'consecutiveRejections', + 'kind', + 'pauseRequested', + 'reasonCode', + 'rejectedPayloadHash', +]; + +/** + * The canonical, constant-size submission a Baby makes inside an open window. + * Every one of the six displays produces the same canonical byte length, which + * is the envelope-size half of §9.3 rule 4 that the Gateway can guarantee on + * its own. + */ +export function normalizedAffectSubmission(displayId: AffectDisplayId): { + kind: 'submit_affect'; + publicArtifact: { displayId: AffectDisplayId }; +} { + return { kind: 'submit_affect', publicArtifact: { displayId } }; +} + +/** What `commitRejection` returns; mirrors the ALD-034 rejection framework. */ +export interface AffectRejectionCommit { + channelEvent: ChannelEvent; + reasonCode: string; + rejectedPayloadHash: Sha256Hash; + consecutiveRejections: number; + pauseRequested: boolean; +} + +/** + * The seam between the affect protocol and the Gateway core. The protocol + * never writes evidence itself except through `evidence.appendAffectEvent`, + * and never counts rejections itself: `commitAffectRejection` is the Gateway's + * one rejection path (ALD-034), which owns the counter and the pause policy. + */ +export interface AffectProtocolHost { + readonly runContext: GatewayRunContext; + readonly evidence: EvidenceWriter; + commitAffectRejection( + turn: number, + sender: BabyRole, + payload: unknown, + ): Promise; + /** Window-close timestamp source; injected so tests are deterministic. */ + now(): string; +} + +/** + * The private `derived`-mode record. It carries the *complete* measurement, so + * it must never be written to a public event or a Baby-facing surface — the + * runtime appends it as a private ledger/audit record (SPEC §9.3 "records the + * complete internal measurement privately"). + */ +export interface DerivedAffectRecord { + windowId: string; + turn: number; + sender: BabyRole; + /** Name of the fixed pre-registered mapping that produced the display. */ + mapping: string; + measurement: AffectStateMeasurement; + /** Domain-separated digest of the canonical measurement. */ + measurementHash: Sha256Hash; + /** The display the mapping selected, i.e. the public `AffectEvent` value. */ + displayId: AffectDisplayId; +} + +/** + * `recordDerivedAffect`'s result: an `AffectSubmitResult` plus, when accepted, + * the private measurement record for the runtime to persist privately. The + * extra field is not part of the public `AffectEvent` and is never delivered + * to a Baby. + */ +export type DerivedAffectResult = AffectSubmitResult & { + privateMeasurement?: DerivedAffectRecord; +}; + +/** The analysis-side tag of `affectMode: "emergent"` (SPEC §9.3). */ +export const EMERGENT_AFFECT_ANALYSIS_TAG = 'emergent-affect' as const; + +export interface EmergentAffectTag { + analysisTag: typeof EMERGENT_AFFECT_ANALYSIS_TAG; + channelEventHash: Sha256Hash; + turn: number; + sender: BabyRole; +} + +/** + * SPEC §9.3 `emergent`: "no `AffectEvent` is produced. The display is an + * artifact of the selected alternate carrier and is recorded as an ordinary + * `ChannelEvent` with analysis tag `emergent-affect`". The tag is derived + * from an already-committed channel event and is *not* part of the signed + * event, so tagging cannot alter evidence. + */ +export function tagEmergentAffect(channelEvent: ChannelEvent): EmergentAffectTag { + return { + analysisTag: EMERGENT_AFFECT_ANALYSIS_TAG, + channelEventHash: channelEvent.entryHash, + turn: channelEvent.turn, + sender: channelEvent.logicalSender, + }; +} + +/** True when the run's mode produces `AffectEvent`s at all (SPEC §11.6). */ +export function producesAffectEvents( + affectMode: RunConfig['affectMode'], +): boolean { + return ( + affectMode === 'declared' || + affectMode === 'permuted' || + affectMode === 'opaque' || + affectMode === 'derived' + ); +} + +/** + * Fails fast on an affect configuration the protocol cannot honour. The + * Nursery Controller should call this when it builds the run context, so an + * unregistered `affectDerivedMapping` or an unrecognised `affectWindowSchedule` + * is refused before turn 1 rather than on the first window. + */ +export function assertAffectConfiguration(config: RunConfig): void { + if (config.affectMode === 'none') { + return; + } + parseAffectWindowSchedule(config.affectWindowSchedule); + if (config.affectMode === 'derived') { + resolveAffectDerivedMapping(config.affectDerivedMapping); + } +} + +type WindowStatus = 'open' | 'consumed'; + +interface WindowState { + window: AffectWindow; + status: WindowStatus; +} + +/** How many past windows and private measurements are retained in memory. */ +const WINDOW_HISTORY_LIMIT = 4096; + +/** The affect mode of an `AffectEvent` (SPEC §11.6 excludes none/emergent). */ +type RecordedAffectMode = AffectEvent['affectMode']; + +export class AffectProtocol { + readonly mode: RunConfig['affectMode']; + private readonly schedule: AffectWindowSchedule; + private readonly mapping: AffectDerivedMapping | undefined; + /** + * `permuted` only. Derived from the run seed through the same labelled + * `SeededPrng.derive` chain the §9.6 controls use (`seed → 'affect' → + * 'permutation'`), so a replay from the registered seed reproduces it + * exactly (SPEC §14.3). It is deranged, so no display maps to itself and + * `permuted` differs from `declared` for every display — an + * implementation-defined choice recorded in BACKLOG §15. + */ + private readonly permutation: readonly number[] | undefined; + private readonly windows = new Map(); + private readonly measurements = new Map(); + + constructor(private readonly host: AffectProtocolHost) { + const config = host.runContext.config; + this.mode = config.affectMode; + if (this.mode === 'none') { + throw new AffectDisabledError(); + } + this.schedule = parseAffectWindowSchedule(config.affectWindowSchedule); + this.mapping = + this.mode === 'derived' + ? resolveAffectDerivedMapping(config.affectDerivedMapping) + : undefined; + this.permutation = + this.mode === 'permuted' + ? derangedPermutation(host.runContext.seed) + : undefined; + } + + // ------------------------------------------------------------------------- + // Window discipline (SPEC §9.3 rules 2 and 5) + // ------------------------------------------------------------------------- + + /** + * Opens the window the fixed schedule allows after `window.turn`'s outcome. + * Any previously open window is closed first: a Baby that did not answer in + * its window has no second chance at it (rule 5), and a window the schedule + * does not allow is refused outright (rule 2, "never at a Baby-chosen + * arbitrary point"). + */ + openWindow(window: AffectWindow): AffectWindow { + assertValidAffectWindow(window); + if (!affectWindowDue(this.schedule, window.turn)) { + throw new InvalidAffectWindowError( + `the fixed schedule ${this.schedule.schedule} opens no window after turn ${window.turn}`, + ); + } + const existing = this.windows.get(window.windowId); + if (existing !== undefined) { + throw new InvalidAffectWindowError( + `window ${window.windowId} was already opened once`, + ); + } + for (const state of this.windows.values()) { + state.status = 'consumed'; + } + this.windows.set(window.windowId, { window, status: 'open' }); + this.evict(this.windows); + return window; + } + + /** The currently open window, if any. */ + openWindowState(): AffectWindow | undefined { + for (const state of this.windows.values()) { + if (state.status === 'open') { + return state.window; + } + } + return undefined; + } + + /** SPEC §6.3: whether `submit_affect` belongs in `availableActions` now. */ + affectActionAvailable(): boolean { + return affectActionAvailable(this.mode, this.openWindowState() !== undefined); + } + + // ------------------------------------------------------------------------- + // Baby submissions: declared / permuted / opaque (SPEC §9.3 rules 1, 3, 6) + // ------------------------------------------------------------------------- + + /** + * The `SymbolGateway.submitAffect` contract shape. The submitting Baby is + * the window's single permitted sender; the runtime must route only that + * Baby's submission here. Use {@link submitAffectFrom} when the caller knows + * the actual submitter and wants rule 5 enforced against an impostor. + */ + submitAffect( + window: AffectWindow, + proposal: unknown, + ): Promise { + return this.submitAffectFrom(window.sender, window, proposal); + } + + /** + * One submission attempt by `sender`. Everything a Baby can get wrong is a + * rejection, never a thrown error: wrong mode, wrong window, wrong sender, + * wrong shape, a sequence, a repetition, a combination, an extra field, a + * string, or a code point outside `A1`-`A6`. + */ + async submitAffectFrom( + sender: BabyRole, + window: AffectWindow, + proposal: unknown, + ): Promise { + assertValidAffectWindow(window); + + // `derived` and `emergent` have no Baby-facing affect surface at all + // (ALD-033 criterion 2). A submission under either mode is a violation. + if (this.mode === 'derived' || this.mode === 'emergent') { + return this.rejectAffect(window, sender, proposal); + } + + const state = this.windows.get(window.windowId); + if ( + state === undefined || + state.status !== 'open' || + state.window.sender !== window.sender || + state.window.recipient !== window.recipient || + state.window.turn !== window.turn + ) { + // Out-of-window, unknown-window, or already-answered window. + return this.rejectAffect(window, sender, proposal); + } + + // Rule 5: only the window's sender may submit in it. + if (sender !== window.sender) { + return this.rejectAffect(window, sender, proposal, state); + } + + const displayId = readAffectSubmission(proposal); + if (displayId === undefined) { + return this.rejectAffect(window, sender, proposal, state); + } + + // Accepted: the window is spent either way (§10.3 no variable retry). + state.status = 'consumed'; + const recordedMode = this.mode as RecordedAffectMode; + const affectEvent = await this.host.evidence.appendAffectEvent({ + runId: this.host.runContext.runId, + turn: window.turn, + windowId: window.windowId, + sender: window.sender, + displayId, + affectMode: recordedMode, + deliveredAt: this.host.now(), + }); + + return { + kind: 'accepted', + affectEvent, + deliveredDisplayId: this.deliveredDisplay(displayId), + }; + } + + // ------------------------------------------------------------------------- + // Derived mode (SPEC §9.3, ALD-033 criterion 2) + // ------------------------------------------------------------------------- + + /** + * Records the adapter's private measurement and applies the fixed + * pre-registered mapping. The complete measurement is returned in + * `privateMeasurement` for the runtime to store privately and never reaches + * the public `AffectEvent`. + * + * A measurement that fails `AffectStateMeasurementSchema`, carries a + * non-finite score, or declares an unsupported `measurementVersion` is a + * *channel violation*, not a thrown error: the measurement is adapter output + * and crosses the same trust boundary as a proposal, so it is committed as + * `channel.rejected` with a payload hash and no content. + */ + async recordDerivedAffect( + window: AffectWindow, + measurement: AffectStateMeasurement, + ): Promise { + assertValidAffectWindow(window); + if (this.mode !== 'derived') { + throw new AffectModeMismatchError(this.mode, ['derived']); + } + const state = this.windows.get(window.windowId); + if (state === undefined || state.status !== 'open') { + throw new AffectWindowNotOpenError(window.windowId); + } + + const validated = readMeasurement(measurement); + if (validated === undefined) { + return this.rejectAffect(window, window.sender, measurement, state); + } + + state.status = 'consumed'; + const mapping = this.mapping as AffectDerivedMapping; + const displayId = mapping.map(validated); + const record: DerivedAffectRecord = { + windowId: window.windowId, + turn: window.turn, + sender: window.sender, + mapping: mapping.name, + measurement: validated, + measurementHash: hashCanonical( + AFFECT_MEASUREMENT_HASH_DOMAIN, + validated, + ), + displayId, + }; + this.measurements.set(window.windowId, record); + this.evict(this.measurements); + + const affectEvent = await this.host.evidence.appendAffectEvent({ + runId: this.host.runContext.runId, + turn: window.turn, + windowId: window.windowId, + sender: window.sender, + displayId, + affectMode: 'derived', + deliveredAt: this.host.now(), + }); + + return { + kind: 'accepted', + affectEvent, + deliveredDisplayId: displayId, + privateMeasurement: record, + }; + } + + /** The private measurement recorded for one window, if still retained. */ + privateMeasurementFor(windowId: string): DerivedAffectRecord | undefined { + return this.measurements.get(windowId); + } + + /** Drains the retained private measurements; the runtime persists them. */ + takePrivateMeasurements(): DerivedAffectRecord[] { + const drained = [...this.measurements.values()]; + this.measurements.clear(); + return drained; + } + + // ------------------------------------------------------------------------- + // Researcher-only accessors + // ------------------------------------------------------------------------- + + /** + * The `permuted`-mode display permutation as `A1..A6` indices. + * + * Researcher-only: this is ground truth. It MUST NOT be given to a Baby + * context, put in a Baby-visible observation, or written to any Baby-facing + * surface — knowing it collapses `permuted` onto `declared`. + */ + researcherOnlyPermutation(): readonly number[] | undefined { + return this.permutation === undefined ? undefined : [...this.permutation]; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** `permuted` applies the run's private permutation; every other mode does not. */ + private deliveredDisplay(chosen: AffectDisplayId): AffectDisplayId { + if (this.permutation === undefined) { + return chosen; + } + const index = AFFECT_DISPLAY_IDS.indexOf(chosen); + const mapped = this.permutation[index] as number; + return AFFECT_DISPLAY_IDS[mapped] as AffectDisplayId; + } + + private async rejectAffect( + window: AffectWindow, + sender: BabyRole, + payload: unknown, + state?: WindowState, + ): Promise> { + if (state !== undefined) { + state.status = 'consumed'; + } + // A payload past the §9.4 complexity budget is hashed as a fixed marker + // rather than canonicalized, for the same reason the proposal path bounds + // complexity before hashing: an adversarial adapter must not be able to + // exhaust the stack instead of failing a shape check. + const hashable = isWithinComplexityBudget(payload) + ? payload + : { affectPayload: 'too-complex' }; + const commit = await this.host.commitAffectRejection( + window.turn, + sender, + hashable, + ); + return { + kind: 'rejected', + channelEvent: commit.channelEvent, + reasonCode: commit.reasonCode, + rejectedPayloadHash: commit.rejectedPayloadHash, + consecutiveRejections: commit.consecutiveRejections, + pauseRequested: commit.pauseRequested, + }; + } + + private evict(map: Map): void { + while (map.size > WINDOW_HISTORY_LIMIT) { + const oldest = map.keys().next(); + if (oldest.done === true) { + return; + } + map.delete(oldest.value); + } + } +} + +/** + * SPEC §9.3 rules 1 and 3. Returns the single allowlisted display, or + * `undefined` for every violation: a non-object, an extra proposal or artifact + * field, a wrong `kind`, a missing or non-string `displayId`, an array + * (sequence or repetition), an object (combination), a custom form, or a code + * point outside `A1`-`A6`. + * + * The raw payload is inspected, never zod-parsed first, so an unknown key is + * detected before a schema could strip it (§11.3). + */ +function readAffectSubmission(proposal: unknown): AffectDisplayId | undefined { + if (!isWithinComplexityBudget(proposal) || !isPlainObject(proposal)) { + return undefined; + } + const keys = Object.keys(proposal); + if (keys.length !== 2 || !('kind' in proposal) || !('publicArtifact' in proposal)) { + return undefined; + } + if (proposal.kind !== 'submit_affect') { + return undefined; + } + const artifact = proposal.publicArtifact; + if (!isPlainObject(artifact)) { + return undefined; + } + const artifactKeys = Object.keys(artifact); + if (artifactKeys.length !== 1 || artifactKeys[0] !== 'displayId') { + return undefined; + } + const displayId = artifact.displayId; + if (typeof displayId !== 'string') { + return undefined; + } + return (AFFECT_DISPLAY_IDS as readonly string[]).includes(displayId) + ? (displayId as AffectDisplayId) + : undefined; +} + +/** + * Validates an adapter-supplied `AffectStateMeasurement`: exactly the schema's + * fields, six finite scores, and a supported `measurementVersion`. Returns the + * parsed value or `undefined`; the caller turns `undefined` into an + * `affect-violation`. + */ +function readMeasurement( + measurement: unknown, +): AffectStateMeasurement | undefined { + if (!isWithinComplexityBudget(measurement) || !isPlainObject(measurement)) { + return undefined; + } + const keys = Object.keys(measurement); + if ( + keys.length !== 2 || + !('measurementVersion' in measurement) || + !('scores' in measurement) + ) { + return undefined; + } + const parsed = AffectStateMeasurementSchema.safeParse(measurement); + if (!parsed.success) { + return undefined; + } + const value = parsed.data; + if (!SUPPORTED_MEASUREMENT_VERSIONS.includes(value.measurementVersion)) { + return undefined; + } + if (value.scores.some((score) => !Number.isFinite(score))) { + return undefined; + } + return value; +} + +/** + * A seeded derangement of `[0..5]`: a permutation in which no display maps to + * itself. Derived deterministically from the run seed; the rotation fallback + * is a derangement for every size >= 2, so the function always terminates. + */ +function derangedPermutation(seed: string): readonly number[] { + const prng = new SeededPrng(seed).derive('affect').derive('permutation'); + const indices = Array.from({ length: AFFECT_DISPLAY_COUNT }, (_, i) => i); + for (let attempt = 0; attempt < 32; attempt += 1) { + const candidate = prng.shuffle(indices); + if (candidate.every((value, index) => value !== index)) { + return candidate; + } + } + return indices.map((value) => (value + 1) % AFFECT_DISPLAY_COUNT); +} diff --git a/packages/gateway/src/carrier-modules.ts b/packages/gateway/src/carrier-modules.ts new file mode 100644 index 0000000..0023eb6 --- /dev/null +++ b/packages/gateway/src/carrier-modules.ts @@ -0,0 +1,386 @@ +/** + * Protocol-module contract and registry (SPECIFICATION.md §9.1-§9.3, + * ALD-030, ALD-034 criterion 3). + * + * The Gateway core knows nothing about symbols, strokes, or affect displays: + * it routes, applies the §9.6 communication control, and commits evidence. A + * carrier module owns exactly one `carrierMode` and answers three questions — + * which proposal kinds it offers, whether a raw proposal is admissible, and + * how to synthesise a valid artifact for the `constant` and `random` + * controls. Registering the later ALD-031/ALD-033 modules therefore cannot + * change the rejection event shape or the pause policy, which is what + * ALD-034 criterion 3 requires. + * + * Modules see the *raw* proposal, not a zod-parsed one, because unknown-key + * detection has to happen before a schema strips them (§11.3). + */ +import type { AgentActionProposal, GatewayRunContext, RunConfig } from '@ald/types'; +import type { SeededPrng } from '@ald/hashing'; + +import { GatewayError, UnsupportedCarrierError } from './errors.js'; +import { containsString, isPlainObject } from './inspect.js'; +import type { GatewayReasonCode } from './reason-codes.js'; + +/** + * A registered module claims a different tool family than its `carrierMode` + * owns (SPEC §9.6 "exactly one carrier family per run", ALD-031 criterion 3). + * A configuration fault, never a Baby channel violation, so it is raised + * rather than committed as `channel.rejected`. + * + * It lives here rather than in `errors.ts` because it is produced only by + * this module's registry checks; the §12.3 mapping in `toGatewayError` + * handles it through its `GatewayError` base like every other Gateway error. + */ +export class InvalidCarrierFamilyError extends GatewayError { + constructor( + readonly carrier: RunConfig['carrierMode'], + readonly expectedKinds: readonly string[], + readonly actualKinds: readonly string[], + ) { + super( + 'INVALID_REQUEST', + `The module registered for carrier ${carrier} offers [${actualKinds.join(', ')}] but that carrier's family is [${expectedKinds.join(', ')}]`, + { carrier, expectedKinds: [...expectedKinds], actualKinds: [...actualKinds] }, + ); + } +} + +export type PublicArtifact = AgentActionProposal['publicArtifact']; +export type ActionKind = AgentActionProposal['kind']; + +/** SPEC §9.1: the Gateway ceiling on message length, regardless of configuration. */ +export const ABSOLUTE_MAX_SYMBOLS_PER_MESSAGE = 16; +/** SPEC §9.1 default `maxSymbolsPerMessage`. */ +export const DEFAULT_MAX_SYMBOLS_PER_MESSAGE = 4; +/** SPEC §9.1 default `maxSymbolRepeats` (consecutive identical symbols). */ +export const DEFAULT_MAX_SYMBOL_REPEATS = 3; + +/** Effective length cap: the configured value clamped to the §9.1 ceiling. */ +export function maxSymbolsFor(config: RunConfig): number { + return Math.min( + config.maxSymbolsPerMessage ?? DEFAULT_MAX_SYMBOLS_PER_MESSAGE, + ABSOLUTE_MAX_SYMBOLS_PER_MESSAGE, + ); +} + +/** Run-scoped inputs a module needs; it holds no per-run state of its own. */ +export interface CarrierContext { + readonly runContext: GatewayRunContext; + readonly maxSymbolRepeats: number; +} + +export type CarrierValidationResult = + | { ok: true; artifact: PublicArtifact } + | { ok: false; reasonCode: GatewayReasonCode; detail: string }; + +export interface CarrierModule { + readonly carrier: RunConfig['carrierMode']; + readonly allowedKinds: readonly ActionKind[]; + /** + * Validates a raw `{ kind, publicArtifact }` object. `kind` membership is + * checked by the Gateway before this call; a module only judges the + * artifact and any unexpected proposal-level fields. On success it returns + * the normalized artifact that will be hashed and delivered. + * + * `detail` is an internal diagnostic: it MUST NOT contain any part of the + * submitted payload (SPEC §9.4). + */ + validate(proposal: unknown, context: CarrierContext): CarrierValidationResult; + /** SPEC §9.6 `random`: a valid artifact drawn from a seeded stream. */ + randomArtifact(prng: SeededPrng, context: CarrierContext): PublicArtifact; + /** SPEC §9.6 `constant`: the module's default pre-registered artifact. */ + constantArtifact(context: CarrierContext): PublicArtifact; +} + +/** + * A rejection from inside a carrier module. Exported so the SPEC §9.2 + * alternate-carrier modules in `carriers/` build their failures the same way + * the fixed-token module does, rather than re-deriving the shape (ALD-031, + * ALD-034 criterion 3). + */ +export function fail( + reasonCode: GatewayReasonCode, + detail: string, +): CarrierValidationResult { + return { ok: false, reasonCode, detail }; +} + +/** + * An extra field is a free-text carrier when it holds a string anywhere, and + * a plain schema violation otherwise (SPEC §9.1: "any accompanying free + * text ... is rejected"). + * + * This is what makes a color field or a semantic tag on a bitmap, canvas, or + * tone artifact rejectable without the module having to enumerate the field + * names a Baby might invent (SPEC §9.2: "Bitmap and canvas carriers have no + * color or text field"): `{"color": "#ff0000"}` is `free-text-present`, + * `{"colorIndex": 3}` is `unexpected-artifact-field`. + */ +export function extraFieldFailure( + container: Record, + extraKeys: readonly string[], + where: string, +): CarrierValidationResult { + const carriesText = extraKeys.some((key) => containsString(container[key])); + return fail( + carriesText ? 'free-text-present' : 'unexpected-artifact-field', + `${where} has ${extraKeys.length} unexpected field(s)`, + ); +} + +/** + * Shape of a bare inventory token: one to three letters followed by one to + * three digits (`S01`, `S256`, `G07`). Applied only to symbols that are *not* + * in the declared inventory, so a run may declare any inventory it likes and + * the allowlist stays the primary control (SPEC §9.1, resolves Q6). A + * non-inventory string that does not even have token shape — prose, a URL, + * padded whitespace, Unicode look-alikes — is reported as free text rather + * than as an allowlist miss. + */ +export const BARE_TOKEN_PATTERN = /^[A-Za-z]{1,3}[0-9]{1,3}$/u; + +export function trailingRepeats( + symbols: readonly string[], + candidate: string, +): number { + let count = 0; + for (let index = symbols.length - 1; index >= 0; index -= 1) { + if (symbols[index] !== candidate) { + break; + } + count += 1; + } + return count; +} + +/** + * SPEC §9.1 fixed-token protocol (ALD-030). The default and only carrier + * registered out of the box. + * + * Validation order is fixed so the reason code for a given payload is + * deterministic: proposal fields, artifact fields, `symbols` shape, length, + * allowlist/free-text per symbol, then consecutive repeats. + */ +export const fixedTokenModule: CarrierModule = { + carrier: 'fixed-token', + allowedKinds: ['emit_symbols'], + + validate(proposal, context) { + if (!isPlainObject(proposal)) { + return fail('invalid-envelope', 'proposal is not an object'); + } + + const extraProposalKeys = Object.keys(proposal).filter( + (key) => key !== 'kind' && key !== 'publicArtifact', + ); + if (extraProposalKeys.length > 0) { + return extraFieldFailure(proposal, extraProposalKeys, 'proposal'); + } + + const artifact = proposal.publicArtifact; + if (!isPlainObject(artifact)) { + return fail('invalid-envelope', 'publicArtifact is not an object'); + } + + const extraArtifactKeys = Object.keys(artifact).filter( + (key) => key !== 'symbols', + ); + if (extraArtifactKeys.length > 0) { + return extraFieldFailure(artifact, extraArtifactKeys, 'publicArtifact'); + } + + const symbols = artifact.symbols; + if (!Array.isArray(symbols)) { + return fail('invalid-envelope', 'publicArtifact.symbols is not an array'); + } + if (symbols.length === 0) { + return fail('empty-message', 'publicArtifact.symbols is empty'); + } + + const cap = maxSymbolsFor(context.runContext.config); + if (symbols.length > cap) { + return fail( + 'message-too-long', + `message carries ${symbols.length} symbols; the cap is ${cap}`, + ); + } + + const inventory = new Set(context.runContext.symbolInventory); + for (let index = 0; index < symbols.length; index += 1) { + const symbol: unknown = symbols[index]; + if (typeof symbol !== 'string') { + return fail('invalid-envelope', `symbol at index ${index} is not a string`); + } + if (inventory.has(symbol)) { + continue; + } + if (!BARE_TOKEN_PATTERN.test(symbol)) { + return fail( + 'free-text-present', + `symbol at index ${index} is not a bare inventory token`, + ); + } + return fail( + 'symbol-not-in-inventory', + `symbol at index ${index} is not in the declared inventory`, + ); + } + + const accepted = symbols as string[]; + let run = 1; + for (let index = 1; index < accepted.length; index += 1) { + run = accepted[index] === accepted[index - 1] ? run + 1 : 1; + if (run > context.maxSymbolRepeats) { + return fail( + 'symbol-repeat-limit', + `more than ${context.maxSymbolRepeats} consecutive identical symbols ending at index ${index}`, + ); + } + } + + return { ok: true, artifact: { symbols: [...accepted] } }; + }, + + /** + * Uniform length in `[1, maxSymbolsPerMessage]` and uniform symbols, with + * candidates that would break the consecutive-repeat limit resampled so the + * result is always an artifact the same module accepts. Length is drawn + * first, then one symbol per position, so the stream stays comparable across + * runs that share a seed but differ in inventory size (RESEARCH.md + * Appendix D.5 condition 3). + */ + randomArtifact(prng, context) { + const inventory = context.runContext.symbolInventory; + const cap = maxSymbolsFor(context.runContext.config); + const length = 1 + prng.nextInt(cap); + const symbols: string[] = []; + while (symbols.length < length) { + const candidate = inventory[prng.nextInt(inventory.length)] as string; + if (trailingRepeats(symbols, candidate) >= context.maxSymbolRepeats) { + continue; + } + symbols.push(candidate); + } + return { symbols }; + }, + + /** The first inventory symbol, unless the run pre-registers another artifact. */ + constantArtifact(context) { + return { symbols: [context.runContext.symbolInventory[0] as string] }; + }, +}; + +const BUILT_IN_MODULES: readonly CarrierModule[] = [fixedTokenModule]; + +const modules = new Map( + BUILT_IN_MODULES.map((module) => [module.carrier, module]), +); + +/** + * Registers a protocol module for its carrier, replacing any previous + * registration. ALD-031/ALD-033 modules register here; nothing else in the + * Gateway changes. + */ +export function registerCarrierModule(module: CarrierModule): void { + modules.set(module.carrier, module); +} + +/** The module for `carrier`, or {@link UnsupportedCarrierError}. */ +export function carrierModule( + carrier: RunConfig['carrierMode'], +): CarrierModule { + const module = modules.get(carrier); + if (!module) { + throw new UnsupportedCarrierError(carrier); + } + return module; +} + +export function registeredCarriers(): RunConfig['carrierMode'][] { + return [...modules.keys()].sort(); +} + +/** Test helper: drop every registration and restore the built-in modules. */ +export function resetCarrierModules(): void { + modules.clear(); + for (const module of BUILT_IN_MODULES) { + modules.set(module.carrier, module); + } +} + +// --------------------------------------------------------------------------- +// Exactly one carrier family per run (SPEC §9.6, ALD-031 criterion 3) +// --------------------------------------------------------------------------- + +/** + * The carrier family each `carrierMode` owns (SPEC §9.1, §9.2). + * + * This is a static table rather than a lookup through the module registry, so + * "which tool is this Baby offered this turn" can be answered — and asserted — + * for a carrier whose module has not been registered in the current process. + * Every entry is a single-element list: SPEC §9.2 makes an alternate carrier + * an experiment condition, and §9.6 requires exactly one carrier family per + * run, so no run ever offers two emit tools at once. + */ +export const CARRIER_ACTION_KINDS: Readonly< + Record +> = { + 'fixed-token': ['emit_symbols'], + 'fixed-glyph': ['emit_glyphs'], + 'generative-bitmap': ['emit_bitmap'], + 'generative-canvas': ['emit_canvas'], + 'generative-tone': ['emit_tones'], +}; + +/** Every emit tool any carrier can offer, in `CarrierModeSchema` order. */ +export const ALL_CARRIER_ACTION_KINDS: readonly ActionKind[] = Object.values( + CARRIER_ACTION_KINDS, +).flat(); + +/** + * `true` when `kind` belongs to `carrier`'s family. A proposal of any other + * carrier's kind is `carrier-mismatch` at the Gateway boundary (SPEC §9.6); + * this predicate is the same judgement made without a registered module. + */ +export function isCarrierActionKind( + carrier: RunConfig['carrierMode'], + kind: string, +): boolean { + return (CARRIER_ACTION_KINDS[carrier] as readonly string[]).includes(kind); +} + +/** + * The emit tools a run's carrier offers (SPEC §6.3: a Baby may act only + * through the tools it is handed). The Nursery Controller builds + * `TurnBudget.availableActions` from the registered module's `allowedKinds`; + * this is the configuration-only form, used to assert that the registered + * module and the configured carrier agree. + */ +export function availableCarrierActions( + config: Pick, +): readonly ActionKind[] { + return CARRIER_ACTION_KINDS[config.carrierMode]; +} + +/** + * ALD-031 criterion 3: assert that the module registered for this run's + * carrier offers exactly that carrier's family and nothing else. Throws + * {@link UnsupportedCarrierError} when no module is registered. + */ +export function assertSingleCarrierFamily( + config: Pick, +): void { + const module = carrierModule(config.carrierMode); + const expected = [...CARRIER_ACTION_KINDS[config.carrierMode]].sort(); + const actual = [...module.allowedKinds].sort(); + const identical = + actual.length === expected.length && + actual.every((kind, index) => kind === expected[index]); + if (!identical) { + throw new InvalidCarrierFamilyError( + config.carrierMode, + expected, + actual, + ); + } +} diff --git a/packages/gateway/src/carriers/bitmap.ts b/packages/gateway/src/carriers/bitmap.ts new file mode 100644 index 0000000..3a84a23 --- /dev/null +++ b/packages/gateway/src/carriers/bitmap.ts @@ -0,0 +1,92 @@ +/** + * `generative-bitmap` protocol module (SPECIFICATION.md §9.2, ALD-031). + * + * §9.2 table row: "Monochrome 16x16 bit matrix", bounded at "256 bits", and + * the §9.2 listing: `bits: Array<0 | 1>` of exactly `gridWidth * gridHeight`. + * The artifact shape is the one `AgentActionProposalSchema` declares — + * `{ bitmap: { bits } }` — and nothing else is admissible: §9.2 states that + * "Bitmap and canvas carriers have no color or text field", so a `color`, + * `alpha`, `label`, or `caption` key is rejected rather than dropped. + * + * The bit count is *exact*, not a maximum. A shorter matrix is + * `bitmap-size-invalid` rather than `empty-message`, because a bitmap carries + * one mark whatever its ink is: an all-zero 256-bit matrix is a legitimate + * (and pre-registered, see `constantArtifact`) mark, while a 200-bit matrix is + * not a bitmap at all. That is a deliberate departure from the mark-list + * carriers, where length is what bounds the message, and is recorded as a + * BACKLOG §15 decision. + */ +import type { CarrierModule } from '../carrier-modules.js'; +import { fail } from '../carrier-modules.js'; +import { BITMAP_BIT_COUNT, isBit } from './bounds.js'; +import { assertNoStrings, readCarrierArtifact, readNestedField } from './marks.js'; + +/** The §9.6 `constant` default: an all-zero matrix (a blank mark). */ +function zeroBits(): (0 | 1)[] { + return new Array<0 | 1>(BITMAP_BIT_COUNT).fill(0); +} + +export const generativeBitmapModule: CarrierModule = { + carrier: 'generative-bitmap', + allowedKinds: ['emit_bitmap'], + + validate(proposal) { + const read = readCarrierArtifact(proposal, 'bitmap'); + if (!read.ok) { + return read.failure; + } + + // SPEC §9.2: this grammar is numeric only. A string anywhere inside it — + // a semantic tag, a color name, a caption smuggled into a cell — is free + // text, checked before the shape so the reason code names the actual + // violation rather than the shape mismatch it also causes. + const text = assertNoStrings(read.value, 'publicArtifact.bitmap'); + if (text !== undefined) { + return text; + } + + const nested = readNestedField(read.value, 'bits', 'publicArtifact.bitmap'); + if (!nested.ok) { + return nested.failure; + } + + const bits = nested.value; + if (!Array.isArray(bits)) { + return fail('invalid-envelope', 'publicArtifact.bitmap.bits is not an array'); + } + if (bits.length !== BITMAP_BIT_COUNT) { + return fail( + 'bitmap-size-invalid', + `bitmap carries ${bits.length} cells; exactly ${BITMAP_BIT_COUNT} are required`, + ); + } + for (let index = 0; index < bits.length; index += 1) { + if (!isBit(bits[index])) { + return fail( + 'bitmap-value-invalid', + `cell at index ${index} is not the literal 0 or 1`, + ); + } + } + + return { ok: true, artifact: { bitmap: { bits: [...(bits as (0 | 1)[])] } } }; + }, + + /** Uniform independent bits: the §9.6 `random` control for this carrier. */ + randomArtifact(prng) { + const bits = Array.from({ length: BITMAP_BIT_COUNT }, () => + prng.nextInt(2) === 1 ? 1 : (0 as 0 | 1), + ); + return { bitmap: { bits } }; + }, + + /** + * SPEC §9.6 `constant` default: the all-zero 256-bit matrix. Chosen because + * it is the unique matrix that carries no ink at all, so a `constant` run + * cannot be confused with a run in which some form happened to recur. + * Recorded as a BACKLOG §15 decision. + */ + constantArtifact() { + return { bitmap: { bits: zeroBits() } }; + }, +}; diff --git a/packages/gateway/src/carriers/bounds.ts b/packages/gateway/src/carriers/bounds.ts new file mode 100644 index 0000000..4bf23fe --- /dev/null +++ b/packages/gateway/src/carriers/bounds.ts @@ -0,0 +1,91 @@ +/** + * The frozen physical grammars of the SPECIFICATION.md §9.2 alternate + * carriers (ALD-031). + * + * Every bound here is transcribed from §9.2's table and its TypeScript + * listing, and duplicated nowhere else in this package: the carrier modules, + * the §9.6 control artifacts, the glyph bundle generator, and the ALD-036 + * conformance vectors all read these constants, so a bound cannot drift + * between the validator that enforces it and the vector that tests it. + * + * A carrier "supplies a bounded physical grammar but no semantic inventory" + * (§9.2): nothing in this file names a meaning, a color, or a label. It only + * says how much can be physically expressed. + */ +import type { RunConfig } from '@ald/types'; + +/** SPEC §9.2: the monochrome bitmap grid, `gridWidth * gridHeight` defaults. */ +export const BITMAP_GRID_WIDTH = 16; +export const BITMAP_GRID_HEIGHT = 16; +/** SPEC §9.2 `generative-bitmap`: exactly 256 bits, never fewer, never more. */ +export const BITMAP_BIT_COUNT = BITMAP_GRID_WIDTH * BITMAP_GRID_HEIGHT; + +/** SPEC §9.2 `generative-canvas`: the quantized stroke grid is 0-15 per axis. */ +export const CANVAS_GRID_MIN = 0; +export const CANVAS_GRID_MAX = 15; +/** SPEC §9.2: quantized pen widths, no color channel. */ +export const CANVAS_STROKE_WIDTHS: readonly [1, 2, 3] = [1, 2, 3]; +/** SPEC §9.2 default `maxStrokes`. */ +export const DEFAULT_MAX_STROKES = 8; +/** SPEC §9.2 / §18: the absolute ceiling regardless of configuration. */ +export const ABSOLUTE_MAX_STROKES = 64; + +/** SPEC §9.2 `generative-tone`: at most eight quantized tones per message. */ +export const MAX_TONES = 8; +/** SPEC §9.2: eight pitch bins, `0`-`7`. */ +export const TONE_PITCH_BINS = 8; +/** SPEC §9.2: four duration bins, `1`-`4`. */ +export const TONE_DURATION_BINS = 4; + +/** SPEC §9.1/§9.2 default inventory size for the two symbolic carriers. */ +export const DEFAULT_SYMBOL_INVENTORY_SIZE = 32; + +/** + * Effective stroke cap: the configured `maxStrokes` clamped to the §9.2 + * ceiling, mirroring how `maxSymbolsFor` clamps `maxSymbolsPerMessage`. A + * run that configures a larger value gets the ceiling, not an error, because + * `RunConfigSchema` already refuses anything above 64. + */ +export function maxStrokesFor(config: Pick): number { + return Math.min(config.maxStrokes ?? DEFAULT_MAX_STROKES, ABSOLUTE_MAX_STROKES); +} + +/** `true` for an integer inside the closed quantized grid range. */ +export function isGridCoordinate(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= CANVAS_GRID_MIN && + value <= CANVAS_GRID_MAX + ); +} + +/** `true` for one of the three quantized pen widths. */ +export function isStrokeWidth(value: unknown): value is 1 | 2 | 3 { + return value === 1 || value === 2 || value === 3; +} + +/** `true` for a `0`-`7` pitch bin. */ +export function isPitchBin(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= 0 && + value < TONE_PITCH_BINS + ); +} + +/** `true` for a `1`-`4` duration bin. */ +export function isDurationBin(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= 1 && + value <= TONE_DURATION_BINS + ); +} + +/** `true` for the literal bit values `0` and `1`. */ +export function isBit(value: unknown): value is 0 | 1 { + return value === 0 || value === 1; +} diff --git a/packages/gateway/src/carriers/canvas.ts b/packages/gateway/src/carriers/canvas.ts new file mode 100644 index 0000000..9b7ed3b --- /dev/null +++ b/packages/gateway/src/carriers/canvas.ts @@ -0,0 +1,163 @@ +/** + * `generative-canvas` protocol module (SPECIFICATION.md §9.2, ALD-031). + * + * §9.2's default grammar, verbatim: a stroke is + * `{ startX, startY, endX, endY }` as integers on the 0-15 quantized grid + * plus `width: 1 | 2 | 3` ("quantized pen width, no color channel"), and a + * proposal is `{ strokes: Stroke[] }` with `strokes.length <= maxStrokes` + * (default 8, absolute ceiling 64). + * + * Two decisions worth stating outright, both recorded as BACKLOG §15 + * decisions: + * + * 1. **Degenerate zero-length strokes are accepted.** A stroke whose start + * equals its end is a dot of the given pen width. §9.2's grammar "only + * bounds what can be physically expressed" and a dot is inside those + * bounds; rejecting it would be the Gateway making a judgement about the + * *content* of a mark rather than its form, which §9.2 and §4.2 both + * forbid. A dot is also the smallest mark a Baby can invent, so refusing + * it would quietly remove the lowest rung of the E13 form inventory. The + * normalized artifact keeps such a stroke verbatim, so its `markHash` is + * stable and analysis can count dots like any other form. + * 2. **Per-stroke extra fields are rejected, never stripped.** A `color`, + * `pressure`, or `label` key on a stroke is `free-text-present` when it + * holds a string anywhere and `unexpected-artifact-field` otherwise. §9.2 + * is explicit that this carrier has no color or text field, and §10.2 + * forbids sanitizing prohibited content and passing it through. + */ +import type { CarrierModule } from '../carrier-modules.js'; +import { extraFieldFailure, fail } from '../carrier-modules.js'; +import { + ABSOLUTE_MAX_STROKES, + CANVAS_GRID_MAX, + CANVAS_GRID_MIN, + isGridCoordinate, + isStrokeWidth, + maxStrokesFor, +} from './bounds.js'; +import { assertNoStrings, readCarrierArtifact } from './marks.js'; +import { isPlainObject } from '../inspect.js'; + +/** Exactly the five fields SPEC §9.2's `Stroke` declares, in that order. */ +const STROKE_FIELDS = ['startX', 'startY', 'endX', 'endY', 'width'] as const; + +const COORDINATE_FIELDS = ['startX', 'startY', 'endX', 'endY'] as const; + +interface NormalizedStroke { + startX: number; + startY: number; + endX: number; + endY: number; + width: 1 | 2 | 3; +} + +export const generativeCanvasModule: CarrierModule = { + carrier: 'generative-canvas', + allowedKinds: ['emit_canvas'], + + validate(proposal, context) { + const read = readCarrierArtifact(proposal, 'strokes'); + if (!read.ok) { + return read.failure; + } + + // SPEC §9.2: "no color channel", and §9.1 rejects any accompanying free + // text. The stroke grammar is numeric only, so a string anywhere inside + // the stroke list is free text whatever key it hides under. + const text = assertNoStrings(read.value, 'publicArtifact.strokes'); + if (text !== undefined) { + return text; + } + + const strokes = read.value; + if (!Array.isArray(strokes)) { + return fail('invalid-envelope', 'publicArtifact.strokes is not an array'); + } + if (strokes.length === 0) { + return fail('empty-message', 'publicArtifact.strokes is empty'); + } + const cap = maxStrokesFor(context.runContext.config); + if (strokes.length > cap) { + return fail( + 'too-many-strokes', + `message carries ${strokes.length} strokes; the cap is ${cap} (ceiling ${ABSOLUTE_MAX_STROKES})`, + ); + } + + const normalized: NormalizedStroke[] = []; + for (let index = 0; index < strokes.length; index += 1) { + const stroke: unknown = strokes[index]; + if (!isPlainObject(stroke)) { + return fail('invalid-envelope', `stroke at index ${index} is not an object`); + } + const extraKeys = Object.keys(stroke).filter( + (key) => !(STROKE_FIELDS as readonly string[]).includes(key), + ); + if (extraKeys.length > 0) { + return extraFieldFailure(stroke, extraKeys, `strokes[${index}]`); + } + for (const field of COORDINATE_FIELDS) { + if (!isGridCoordinate(stroke[field])) { + return fail( + 'stroke-out-of-range', + `strokes[${index}].${field} is outside the integer grid [${CANVAS_GRID_MIN}, ${CANVAS_GRID_MAX}]`, + ); + } + } + if (!isStrokeWidth(stroke.width)) { + return fail( + 'stroke-width-invalid', + `strokes[${index}].width is not one of the quantized widths 1, 2, 3`, + ); + } + normalized.push({ + startX: stroke.startX as number, + startY: stroke.startY as number, + endX: stroke.endX as number, + endY: stroke.endY as number, + width: stroke.width, + }); + } + + return { ok: true, artifact: { strokes: normalized } }; + }, + + /** + * Uniform stroke count in `[1, maxStrokes]`, then uniform endpoints and pen + * width per stroke — count first, exactly as the fixed-token module draws + * its length first, so one seed gives comparable §9.6 `random` streams + * across runs that differ only in `maxStrokes`. + */ + randomArtifact(prng, context) { + const cap = maxStrokesFor(context.runContext.config); + const count = 1 + prng.nextInt(cap); + const strokes = Array.from({ length: count }, () => ({ + startX: prng.nextInt(CANVAS_GRID_MAX + 1), + startY: prng.nextInt(CANVAS_GRID_MAX + 1), + endX: prng.nextInt(CANVAS_GRID_MAX + 1), + endY: prng.nextInt(CANVAS_GRID_MAX + 1), + width: (prng.nextInt(3) + 1) as 1 | 2 | 3, + })); + return { strokes }; + }, + + /** + * SPEC §9.6 `constant` default: one minimal stroke — a width-1 dot at the + * grid origin. It is the smallest artifact the grammar admits, and it makes + * the `constant` control visibly distinct from any drawn form. Recorded as a + * BACKLOG §15 decision. + */ + constantArtifact() { + return { + strokes: [ + { + startX: CANVAS_GRID_MIN, + startY: CANVAS_GRID_MIN, + endX: CANVAS_GRID_MIN, + endY: CANVAS_GRID_MIN, + width: 1 as const, + }, + ], + }; + }, +}; diff --git a/packages/gateway/src/carriers/errors.ts b/packages/gateway/src/carriers/errors.ts new file mode 100644 index 0000000..26da6ac --- /dev/null +++ b/packages/gateway/src/carriers/errors.ts @@ -0,0 +1,80 @@ +/** + * Configuration faults specific to the SPECIFICATION.md §9.2 alternate + * carriers (ALD-031). + * + * None of these is a Baby channel violation: a Baby cannot cause one, and + * none is ever committed as `channel.rejected`. They are raised while a run + * context is being built, so a misconfigured run cannot start (SPEC §9.2: + * "The unfamiliar glyph bundle MUST be generated and frozen before + * pre-registration"). + */ +import type { RunConfig } from '@ald/types'; + +import { GatewayError } from '../errors.js'; + +/** + * SPEC §9.2: a `fixed-glyph` run must name the frozen glyph bundle it renders + * its inventory from. Without `RunConfig.glyphBundleHash` there is nothing to + * bind the Baby-visible glyph images to, and the §9.2 requirement that the + * bundle be frozen before pre-registration cannot be checked at all — so the + * Gateway refuses to be constructed rather than running a glyph condition + * whose stimuli are unpinned. + */ +export class MissingGlyphBundleHashError extends GatewayError { + constructor() { + super( + 'INVALID_REQUEST', + 'A fixed-glyph run requires RunConfig.glyphBundleHash: the unfamiliar-glyph bundle must be generated and frozen before pre-registration (SPEC §9.2)', + { carrier: 'fixed-glyph', field: 'glyphBundleHash' }, + ); + } +} + +/** + * The glyph bundle handed to `verifyGlyphBundle` does not hash to the value + * the run configuration pins. Raised by the verification helper, never during + * a turn. + */ +export class GlyphBundleMismatchError extends GatewayError { + constructor( + readonly expectedHash: string, + readonly actualHash: string, + ) { + super( + 'INVALID_REQUEST', + 'The glyph bundle does not match the pinned glyphBundleHash (SPEC §9.2)', + { expectedHash, actualHash }, + ); + } +} + +/** + * A glyph bundle could not be generated whose every glyph passes the leakage + * audit within the attempt budget. Generation is deterministic, so this is a + * parameter fault (too few strokes, too small a grid), not a flake. + */ +export class GlyphBundleAuditFailedError extends GatewayError { + constructor( + readonly glyphIndex: number, + readonly attempts: number, + readonly reasonCodes: readonly string[], + ) { + super( + 'INVALID_REQUEST', + `Glyph ${glyphIndex} still fails the §9.2 glyph leakage audit after ${attempts} seeded attempts`, + { glyphIndex, attempts, reasonCodes: [...reasonCodes] }, + ); + } +} + +/** + * The run's carrier is one this build does not have a grammar for. Distinct + * from `UnsupportedCarrierError`, which reports an unregistered *module*. + */ +export class UnknownCarrierGrammarError extends GatewayError { + constructor(readonly carrier: RunConfig['carrierMode']) { + super('INVALID_REQUEST', `No §9.2 grammar is defined for carrier ${carrier}`, { + carrier, + }); + } +} diff --git a/packages/gateway/src/carriers/glyph-audit.ts b/packages/gateway/src/carriers/glyph-audit.ts new file mode 100644 index 0000000..638b5d3 --- /dev/null +++ b/packages/gateway/src/carriers/glyph-audit.ts @@ -0,0 +1,259 @@ +/** + * Glyph leakage audit — a **heuristic** screen, not a detector + * (SPECIFICATION.md §9.2: the glyph bundle "MUST ... pass the OCR/glyph + * leakage audit"; ALD-031, with the real detector owned by ALD-039). + * + * What this claims: given a 16x16 monochrome bitmap, it computes four cheap + * structural features and flags the bitmap when those features look like a + * written character rather than like an abstract mark. That is enough to keep + * an accidentally letter-shaped glyph out of a generated bundle, and it is + * deterministic, so a bundle that passes it passes it identically for a third + * party rebuilding the bundle from its seed. + * + * What this explicitly does NOT claim: it is not OCR, it does not recognise + * characters, and a bitmap it passes is not certified text-free. The real + * text/OCR detection over Baby-visible images is ALD-039's + * `OcrDetector`/`HeuristicTextDetector`, and this module exposes a + * {@link GlyphLeakageAudit} hook so that detector can be plugged in as the + * bundle's audit instead of this screen (SPEC §9.2, ALD-039). + * + * The thresholds below are pre-registered constants of `glyph-audit-v1`: they + * are part of the audit version string, so changing one is a new audit + * version rather than a silent retune. + */ + +/** Version recorded alongside every audit result (SPEC §15.3 analysis version). */ +export const GLYPH_AUDIT_VERSION = 'glyph-audit-v1'; + +/** Machine-readable cues; a closed set, no payload text. */ +export const GLYPH_AUDIT_REASON_CODES = [ + /** Nearly all ink sits inside one x-height band, as written text does. */ + 'baseline-band-concentration', + /** One small connected mark: a single stroke reads as a character. */ + 'too-few-components', + /** Almost no ink at all: nothing to distinguish, and nothing to reuse. */ + 'ink-too-sparse', + /** Ink fills the grid: a solid block carries no reusable form either. */ + 'ink-too-dense', +] as const; + +export type GlyphAuditReasonCode = (typeof GLYPH_AUDIT_REASON_CODES)[number]; + +/** Pre-registered thresholds of `glyph-audit-v1`. */ +export const GLYPH_AUDIT_THRESHOLDS = { + /** Height of the x-height band scanned for baseline concentration. */ + bandHeight: 8, + /** Ink share inside one band at or above which the mark reads as text. */ + bandConcentration: 0.95, + /** Vertical ink extent at or below which the band cue is meaningful. */ + maxVerticalExtent: 0.7, + /** Minimum number of 4-connected ink components. */ + minComponents: 2, + /** Ink density below which a mark is too sparse to be a reusable form. */ + minInkDensity: 0.04, + /** Ink density above which a mark is a solid block. */ + maxInkDensity: 0.7, +} as const; + +export interface GlyphBitmapFeatures { + /** Fraction of cells that are ink. */ + inkDensity: number; + /** Largest ink share inside any `bandHeight`-row horizontal band. */ + rowBandConcentration: number; + /** Fraction of rows that contain any ink. */ + verticalExtent: number; + /** Fraction of columns that contain any ink. */ + horizontalExtent: number; + /** Number of 4-connected ink components. */ + componentCount: number; +} + +export interface GlyphAuditFinding { + /** Index of the glyph inside the bundle. */ + index: number; + glyphId: string; + textLike: boolean; + /** Highest normalized cue value in `[0, 1]`; higher is more text-like. */ + score: number; + reasonCodes: GlyphAuditReasonCode[]; + features: GlyphBitmapFeatures; +} + +export interface GlyphLeakageAuditResult { + auditVersion: string; + glyphCount: number; + textLikeCount: number; + /** `true` when no glyph in the bundle was flagged. */ + pass: boolean; + findings: GlyphAuditFinding[]; +} + +/** One 16x16 monochrome glyph as the audit sees it. */ +export interface AuditableGlyph { + glyphId: string; + width: number; + height: number; + bits: readonly (0 | 1)[]; +} + +/** + * Pluggable audit over a whole bundle. ALD-039's real text detector satisfies + * this shape, so a bundle can be generated under the production detector + * without this module changing. + */ +export type GlyphLeakageAudit = ( + glyphs: readonly AuditableGlyph[], +) => GlyphLeakageAuditResult; + +function bitAt( + bits: readonly (0 | 1)[], + width: number, + x: number, + y: number, +): 0 | 1 { + return bits[y * width + x] ?? 0; +} + +/** Number of 4-connected ink components, by iterative flood fill. */ +function countComponents( + bits: readonly (0 | 1)[], + width: number, + height: number, +): number { + const seen = new Uint8Array(width * height); + let components = 0; + for (let start = 0; start < width * height; start += 1) { + if (bits[start] !== 1 || seen[start] === 1) { + continue; + } + components += 1; + const stack = [start]; + seen[start] = 1; + while (stack.length > 0) { + const cell = stack.pop() as number; + const x = cell % width; + const y = Math.floor(cell / width); + const neighbours: Array<[number, number]> = [ + [x - 1, y], + [x + 1, y], + [x, y - 1], + [x, y + 1], + ]; + for (const [nx, ny] of neighbours) { + if (nx < 0 || ny < 0 || nx >= width || ny >= height) { + continue; + } + const index = ny * width + nx; + if (bits[index] === 1 && seen[index] === 0) { + seen[index] = 1; + stack.push(index); + } + } + } + } + return components; +} + +/** The four `glyph-audit-v1` features of one monochrome bitmap. */ +export function glyphBitmapFeatures( + bits: readonly (0 | 1)[], + width: number, + height: number, +): GlyphBitmapFeatures { + const cells = width * height; + let ink = 0; + const rowInk = new Array(height).fill(0); + const columnInk = new Array(width).fill(0); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (bitAt(bits, width, x, y) === 1) { + ink += 1; + rowInk[y] = (rowInk[y] ?? 0) + 1; + columnInk[x] = (columnInk[x] ?? 0) + 1; + } + } + } + + const band = Math.min(GLYPH_AUDIT_THRESHOLDS.bandHeight, height); + let bestBand = 0; + for (let top = 0; top + band <= height; top += 1) { + let inBand = 0; + for (let y = top; y < top + band; y += 1) { + inBand += rowInk[y] ?? 0; + } + bestBand = Math.max(bestBand, inBand); + } + + return { + inkDensity: cells === 0 ? 0 : ink / cells, + rowBandConcentration: ink === 0 ? 0 : bestBand / ink, + verticalExtent: + height === 0 ? 0 : rowInk.filter((count) => count > 0).length / height, + horizontalExtent: + width === 0 ? 0 : columnInk.filter((count) => count > 0).length / width, + componentCount: countComponents(bits, width, height), + }; +} + +/** + * Flag one glyph as text-like or not. + * + * The baseline cue is the one that actually distinguishes writing: Latin, + * Cyrillic and Greek characters put nearly all of their ink between the + * baseline and the x-height line, so a mark whose ink is ≥95% inside one + * eight-row band while occupying ≤70% of the rows is shaped like a letter. + * The remaining three cues reject marks that carry no reusable form at all + * (a single small stroke, an empty grid, a filled grid) — those are not + * "text-like" in the OCR sense, but §9.2 wants a bundle of usable unfamiliar + * glyphs, and rejecting them here is what makes the generated bundle usable. + */ +export function auditGlyphBitmap( + glyph: AuditableGlyph, + index: number, +): GlyphAuditFinding { + const features = glyphBitmapFeatures(glyph.bits, glyph.width, glyph.height); + const reasonCodes: GlyphAuditReasonCode[] = []; + const scores: number[] = []; + + if ( + features.rowBandConcentration >= GLYPH_AUDIT_THRESHOLDS.bandConcentration && + features.verticalExtent <= GLYPH_AUDIT_THRESHOLDS.maxVerticalExtent + ) { + reasonCodes.push('baseline-band-concentration'); + scores.push(features.rowBandConcentration); + } + if (features.componentCount < GLYPH_AUDIT_THRESHOLDS.minComponents) { + reasonCodes.push('too-few-components'); + scores.push(1); + } + if (features.inkDensity < GLYPH_AUDIT_THRESHOLDS.minInkDensity) { + reasonCodes.push('ink-too-sparse'); + scores.push(1); + } + if (features.inkDensity > GLYPH_AUDIT_THRESHOLDS.maxInkDensity) { + reasonCodes.push('ink-too-dense'); + scores.push(1); + } + + return { + index, + glyphId: glyph.glyphId, + textLike: reasonCodes.length > 0, + score: scores.length === 0 ? 0 : Math.max(...scores), + reasonCodes, + features, + }; +} + +/** The built-in `glyph-audit-v1` screen over a whole bundle. */ +export const heuristicGlyphLeakageAudit: GlyphLeakageAudit = (glyphs) => { + const findings = glyphs.map((glyph, index) => auditGlyphBitmap(glyph, index)); + const textLikeCount = findings.filter((finding) => finding.textLike).length; + return { + auditVersion: GLYPH_AUDIT_VERSION, + glyphCount: glyphs.length, + textLikeCount, + pass: textLikeCount === 0, + findings, + }; +}; diff --git a/packages/gateway/src/carriers/glyph-bundle.ts b/packages/gateway/src/carriers/glyph-bundle.ts new file mode 100644 index 0000000..0aff1a4 --- /dev/null +++ b/packages/gateway/src/carriers/glyph-bundle.ts @@ -0,0 +1,257 @@ +/** + * The frozen unfamiliar-glyph bundle (SPECIFICATION.md §9.2, ALD-031). + * + * §9.2: "The unfamiliar glyph bundle MUST be generated and frozen before + * pre-registration, contain no Unicode text labels in Baby-visible data, and + * pass the OCR/glyph leakage audit." + * + * All three properties are structural here rather than procedural: + * + * - **No Unicode text.** A glyph is a 16x16 monochrome bit matrix drawn from + * random bounded straight strokes. There is no font, no glyph outline + * library, and no code point anywhere in the pipeline — the only strings in + * a bundle are the opaque `G01`-style identifiers, which are indices, not + * labels, and are never rendered into the image. + * - **Frozen.** The whole bundle is a deterministic function of one seed and + * its size, so `glyphBundleHash` pins it: a third party regenerates the + * identical bundle from the pre-registration and recomputes the same hash. + * - **Audited.** Every glyph is screened by a {@link GlyphLeakageAudit} + * (`glyph-audit.ts` by default, ALD-039's real text detector when it is + * available) and a flagged glyph is *resampled*, not accepted — so the + * bundle that gets hashed is audit-clean by construction and + * `auditGlyphBundle` on it is a re-check, not the first check. + * + * The hash domain is implementation-defined: SPEC §11.1 and §18 name + * `glyphBundleHash` but no separator string. The implementation-defined + * separator lives in `HASH_DOMAINS` beside every other evidence hash domain. + */ +import { HASH_DOMAINS } from '@ald/types'; +import { SeededPrng, hashCanonical } from '@ald/hashing'; + +import { + BITMAP_GRID_HEIGHT, + BITMAP_GRID_WIDTH, + CANVAS_GRID_MAX, + DEFAULT_SYMBOL_INVENTORY_SIZE, +} from './bounds.js'; +import { GlyphBundleAuditFailedError, GlyphBundleMismatchError } from './errors.js'; +import { + heuristicGlyphLeakageAudit, + type AuditableGlyph, + type GlyphLeakageAudit, + type GlyphLeakageAuditResult, +} from './glyph-audit.js'; +import { glyphInventory } from './inventory.js'; + +/** + * Implementation-defined hash domain for the §9.2 glyph bundle. + */ +export const GLYPH_BUNDLE_HASH_DOMAIN = HASH_DOMAINS.glyphBundle; + +/** Bundle format version; part of the hashed preimage. */ +export const GLYPH_BUNDLE_VERSION = 1 as const; + +/** Strokes drawn per glyph, inclusive range. Part of the hashed preimage. */ +export const GLYPH_STROKES_MIN = 3; +export const GLYPH_STROKES_MAX = 5; + +/** Seeded resample attempts before generation gives up on one glyph. */ +const AUDIT_ATTEMPT_BUDGET = 64; + +export interface GlyphBitmap { + /** Opaque inventory identifier (`G01`); an index, never a label. */ + glyphId: string; + width: number; + height: number; + /** Row-major monochrome cells, `width * height` of them. */ + bits: (0 | 1)[]; +} + +export interface GlyphBundle { + version: typeof GLYPH_BUNDLE_VERSION; + /** The seed the bundle is a pure function of; researcher-visible only. */ + seed: string; + width: number; + height: number; + strokesMin: number; + strokesMax: number; + auditVersion: string; + glyphs: GlyphBitmap[]; +} + +export interface GenerateGlyphBundleOptions { + seed: string; + /** Inventory size, 2-256; defaults to the §9.1/§9.2 default of 32. */ + size?: number; + /** + * SPEC §9.2 audit hook. Defaults to `glyph-audit.ts`'s heuristic screen; + * pass ALD-039's `OcrDetector`-backed audit to generate a bundle under the + * production detector instead. + */ + leakageAudit?: GlyphLeakageAudit; +} + +/** Draw one straight stroke into `bits` with Bresenham; width 1, monochrome. */ +function drawStroke( + bits: (0 | 1)[], + width: number, + x0: number, + y0: number, + x1: number, + y1: number, +): void { + let x = x0; + let y = y0; + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const stepX = x0 < x1 ? 1 : -1; + const stepY = y0 < y1 ? 1 : -1; + let error = dx - dy; + for (;;) { + bits[y * width + x] = 1; + if (x === x1 && y === y1) { + return; + } + const doubled = error * 2; + if (doubled > -dy) { + error -= dy; + x += stepX; + } + if (doubled < dx) { + error += dx; + y += stepY; + } + } +} + +/** + * One candidate glyph from a labelled child stream. Strokes span the whole + * 0-15 grid — no baseline, no x-height band, no character cell — which is why + * the audit's baseline cue almost never fires on a generated glyph and does + * fire on a rendered letter. + */ +function drawCandidate(prng: SeededPrng, width: number, height: number): (0 | 1)[] { + const bits = new Array<0 | 1>(width * height).fill(0); + const strokes = + GLYPH_STROKES_MIN + prng.nextInt(GLYPH_STROKES_MAX - GLYPH_STROKES_MIN + 1); + for (let stroke = 0; stroke < strokes; stroke += 1) { + drawStroke( + bits, + width, + prng.nextInt(CANVAS_GRID_MAX + 1), + prng.nextInt(CANVAS_GRID_MAX + 1), + prng.nextInt(CANVAS_GRID_MAX + 1), + prng.nextInt(CANVAS_GRID_MAX + 1), + ); + } + return bits; +} + +/** + * Generate the bundle for `seed` and `size`. Pure and total: the same + * arguments always produce the identical bundle, and a glyph that the audit + * flags is resampled from a further-derived stream rather than kept. + */ +export function generateGlyphBundle( + options: GenerateGlyphBundleOptions, +): GlyphBundle { + const size = options.size ?? DEFAULT_SYMBOL_INVENTORY_SIZE; + const ids = glyphInventory(size); + const audit = options.leakageAudit ?? heuristicGlyphLeakageAudit; + const root = new SeededPrng(options.seed).derive('glyph-bundle'); + const width = BITMAP_GRID_WIDTH; + const height = BITMAP_GRID_HEIGHT; + + const glyphs: GlyphBitmap[] = []; + let auditVersion = ''; + + ids.forEach((glyphId, index) => { + let accepted: GlyphBitmap | undefined; + let lastReasonCodes: string[] = []; + for ( + let attempt = 0; + attempt < AUDIT_ATTEMPT_BUDGET && accepted === undefined; + attempt += 1 + ) { + const candidate: GlyphBitmap = { + glyphId, + width, + height, + bits: drawCandidate( + root.derive(`${String(index)}/${String(attempt)}`), + width, + height, + ), + }; + const result = audit([candidate]); + auditVersion = result.auditVersion; + if (result.pass) { + accepted = candidate; + } else { + lastReasonCodes = result.findings.flatMap( + (finding) => finding.reasonCodes, + ); + } + } + if (accepted === undefined) { + throw new GlyphBundleAuditFailedError( + index, + AUDIT_ATTEMPT_BUDGET, + lastReasonCodes, + ); + } + glyphs.push(accepted); + }); + + return { + version: GLYPH_BUNDLE_VERSION, + seed: options.seed, + width, + height, + strokesMin: GLYPH_STROKES_MIN, + strokesMax: GLYPH_STROKES_MAX, + auditVersion, + glyphs, + }; +} + +/** + * `glyphBundleHash` (SPEC §9.2, §11.1): the domain-separated hash of the RFC + * 8785 canonical form of the whole bundle, including the seed and the drawing + * parameters, so the hash pins the *recipe* as well as the pixels. + */ +export function hashGlyphBundle(bundle: GlyphBundle): string { + return hashCanonical(GLYPH_BUNDLE_HASH_DOMAIN, bundle); +} + +/** The audit re-check over an existing bundle (SPEC §9.2). */ +export function auditGlyphBundle( + bundle: GlyphBundle, + leakageAudit: GlyphLeakageAudit = heuristicGlyphLeakageAudit, +): GlyphLeakageAuditResult { + return leakageAudit(bundle.glyphs as readonly AuditableGlyph[]); +} + +/** + * Verify a bundle against the hash a run configuration pins. Throws + * {@link GlyphBundleMismatchError} on a mismatch rather than returning + * `false`, because a silently unverified bundle is exactly the failure §9.2 + * is guarding against; the boolean form is {@link isGlyphBundleHash}. + */ +export function verifyGlyphBundle( + bundle: GlyphBundle, + expectedHash: string, +): void { + const actualHash = hashGlyphBundle(bundle); + if (actualHash !== expectedHash) { + throw new GlyphBundleMismatchError(expectedHash, actualHash); + } +} + +/** Non-throwing form of {@link verifyGlyphBundle}. */ +export function isGlyphBundleHash( + bundle: GlyphBundle, + expectedHash: string, +): boolean { + return hashGlyphBundle(bundle) === expectedHash; +} diff --git a/packages/gateway/src/carriers/glyph.ts b/packages/gateway/src/carriers/glyph.ts new file mode 100644 index 0000000..e858869 --- /dev/null +++ b/packages/gateway/src/carriers/glyph.ts @@ -0,0 +1,106 @@ +/** + * `fixed-glyph` protocol module (SPECIFICATION.md §9.2, ALD-031). + * + * §9.2 table row: "IDs `G01`-`G32` rendered from a pre-generated + * unfamiliar-glyph set", bounded at "32 glyphs, 4 per message". A proposal is + * `{ "glyphs": string[] }` and the discipline is the fixed-token discipline + * of §9.1 — allowlist, length cap, consecutive-repeat cap — over a different + * inventory, so `marks.ts` runs the identical ordered checks and only the + * allowlist reason code differs (`glyph-not-in-inventory`). + * + * The glyph *images* never travel on this channel: a delivered artifact + * carries inventory identifiers, and the identifiers are rendered from the + * frozen bundle by the presentation layer that builds a Baby's observation. + * That is why this module requires `RunConfig.glyphBundleHash` — a glyph + * condition whose stimuli are not pinned cannot support any claim about what + * the Babies actually saw (§9.2 "generated and frozen before + * pre-registration"). + */ +import type { RunConfig } from '@ald/types'; + +import type { CarrierContext, CarrierModule } from '../carrier-modules.js'; +import { MissingGlyphBundleHashError } from './errors.js'; +import { glyphInventory } from './inventory.js'; +import { randomMarkList, readCarrierArtifact, validateMarkList } from './marks.js'; +import { DEFAULT_SYMBOL_INVENTORY_SIZE } from './bounds.js'; + +/** + * SPEC §9.2: a `fixed-glyph` run must pin its frozen bundle. Raised while the + * Gateway is being constructed — `SymbolGatewayImpl`'s constructor resolves + * the §9.6 constant artifact, which reaches this check — so a run configured + * without `glyphBundleHash` can never take a turn. + */ +export function assertGlyphRunConfig( + config: Pick, +): void { + if (config.carrierMode === 'fixed-glyph' && config.glyphBundleHash === undefined) { + throw new MissingGlyphBundleHashError(); + } +} + +/** + * The glyph inventory this run declares. + * + * Derived from `symbolInventorySize` rather than read from + * `GatewayRunContext.symbolInventory`, because the Nursery Controller + * currently fills that field with `fixedTokenInventory` for every carrier. + * Deriving it keeps a glyph run correct today and identical once the runtime + * switches to `carrierInventory` (see `inventory.ts`). + */ +export function glyphInventoryFor(context: CarrierContext): string[] { + return glyphInventory( + context.runContext.config.symbolInventorySize ?? DEFAULT_SYMBOL_INVENTORY_SIZE, + ); +} + +export const fixedGlyphModule: CarrierModule = { + carrier: 'fixed-glyph', + allowedKinds: ['emit_glyphs'], + + validate(proposal, context) { + // Unreachable at turn time: the Gateway constructor already ran this + // check through `constantArtifact`/`validate`, so a run whose config + // lacks the bundle hash never reaches a submission. Kept so the module is + // safe to call directly (conformance vectors, control artifacts). + assertGlyphRunConfig(context.runContext.config); + + const read = readCarrierArtifact(proposal, 'glyphs'); + if (!read.ok) { + return read.failure; + } + + const marks = validateMarkList(read.value, context, { + notInInventory: 'glyph-not-in-inventory', + inventory: glyphInventoryFor(context), + noun: 'glyphs', + }); + if (!marks.ok) { + return marks.failure; + } + + return { ok: true, artifact: { glyphs: marks.marks } }; + }, + + randomArtifact(prng, context) { + assertGlyphRunConfig(context.runContext.config); + return { + glyphs: randomMarkList(prng, context, glyphInventoryFor(context)), + }; + }, + + /** + * SPEC §9.6 `constant` default: the first glyph of the declared inventory, + * mirroring the fixed-token module's "first inventory symbol". Recorded as a + * BACKLOG §15 decision. + */ + constantArtifact(context) { + assertGlyphRunConfig(context.runContext.config); + const inventory = glyphInventoryFor(context); + const first = inventory[0]; + if (first === undefined) { + // `glyphInventory` refuses a size below 2, so this is unreachable. + throw new Error('the fixed-glyph inventory is empty'); + } + return { glyphs: [first] }; + }, +}; diff --git a/packages/gateway/src/carriers/index.ts b/packages/gateway/src/carriers/index.ts new file mode 100644 index 0000000..335b5d2 --- /dev/null +++ b/packages/gateway/src/carriers/index.ts @@ -0,0 +1,97 @@ +/** + * The SPECIFICATION.md §9.2 alternate neutral carrier protocols (ALD-031). + * + * Four protocol modules — `fixed-glyph`, `generative-bitmap`, + * `generative-canvas`, `generative-tone` — plus the frozen glyph bundle, its + * leakage audit, and the carrier-qualified content addressing of §9.2. See + * `packages/gateway/README.md` for the threat-model and claim boundaries, and + * `register.ts` for why nothing here is registered by default. + */ +export { + ABSOLUTE_MAX_STROKES, + BITMAP_BIT_COUNT, + BITMAP_GRID_HEIGHT, + BITMAP_GRID_WIDTH, + CANVAS_GRID_MAX, + CANVAS_GRID_MIN, + CANVAS_STROKE_WIDTHS, + DEFAULT_MAX_STROKES, + DEFAULT_SYMBOL_INVENTORY_SIZE, + MAX_TONES, + TONE_DURATION_BINS, + TONE_PITCH_BINS, + isBit, + isDurationBin, + isGridCoordinate, + isPitchBin, + isStrokeWidth, + maxStrokesFor, +} from './bounds.js'; +export { generativeBitmapModule } from './bitmap.js'; +export { generativeCanvasModule } from './canvas.js'; +export { + GlyphBundleAuditFailedError, + GlyphBundleMismatchError, + MissingGlyphBundleHashError, + UnknownCarrierGrammarError, +} from './errors.js'; +export { + assertGlyphRunConfig, + fixedGlyphModule, + glyphInventoryFor, +} from './glyph.js'; +export { + GLYPH_AUDIT_REASON_CODES, + GLYPH_AUDIT_THRESHOLDS, + GLYPH_AUDIT_VERSION, + auditGlyphBitmap, + glyphBitmapFeatures, + heuristicGlyphLeakageAudit, + type AuditableGlyph, + type GlyphAuditFinding, + type GlyphAuditReasonCode, + type GlyphBitmapFeatures, + type GlyphLeakageAudit, + type GlyphLeakageAuditResult, +} from './glyph-audit.js'; +export { + GLYPH_BUNDLE_HASH_DOMAIN, + GLYPH_BUNDLE_VERSION, + GLYPH_STROKES_MAX, + GLYPH_STROKES_MIN, + auditGlyphBundle, + generateGlyphBundle, + hashGlyphBundle, + isGlyphBundleHash, + verifyGlyphBundle, + type GenerateGlyphBundleOptions, + type GlyphBitmap, + type GlyphBundle, +} from './glyph-bundle.js'; +export { + GLYPH_ID_PATTERN, + carrierInventory, + glyphInventory, + isSymbolicCarrier, +} from './inventory.js'; +export { carrierMarkHash } from './mark.js'; +export { + CARRIER_MARK_HASH_VECTORS, + type CarrierMarkVector, +} from './mark-vectors.js'; +export { + assertNoStrings, + randomMarkList, + readCarrierArtifact, + readNestedField, + validateMarkList, + type ArtifactReadResult, + type MarkListResult, + type MarkListRules, +} from './marks.js'; +export { + ALTERNATE_CARRIER_MODULES, + ALTERNATE_CARRIERS, + registerAlternateCarriers, +} from './register.js'; +export { generativeToneModule } from './tone.js'; diff --git a/packages/gateway/src/carriers/inventory.ts b/packages/gateway/src/carriers/inventory.ts new file mode 100644 index 0000000..0918bcb --- /dev/null +++ b/packages/gateway/src/carriers/inventory.ts @@ -0,0 +1,74 @@ +/** + * Per-carrier mark inventories (SPECIFICATION.md §9.1, §9.2; ALD-031). + * + * Only the two *symbolic* carriers have an inventory at all: `fixed-token` + * declares `S01`-`S` (§9.1) and `fixed-glyph` declares `G01`-`G` + * rendered from the frozen unfamiliar-glyph bundle (§9.2). The three + * generative carriers have no inventory by construction — their grammar + * bounds what can be drawn or sounded, and every artifact a Baby produces is + * a new mark identified only by its `markHash` (§9.2 "so that a repeated form + * can be recognized without assigning it a meaning"). + * + * The glyph *identifiers* carry no semantics: `G07` is an opaque index into a + * seeded bundle of 16x16 bitmaps, and the bundle itself is generated from + * random bounded strokes rather than from any font or Unicode code point + * (`glyph-bundle.ts`). + */ +import { fixedTokenInventory, type RunConfig } from '@ald/types'; + +import { DEFAULT_SYMBOL_INVENTORY_SIZE } from './bounds.js'; + +/** Shape of a glyph identifier: `G` plus a zero-padded ordinal. */ +export const GLYPH_ID_PATTERN = /^G[0-9]{2,3}$/u; + +/** + * Default `fixed-glyph` inventory `G01`..`G`, padded exactly as + * `fixedTokenInventory` pads `S01`..`S` so the two symbolic carriers + * stay index-comparable at the same inventory size. + */ +export function glyphInventory(size: number): string[] { + if (!Number.isInteger(size) || size < 2 || size > 256) { + throw new Error('symbolInventorySize must be an integer between 2 and 256'); + } + const width = size >= 100 ? 3 : 2; + return Array.from( + { length: size }, + (_, index) => `G${String(index + 1).padStart(width, '0')}`, + ); +} + +/** + * Whether a carrier declares a mark inventory at all. The generative carriers + * do not: SPEC §9.2 gives them a grammar, not a vocabulary. + */ +export function isSymbolicCarrier( + carrier: RunConfig['carrierMode'], +): carrier is 'fixed-token' | 'fixed-glyph' { + return carrier === 'fixed-token' || carrier === 'fixed-glyph'; +} + +/** + * The declared inventory for a run's carrier, or an empty list for a + * generative carrier. + * + * The Nursery Controller currently builds `GatewayRunContext.symbolInventory` + * with `fixedTokenInventory` unconditionally; this is the carrier-aware form + * it should use instead (see this package's README and the integrator notes), + * and it is what the `fixed-glyph` module validates against so a run is + * correct either way. + */ +export function carrierInventory( + config: Pick, +): string[] { + const size = config.symbolInventorySize ?? DEFAULT_SYMBOL_INVENTORY_SIZE; + switch (config.carrierMode) { + case 'fixed-token': + return fixedTokenInventory(size); + case 'fixed-glyph': + return glyphInventory(size); + case 'generative-bitmap': + case 'generative-canvas': + case 'generative-tone': + return []; + } +} diff --git a/packages/gateway/src/carriers/mark-vectors.ts b/packages/gateway/src/carriers/mark-vectors.ts new file mode 100644 index 0000000..8eba3e9 --- /dev/null +++ b/packages/gateway/src/carriers/mark-vectors.ts @@ -0,0 +1,114 @@ +/** + * Reproducible `markHash` vectors for the SPECIFICATION.md §9.2 carriers + * (ALD-031 criterion 1). + * + * These are *published constants*, not fixtures generated by the code they + * check: each expected hash below is the SHA-256 of + * `"dtsf-carrier-mark-v1" || 0x00 || carrierMode || 0x00 || + * RFC8785(canonicalArtifact)` and was written down independently of + * `hashCarrierMark`. A third party can recompute every value with any RFC + * 8785 canonicalizer and a SHA-256 implementation, which is what makes + * "reproduces the same carrier-qualified `markHash`" checkable outside this + * repository rather than only self-consistent inside it. + * + * The last two vectors are the pair that matters for the carrier + * qualification: an identical canonical artifact under two different + * `carrierMode` values must hash differently, so a bitmap can never be + * mistaken for a glyph message that happens to serialize the same way. + */ +import type { RunConfig } from '@ald/types'; + +import type { PublicArtifact } from '../carrier-modules.js'; + +export interface CarrierMarkVector { + name: string; + carrier: RunConfig['carrierMode']; + artifact: PublicArtifact; + /** `sha256:<64 hex>`, computed from the §9.2 construction by hand. */ + markHash: string; +} + +/** Exactly 256 bits, the §9.2 `generative-bitmap` grid. */ +function bits(fill: (index: number) => 0 | 1): (0 | 1)[] { + return Array.from({ length: 256 }, (_, index) => fill(index)); +} + +export const CARRIER_MARK_HASH_VECTORS: readonly CarrierMarkVector[] = [ + { + name: 'fixed-token two symbols', + carrier: 'fixed-token', + artifact: { symbols: ['S01', 'S02'] }, + markHash: + 'sha256:fd724090a3433e99febedb8462157d6531274cb2a2ce190b4bebc228d8f6367b', + }, + { + name: 'fixed-glyph two glyphs', + carrier: 'fixed-glyph', + artifact: { glyphs: ['G01', 'G02'] }, + markHash: + 'sha256:5b1eef211156e6107dd5c5b59d8796e36ea6e1b834dbc8210aa851f42a010937', + }, + { + name: 'fixed-glyph single glyph', + carrier: 'fixed-glyph', + artifact: { glyphs: ['G07'] }, + markHash: + 'sha256:696bc1ebdb3f522642e5cb63cae18332f7f712d2888f10f0401e7e24ea94610a', + }, + { + name: 'generative-bitmap all-zero matrix (the §9.6 constant artifact)', + carrier: 'generative-bitmap', + artifact: { bitmap: { bits: bits(() => 0) } }, + markHash: + 'sha256:a19a915d65af225e85de965b2bd1b0dc985038d53362c7e64d3b962412134b2d', + }, + { + name: 'generative-bitmap alternating matrix', + carrier: 'generative-bitmap', + artifact: { bitmap: { bits: bits((index) => (index % 2 === 0 ? 1 : 0)) } }, + markHash: + 'sha256:6d2650c4d8d8f985b3c622d962fbd4664436d5f55cb9eb96844d257dba4999c5', + }, + { + name: 'generative-canvas corner-to-corner stroke', + carrier: 'generative-canvas', + artifact: { strokes: [{ startX: 0, startY: 0, endX: 15, endY: 15, width: 1 }] }, + markHash: + 'sha256:e05381a521300a67cbb410cc3327d479f00dc29c484797a41f115e04b1fde80d', + }, + { + name: 'generative-canvas degenerate zero-length stroke (a dot)', + carrier: 'generative-canvas', + artifact: { strokes: [{ startX: 4, startY: 4, endX: 4, endY: 4, width: 2 }] }, + markHash: + 'sha256:75f572065fc20b646d7d9fe30df3bf0201cab1d4bb8c2e24d927e9d21258ce34', + }, + { + name: 'generative-tone one minimal tone (the §9.6 constant artifact)', + carrier: 'generative-tone', + artifact: { tones: { tones: [{ pitchBin: 0, durationBin: 1 }] } }, + markHash: + 'sha256:136f15c6f8e65c845c7f6a0aee9815ed8ecf6d8fcfb9169b129d7ea8093ae2be', + }, + { + name: 'generative-tone two-tone sequence at both bin extremes', + carrier: 'generative-tone', + artifact: { + tones: { + tones: [ + { pitchBin: 7, durationBin: 4 }, + { pitchBin: 3, durationBin: 2 }, + ], + }, + }, + markHash: + 'sha256:114e429e1ad621e90e282349cd884695d27a1ac3bc190f5a7aa44264787896a7', + }, + { + name: 'carrier qualification: the fixed-glyph artifact under generative-bitmap', + carrier: 'generative-bitmap', + artifact: { glyphs: ['G01', 'G02'] } as PublicArtifact, + markHash: + 'sha256:1146905630a8334676124a4c969afe283cec590e72ab851712c957229c0c5428', + }, +]; diff --git a/packages/gateway/src/carriers/mark.ts b/packages/gateway/src/carriers/mark.ts new file mode 100644 index 0000000..eb875ad --- /dev/null +++ b/packages/gateway/src/carriers/mark.ts @@ -0,0 +1,50 @@ +/** + * Carrier-qualified content addressing (SPECIFICATION.md §9.2, ALD-031 + * criterion 1). + * + * §9.2, verbatim: "A produced alternate-carrier artifact is content-addressed: + * `markHash = SHA-256("dtsf-carrier-mark-v1" || 0x00 || carrierMode || 0x00 || + * canonicalArtifact)` (RFC 8785 canonicalization ...) so that a repeated form + * can be recognized without assigning it a meaning." + * + * `hashCarrierMark` in `@ald/hashing` is that construction, and the Evidence + * Writer already uses it for `ChannelEvent.publicArtifactHash` — so a + * delivered artifact's `markHash` *is* its channel-event artifact hash, with + * no second hashing scheme to keep in step. This module exists to make that + * identity explicit and to give the learner adapters and the ALD-032 leakage + * evaluator one named entry point: + * + * - the same artifact under the same carrier always yields the same hash, in + * any process, because RFC 8785 canonicalization fixes key order and number + * formatting; + * - the same artifact under a *different* carrier yields a different hash, + * because `carrierMode` is inside the preimage — a bitmap and a canvas + * drawing that happened to canonicalize identically could never be + * confused for the same form. + * + * Normalization is the carrier module's job, not this function's: `validate` + * returns the artifact with exactly the declared fields and the submitted + * order preserved, and that normalized value is what gets hashed. Hashing an + * unvalidated artifact would content-address a payload the Gateway never + * accepted, so `carrierMarkHash` is documented for use on validated + * artifacts only. + */ +import { hashCarrierMark } from '@ald/hashing'; +import type { RunConfig } from '@ald/types'; + +import type { PublicArtifact } from '../carrier-modules.js'; + +/** + * The §9.2 `markHash` of one **validated** artifact under `carrier`. + * + * Identical to `ChannelEvent.publicArtifactHash` for the same delivery, and + * identical to `hashCarrierMark(carrier, artifact)` — the wrapper exists so + * call sites read as "content-address this mark" rather than "hash this + * object", and so a future change to the construction has one place to land. + */ +export function carrierMarkHash( + carrier: RunConfig['carrierMode'], + artifact: PublicArtifact, +): string { + return hashCarrierMark(carrier, artifact); +} diff --git a/packages/gateway/src/carriers/marks.ts b/packages/gateway/src/carriers/marks.ts new file mode 100644 index 0000000..e1a7e15 --- /dev/null +++ b/packages/gateway/src/carriers/marks.ts @@ -0,0 +1,267 @@ +/** + * Shared validation steps every SPECIFICATION.md §9.2 carrier module runs + * before it looks at its own grammar (ALD-031, ALD-034 criterion 3). + * + * These are the checks that must be *identical* across carriers, because the + * §9.4 rejection framework and the §11.3 envelope rules are carrier- + * independent: a proposal holds exactly `kind` and `publicArtifact`, an + * artifact holds exactly the fields its carrier defines, an extra field + * holding a string anywhere is free text, and a mark list is bounded in + * length and in consecutive repeats. + * + * Everything here operates on the *raw* proposal. `z.object()` strips unknown + * keys on `.parse()`, so a check that ran after parsing could not see a + * smuggled `color`, `label`, or `runId` field at all (SPEC §11.3). + */ +import { + extraFieldFailure, + fail, + maxSymbolsFor, + trailingRepeats, + BARE_TOKEN_PATTERN, + type CarrierContext, + type CarrierValidationResult, +} from '../carrier-modules.js'; +import { containsString, isPlainObject } from '../inspect.js'; +import type { GatewayReasonCode } from '../reason-codes.js'; + +/** + * `free-text-present` when a string appears anywhere inside `value`, + * `undefined` otherwise. + * + * The three generative grammars of SPEC §9.2 are numeric only — bits, + * quantized coordinates and pen widths, quantized pitch and duration bins — + * so a string anywhere inside one is free text by construction, whatever key + * it hides under: a color name, a semantic tag, a caption, a filename, or a + * base64 audio blob. Running this before the shape checks makes the reason + * code name the real violation (§9.1 "any accompanying free text ... is + * rejected") rather than the shape mismatch the same payload also causes. + * + * `where` is a diagnostic path only; no part of the payload is ever included + * (SPEC §9.4). + */ +export function assertNoStrings( + value: unknown, + where: string, +): CarrierValidationResult | undefined { + return containsString(value) + ? fail('free-text-present', `${where} carries a string value`) + : undefined; +} + +/** A raw proposal narrowed to the single artifact field its carrier defines. */ +export type ArtifactReadResult = + | { ok: true; artifact: Record; value: unknown } + | { ok: false; failure: CarrierValidationResult }; + +/** + * Steps 1-3 of every module's `validate`: the proposal frame, the artifact + * frame, and the artifact's single declared field. + * + * `field` is the one key the carrier's `publicArtifact` may hold, taken from + * `AgentActionProposalSchema` (`glyphs`, `bitmap`, `strokes`, `tones`). Any + * other key is `free-text-present` when it carries a string anywhere and + * `unexpected-artifact-field` otherwise — which is how SPEC §9.2's "no color + * or text field" is enforced without enumerating field names. + */ +export function readCarrierArtifact( + proposal: unknown, + field: string, +): ArtifactReadResult { + if (!isPlainObject(proposal)) { + return { + ok: false, + failure: fail('invalid-envelope', 'proposal is not an object'), + }; + } + + const extraProposalKeys = Object.keys(proposal).filter( + (key) => key !== 'kind' && key !== 'publicArtifact', + ); + if (extraProposalKeys.length > 0) { + return { + ok: false, + failure: extraFieldFailure(proposal, extraProposalKeys, 'proposal'), + }; + } + + const artifact = proposal.publicArtifact; + if (!isPlainObject(artifact)) { + return { + ok: false, + failure: fail('invalid-envelope', 'publicArtifact is not an object'), + }; + } + + const extraArtifactKeys = Object.keys(artifact).filter( + (key) => key !== field, + ); + if (extraArtifactKeys.length > 0) { + return { + ok: false, + failure: extraFieldFailure(artifact, extraArtifactKeys, 'publicArtifact'), + }; + } + + if (!(field in artifact)) { + return { + ok: false, + failure: fail('invalid-envelope', `publicArtifact.${field} is absent`), + }; + } + + return { ok: true, artifact, value: artifact[field] }; +} + +/** + * A nested object that must hold exactly one declared field, used by the + * bitmap (`{ bitmap: { bits } }`) and tone (`{ tones: { tones } }`) artifact + * shapes of `AgentActionProposalSchema`. + */ +export function readNestedField( + container: unknown, + field: string, + where: string, +): ArtifactReadResult { + if (!isPlainObject(container)) { + return { + ok: false, + failure: fail('invalid-envelope', `${where} is not an object`), + }; + } + const extraKeys = Object.keys(container).filter((key) => key !== field); + if (extraKeys.length > 0) { + return { ok: false, failure: extraFieldFailure(container, extraKeys, where) }; + } + if (!(field in container)) { + return { + ok: false, + failure: fail('invalid-envelope', `${where}.${field} is absent`), + }; + } + return { ok: true, artifact: container, value: container[field] }; +} + +export interface MarkListRules { + /** Which allowlist miss to report, e.g. `glyph-not-in-inventory`. */ + notInInventory: GatewayReasonCode; + /** The declared inventory this carrier's marks are drawn from. */ + inventory: readonly string[]; + /** Diagnostic noun used in `detail`; never any part of the payload. */ + noun: string; +} + +export type MarkListResult = + | { ok: true; marks: string[] } + | { ok: false; failure: CarrierValidationResult }; + +/** + * The §9.1 mark-list discipline, applied to a symbolic carrier's id list. + * + * Validation order is fixed so the reason code for a given payload is + * deterministic — shape, emptiness, length, allowlist/free text per mark, + * then consecutive repeats — and matches the fixed-token module's order + * exactly, because §9.2 gives `fixed-glyph` "the same repeat rule as tokens" + * at "32 glyphs, 4 per message". + */ +export function validateMarkList( + value: unknown, + context: CarrierContext, + rules: MarkListRules, +): MarkListResult { + if (!Array.isArray(value)) { + return { + ok: false, + failure: fail('invalid-envelope', `publicArtifact.${rules.noun} is not an array`), + }; + } + if (value.length === 0) { + return { + ok: false, + failure: fail('empty-message', `publicArtifact.${rules.noun} is empty`), + }; + } + + const cap = maxSymbolsFor(context.runContext.config); + if (value.length > cap) { + return { + ok: false, + failure: fail( + 'message-too-long', + `message carries ${value.length} marks; the cap is ${cap}`, + ), + }; + } + + const inventory = new Set(rules.inventory); + for (let index = 0; index < value.length; index += 1) { + const mark: unknown = value[index]; + if (typeof mark !== 'string') { + return { + ok: false, + failure: fail('invalid-envelope', `mark at index ${index} is not a string`), + }; + } + if (inventory.has(mark)) { + continue; + } + if (!BARE_TOKEN_PATTERN.test(mark)) { + return { + ok: false, + failure: fail( + 'free-text-present', + `mark at index ${index} is not a bare inventory token`, + ), + }; + } + return { + ok: false, + failure: fail( + rules.notInInventory, + `mark at index ${index} is not in the declared inventory`, + ), + }; + } + + const accepted = value as string[]; + let run = 1; + for (let index = 1; index < accepted.length; index += 1) { + run = accepted[index] === accepted[index - 1] ? run + 1 : 1; + if (run > context.maxSymbolRepeats) { + return { + ok: false, + failure: fail( + 'symbol-repeat-limit', + `more than ${context.maxSymbolRepeats} consecutive identical marks ending at index ${index}`, + ), + }; + } + } + + return { ok: true, marks: [...accepted] }; +} + +/** + * A seeded mark list that the same module accepts: uniform length in + * `[1, maxSymbolsPerMessage]`, uniform marks, with candidates that would + * break the consecutive-repeat limit resampled. Identical draw order to the + * fixed-token module's `randomArtifact`, so the §9.6 `random` control stream + * stays comparable between the two symbolic carriers at one seed. + */ +export function randomMarkList( + prng: { nextInt(maxExclusive: number): number }, + context: CarrierContext, + inventory: readonly string[], +): string[] { + const cap = maxSymbolsFor(context.runContext.config); + const length = 1 + prng.nextInt(cap); + const marks: string[] = []; + while (marks.length < length) { + const candidate = inventory[prng.nextInt(inventory.length)] as string; + if (trailingRepeats(marks, candidate) >= context.maxSymbolRepeats) { + continue; + } + marks.push(candidate); + } + return marks; +} diff --git a/packages/gateway/src/carriers/register.ts b/packages/gateway/src/carriers/register.ts new file mode 100644 index 0000000..d38463c --- /dev/null +++ b/packages/gateway/src/carriers/register.ts @@ -0,0 +1,68 @@ +/** + * Opting a build into the SPECIFICATION.md §9.2 alternate carriers + * (ALD-031 criterion 3). + * + * §9.2 opens with the constraint this module encodes: "Alternate carriers are + * **explicit experiment conditions, never the default**." So the four modules + * are not registered when this package is imported. A process that runs a + * `fixed-glyph`, `generative-bitmap`, `generative-canvas`, or + * `generative-tone` condition calls {@link registerAlternateCarriers} once + * during bootstrap; a process that does not is left with the §9.1 fixed-token + * default and refuses an alternate-carrier run outright + * (`UnsupportedCarrierError`), which is the safer failure. + * + * Registration is deliberately all-or-nothing across modules *and* their + * ALD-036 conformance vectors: ALD-031 criterion 3 requires every registered + * carrier to contribute accept/reject vectors, so registering a module without + * its vectors would make `assertEveryCarrierHasVectors(registeredCarriers())` + * fail — and that assertion is the EPIC-06 gate. Doing both in one call means + * the gate cannot be tripped by a half-registration. + */ +import type { RunConfig } from '@ald/types'; + +import { + registerCarrierModule, + type CarrierModule, +} from '../carrier-modules.js'; +import { + ALTERNATE_CARRIER_VECTORS, + registerConformanceVectors, +} from '../conformance-vectors.js'; +import { generativeBitmapModule } from './bitmap.js'; +import { generativeCanvasModule } from './canvas.js'; +import { fixedGlyphModule } from './glyph.js'; +import { generativeToneModule } from './tone.js'; + +/** The four §9.2 modules, in `CarrierModeSchema` order. */ +export const ALTERNATE_CARRIER_MODULES: readonly CarrierModule[] = [ + fixedGlyphModule, + generativeBitmapModule, + generativeCanvasModule, + generativeToneModule, +]; + +/** Every §9.2 carrier an alternate module exists for. */ +export const ALTERNATE_CARRIERS: readonly RunConfig['carrierMode'][] = + ALTERNATE_CARRIER_MODULES.map((module) => module.carrier); + +/** + * Register the four §9.2 modules and their ALD-036 vectors. Idempotent: + * `registerCarrierModule` and `registerConformanceVectors` both replace any + * previous registration for the same carrier, so calling this twice — or + * calling it after `resetCarrierModules()` in a test — leaves the same state. + */ +export function registerAlternateCarriers(): void { + for (const module of ALTERNATE_CARRIER_MODULES) { + registerCarrierModule(module); + const vectors = ALTERNATE_CARRIER_VECTORS.get(module.carrier); + if (vectors === undefined) { + // Unreachable: `ALTERNATE_CARRIER_VECTORS` is keyed by the same four + // carriers. Kept explicit so a future fifth module cannot be registered + // without vectors and quietly break the EPIC-06 gate. + throw new Error( + `No ALD-036 conformance vectors are defined for carrier ${module.carrier}`, + ); + } + registerConformanceVectors(module.carrier, vectors); + } +} diff --git a/packages/gateway/src/carriers/tone.ts b/packages/gateway/src/carriers/tone.ts new file mode 100644 index 0000000..61c2f2b --- /dev/null +++ b/packages/gateway/src/carriers/tone.ts @@ -0,0 +1,123 @@ +/** + * `generative-tone` protocol module (SPECIFICATION.md §9.2, ALD-031). + * + * §9.2 table row: "Sequence of quantized `(pitchBin, durationBin)` pairs", + * bounded at "8 tones", and §9.2's prose: "Tone carriers use eight pitch bins + * and four duration bins; raw audio upload is prohibited." The prohibition is + * structural rather than a filter: the only admissible artifact is the + * `{ tones: { tones: [...] } }` shape `AgentActionProposalSchema` declares, so + * there is no field a sample buffer, data URL, or file reference could ride + * in — any such key is rejected by the extra-field check before its contents + * are looked at. + */ +import type { CarrierModule } from '../carrier-modules.js'; +import { extraFieldFailure, fail } from '../carrier-modules.js'; +import { + MAX_TONES, + TONE_DURATION_BINS, + TONE_PITCH_BINS, + isDurationBin, + isPitchBin, +} from './bounds.js'; +import { assertNoStrings, readCarrierArtifact, readNestedField } from './marks.js'; +import { isPlainObject } from '../inspect.js'; + +const TONE_FIELDS = ['pitchBin', 'durationBin'] as const; + +interface NormalizedTone { + pitchBin: number; + durationBin: number; +} + +export const generativeToneModule: CarrierModule = { + carrier: 'generative-tone', + allowedKinds: ['emit_tones'], + + validate(proposal) { + const read = readCarrierArtifact(proposal, 'tones'); + if (!read.ok) { + return read.failure; + } + + // SPEC §9.2: quantized numeric pairs only, and "raw audio upload is + // prohibited" — a data URL, file name, or note name is free text. + const text = assertNoStrings(read.value, 'publicArtifact.tones'); + if (text !== undefined) { + return text; + } + + const nested = readNestedField(read.value, 'tones', 'publicArtifact.tones'); + if (!nested.ok) { + return nested.failure; + } + + const tones = nested.value; + if (!Array.isArray(tones)) { + return fail( + 'invalid-envelope', + 'publicArtifact.tones.tones is not an array', + ); + } + if (tones.length === 0) { + // SPEC §9.1: a carrier message must contain at least one mark. + return fail('empty-message', 'publicArtifact.tones.tones is empty'); + } + if (tones.length > MAX_TONES) { + return fail( + 'too-many-tones', + `message carries ${tones.length} tones; the cap is ${MAX_TONES}`, + ); + } + + const normalized: NormalizedTone[] = []; + for (let index = 0; index < tones.length; index += 1) { + const tone: unknown = tones[index]; + if (!isPlainObject(tone)) { + return fail('invalid-envelope', `tone at index ${index} is not an object`); + } + const extraKeys = Object.keys(tone).filter( + (key) => !(TONE_FIELDS as readonly string[]).includes(key), + ); + if (extraKeys.length > 0) { + return extraFieldFailure(tone, extraKeys, `tones[${index}]`); + } + if (!isPitchBin(tone.pitchBin)) { + return fail( + 'tone-out-of-range', + `tones[${index}].pitchBin is outside the ${TONE_PITCH_BINS} pitch bins`, + ); + } + if (!isDurationBin(tone.durationBin)) { + return fail( + 'tone-out-of-range', + `tones[${index}].durationBin is outside the ${TONE_DURATION_BINS} duration bins`, + ); + } + normalized.push({ + pitchBin: tone.pitchBin, + durationBin: tone.durationBin, + }); + } + + return { ok: true, artifact: { tones: { tones: normalized } } }; + }, + + /** Uniform sequence length first, then uniform bins per tone. */ + randomArtifact(prng) { + const count = 1 + prng.nextInt(MAX_TONES); + const tones = Array.from({ length: count }, () => ({ + pitchBin: prng.nextInt(TONE_PITCH_BINS), + durationBin: 1 + prng.nextInt(TONE_DURATION_BINS), + })); + return { tones: { tones } }; + }, + + /** + * SPEC §9.6 `constant` default: one tone at the lowest pitch bin and the + * shortest duration bin — the minimal admissible sequence, mirroring the + * canvas carrier's single dot. Recorded as a BACKLOG §15 decision. + */ + constantArtifact() { + return { tones: { tones: [{ pitchBin: 0, durationBin: 1 }] } }; + }, +}; diff --git a/packages/gateway/src/conformance-vectors.ts b/packages/gateway/src/conformance-vectors.ts new file mode 100644 index 0000000..0ebf399 --- /dev/null +++ b/packages/gateway/src/conformance-vectors.ts @@ -0,0 +1,1001 @@ +/** + * Fixed-token conformance vectors (ALD-036, ALD-030, ALD-034, ALD-035). + * + * The vectors are data, not tests: they run against a real + * `SymbolGatewayImpl` and a real `EvidenceWriter` with no learner adapter + * involved (ALD-036 criterion 2), and the later ALD-031/ALD-033 modules + * extend the suite by exporting their own vector arrays in the same shape. + * + * Each vector's `envelope` is deliberately typed `unknown`: several of them + * are malformed submissions that a `TurnProposalEnvelope` could not express, + * which is exactly what the boundary has to survive. + */ +import { fixedTokenInventory, type RunConfig } from '@ald/types'; + +import type { GatewayReasonCode } from './reason-codes.js'; + +/** SPEC §9.1 default inventory (`S01`-`S32`) the vectors are written against. */ +export const CONFORMANCE_INVENTORY: readonly string[] = fixedTokenInventory(32); +/** SPEC §9.1 default `maxSymbolsPerMessage` the vectors assume. */ +export const CONFORMANCE_MAX_SYMBOLS = 4; +/** SPEC §9.1 default `maxSymbolRepeats` the vectors assume. */ +export const CONFORMANCE_MAX_SYMBOL_REPEATS = 3; + +/** A distinctive hash used inside metadata vectors; never produced by a run. */ +const SENTINEL_HASH = `sha256:${'a'.repeat(64)}`; + +export interface ConformanceVector { + name: string; + /** Raw submission handed to `submitProposal`. */ + envelope: unknown; + /** `accepted`, or the exact reason code the Gateway must report. */ + expect: 'accepted' | GatewayReasonCode; +} + +/** A valid `intention.recorded` draft (SPEC §8.1 step 2, §11.4). */ +export function conformanceIntentionDraft( + overrides: Record = {}, +): Record { + return { + eventType: 'intention.recorded', + contentSchema: 'agent-native-ledger', + subjectId: 'subject-conformance', + content: { artifactRef: 'artifact-conformance' }, + blindingNonce: 'nonce-conformance', + evidenceRefs: [], + ...overrides, + }; +} + +function envelope( + proposal: unknown, + draft: unknown = conformanceIntentionDraft(), +): unknown { + return { proposal, privateLedgerDraft: draft }; +} + +function symbolProposal(publicArtifact: unknown): unknown { + return { kind: 'emit_symbols', publicArtifact }; +} + +/** + * A value nested `depth` array levels deep, for exercising the SPEC §9.4 + * complexity budget (default `maxDepth` of 32; see inspect.ts). + */ +function deeplyNested(depth: number): unknown { + let value: unknown = 'leaf'; + for (let level = 0; level < depth; level += 1) { + value = [value]; + } + return value; +} + +export const FIXED_TOKEN_VECTORS: readonly ConformanceVector[] = [ + // --- acceptance (ALD-030 criterion 1) ----------------------------------- + { + name: 'accepts one inventory symbol', + envelope: envelope(symbolProposal({ symbols: ['S01'] })), + expect: 'accepted', + }, + { + name: 'accepts two inventory symbols', + envelope: envelope(symbolProposal({ symbols: ['S13', 'S04'] })), + expect: 'accepted', + }, + { + name: 'accepts three inventory symbols', + envelope: envelope(symbolProposal({ symbols: ['S32', 'S01', 'S17'] })), + expect: 'accepted', + }, + { + name: 'accepts the maximum of four symbols', + envelope: envelope( + symbolProposal({ symbols: ['S02', 'S02', 'S31', 'S31'] }), + ), + expect: 'accepted', + }, + { + name: 'accepts exactly maxSymbolRepeats consecutive repeats', + envelope: envelope(symbolProposal({ symbols: ['S05', 'S05', 'S05'] })), + expect: 'accepted', + }, + + // --- length and repetition (SPEC §9.1) ---------------------------------- + { + name: 'rejects an empty symbol list', + envelope: envelope(symbolProposal({ symbols: [] })), + expect: 'empty-message', + }, + { + name: 'rejects five symbols against a cap of four', + envelope: envelope( + symbolProposal({ symbols: ['S01', 'S02', 'S03', 'S04', 'S05'] }), + ), + expect: 'message-too-long', + }, + { + name: 'rejects seventeen symbols, over the absolute ceiling of sixteen', + envelope: envelope( + symbolProposal({ + symbols: Array.from({ length: 17 }, (_, index) => + CONFORMANCE_INVENTORY[index % CONFORMANCE_INVENTORY.length] as string, + ), + }), + ), + expect: 'message-too-long', + }, + { + name: 'rejects four consecutive repeats of one symbol', + envelope: envelope( + symbolProposal({ symbols: ['S07', 'S07', 'S07', 'S07'] }), + ), + expect: 'symbol-repeat-limit', + }, + + // --- allowlist and free text (SPEC §9.1, resolves Q6) ------------------- + { + name: 'rejects a well-formed symbol outside the declared inventory', + envelope: envelope(symbolProposal({ symbols: ['S33'] })), + expect: 'symbol-not-in-inventory', + }, + { + name: 'rejects a symbol with surrounding whitespace rather than trimming it', + envelope: envelope(symbolProposal({ symbols: [' S01 '] })), + expect: 'free-text-present', + }, + { + name: 'rejects prose in the symbol list', + envelope: envelope(symbolProposal({ symbols: ['hello world'] })), + expect: 'free-text-present', + }, + { + name: 'rejects a URL in the symbol list', + envelope: envelope(symbolProposal({ symbols: ['https://example.test/a'] })), + expect: 'free-text-present', + }, + { + name: 'rejects Unicode look-alikes of an inventory symbol', + envelope: envelope(symbolProposal({ symbols: ['S01'] })), + expect: 'free-text-present', + }, + { + name: 'rejects free text smuggled into an extra artifact field', + envelope: envelope( + symbolProposal({ symbols: ['S01'], note: 'please pick the red one' }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a non-string extra artifact field', + envelope: envelope(symbolProposal({ symbols: ['S01'], strength: 3 })), + expect: 'unexpected-artifact-field', + }, + + // --- trusted metadata (SPEC §11.3, ALD-035 criterion 1) ----------------- + { + name: 'rejects Baby-supplied trusted metadata at the top level', + envelope: envelope({ + kind: 'emit_symbols', + publicArtifact: { symbols: ['S01'] }, + runId: 'run-forged', + }), + expect: 'trusted-metadata-present', + }, + { + name: 'rejects Baby-supplied trusted metadata nested in the artifact', + envelope: envelope( + symbolProposal({ + symbols: ['S01'], + provenance: { previousHash: SENTINEL_HASH }, + }), + ), + expect: 'trusted-metadata-present', + }, + { + name: 'rejects a Baby-supplied sender identity', + envelope: envelope({ + kind: 'emit_symbols', + publicArtifact: { symbols: ['S01'] }, + sender: 'baby-b', + }), + expect: 'trusted-metadata-present', + }, + + // --- carrier routing (SPEC §9.6) ---------------------------------------- + { + name: 'rejects a task action submitted on the channel', + envelope: envelope({ + kind: 'select_object', + publicArtifact: { objectRef: 'object-1' }, + }), + expect: 'carrier-mismatch', + }, + { + name: 'rejects an unknown proposal kind', + envelope: envelope( + { kind: 'emit_prose', publicArtifact: { symbols: ['S01'] } }, + ), + expect: 'carrier-mismatch', + }, + + // --- required intention draft (SPEC §8.1 step 2) ------------------------ + { + name: 'rejects an interpretation draft where the intention belongs', + envelope: envelope( + symbolProposal({ symbols: ['S01'] }), + conformanceIntentionDraft({ eventType: 'interpretation.recorded' }), + ), + expect: 'missing-intention', + }, + { + name: 'rejects an intention draft missing its required content field', + envelope: envelope( + symbolProposal({ symbols: ['S01'] }), + conformanceIntentionDraft({ content: {} }), + ), + expect: 'missing-intention', + }, + { + name: 'rejects an unknown ledger event type', + envelope: envelope( + symbolProposal({ symbols: ['S01'] }), + conformanceIntentionDraft({ eventType: 'intention.smuggled' }), + ), + expect: 'missing-intention', + }, + + // --- complexity budget (SPEC §9.4) -------------------------------------- + { + name: 'rejects a public artifact nested past the complexity budget', + envelope: envelope(symbolProposal({ symbols: [deeplyNested(50)] })), + expect: 'payload-too-complex', + }, + { + name: 'rejects a private ledger draft nested past the complexity budget', + envelope: envelope( + symbolProposal({ symbols: ['S01'] }), + conformanceIntentionDraft({ content: deeplyNested(50) }), + ), + expect: 'payload-too-complex', + }, + + // --- envelope frame (SPEC §11.3, ALD-035) ------------------------------- + { + name: 'rejects an envelope with no private ledger draft', + envelope: { proposal: symbolProposal({ symbols: ['S01'] }) }, + expect: 'invalid-envelope', + }, + { + name: 'rejects an envelope with an extra top-level field', + envelope: { + proposal: symbolProposal({ symbols: ['S01'] }), + privateLedgerDraft: conformanceIntentionDraft(), + broadcast: true, + }, + expect: 'invalid-envelope', + }, + { + name: 'rejects a non-object envelope', + envelope: 'S01', + expect: 'invalid-envelope', + }, + { + name: 'rejects a symbol list that is not an array', + envelope: envelope(symbolProposal({ symbols: 'S01' })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a non-string symbol', + envelope: envelope(symbolProposal({ symbols: [1] })), + expect: 'invalid-envelope', + }, + { + name: 'rejects an artifact with no symbols field', + envelope: envelope(symbolProposal({})), + expect: 'invalid-envelope', + }, + { + name: 'rejects a proposal with no kind', + envelope: envelope({ publicArtifact: { symbols: ['S01'] } }), + expect: 'invalid-envelope', + }, +]; + +// --------------------------------------------------------------------------- +// Per-carrier vector registry (ALD-036 criterion 3) +// --------------------------------------------------------------------------- + +const carrierVectors = new Map< + RunConfig['carrierMode'], + readonly ConformanceVector[] +>([['fixed-token', FIXED_TOKEN_VECTORS]]); + +/** + * Registers the vector set for one carrier. A module must contribute at least + * one acceptance and one rejection vector, so a carrier cannot be declared + * conformant on happy-path coverage alone. + */ +export function registerConformanceVectors( + carrier: RunConfig['carrierMode'], + vectors: readonly ConformanceVector[], +): void { + if (!vectors.some((vector) => vector.expect === 'accepted')) { + throw new Error(`${carrier} vectors must include at least one acceptance`); + } + if (!vectors.some((vector) => vector.expect !== 'accepted')) { + throw new Error(`${carrier} vectors must include at least one rejection`); + } + carrierVectors.set(carrier, vectors); +} + +export function conformanceVectorsFor( + carrier: RunConfig['carrierMode'], +): readonly ConformanceVector[] | undefined { + return carrierVectors.get(carrier); +} + +/** + * The EPIC-06 gate: every registered protocol module must contribute vectors + * before the consolidated suite can be called green (ALD-036, ALD-078). + */ +export function assertEveryCarrierHasVectors( + carriers: readonly RunConfig['carrierMode'][], +): void { + const missing = carriers.filter((carrier) => !carrierVectors.has(carrier)); + if (missing.length > 0) { + throw new Error( + `Registered carrier module(s) contribute no conformance vectors: ${missing.join(', ')}`, + ); + } +} + +/** Test helper: drop every registration except the built-in fixed-token set. */ +export function resetConformanceVectors(): void { + carrierVectors.clear(); + carrierVectors.set('fixed-token', FIXED_TOKEN_VECTORS); +} + +// --------------------------------------------------------------------------- +// SPEC §9.2 alternate-carrier vectors (ALD-031 criterion 3, ALD-036) +// --------------------------------------------------------------------------- +// +// One array per §9.2 carrier, in the same shape as `FIXED_TOKEN_VECTORS` and +// exercised by the same driver. They are registered by +// `registerAlternateCarriers()` (`carriers/register.ts`) rather than at module +// load, because SPEC §9.2 makes an alternate carrier "an explicit experiment +// condition, never the default": a build that has not opted into the +// alternate carriers has no module registered for them either, and +// `assertEveryCarrierHasVectors(registeredCarriers())` must stay satisfiable +// in both states. +// +// Every vector below is written against `CONFORMANCE_MAX_SYMBOLS` (4), +// `CONFORMANCE_MAX_SYMBOL_REPEATS` (3) and the §9.2 defaults: a 32-mark glyph +// inventory, exactly 256 bitmap bits, 8 strokes, 8 tones. + +/** SPEC §9.2 default `fixed-glyph` inventory the glyph vectors assume. */ +export const CONFORMANCE_GLYPH_INVENTORY: readonly string[] = Array.from( + { length: 32 }, + (_, index) => `G${String(index + 1).padStart(2, '0')}`, +); + +/** Exactly 256 bits, the §9.2 `generative-bitmap` grid. */ +function bits(fill: (index: number) => 0 | 1, count = 256): (0 | 1)[] { + return Array.from({ length: count }, (_, index) => fill(index)); +} + +function stroke( + overrides: Record = {}, +): Record { + return { startX: 1, startY: 2, endX: 13, endY: 14, width: 1, ...overrides }; +} + +function tone( + overrides: Record = {}, +): Record { + return { pitchBin: 3, durationBin: 2, ...overrides }; +} + +function glyphProposal(publicArtifact: unknown): unknown { + return { kind: 'emit_glyphs', publicArtifact }; +} + +function bitmapProposal(publicArtifact: unknown): unknown { + return { kind: 'emit_bitmap', publicArtifact }; +} + +function canvasProposal(publicArtifact: unknown): unknown { + return { kind: 'emit_canvas', publicArtifact }; +} + +function toneProposal(publicArtifact: unknown): unknown { + return { kind: 'emit_tones', publicArtifact }; +} + +export const FIXED_GLYPH_VECTORS: readonly ConformanceVector[] = [ + // --- acceptance (ALD-031 criterion 1) ----------------------------------- + { + name: 'accepts one inventory glyph', + envelope: envelope(glyphProposal({ glyphs: ['G01'] })), + expect: 'accepted', + }, + { + name: 'accepts three inventory glyphs', + envelope: envelope(glyphProposal({ glyphs: ['G32', 'G01', 'G17'] })), + expect: 'accepted', + }, + { + name: 'accepts the maximum of four glyphs', + envelope: envelope(glyphProposal({ glyphs: ['G02', 'G02', 'G31', 'G31'] })), + expect: 'accepted', + }, + { + name: 'accepts exactly maxSymbolRepeats consecutive repeats', + envelope: envelope(glyphProposal({ glyphs: ['G05', 'G05', 'G05'] })), + expect: 'accepted', + }, + + // --- length and repetition (SPEC §9.1 rules, §9.2 "same repeat rule") --- + { + name: 'rejects an empty glyph list', + envelope: envelope(glyphProposal({ glyphs: [] })), + expect: 'empty-message', + }, + { + name: 'rejects five glyphs against a cap of four', + envelope: envelope( + glyphProposal({ glyphs: ['G01', 'G02', 'G03', 'G04', 'G05'] }), + ), + expect: 'message-too-long', + }, + { + name: 'rejects four consecutive repeats of one glyph', + envelope: envelope( + glyphProposal({ glyphs: ['G07', 'G07', 'G07', 'G07'] }), + ), + expect: 'symbol-repeat-limit', + }, + + // --- allowlist and free text (SPEC §9.2) -------------------------------- + { + name: 'rejects a well-formed glyph id outside the frozen bundle', + envelope: envelope(glyphProposal({ glyphs: ['G33'] })), + expect: 'glyph-not-in-inventory', + }, + { + name: 'rejects a fixed-token symbol submitted as a glyph id', + envelope: envelope(glyphProposal({ glyphs: ['S01'] })), + expect: 'glyph-not-in-inventory', + }, + { + name: 'rejects a glyph id with surrounding whitespace rather than trimming it', + envelope: envelope(glyphProposal({ glyphs: [' G01 '] })), + expect: 'free-text-present', + }, + { + name: 'rejects prose in the glyph list', + envelope: envelope(glyphProposal({ glyphs: ['a small red circle'] })), + expect: 'free-text-present', + }, + { + name: 'rejects a semantic tag smuggled into an extra artifact field', + envelope: envelope( + glyphProposal({ glyphs: ['G01'], meaning: 'the round one' }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a non-string extra artifact field', + envelope: envelope(glyphProposal({ glyphs: ['G01'], strength: 3 })), + expect: 'unexpected-artifact-field', + }, + + // --- envelope frame and routing ---------------------------------------- + { + name: 'rejects a non-string glyph id', + envelope: envelope(glyphProposal({ glyphs: [1] })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a glyph list that is not an array', + envelope: envelope(glyphProposal({ glyphs: 'G01' })), + expect: 'invalid-envelope', + }, + { + name: 'rejects an artifact with no glyphs field', + envelope: envelope(glyphProposal({})), + expect: 'invalid-envelope', + }, + { + name: 'rejects a fixed-token proposal on a glyph run', + envelope: envelope({ + kind: 'emit_symbols', + publicArtifact: { symbols: ['S01'] }, + }), + expect: 'carrier-mismatch', + }, + { + name: 'rejects Baby-supplied trusted metadata on a glyph proposal', + envelope: envelope({ + kind: 'emit_glyphs', + publicArtifact: { glyphs: ['G01'] }, + runId: 'run-forged', + }), + expect: 'trusted-metadata-present', + }, + { + name: 'rejects a glyph proposal with no intention draft', + envelope: envelope( + glyphProposal({ glyphs: ['G01'] }), + conformanceIntentionDraft({ eventType: 'interpretation.recorded' }), + ), + expect: 'missing-intention', + }, +]; + +export const GENERATIVE_BITMAP_VECTORS: readonly ConformanceVector[] = [ + // --- acceptance (ALD-031 criterion 1) ----------------------------------- + { + name: 'accepts the all-zero 256-bit matrix', + envelope: envelope(bitmapProposal({ bitmap: { bits: bits(() => 0) } })), + expect: 'accepted', + }, + { + name: 'accepts the all-one 256-bit matrix', + envelope: envelope(bitmapProposal({ bitmap: { bits: bits(() => 1) } })), + expect: 'accepted', + }, + { + name: 'accepts an alternating 256-bit matrix', + envelope: envelope( + bitmapProposal({ + bitmap: { bits: bits((index) => (index % 2 === 0 ? 1 : 0)) }, + }), + ), + expect: 'accepted', + }, + + // --- exact grid size (SPEC §9.2 "256 bits") ----------------------------- + { + name: 'rejects a 255-bit matrix', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0, 255) } }), + ), + expect: 'bitmap-size-invalid', + }, + { + name: 'rejects a 257-bit matrix', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0, 257) } }), + ), + expect: 'bitmap-size-invalid', + }, + { + name: 'rejects an empty bit matrix', + envelope: envelope(bitmapProposal({ bitmap: { bits: [] } })), + expect: 'bitmap-size-invalid', + }, + + // --- cell values -------------------------------------------------------- + { + name: 'rejects a cell value of 2', + envelope: envelope( + bitmapProposal({ + bitmap: { bits: bits((index) => (index === 5 ? (2 as 0 | 1) : 0)) }, + }), + ), + expect: 'bitmap-value-invalid', + }, + { + name: 'rejects a fractional cell value', + envelope: envelope( + bitmapProposal({ + bitmap: { bits: bits((index) => (index === 0 ? (0.5 as 0 | 1) : 0)) }, + }), + ), + expect: 'bitmap-value-invalid', + }, + { + name: 'rejects a string cell as free text, not as a value error', + envelope: envelope( + bitmapProposal({ + bitmap: { + bits: bits((index) => (index === 3 ? ('1' as unknown as 0 | 1) : 0)), + }, + }), + ), + expect: 'free-text-present', + }, + + // --- no color or text field (SPEC §9.2) --------------------------------- + { + name: 'rejects a color field beside the bitmap', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0) }, color: '#ff0000' }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a caption nested inside the bitmap', + envelope: envelope( + bitmapProposal({ + bitmap: { bits: bits(() => 0), caption: 'the tall one' }, + }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a numeric grid override nested inside the bitmap', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0), gridWidth: 16 } }), + ), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects an explicit bitmap width side feature', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0) }, width: 16 }), + ), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects an explicit bitmap height side feature', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0) }, height: 16 }), + ), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects a bitmap compression side feature', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0) }, compression: 'rle' }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a bitmap container side feature', + envelope: envelope( + bitmapProposal({ bitmap: { bits: bits(() => 0) }, container: 'png' }), + ), + expect: 'free-text-present', + }, + + // --- envelope frame and routing ---------------------------------------- + { + name: 'rejects a bitmap that is not an object', + envelope: envelope(bitmapProposal({ bitmap: [0, 1] })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a bits field that is not an array', + envelope: envelope(bitmapProposal({ bitmap: { bits: 256 } })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a canvas proposal on a bitmap run', + envelope: envelope(canvasProposal({ strokes: [stroke()] })), + expect: 'carrier-mismatch', + }, +]; + +export const GENERATIVE_CANVAS_VECTORS: readonly ConformanceVector[] = [ + // --- acceptance (ALD-031 criterion 1) ----------------------------------- + { + name: 'accepts one stroke', + envelope: envelope(canvasProposal({ strokes: [stroke()] })), + expect: 'accepted', + }, + { + name: 'accepts the default maximum of eight strokes', + envelope: envelope( + canvasProposal({ + strokes: Array.from({ length: 8 }, (_, index) => + stroke({ startX: index, endX: 15 - index }), + ), + }), + ), + expect: 'accepted', + }, + { + name: 'accepts a degenerate zero-length stroke as a dot', + envelope: envelope( + canvasProposal({ + strokes: [{ startX: 4, startY: 4, endX: 4, endY: 4, width: 2 }], + }), + ), + expect: 'accepted', + }, + { + name: 'accepts all three quantized pen widths', + envelope: envelope( + canvasProposal({ + strokes: [stroke({ width: 1 }), stroke({ width: 2 }), stroke({ width: 3 })], + }), + ), + expect: 'accepted', + }, + { + name: 'accepts strokes at both grid extremes', + envelope: envelope( + canvasProposal({ + strokes: [{ startX: 0, startY: 0, endX: 15, endY: 15, width: 3 }], + }), + ), + expect: 'accepted', + }, + + // --- stroke count (SPEC §9.2 maxStrokes) -------------------------------- + { + name: 'rejects an empty stroke list', + envelope: envelope(canvasProposal({ strokes: [] })), + expect: 'empty-message', + }, + { + name: 'rejects nine strokes against a cap of eight', + envelope: envelope( + canvasProposal({ strokes: Array.from({ length: 9 }, () => stroke()) }), + ), + expect: 'too-many-strokes', + }, + + // --- coordinate and width bounds ---------------------------------------- + { + name: 'rejects a stroke past the right edge of the grid', + envelope: envelope(canvasProposal({ strokes: [stroke({ endX: 16 })] })), + expect: 'stroke-out-of-range', + }, + { + name: 'rejects a negative stroke coordinate', + envelope: envelope(canvasProposal({ strokes: [stroke({ startY: -1 })] })), + expect: 'stroke-out-of-range', + }, + { + name: 'rejects a fractional stroke coordinate', + envelope: envelope(canvasProposal({ strokes: [stroke({ startX: 1.5 })] })), + expect: 'stroke-out-of-range', + }, + { + name: 'rejects a pen width of four', + envelope: envelope(canvasProposal({ strokes: [stroke({ width: 4 })] })), + expect: 'stroke-width-invalid', + }, + { + name: 'rejects a pen width of zero', + envelope: envelope(canvasProposal({ strokes: [stroke({ width: 0 })] })), + expect: 'stroke-width-invalid', + }, + + // --- no color or text field (SPEC §9.2) --------------------------------- + { + name: 'rejects a per-stroke color field', + envelope: envelope( + canvasProposal({ strokes: [stroke({ color: '#00ff00' })] }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a per-stroke semantic tag', + envelope: envelope( + canvasProposal({ strokes: [stroke({ label: 'the square one' })] }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a per-stroke numeric extra field', + envelope: envelope( + canvasProposal({ strokes: [stroke({ pressure: 2 })] }), + ), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects an explicit canvas width side feature', + envelope: envelope(canvasProposal({ strokes: [stroke()], canvasWidth: 16 })), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects an explicit canvas height side feature', + envelope: envelope(canvasProposal({ strokes: [stroke()], canvasHeight: 16 })), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects a canvas compression side feature', + envelope: envelope(canvasProposal({ strokes: [stroke()], compression: 'svgz' })), + expect: 'free-text-present', + }, + { + name: 'rejects a canvas container side feature', + envelope: envelope(canvasProposal({ strokes: [stroke()], container: 'svg' })), + expect: 'free-text-present', + }, + { + name: 'rejects a string pen width as free text', + envelope: envelope(canvasProposal({ strokes: [stroke({ width: '1' })] })), + expect: 'free-text-present', + }, + + // --- envelope frame and routing ---------------------------------------- + { + name: 'rejects a stroke list that is not an array', + envelope: envelope(canvasProposal({ strokes: stroke() })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a stroke that is not an object', + envelope: envelope(canvasProposal({ strokes: [[0, 0, 1, 1, 1]] })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a tone proposal on a canvas run', + envelope: envelope(toneProposal({ tones: { tones: [tone()] } })), + expect: 'carrier-mismatch', + }, +]; + +export const GENERATIVE_TONE_VECTORS: readonly ConformanceVector[] = [ + // --- acceptance (ALD-031 criterion 1) ----------------------------------- + { + name: 'accepts one tone', + envelope: envelope(toneProposal({ tones: { tones: [tone()] } })), + expect: 'accepted', + }, + { + name: 'accepts the maximum of eight tones', + envelope: envelope( + toneProposal({ + tones: { + tones: Array.from({ length: 8 }, (_, index) => + tone({ pitchBin: index }), + ), + }, + }), + ), + expect: 'accepted', + }, + { + name: 'accepts both bin extremes', + envelope: envelope( + toneProposal({ + tones: { + tones: [ + { pitchBin: 0, durationBin: 1 }, + { pitchBin: 7, durationBin: 4 }, + ], + }, + }), + ), + expect: 'accepted', + }, + + // --- sequence length (SPEC §9.2 "8 tones") ------------------------------ + { + name: 'rejects an empty tone sequence', + envelope: envelope(toneProposal({ tones: { tones: [] } })), + expect: 'empty-message', + }, + { + name: 'rejects nine tones against a cap of eight', + envelope: envelope( + toneProposal({ + tones: { tones: Array.from({ length: 9 }, () => tone()) }, + }), + ), + expect: 'too-many-tones', + }, + + // --- bin bounds --------------------------------------------------------- + { + name: 'rejects a ninth pitch bin', + envelope: envelope( + toneProposal({ tones: { tones: [tone({ pitchBin: 8 })] } }), + ), + expect: 'tone-out-of-range', + }, + { + name: 'rejects a negative pitch bin', + envelope: envelope( + toneProposal({ tones: { tones: [tone({ pitchBin: -1 })] } }), + ), + expect: 'tone-out-of-range', + }, + { + name: 'rejects a zero duration bin', + envelope: envelope( + toneProposal({ tones: { tones: [tone({ durationBin: 0 })] } }), + ), + expect: 'tone-out-of-range', + }, + { + name: 'rejects a fifth duration bin', + envelope: envelope( + toneProposal({ tones: { tones: [tone({ durationBin: 5 })] } }), + ), + expect: 'tone-out-of-range', + }, + + // --- no text field, no raw audio (SPEC §9.2) ---------------------------- + { + name: 'rejects a note name beside the quantized bins', + envelope: envelope( + toneProposal({ tones: { tones: [tone({ noteName: 'C4' })] } }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a base64 audio payload smuggled beside the sequence', + envelope: envelope( + toneProposal({ + tones: { tones: [tone()] }, + sample: 'UklGRiQAAABXQVZF', + }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a numeric tempo field beside the sequence', + envelope: envelope( + toneProposal({ tones: { tones: [tone()] }, tempo: 120 }), + ), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects a per-tone numeric extra field', + envelope: envelope( + toneProposal({ tones: { tones: [tone({ velocity: 5 })] } }), + ), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects a tone sample-rate side feature', + envelope: envelope( + toneProposal({ tones: { tones: [tone()] }, sampleRate: 48_000 }), + ), + expect: 'unexpected-artifact-field', + }, + { + name: 'rejects a tone compression side feature', + envelope: envelope( + toneProposal({ tones: { tones: [tone()] }, compression: 'flac' }), + ), + expect: 'free-text-present', + }, + { + name: 'rejects a tone container side feature', + envelope: envelope( + toneProposal({ tones: { tones: [tone()] }, container: 'wav' }), + ), + expect: 'free-text-present', + }, + + // --- envelope frame and routing ---------------------------------------- + { + name: 'rejects a tone sequence that is not an array', + envelope: envelope(toneProposal({ tones: { tones: tone() } })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a tone that is not an object', + envelope: envelope(toneProposal({ tones: { tones: [[3, 2]] } })), + expect: 'invalid-envelope', + }, + { + name: 'rejects a glyph proposal on a tone run', + envelope: envelope(glyphProposal({ glyphs: ['G01'] })), + expect: 'carrier-mismatch', + }, +]; + +/** + * Vector set per §9.2 alternate carrier, keyed exactly as `carrierMode` is. + * `registerAlternateCarriers()` feeds this map into + * `registerConformanceVectors`, so ALD-036's + * `assertEveryCarrierHasVectors(registeredCarriers())` covers all five + * carriers once a run has opted into the alternate ones (ALD-031 criterion 3). + */ +export const ALTERNATE_CARRIER_VECTORS: ReadonlyMap< + RunConfig['carrierMode'], + readonly ConformanceVector[] +> = new Map([ + ['fixed-glyph', FIXED_GLYPH_VECTORS], + ['generative-bitmap', GENERATIVE_BITMAP_VECTORS], + ['generative-canvas', GENERATIVE_CANVAS_VECTORS], + ['generative-tone', GENERATIVE_TONE_VECTORS], +] as const); diff --git a/packages/gateway/src/errors.ts b/packages/gateway/src/errors.ts new file mode 100644 index 0000000..0b9547e --- /dev/null +++ b/packages/gateway/src/errors.ts @@ -0,0 +1,290 @@ +/** + * Gateway error taxonomy and the SPECIFICATION.md §12.3 response mapping. + * + * A *rejection* is not an error: `submitProposal` returns a + * `GatewaySubmitResult` of kind `rejected` because the rejection itself is + * evidence and must be committed before the caller learns about it. The + * classes here cover the cases where the caller — the Nursery Controller or a + * twin route — asked for something the Gateway may not do at all, plus the + * receiver-side interpretation rejection, which throws *after* its + * `channel.rejected` event is committed. + * + * Every class carries the §12.3 `code` and HTTP status so the twin routes + * (ALD-048/ALD-052) can render `{ error: { code, message, details? } }` + * without re-deriving the mapping. + */ +import { ZodError } from 'zod'; + +import type { ChannelEvent, GatewaySubmitResult, RunConfig } from '@ald/types'; + +import type { GatewayReasonCode } from './reason-codes.js'; + +/** The §12.3 error codes the Gateway can produce. */ +export type GatewayErrorCode = + | 'CHANNEL_REJECTED' + | 'INVALID_REQUEST' + | 'CONFLICT' + | 'FORBIDDEN'; + +/** HTTP statuses paired with {@link GatewayErrorCode} in SPEC §12.3. */ +export type GatewayErrorStatus = 400 | 403 | 409 | 422; + +export interface GatewayErrorBody { + code: GatewayErrorCode; + message: string; + details?: Record; +} + +export interface GatewayErrorResponse { + status: GatewayErrorStatus; + error: GatewayErrorBody; +} + +const STATUS_FOR_CODE: Record = { + INVALID_REQUEST: 400, + FORBIDDEN: 403, + CONFLICT: 409, + CHANNEL_REJECTED: 422, +}; + +/** Base class for every condition the Gateway refuses outright. */ +export class GatewayError extends Error { + readonly status: GatewayErrorStatus; + + constructor( + readonly code: GatewayErrorCode, + message: string, + readonly details?: Record, + ) { + super(message); + this.name = new.target.name; + this.status = STATUS_FOR_CODE[code]; + } +} + +export function isGatewayError(value: unknown): value is GatewayError { + return value instanceof GatewayError; +} + +/** + * SPEC §9.2 carriers other than `fixed-token` are separate experiment + * conditions and are only reachable once their protocol module is registered + * (BACKLOG ALD-031 for the neutral carriers, ALD-033 for the affect channel). + */ +export class UnsupportedCarrierError extends GatewayError { + constructor(readonly carrier: RunConfig['carrierMode']) { + super( + 'INVALID_REQUEST', + `No protocol module is registered for carrier ${carrier}; register one (ALD-031 for neutral carriers, ALD-033 for the affect channel) before constructing a Gateway`, + { carrier, backlogRefs: ['ALD-031', 'ALD-033'] }, + ); + } +} + +/** + * SPEC §9.6 `shuffled` delivers an artifact from *another* episode of the + * same evaluation batch, so the caller must supply the batch and this + * episode's position in it. + */ +export class ShuffledBatchRequiredError extends GatewayError { + constructor(readonly turn: number) { + super( + 'INVALID_REQUEST', + `The shuffled communication condition requires batchArtifacts and a batchIndex inside them (turn ${turn})`, + { turn, condition: 'shuffled' }, + ); + } +} + +/** + * SPEC §9.6: under `oracle` no learner output is used, so a Baby proposal is + * never routed. The Nursery Controller calls `submitControlArtifact` with the + * Scenario Engine's minimal sufficient artifact instead. + */ +export class OracleRequiresControlArtifactError extends GatewayError { + constructor() { + super( + 'FORBIDDEN', + 'The oracle communication condition uses no learner output; call submitControlArtifact with the Scenario Engine artifact', + { condition: 'oracle' }, + ); + } +} + +/** Mirror of {@link OracleRequiresControlArtifactError} for the other five conditions. */ +export class ControlArtifactNotPermittedError extends GatewayError { + constructor(readonly condition: RunConfig['communicationCondition']) { + super( + 'FORBIDDEN', + `Control artifacts are only accepted under the oracle condition, not ${condition}`, + { condition }, + ); + } +} + +/** + * A researcher-supplied artifact (pre-registered constant, oracle output, or + * a batch member for `shuffled`) that the active carrier module rejects. This + * is a configuration/orchestration fault, not a Baby channel violation, so it + * is never committed as `channel.rejected`. + */ +export class InvalidControlArtifactError extends GatewayError { + constructor( + readonly reasonCode: GatewayReasonCode, + readonly origin: 'constant' | 'oracle' | 'shuffled-batch', + detail: string, + ) { + super( + 'INVALID_REQUEST', + `The ${origin} artifact is not valid for the active carrier (${reasonCode}): ${detail}`, + { reasonCode, origin }, + ); + } +} + +/** The declared symbol inventory itself violates SPEC §9.1. */ +export class InvalidSymbolInventoryError extends GatewayError { + constructor(detail: string) { + super('INVALID_REQUEST', `Invalid symbol inventory: ${detail}`, {}); + } +} + +/** + * SPEC §11.3: the receiver must echo the `channelEventHash` of the delivery + * addressed to it. The `channel.rejected` event is committed first, so + * `channelEvent` is always a committed event. + */ +export class InterpretationRejectedError extends GatewayError { + constructor( + readonly reasonCode: GatewayReasonCode, + readonly channelEvent: ChannelEvent, + readonly rejectedPayloadHash: string, + readonly consecutiveRejections: number, + readonly pauseRequested: boolean, + ) { + super('CHANNEL_REJECTED', `Interpretation rejected: ${reasonCode}`, { + reasonCode, + channelEventHash: channelEvent.entryHash, + rejectedPayloadHash, + consecutiveRejections, + pauseRequested, + }); + } +} + +/** + * SPEC §8.3: the per-turn response deadline elapsed. The caller commits the + * forfeited turn with `rejectForTimeout`; the deadline helper only reports + * that the budget is gone. + */ +export class TurnDeadlineExceededError extends GatewayError { + readonly reasonCode: GatewayReasonCode = 'timeout'; + + constructor(readonly budgetMs: number) { + super( + 'CHANNEL_REJECTED', + `Turn response budget of ${budgetMs}ms elapsed`, + { reasonCode: 'timeout', budgetMs }, + ); + } +} + +/** Evidence Writer errors expose a machine-readable `code`; see @ald/evidence. */ +const EVIDENCE_CODE_MAP: Record = { + 'duplicate-run': 'CONFLICT', + 'unknown-run': 'CONFLICT', + 'duplicate-event': 'CONFLICT', + 'fork-detected': 'CONFLICT', + 'interpretation-binding': 'CONFLICT', + 'integrity-blocked': 'CONFLICT', + 'checkpoint-chain': 'CONFLICT', + 'experiment-record-version': 'CONFLICT', + 'invalid-request': 'INVALID_REQUEST', +}; + +function evidenceErrorCode(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === 'string' && code in EVIDENCE_CODE_MAP ? code : undefined; +} + +/** + * SPEC §12.3 response mapping used by the twin routes. + * + * Unmapped failures (an infrastructure fault, say) are reported as + * `CONFLICT`/409 with `details.unmapped`, never as a request error: the + * caller cannot fix them by changing the request, and the §12.3 code list has + * no server-fault member. A route layer that distinguishes 5xx should test + * {@link isGatewayError} first and handle the remainder itself. + */ +export function toGatewayError(error: unknown): GatewayErrorResponse { + if (isGatewayError(error)) { + return { + status: error.status, + error: { + code: error.code, + message: error.message, + ...(error.details === undefined ? {} : { details: error.details }), + }, + }; + } + + if (error instanceof ZodError) { + return { + status: 400, + error: { + code: 'INVALID_REQUEST', + message: 'Request failed schema validation', + details: { issues: error.issues.length }, + }, + }; + } + + const evidenceCode = evidenceErrorCode(error); + if (evidenceCode !== undefined) { + const code = EVIDENCE_CODE_MAP[evidenceCode] as GatewayErrorCode; + return { + status: STATUS_FOR_CODE[code], + error: { + code, + message: error instanceof Error ? error.message : String(error), + details: { evidenceCode }, + }, + }; + } + + return { + status: 409, + error: { + code: 'CONFLICT', + message: 'The Gateway could not complete the request', + details: { unmapped: true }, + }, + }; +} + +/** + * SPEC §12.3 mapping for a committed channel rejection. The body carries the + * reason code, the payload hash, and the counters only — never the attempted + * content. + */ +export function toGatewayRejectionResponse( + result: Extract, +): GatewayErrorResponse { + return { + status: 422, + error: { + code: 'CHANNEL_REJECTED', + message: `Proposal rejected: ${result.reasonCode}`, + details: { + reasonCode: result.reasonCode, + rejectedPayloadHash: result.rejectedPayloadHash, + channelEventHash: result.channelEvent.entryHash, + consecutiveRejections: result.consecutiveRejections, + pauseRequested: result.pauseRequested, + }, + }, + }; +} diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts new file mode 100644 index 0000000..cb00537 --- /dev/null +++ b/packages/gateway/src/index.ts @@ -0,0 +1,250 @@ +/** + * `@ald/gateway` — the Symbol Gateway: the single mediation point for every + * inter-agent artifact (SPECIFICATION.md §4.1 item 3, §9). + * + * There is no route from one Baby to the other. A proposal is validated + * against the run's registered protocol module, transformed by the §9.6 + * communication-control condition, committed atomically through the one + * Evidence Writer, and only then released to the receiver. Every rejection is + * itself evidence and carries a reason code and a payload hash, never the + * attempted content. + * + * Owning backlog items: ALD-029 (core router), ALD-030 (fixed-token + * protocol), ALD-034 (rejection framework), ALD-035 (boundary schemas), + * ALD-036 (conformance suite). + */ +export { + ABSOLUTE_MAX_SYMBOLS_PER_MESSAGE, + carrierModule, + DEFAULT_MAX_SYMBOL_REPEATS, + DEFAULT_MAX_SYMBOLS_PER_MESSAGE, + fixedTokenModule, + maxSymbolsFor, + registerCarrierModule, + registeredCarriers, + resetCarrierModules, + type ActionKind, + type CarrierContext, + type CarrierModule, + type CarrierValidationResult, + type PublicArtifact, +} from './carrier-modules.js'; +export { + ControlArtifactNotPermittedError, + GatewayError, + InterpretationRejectedError, + InvalidControlArtifactError, + InvalidSymbolInventoryError, + isGatewayError, + OracleRequiresControlArtifactError, + ShuffledBatchRequiredError, + toGatewayError, + toGatewayRejectionResponse, + TurnDeadlineExceededError, + UnsupportedCarrierError, + type GatewayErrorBody, + type GatewayErrorCode, + type GatewayErrorResponse, + type GatewayErrorStatus, +} from './errors.js'; +export { + DEFAULT_COMPLEXITY_BUDGET, + findTrustedMetadataKey, + isWithinComplexityBudget, + PayloadTooComplexError, + TRUSTED_METADATA_KEYS, + type ComplexityBudget, + type TrustedMetadataKey, +} from './inspect.js'; +export { + GATEWAY_REASON_CODES, + isGatewayReasonCode, + type GatewayReasonCode, +} from './reason-codes.js'; +export { + GATEWAY_ACTOR_ID, + MAX_CONSECUTIVE_REJECTIONS_REASON, + SymbolGatewayImpl, + withTurnDeadline, + type GatewayDelivery, + type GatewayRejection, + type SymbolGatewayOptions, +} from './symbol-gateway.js'; +export { + assertEveryCarrierHasVectors, + conformanceVectorsFor, + registerConformanceVectors, + resetConformanceVectors, + CONFORMANCE_INVENTORY, + CONFORMANCE_MAX_SYMBOL_REPEATS, + CONFORMANCE_MAX_SYMBOLS, + conformanceIntentionDraft, + FIXED_TOKEN_VECTORS, + type ConformanceVector, +} from './conformance-vectors.js'; +export { + InMemoryEvidenceError, + InMemoryEvidenceWriter, + REQUIRED_SIGNER_DOMAINS, + StepClock, + type InMemoryEvidenceWriterOptions, +} from './testing.js'; +// --- Six-display affect protocol (SPEC §9.3, ALD-033) ---------------------- +export { + AFFECT_ACCEPTED_RESULT_KEYS, + AFFECT_MEASUREMENT_HASH_DOMAIN, + AFFECT_REJECTED_RESULT_KEYS, + AFFECT_VIOLATION_REASON, + AffectProtocol, + EMERGENT_AFFECT_ANALYSIS_TAG, + assertAffectConfiguration, + normalizedAffectSubmission, + producesAffectEvents, + tagEmergentAffect, + type AffectProtocolHost, + type AffectRejectionCommit, + type DerivedAffectRecord, + type DerivedAffectResult, + type EmergentAffectTag, +} from './affect.js'; +export { + AffectDisabledError, + AffectModeMismatchError, + AffectProtocolError, + AffectWindowNotOpenError, + InvalidAffectWindowError, + InvalidAffectWindowScheduleError, + UnknownAffectDerivedMappingError, + type AffectErrorCode, +} from './affect-errors.js'; +export { + AFFECT_DISPLAY_COUNT, + DEFAULT_AFFECT_DERIVED_MAPPING, + SUPPORTED_MEASUREMENT_VERSIONS, + argmaxV1Mapping, + registerAffectDerivedMapping, + registeredAffectDerivedMappings, + resetAffectDerivedMappings, + resolveAffectDerivedMapping, + type AffectDerivedMapping, +} from './affect-mapping.js'; +export { + AFFECT_WINDOW_TRIGGER, + RECOGNISED_AFFECT_WINDOW_SCHEDULES, + affectActionAvailable, + affectWindowDue, + affectWindowFor, + affectWindowId, + assertValidAffectWindow, + createAffectWindow, + parseAffectWindowSchedule, + type AffectWindowSchedule, +} from './affect-windows.js'; +export { + AFFECT_NO_SUBMISSION_VECTORS, + AFFECT_SUBMISSION_MODES, + AFFECT_VECTOR_NAMESPACE, + affectVectorKey, + AFFECT_SUBMISSION_VECTORS, + AFFECT_VECTOR_MODES, + affectVectorsFor, + assertEveryAffectModeHasVectors, + type AffectVector, + type AffectVectorMode, +} from './affect-vectors.js'; + +// --------------------------------------------------------------------------- +// SPEC §9.2 alternate neutral carriers (ALD-031, ALD-032) +// --------------------------------------------------------------------------- +export { + ABSOLUTE_MAX_STROKES, + ALTERNATE_CARRIER_MODULES, + ALTERNATE_CARRIERS, + BITMAP_BIT_COUNT, + CARRIER_MARK_HASH_VECTORS, + BITMAP_GRID_HEIGHT, + BITMAP_GRID_WIDTH, + CANVAS_GRID_MAX, + CANVAS_GRID_MIN, + CANVAS_STROKE_WIDTHS, + DEFAULT_MAX_STROKES, + DEFAULT_SYMBOL_INVENTORY_SIZE, + GLYPH_AUDIT_REASON_CODES, + GLYPH_AUDIT_THRESHOLDS, + GLYPH_AUDIT_VERSION, + GLYPH_BUNDLE_HASH_DOMAIN, + GLYPH_BUNDLE_VERSION, + GLYPH_ID_PATTERN, + GLYPH_STROKES_MAX, + GLYPH_STROKES_MIN, + GlyphBundleAuditFailedError, + GlyphBundleMismatchError, + MAX_TONES, + MissingGlyphBundleHashError, + TONE_DURATION_BINS, + TONE_PITCH_BINS, + UnknownCarrierGrammarError, + assertGlyphRunConfig, + assertNoStrings, + auditGlyphBitmap, + auditGlyphBundle, + carrierInventory, + carrierMarkHash, + fixedGlyphModule, + generateGlyphBundle, + generativeBitmapModule, + generativeCanvasModule, + generativeToneModule, + glyphBitmapFeatures, + glyphInventory, + glyphInventoryFor, + hashGlyphBundle, + heuristicGlyphLeakageAudit, + isBit, + isDurationBin, + isGlyphBundleHash, + isGridCoordinate, + isPitchBin, + isStrokeWidth, + isSymbolicCarrier, + maxStrokesFor, + randomMarkList, + readCarrierArtifact, + readNestedField, + registerAlternateCarriers, + validateMarkList, + verifyGlyphBundle, + type ArtifactReadResult, + type AuditableGlyph, + type CarrierMarkVector, + type GenerateGlyphBundleOptions, + type GlyphAuditFinding, + type GlyphAuditReasonCode, + type GlyphBitmap, + type GlyphBitmapFeatures, + type GlyphBundle, + type GlyphLeakageAudit, + type GlyphLeakageAuditResult, + type MarkListResult, + type MarkListRules, +} from './carriers/index.js'; +export { + ALL_CARRIER_ACTION_KINDS, + CARRIER_ACTION_KINDS, + InvalidCarrierFamilyError, + assertSingleCarrierFamily, + availableCarrierActions, + extraFieldFailure, + fail, + isCarrierActionKind, + trailingRepeats, + BARE_TOKEN_PATTERN, +} from './carrier-modules.js'; +export { + ALTERNATE_CARRIER_VECTORS, + CONFORMANCE_GLYPH_INVENTORY, + FIXED_GLYPH_VECTORS, + GENERATIVE_BITMAP_VECTORS, + GENERATIVE_CANVAS_VECTORS, + GENERATIVE_TONE_VECTORS, +} from './conformance-vectors.js'; diff --git a/packages/gateway/src/inspect.ts b/packages/gateway/src/inspect.ts new file mode 100644 index 0000000..2259415 --- /dev/null +++ b/packages/gateway/src/inspect.ts @@ -0,0 +1,266 @@ +/** + * Structural inspection helpers used by the Gateway boundary checks. + * + * All of these run against the *raw* submission, before any zod parse: zod + * object schemas strip unknown keys, so a check that ran after parsing could + * not see a Baby-supplied `runId` or a free-text field at all + * (SPECIFICATION.md §11.3, ALD-035 criterion 1). + */ + +/** + * SPEC §11.3: fields the Gateway derives from the authenticated service + * identity and authoritative run state. A proposal containing any of them at + * any depth is rejected rather than sanitized, so a Baby can never influence + * the trusted framing of its own message (CONCEPT-IDEA.md §9). + */ +export const TRUSTED_METADATA_KEYS = [ + 'runId', + 'turn', + 'sender', + 'logicalSender', + 'sequence', + 'timestamp', + 'recordedAt', + 'hash', + 'entryHash', + 'previousHash', + 'previousEntryHash', + 'channelEventHash', +] as const; + +export type TrustedMetadataKey = (typeof TRUSTED_METADATA_KEYS)[number]; + +const TRUSTED_METADATA_SET: ReadonlySet = new Set(TRUSTED_METADATA_KEYS); + +/** + * SPEC §9.4: an explicit structural ceiling every recursive inspection below + * enforces on Baby-controlled input. + * + * Without a bound, a payload nested (or wide) enough can exhaust the native + * call stack — or overwhelm an unbounded canonicalizer downstream — before + * any rejection reason is decided. That would let a hostile or + * malfunctioning adapter escape both the intended reason code and the §9.4 + * consecutive-rejection counter by crashing the turn instead of producing a + * committed `channel.rejected` event. + */ +export interface ComplexityBudget { + /** Maximum nesting depth a value may reach; the root value is depth `0`. */ + readonly maxDepth: number; + /** Maximum number of array elements / object property values visited in total. */ + readonly maxNodes: number; +} + +/** + * SPEC §9.4: the default budget applied at every Gateway boundary check. 32 + * levels and 10,000 nodes comfortably exceed any well-formed §9.1/§11.4 + * artifact or ledger draft while staying far below the point at which + * traversal, JSON serialization, or canonicalization risk exhausting the + * call stack. + */ +export const DEFAULT_COMPLEXITY_BUDGET: ComplexityBudget = { + maxDepth: 32, + maxNodes: 10_000, +}; + +/** + * Thrown by the bounded helpers in this module when a value's nesting depth + * or cumulative node count exceeds a {@link ComplexityBudget}. The Gateway + * boundary (symbol-gateway.ts) catches this — or, more commonly, calls + * {@link isWithinComplexityBudget} first — and commits a `payload-too-complex` + * rejection (SPEC §9.4) instead of letting a `RangeError` unwind past the + * rejection framework uncaught. + */ +export class PayloadTooComplexError extends Error { + constructor(readonly budget: ComplexityBudget) { + super( + `payload exceeds the Gateway complexity budget (maxDepth=${budget.maxDepth}, maxNodes=${budget.maxNodes})`, + ); + this.name = 'PayloadTooComplexError'; + } +} + +/** A JSON object, as opposed to an array, a class instance, or `null`. */ +export function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + ); +} + +/** + * Throws {@link PayloadTooComplexError} the instant `depth` or `nodes` would + * exceed `budget`. Every bounded traversal in this module checks this before + * recursing one level deeper, so none of them can recurse past + * `budget.maxDepth` regardless of how deeply (or widely) the caller's input + * is nested — the check fires long before the native call stack could ever + * be at risk (SPEC §9.4). + */ +function assertWithinBudget( + depth: number, + nodes: number, + budget: ComplexityBudget, +): void { + if (depth > budget.maxDepth || nodes > budget.maxNodes) { + throw new PayloadTooComplexError(budget); + } +} + +/** + * `true` when `value`'s nesting depth and total node count both stay within + * `budget`, `false` otherwise. Never throws and never overflows the call + * stack: recursion is capped at `budget.maxDepth + 1` frames by construction. + * + * This is the primary boundary guard (SPEC §9.4): the Gateway calls it on a + * whole submission — proposal *and* `privateLedgerDraft` alike — before any + * other recursive inspection or canonical hashing touches it, so a + * complexity violation is always caught at the earliest possible point + * rather than by whichever downstream routine happens to overflow first. + */ +export function isWithinComplexityBudget( + value: unknown, + budget: ComplexityBudget = DEFAULT_COMPLEXITY_BUDGET, +): boolean { + const nodes = { count: 0 }; + const visit = (node: unknown, depth: number): void => { + nodes.count += 1; + assertWithinBudget(depth, nodes.count, budget); + if (Array.isArray(node)) { + for (const element of node) { + visit(element, depth + 1); + } + return; + } + if (isPlainObject(node)) { + for (const nested of Object.values(node)) { + visit(nested, depth + 1); + } + } + }; + try { + visit(value, 0); + return true; + } catch (error) { + if (error instanceof PayloadTooComplexError) { + return false; + } + throw error; + } +} + +/** + * First trusted-metadata key found anywhere inside `value`, or `undefined`. + * + * Only the matched key from {@link TRUSTED_METADATA_KEYS} is returned — never + * a path or a value — so the result is safe to log and cannot echo attempted + * content back to a caller. + * + * Bounded by `budget` (SPEC §9.4): this recurses over arbitrary + * Baby-controlled structure, so it throws {@link PayloadTooComplexError} + * rather than recursing until the native call stack is exhausted. Callers on + * the Gateway boundary check {@link isWithinComplexityBudget} first, which + * makes that throw unreachable in practice; it remains here as the recursive + * inspection's own budget, not merely a precondition borrowed from the + * caller. + */ +export function findTrustedMetadataKey( + value: unknown, + budget: ComplexityBudget = DEFAULT_COMPLEXITY_BUDGET, +): TrustedMetadataKey | undefined { + const nodes = { count: 0 }; + const visit = (node: unknown, depth: number): TrustedMetadataKey | undefined => { + nodes.count += 1; + assertWithinBudget(depth, nodes.count, budget); + + if (Array.isArray(node)) { + for (const element of node) { + const found = visit(element, depth + 1); + if (found !== undefined) { + return found; + } + } + return undefined; + } + + if (!isPlainObject(node)) { + return undefined; + } + + for (const key of Object.keys(node)) { + if (TRUSTED_METADATA_SET.has(key)) { + return key as TrustedMetadataKey; + } + } + for (const nested of Object.values(node)) { + const found = visit(nested, depth + 1); + if (found !== undefined) { + return found; + } + } + return undefined; + }; + return visit(value, 0); +} + +/** + * True when a string value appears anywhere inside `value`. Key names are not + * considered: a numeric extra field is a schema violation, while a + * string-valued one is a free-text carrier (SPEC §9.1). + * + * Bounded by `budget` (SPEC §9.4) for the same reason as + * {@link findTrustedMetadataKey}. In practice every call site inspects a + * subtree of a value the Gateway boundary already ran through + * {@link isWithinComplexityBudget}, so a subtree can never itself exceed the + * budget; the bound here is defense in depth, not the primary guard. + */ +export function containsString( + value: unknown, + budget: ComplexityBudget = DEFAULT_COMPLEXITY_BUDGET, +): boolean { + const nodes = { count: 0 }; + const visit = (node: unknown, depth: number): boolean => { + nodes.count += 1; + assertWithinBudget(depth, nodes.count, budget); + if (typeof node === 'string') { + return true; + } + if (Array.isArray(node)) { + return node.some((element) => visit(element, depth + 1)); + } + if (!isPlainObject(node)) { + return false; + } + return Object.values(node).some((nested) => visit(nested, depth + 1)); + }; + return visit(value, 0); +} + +/** + * JSON-safe copy for hashing a rejected payload. The raw payload is never + * stored (SPEC §9.4), only its domain-separated hash, so an unserializable + * submission collapses to a fixed marker rather than failing the commit. + * + * Bounded by `budget` (SPEC §9.4): the canonicalizer that hashes the result + * (`@ald/hashing`) has no depth limit of its own, so a payload nested or wide + * enough to exceed the budget is replaced with a marker *before* it is ever + * handed to `JSON.stringify` or canonicalization, rather than discovered only + * after one of those recursors exhausts the call stack. Unlike + * {@link findTrustedMetadataKey} and {@link containsString}, this never + * throws — it is the last line of defense before hashing a rejection, so it + * always returns something hashable. + */ +export function jsonSafe( + value: unknown, + budget: ComplexityBudget = DEFAULT_COMPLEXITY_BUDGET, +): unknown { + if (!isWithinComplexityBudget(value, budget)) { + return { tooComplex: true }; + } + try { + return JSON.parse(JSON.stringify(value ?? null)) as unknown; + } catch { + return { unserializable: true }; + } +} diff --git a/packages/gateway/src/reason-codes.ts b/packages/gateway/src/reason-codes.ts new file mode 100644 index 0000000..b90faa7 --- /dev/null +++ b/packages/gateway/src/reason-codes.ts @@ -0,0 +1,76 @@ +/** + * The closed set of Gateway rejection reason codes (SPECIFICATION.md §9.4, + * ALD-034). + * + * A rejection reveals *nothing* about the attempted content: a + * `channel.rejected` event carries only `reasonCode` and a domain-separated + * hash of the rejected payload, and the value returned to the caller carries + * the same two fields plus counters. Every code below is therefore a bare + * enum string with no interpolated payload text + * (EXPERIMENT-NOTEBOOK.md E01). + * + * `invalid-envelope`, `unexpected-artifact-field`, `missing-interpretation`, + * `payload-too-complex` and `timeout` are implementation-defined codes; the + * remainder are named in SPECIFICATION.md §9.1/§9.4 or in the + * ALD-030/ALD-034/ALD-035 acceptance criteria. + */ +export const GATEWAY_REASON_CODES = [ + /** The submission is not a well-formed §11.3 envelope. */ + 'invalid-envelope', + /** SPEC §11.3: the Baby supplied a field only the Gateway may set. */ + 'trusted-metadata-present', + /** SPEC §8.1 step 2: the required `intention.recorded` draft is absent or invalid. */ + 'missing-intention', + /** + * SPEC §9.4: the raw submission's nesting depth or node count exceeds the + * Gateway's structural complexity budget (inspect.ts `ComplexityBudget`). + * Checked before any other recursive inspection or canonical hashing, so a + * hostile or malformed adapter cannot escape the §9.4 rejection counter by + * exhausting the call stack instead of failing a shape check. + */ + 'payload-too-complex', + /** SPEC §8.2: the receiver draft is not a valid `interpretation.recorded` event. */ + 'missing-interpretation', + /** SPEC §9.6: the proposal kind is not offered by the run's carrier. */ + 'carrier-mismatch', + /** SPEC §9.1: the public artifact carries a field the carrier does not define. */ + 'unexpected-artifact-field', + /** SPEC §9.1: free text, prose, URLs, or non-inventory code points appeared. */ + 'free-text-present', + /** SPEC §9.1: allowlist violation — the symbol is not in the declared inventory. */ + 'symbol-not-in-inventory', + /** SPEC §9.1: a carrier message must contain at least one mark. */ + 'empty-message', + /** SPEC §9.1: over `maxSymbolsPerMessage` (absolute ceiling 16). */ + 'message-too-long', + /** SPEC §9.1: over `maxSymbolRepeats` consecutive identical symbols. */ + 'symbol-repeat-limit', + /** SPEC §11.3: the echoed `channelEventHash` does not match the recorded delivery. */ + 'interpretation-hash-mismatch', + /** SPEC §8.3: the turn response budget elapsed; the turn is forfeited. */ + 'timeout', + /** SPEC §9.2 `fixed-glyph`: the glyph id is not in the frozen glyph bundle. */ + 'glyph-not-in-inventory', + /** SPEC §9.2 `generative-bitmap`: the bit matrix is not exactly 16x16 = 256 bits. */ + 'bitmap-size-invalid', + /** SPEC §9.2 `generative-bitmap`: a cell is not the literal `0` or `1`. */ + 'bitmap-value-invalid', + /** SPEC §9.2 `generative-canvas`: over `maxStrokes` (absolute ceiling 64). */ + 'too-many-strokes', + /** SPEC §9.2 `generative-canvas`: a stroke coordinate is outside the 0-15 grid. */ + 'stroke-out-of-range', + /** SPEC §9.2 `generative-canvas`: the quantized pen width is not 1, 2, or 3. */ + 'stroke-width-invalid', + /** SPEC §9.2 `generative-tone`: over the eight-tone sequence bound. */ + 'too-many-tones', + /** SPEC §9.2 `generative-tone`: a pitch bin is not 0-7 or a duration bin not 1-4. */ + 'tone-out-of-range', + /** SPEC §9.3 rule 6: a non-allowlisted or malformed affect payload (ALD-033). */ + 'affect-violation', +] as const; + +export type GatewayReasonCode = (typeof GATEWAY_REASON_CODES)[number]; + +export function isGatewayReasonCode(value: string): value is GatewayReasonCode { + return (GATEWAY_REASON_CODES as readonly string[]).includes(value); +} diff --git a/packages/gateway/src/symbol-gateway.ts b/packages/gateway/src/symbol-gateway.ts new file mode 100644 index 0000000..4b44311 --- /dev/null +++ b/packages/gateway/src/symbol-gateway.ts @@ -0,0 +1,878 @@ +/** + * The Symbol Gateway (SPECIFICATION.md §4.1 item 3, §8.1-§8.3, §9; + * ALD-029/ALD-030/ALD-034/ALD-035). + * + * This class is the single mediation point for every inter-agent artifact. + * It never writes SQLite: it validates, applies the §9.6 communication + * control, and calls the one Evidence Writer through the `EvidenceWriter` + * contract, releasing the public artifact to the receiver only after that + * commit succeeds (§8.2). It holds no learner state and no scenario ground + * truth. + * + * What it deliberately does *not* do: + * - it does not forward the sender's intended-meaning field to the receiver + * (§4.2 trust boundary: only the validated public artifact crosses); + * - it does not echo any part of a rejected payload back to the caller, only + * a reason code and a domain-separated hash (§9.4); + * - it does not pause the run itself. Reaching `maxConsecutiveRejections` + * commits the `safety-trigger` audit entry (§14.5) and sets + * `pauseRequested`; the run state transition belongs to the Nursery + * Controller (§7.2). + */ +import { + babyIdForRole, + HASH_DOMAINS, + LedgerDraftEnvelopeSchema, + TurnProposalEnvelopeSchema, + type AffectStateMeasurement, + type AffectSubmitResult, + type AffectWindow, + type AgentActionProposal, + type ArtifactProbe, + type ArtifactProbeApplication, + type BabyRole, + type ChannelEvent, + type DeliveredChannelArtifact, + type EvidenceWriter, + type GatewayRunContext, + type GatewaySubmitResult, + type GatewayTurnContext, + type LedgerDraftEnvelope, + type LedgerEvent, + type RunConfig, + type Sha256Hash, + type SymbolGateway, + type TurnProposalEnvelope, +} from '@ald/types'; +import { hashCanonical, hashCarrierMark, SeededPrng } from '@ald/hashing'; +import { validateLedgerEventDraft } from '@ald/evidence'; + +import { AffectProtocol, type DerivedAffectResult } from './affect.js'; +import { + carrierModule, + DEFAULT_MAX_SYMBOL_REPEATS, + type CarrierContext, + type CarrierModule, + type PublicArtifact, +} from './carrier-modules.js'; +import { + ControlArtifactNotPermittedError, + InterpretationRejectedError, + InvalidControlArtifactError, + InvalidSymbolInventoryError, + OracleRequiresControlArtifactError, + ShuffledBatchRequiredError, + TurnDeadlineExceededError, +} from './errors.js'; +import { + findTrustedMetadataKey, + isPlainObject, + isWithinComplexityBudget, + jsonSafe, +} from './inspect.js'; +import type { GatewayReasonCode } from './reason-codes.js'; + +export interface SymbolGatewayOptions { + /** SPEC §9.1 consecutive identical symbols allowed; default `3`. */ + maxSymbolRepeats?: number; + /** + * SPEC §9.6 `constant`: the run's pre-registered replacement artifact. It + * is validated against the active carrier when the Gateway is constructed. + */ + constantArtifact?: PublicArtifact; +} + +/** What the Gateway remembers about one delivery, for §11.3 echo binding. */ +export interface GatewayDelivery { + turn: number; + recipient: BabyRole; + channelEventHash: Sha256Hash; + deliveredArtifactHash: Sha256Hash; +} + +export type GatewayRejection = Extract; + +/** Actor id recorded on the automatic pause request (SPEC §14.2). */ +export const GATEWAY_ACTOR_ID = 'symbol-gateway'; +/** Reason code of the §9.4 automatic-pause audit entry. */ +export const MAX_CONSECUTIVE_REJECTIONS_REASON = 'max-consecutive-rejections'; + +/** Maximum reseeded shuffles attempted before falling back to a rotation. */ +const DERANGEMENT_ATTEMPTS = 20; + +interface EnvelopeShape { + proposal: Record; + kind: string; + privateLedgerDraft: Record; +} + +type ValidatedDraft = ReturnType; + +/** True when `value` has exactly the given keys, no more and no fewer. */ +function hasExactKeys( + value: Record, + expected: readonly string[], +): boolean { + const keys = Object.keys(value); + return ( + keys.length === expected.length && expected.every((key) => key in value) + ); +} + +/** + * SPEC §8.3: race `promise` against the turn response budget. The Gateway + * does not start the clock itself — the Nursery Controller owns turn timing — + * so this is exposed as a helper the caller wraps its adapter call in. A + * rejected deadline is committed with {@link SymbolGatewayImpl.rejectForTimeout}. + */ +export function withTurnDeadline( + promise: PromiseLike, + budgetMs: number, +): Promise { + if (!Number.isFinite(budgetMs) || budgetMs <= 0) { + return Promise.reject(new TurnDeadlineExceededError(budgetMs)); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new TurnDeadlineExceededError(budgetMs)); + }, budgetMs); + Promise.resolve(promise).then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); +} + +/** + * Reads the §11.3 envelope frame without a zod parse. + * + * The envelope must carry exactly `proposal` and `privateLedgerDraft`, the + * proposal must be an object with exactly a string `kind` plus a + * `publicArtifact`, and the draft must be an object. Anything else is + * `invalid-envelope`; the carrier module then judges the artifact itself, so + * carrier-specific reason codes win over the generic one. + */ +function readEnvelopeShape(value: unknown): EnvelopeShape | undefined { + if (!isPlainObject(value)) { + return undefined; + } + if (!hasExactKeys(value, ['proposal', 'privateLedgerDraft'])) { + return undefined; + } + const proposal = value.proposal; + const draft = value.privateLedgerDraft; + if (!isPlainObject(proposal) || !isPlainObject(draft)) { + return undefined; + } + if (typeof proposal.kind !== 'string') { + return undefined; + } + return { proposal, kind: proposal.kind, privateLedgerDraft: draft }; +} + +function deliveryKey(turn: number, recipient: BabyRole): string { + return `${turn}:${recipient}`; +} + +function isDerangement(permutation: readonly number[]): boolean { + return permutation.every((value, index) => value !== index); +} + +export class SymbolGatewayImpl implements SymbolGateway { + private readonly module: CarrierModule; + private readonly maxSymbolRepeats: number; + private readonly constant: PublicArtifact; + /** + * SPEC §9.6 seeded control stream. + * + * RESEARCH.md Appendix D.4 binds condition randomness to + * `scenarioSeed || 0x00 || communicationCondition`. Here the run seed + * (`GatewayRunContext.seed`, derived by the runtime from + * `RunConfig.randomSeed`) plays the role of the scenario seed, and the + * `dtsf-seed-v1` PRNG's labelled `derive` chain plays the role of the + * `|| 0x00 ||` concatenation: `seed → 'gateway' → `. Per-turn + * (`random`) and per-batch (`shuffled`) streams branch off that node, so a + * replay from the same seed reproduces every substitution exactly + * (SPEC §14.3). + */ + private readonly conditionPrng: SeededPrng; + private readonly deliveries = new Map(); + private readonly permutations = new Map(); + private rejections = 0; + /** SPEC §9.3: built on first affect call (see the `affect` accessor). */ + private affectProtocol: AffectProtocol | undefined; + + constructor( + readonly runContext: GatewayRunContext, + private readonly evidence: EvidenceWriter, + options: SymbolGatewayOptions = {}, + ) { + this.assertInventory(runContext.symbolInventory); + this.module = carrierModule(runContext.config.carrierMode); + this.maxSymbolRepeats = options.maxSymbolRepeats ?? DEFAULT_MAX_SYMBOL_REPEATS; + if ( + !Number.isInteger(this.maxSymbolRepeats) || + this.maxSymbolRepeats < 1 + ) { + throw new InvalidSymbolInventoryError( + 'maxSymbolRepeats must be a positive integer', + ); + } + this.conditionPrng = new SeededPrng(runContext.seed) + .derive('gateway') + .derive(runContext.config.communicationCondition); + this.constant = this.resolveConstantArtifact(options.constantArtifact); + } + + // ------------------------------------------------------------------------- + // Sender path (SPEC §8.1 steps 2-5) + // ------------------------------------------------------------------------- + + async submitProposal( + turn: GatewayTurnContext, + envelope: TurnProposalEnvelope, + ): Promise { + const condition = this.condition; + if (condition === 'oracle') { + throw new OracleRequiresControlArtifactError(); + } + + const raw: unknown = envelope; + + // 1. §11.3 envelope frame. + const shape = readEnvelopeShape(raw); + if (!shape) { + return this.rejectProposal(turn, raw, 'invalid-envelope'); + } + + // 1b. §9.4: bound the whole envelope's structural complexity — proposal + // *and* privateLedgerDraft alike — before any recursive inspection or + // canonical hashing touches it. A payload nested or wide enough to + // exceed the budget is rejected outright here, rather than risking an + // unbounded recursion (in this module's own checks, or later in + // `@ald/hashing` canonicalization on the Evidence Writer path) from + // escaping the rejection framework as an uncaught RangeError. + if (!isWithinComplexityBudget(raw)) { + return this.rejectProposal(turn, raw, 'payload-too-complex'); + } + + // 2. §11.3 trusted metadata is Gateway-assigned; a Baby may never set it. + if (findTrustedMetadataKey(shape.proposal) !== undefined) { + return this.rejectProposal(turn, raw, 'trusted-metadata-present'); + } + + // 3. §8.1 step 2: the intention event is required, not optional. + let draft: ValidatedDraft; + try { + draft = validateLedgerEventDraft(shape.privateLedgerDraft); + } catch { + return this.rejectProposal(turn, raw, 'missing-intention'); + } + if (draft.eventType !== 'intention.recorded') { + return this.rejectProposal(turn, raw, 'missing-intention'); + } + + // 4. §9.6: exactly one carrier family is available in a run. + if (!(this.module.allowedKinds as readonly string[]).includes(shape.kind)) { + return this.rejectProposal(turn, raw, 'carrier-mismatch'); + } + const validation = this.module.validate(shape.proposal, this.carrierContext); + if (!validation.ok) { + return this.rejectProposal(turn, raw, validation.reasonCode); + } + + // 5. ALD-035: the normalized envelope must satisfy the §11.3 schema. + let parsed: TurnProposalEnvelope; + try { + parsed = TurnProposalEnvelopeSchema.parse({ + proposal: { kind: shape.kind, publicArtifact: validation.artifact }, + privateLedgerDraft: shape.privateLedgerDraft, + }); + } catch { + return this.rejectProposal(turn, raw, 'invalid-envelope'); + } + + // 6. §9.6: control replacement happens after validation, before the + // channel event is constructed. + const controlledArtifact = this.applyCommunicationControl( + turn, + parsed.proposal.publicArtifact, + ); + const probeApplication = + turn.probe === undefined + ? undefined + : this.applyArtifactProbe(turn.probe, controlledArtifact); + const deliveredArtifact = + probeApplication?.status === 'applied' + ? probeApplication.artifactAfter + : controlledArtifact; + + const commit = await this.evidence.commitTurn({ + runId: this.runContext.runId, + turn: turn.turn, + sender: turn.sender, + recipient: turn.recipient, + carrier: this.carrier, + communicationCondition: condition, + proposal: parsed.proposal, + intentionDraft: parsed.privateLedgerDraft, + deliveredArtifact, + }); + + this.rejections = 0; + if (commit.delivery !== null) { + this.recordDelivery({ + turn: turn.turn, + recipient: turn.recipient, + channelEventHash: commit.channelEvent.entryHash, + deliveredArtifactHash: commit.channelEvent.publicArtifactHash, + }); + } + + return { + kind: 'accepted', + channelEvent: commit.channelEvent, + senderLedgerEvent: commit.senderLedgerEvent, + delivery: commit.delivery, + babyProposalHash: hashCanonical(HASH_DOMAINS.babyProposal, parsed.proposal), + deliveredArtifactHash: commit.channelEvent.publicArtifactHash, + ...(probeApplication === undefined ? {} : { probeApplication }), + }; + } + + /** + * SPEC §9.6 `oracle` (E03 only, enforced by `RunConfigSchema`): the + * Scenario Engine's minimal sufficient artifact is committed with + * `origin: "gateway-control"` and no Baby proposal or sender-ledger + * binding. + */ + async submitControlArtifact( + turn: GatewayTurnContext, + artifact: AgentActionProposal['publicArtifact'], + ): Promise<{ channelEvent: ChannelEvent; delivery: DeliveredChannelArtifact }> { + if (this.condition !== 'oracle') { + throw new ControlArtifactNotPermittedError(this.condition); + } + + const validated = this.validateArtifact(artifact, 'oracle'); + const { channelEvent, delivery } = await this.evidence.commitControlArtifact({ + runId: this.runContext.runId, + turn: turn.turn, + logicalSender: turn.sender, + recipient: turn.recipient, + carrier: this.carrier, + deliveredArtifact: validated, + }); + + this.recordDelivery({ + turn: turn.turn, + recipient: turn.recipient, + channelEventHash: channelEvent.entryHash, + deliveredArtifactHash: channelEvent.publicArtifactHash, + }); + + return { channelEvent, delivery }; + } + + // ------------------------------------------------------------------------- + // Receiver path (SPEC §8.1 step 6, §8.2, §11.3) + // ------------------------------------------------------------------------- + + /** + * The receiver's interpretation event. The echoed `channelEventHash` must + * be the one the Gateway recorded for this turn *and this recipient*, so a + * Baby cannot bind an interpretation to a delivery addressed to the other + * Baby. A mismatch is a channel violation: the rejection is committed with + * `sender` set to the offending recipient, then thrown. + */ + async submitInterpretation( + turn: GatewayTurnContext, + recipient: BabyRole, + envelope: LedgerDraftEnvelope, + ): Promise { + const raw: unknown = envelope; + if (!isPlainObject(raw) || !hasExactKeys(raw, ['channelEventHash', 'privateLedgerDraft'])) { + return this.rejectInterpretation(turn, recipient, 'invalid-envelope', raw); + } + + // §9.4: bound the envelope's structural complexity — in particular + // `privateLedgerDraft.content` — before it reaches `validateLedgerEventDraft` + // or the Evidence Writer's canonical hashing (`appendLedgerEvent`), for the + // same reason as the proposal path above. + if (!isWithinComplexityBudget(raw)) { + return this.rejectInterpretation(turn, recipient, 'payload-too-complex', raw); + } + + let parsed: LedgerDraftEnvelope; + try { + parsed = LedgerDraftEnvelopeSchema.parse(raw); + } catch { + return this.rejectInterpretation(turn, recipient, 'invalid-envelope', raw); + } + + let draft: ValidatedDraft; + try { + draft = validateLedgerEventDraft(parsed.privateLedgerDraft); + } catch { + return this.rejectInterpretation( + turn, + recipient, + 'missing-interpretation', + raw, + ); + } + if (draft.eventType !== 'interpretation.recorded') { + return this.rejectInterpretation( + turn, + recipient, + 'missing-interpretation', + raw, + ); + } + + const delivery = this.deliveries.get(deliveryKey(turn.turn, recipient)); + if ( + delivery === undefined || + delivery.channelEventHash !== parsed.channelEventHash + ) { + return this.rejectInterpretation( + turn, + recipient, + 'interpretation-hash-mismatch', + raw, + ); + } + + return this.evidence.appendLedgerEvent({ + runId: this.runContext.runId, + babyId: babyIdForRole(recipient), + turn: turn.turn, + draft, + channelEventHash: parsed.channelEventHash, + }); + } + + // ------------------------------------------------------------------------- + // Rejection framework (SPEC §9.4, ALD-034) + // ------------------------------------------------------------------------- + + /** + * SPEC §8.3: commit the forfeited turn as `channel.rejected` with reason + * `timeout`. There is no payload to hash, so the domain-separated hash of + * canonical `null` is recorded — the same convention §11.5 uses for the + * `disabled` public-artifact hash. + */ + async rejectForTimeout( + turn: GatewayTurnContext, + sender: BabyRole, + ): Promise { + const rejection = await this.commitRejection(turn.turn, sender, 'timeout', null); + return { kind: 'rejected', ...rejection }; + } + + // ------------------------------------------------------------------------- + // Affect protocol (SPEC §9.3, ALD-033) — thin delegations; the rules, + // the modes, the window discipline, and the derived mapping all live in + // `affect.ts`. + // ------------------------------------------------------------------------- + + /** + * The run's affect protocol. Created on first use, so a run with + * `affectMode: "none"` never builds one and an affect call on such a run + * raises `AffectDisabledError`. The Nursery Controller reaches + * `openWindow`/`takePrivateMeasurements` through this accessor; it may also + * construct an `AffectProtocol` directly with its own clock. + */ + get affect(): AffectProtocol { + this.affectProtocol ??= new AffectProtocol({ + runContext: this.runContext, + evidence: this.evidence, + commitAffectRejection: (turn, sender, payload) => + this.commitRejection(turn, sender, 'affect-violation', payload), + now: () => new Date().toISOString(), + }); + return this.affectProtocol; + } + + submitAffect( + window: AffectWindow, + proposal: unknown, + ): Promise { + return this.affect.submitAffect(window, proposal); + } + + recordDerivedAffect( + window: AffectWindow, + measurement: AffectStateMeasurement, + ): Promise { + return this.affect.recordDerivedAffect(window, measurement); + } + + consecutiveRejections(): number { + return this.rejections; + } + + resetRejectionCounter(): void { + this.rejections = 0; + } + + // ------------------------------------------------------------------------- + // Read-side helpers for the Nursery Controller + // ------------------------------------------------------------------------- + + /** The delivery recorded for one turn and recipient, if any. */ + deliveryFor(turn: number, recipient: BabyRole): GatewayDelivery | undefined { + return this.deliveries.get(deliveryKey(turn, recipient)); + } + + /** The active carrier's protocol module. */ + get carrierProtocol(): CarrierModule { + return this.module; + } + + /** SPEC §9.6 condition the Gateway applies to every accepted proposal. */ + get condition(): RunConfig['communicationCondition'] { + return this.runContext.config.communicationCondition; + } + + get carrier(): RunConfig['carrierMode'] { + return this.runContext.config.carrierMode; + } + + get carrierContext(): CarrierContext { + return { + runContext: this.runContext, + maxSymbolRepeats: this.maxSymbolRepeats, + }; + } + + /** SPEC §8.3 wrapper bound to this run's `turnResponseBudgetMs`. */ + withTurnDeadline(promise: PromiseLike, budgetMs?: number): Promise { + return withTurnDeadline( + promise, + budgetMs ?? this.runContext.config.turnResponseBudgetMs, + ); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private assertInventory(inventory: readonly string[]): void { + if (inventory.length < 2) { + throw new InvalidSymbolInventoryError( + 'a run must declare at least two symbols', + ); + } + if (new Set(inventory).size !== inventory.length) { + throw new InvalidSymbolInventoryError('inventory symbols must be unique'); + } + for (const symbol of inventory) { + if (symbol.length === 0 || /\s/u.test(symbol)) { + throw new InvalidSymbolInventoryError( + 'inventory symbols must be non-empty and contain no whitespace', + ); + } + } + } + + private resolveConstantArtifact( + provided: PublicArtifact | undefined, + ): PublicArtifact { + if (provided === undefined) { + return this.module.constantArtifact(this.carrierContext); + } + return this.validateArtifact(provided, 'constant'); + } + + /** + * Runs a researcher-supplied artifact through the carrier module. The + * module judges a full proposal, so the artifact is wrapped in the carrier's + * first offered kind; the wrapper is discarded and never delivered. + */ + private validateArtifact( + artifact: PublicArtifact, + origin: 'constant' | 'oracle' | 'shuffled-batch', + ): PublicArtifact { + const kind = this.module.allowedKinds[0] as AgentActionProposal['kind']; + const validation = this.module.validate( + { kind, publicArtifact: artifact }, + this.carrierContext, + ); + if (!validation.ok) { + throw new InvalidControlArtifactError( + validation.reasonCode, + origin, + validation.detail, + ); + } + return validation.artifact; + } + + /** SPEC §9.6 table, applied after validation and before the channel event. */ + private applyCommunicationControl( + turn: GatewayTurnContext, + artifact: PublicArtifact, + ): PublicArtifact | null { + switch (this.condition) { + case 'normal': + return artifact; + case 'disabled': + return null; + case 'constant': + return this.constant; + case 'random': + return this.module.randomArtifact( + this.conditionPrng.derive(String(turn.turn)), + this.carrierContext, + ); + case 'shuffled': + return this.shuffledArtifact(turn); + case 'oracle': + throw new OracleRequiresControlArtifactError(); + } + } + + /** + * Apply a trusted §15.2 probe to a symbolic delivery. A probe that cannot be + * applied is returned as an explicit shortfall and leaves the artifact + * unchanged; it is never silently converted into a different intervention. + */ + private applyArtifactProbe( + probe: ArtifactProbe, + artifact: PublicArtifact | null, + ): ArtifactProbeApplication { + const probeHash = hashCanonical(HASH_DOMAINS.causalProbe, probe); + const artifactHashBefore = hashCarrierMark(this.carrier, artifact); + const skipped = ( + reasonCode: ArtifactProbeApplication['reasonCode'], + ): ArtifactProbeApplication => ({ + probe, + probeHash, + status: 'skipped', + ...(reasonCode === undefined ? {} : { reasonCode }), + artifactBefore: artifact, + artifactAfter: artifact, + artifactHashBefore, + artifactHashAfter: artifactHashBefore, + }); + + if (artifact === null) { + return skipped('no-delivery'); + } + const field = + this.carrier === 'fixed-token' + ? 'symbols' + : this.carrier === 'fixed-glyph' + ? 'glyphs' + : null; + if (field === null || !isPlainObject(artifact)) { + return skipped('unsupported-carrier'); + } + const rawMarks = (artifact as Record)[field]; + if (!Array.isArray(rawMarks) || rawMarks.some((mark) => typeof mark !== 'string')) { + return skipped('invalid-perturbed-artifact'); + } + if ( + !Number.isInteger(probe.position) || + probe.position < 0 || + probe.position >= rawMarks.length + ) { + return skipped('position-out-of-range'); + } + + const marks = [...(rawMarks as string[])]; + if (probe.kind === 'ablation') { + if (marks.length === 1) { + return skipped('would-empty-artifact'); + } + marks.splice(probe.position, 1); + } else { + if (probe.substitute === undefined) { + return skipped('missing-substitute'); + } + if (!this.runContext.symbolInventory.includes(probe.substitute)) { + return skipped('substitute-not-in-inventory'); + } + marks[probe.position] = probe.substitute; + } + + const candidate = { [field]: marks } as PublicArtifact; + const kind = this.module.allowedKinds[0] as AgentActionProposal['kind']; + const validation = this.module.validate( + { kind, publicArtifact: candidate }, + this.carrierContext, + ); + if (!validation.ok) { + return skipped('invalid-perturbed-artifact'); + } + const artifactAfter = validation.artifact; + const artifactHashAfter = hashCarrierMark(this.carrier, artifactAfter); + if (artifactHashAfter === artifactHashBefore) { + return skipped('no-artifact-change'); + } + return { + probe, + probeHash, + status: 'applied', + artifactBefore: artifact, + artifactAfter, + artifactHashBefore, + artifactHashAfter, + }; + } + + /** + * SPEC §9.6 `shuffled`: deliver another episode's artifact from the same + * evaluation batch under a seeded permutation. The permutation is derived + * per batch size (not per turn) so every episode of a batch uses the same + * one, and a batch of two or more is deranged so no episode can receive its + * own artifact. + */ + private shuffledArtifact(turn: GatewayTurnContext): PublicArtifact { + const batch = turn.batchArtifacts; + const index = turn.batchIndex; + if ( + batch === undefined || + batch.length === 0 || + index === undefined || + !Number.isInteger(index) || + index < 0 || + index >= batch.length + ) { + throw new ShuffledBatchRequiredError(turn.turn); + } + + const permutation = this.permutationFor(batch.length); + const source = permutation[index] as number; + return this.validateArtifact(batch[source] as PublicArtifact, 'shuffled-batch'); + } + + private permutationFor(size: number): number[] { + const cached = this.permutations.get(size); + if (cached) { + return cached; + } + + const indices = Array.from({ length: size }, (_, index) => index); + const prng = this.conditionPrng.derive(String(size)); + let permutation = prng.shuffle(indices); + if (size >= 2) { + for ( + let attempt = 0; + attempt < DERANGEMENT_ATTEMPTS && !isDerangement(permutation); + attempt += 1 + ) { + permutation = prng.shuffle(indices); + } + if (!isDerangement(permutation)) { + // Deterministic fallback: rotate by one, which is a derangement for + // every size >= 2. + permutation = indices.map((value) => (value + 1) % size); + } + } + + this.permutations.set(size, permutation); + return permutation; + } + + private recordDelivery(delivery: GatewayDelivery): void { + this.deliveries.set( + deliveryKey(delivery.turn, delivery.recipient), + delivery, + ); + } + + private async rejectProposal( + turn: GatewayTurnContext, + payload: unknown, + reasonCode: GatewayReasonCode, + ): Promise { + const rejection = await this.commitRejection( + turn.turn, + turn.sender, + reasonCode, + payload, + ); + return { kind: 'rejected', ...rejection }; + } + + private async rejectInterpretation( + turn: GatewayTurnContext, + recipient: BabyRole, + reasonCode: GatewayReasonCode, + payload: unknown, + ): Promise { + const rejection = await this.commitRejection( + turn.turn, + recipient, + reasonCode, + payload, + ); + throw new InterpretationRejectedError( + reasonCode, + rejection.channelEvent, + rejection.rejectedPayloadHash, + rejection.consecutiveRejections, + rejection.pauseRequested, + ); + } + + /** + * The one place a `channel.rejected` event is created (ALD-034). Every + * registered protocol module shares this shape, this counter, and this + * pause policy. + */ + private async commitRejection( + turn: number, + sender: BabyRole, + reasonCode: GatewayReasonCode, + payload: unknown, + ): Promise> { + const rejectedPayloadHash = hashCanonical( + HASH_DOMAINS.rejectedPayload, + jsonSafe(payload), + ); + const channelEvent = await this.evidence.commitRejection({ + runId: this.runContext.runId, + turn, + sender, + carrier: this.carrier, + communicationCondition: this.condition, + reasonCode, + rejectedPayloadHash, + }); + + this.rejections += 1; + const ceiling = this.runContext.config.maxConsecutiveRejections; + if (this.rejections === ceiling) { + await this.evidence.appendInterventionEvent({ + runId: this.runContext.runId, + eventType: 'safety-trigger', + actorId: GATEWAY_ACTOR_ID, + reasonCode: MAX_CONSECUTIVE_REJECTIONS_REASON, + details: { + consecutiveRejections: this.rejections, + maxConsecutiveRejections: ceiling, + sender, + turn, + lastReasonCode: reasonCode, + }, + }); + } + + return { + channelEvent, + reasonCode, + rejectedPayloadHash, + consecutiveRejections: this.rejections, + pauseRequested: this.rejections >= ceiling, + }; + } +} diff --git a/packages/gateway/src/testing.ts b/packages/gateway/src/testing.ts new file mode 100644 index 0000000..445c391 --- /dev/null +++ b/packages/gateway/src/testing.ts @@ -0,0 +1,835 @@ +/** + * `InMemoryEvidenceWriter` — a faithful, dependency-free `EvidenceWriter` + * for Gateway and Nursery Controller tests. + * + * It is not a mock: it assigns per-stream sequences, builds the same unsigned + * events the SQLite writer builds, computes real entry hashes with + * `computeEntryHash`, signs them with a real Ed25519 registry, validates + * every event against its zod schema, and enforces the LEDGER §4 hash chain + * and the SPEC §11.3 interpretation binding. What it does *not* do is + * persist, checkpoint, or serialize concurrent writers — the production + * `SqliteEvidenceWriter` in `@ald/evidence` owns all of that. + * + * The Gateway talks to the Evidence Writer only through the contract, so a + * test that passes here exercises the same code path as production + * (SPEC §4.2 Symbol Gateway ⇄ Evidence Writer boundary). + */ +import { + AffectEventSchema, + AnchorReceiptSchema, + AuditLedgerEntrySchema, + babyIdForRole, + BundleAttachmentSchema, + ChannelEventSchema, + CheckpointManifestSchema, + EVENT_STREAMS, + ExperimentRecordSchema, + GENESIS_HASH, + HASH_DOMAINS, + InterventionEventSchema, + LedgerEventSchema, + ledgerStreamForRole, + roleForBabyId, + RunConfigSchema, + STREAM_SIGNER, + TurnRecordSchema, + type AffectAppendRequest, + type AffectEvent, + type AnalysisAttachmentAppendRequest, + type AnchorReceipt, + type AuditLedgerAppendRequest, + type AuditLedgerEntry, + type BabyId, + type BabyRole, + type ChainHead, + type ChannelEvent, + type CheckpointManifest, + type Clock, + type ControlArtifactCommitRequest, + type DeliveredChannelArtifact, + type EventRange, + type EventStream, + type EvidenceWriter, + type ExperimentRecord, + type ForkReport, + type InterventionAppendRequest, + type InterventionEvent, + type LedgerAppendRequest, + type LedgerEvent, + type LedgerEventDraft, + type RecoveryReport, + type RejectionCommitRequest, + type RunConfig, + type RunMetadataRecord, + type Sha256Hash, + type SignerDomain, + type SignerPublicKey, + type SignerRegistry, + type StoredEvent, + type StoredAnalysisAttachment, + type TurnCommitRequest, + type TurnCommitResult, + type TurnRecord, + type TurnRecordAppendRequest, +} from '@ald/types'; +import { + canonicalJson, + computeEntryHash, + encodeHash, + hashCanonical, + hashCarrierMark, + hashRunId, + InMemorySignerRegistry, + sha256Bytes, +} from '@ald/hashing'; +import { validateLedgerEventDraft } from '@ald/evidence'; + +/** Deterministic clock: one step per call from a fixed epoch. */ +export class StepClock implements Clock { + private current: number; + + constructor( + startMs = Date.UTC(2026, 0, 1, 0, 0, 0), + private readonly stepMs = 1, + ) { + this.current = startMs; + } + + now(): string { + const value = new Date(this.current).toISOString(); + this.current += this.stepMs; + return value; + } +} + +export interface InMemoryEvidenceWriterOptions { + signers: SignerRegistry; + clock?: Clock; + softwareCommit?: string; +} + +/** Raised for a request the writer refuses; mirrors `@ald/evidence` codes. */ +export class InMemoryEvidenceError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = 'InMemoryEvidenceError'; + } +} + +interface StoredRecord extends StoredEvent { + runId: string; +} + +type LedgerStream = 'baby-a-ledger' | 'baby-b-ledger'; + +/** + * `ledgerStreamForRole` is declared as returning the whole `EventStream` + * union, so it cannot be handed straight to a signer lookup that excludes the + * unsigned `intervention` stream. Narrowing happens here rather than in + * `@ald/types`. + */ +function ledgerStreamOf(role: BabyRole): LedgerStream { + return ledgerStreamForRole(role) as LedgerStream; +} + +export class InMemoryEvidenceWriter implements EvidenceWriter { + private readonly clock: Clock; + private readonly signers: SignerRegistry; + private readonly records: StoredRecord[] = []; + private readonly runs = new Map(); + private readonly channelEventsByHash = new Map(); + private readonly checkpoints: CheckpointManifest[] = []; + private readonly anchors: AnchorReceipt[] = []; + private readonly experiments: ExperimentRecord[] = []; + private readonly attachments: StoredAnalysisAttachment[] = []; + /** Serializes the async sign-then-append sequence, as the writer mutex does. */ + private tail: Promise = Promise.resolve(); + + constructor(options: InMemoryEvidenceWriterOptions) { + this.signers = options.signers; + this.clock = options.clock ?? new StepClock(); + } + + /** Convenience factory: a fresh generated signer registry for one run. */ + static forRun(runId: string, clock?: Clock): InMemoryEvidenceWriter { + return new InMemoryEvidenceWriter({ + signers: InMemorySignerRegistry.generate(runId), + ...(clock === undefined ? {} : { clock }), + }); + } + + get signerRegistry(): SignerRegistry { + return this.signers; + } + + // ------------------------------------------------------------------------- + // Read side + // ------------------------------------------------------------------------- + + listRuns(): string[] { + return [...this.runs.keys()].sort(); + } + + readRunMetadata(runId: string): RunMetadataRecord | undefined { + return this.runs.get(runId); + } + + chainHead(runId: string, stream: EventStream): ChainHead { + const events = this.streamRecords(runId, stream); + const last = events[events.length - 1]; + return { + stream, + size: events.length, + lastEntryHash: last?.entryHash ?? GENESIS_HASH, + }; + } + + readEvents( + runId: string, + stream: EventStream, + range?: EventRange, + ): StoredEvent[] { + return this.streamRecords(runId, stream).filter( + (record) => + (range?.fromSequence === undefined || + record.sequence >= range.fromSequence) && + (range?.toSequence === undefined || record.sequence <= range.toSequence), + ); + } + + readCheckpoints(runId: string): CheckpointManifest[] { + return this.checkpoints.filter( + (manifest) => manifest.runIdHash === hashRunId(runId), + ); + } + + readAnchorReceipts(runId: string): AnchorReceipt[] { + return this.anchors.filter((receipt) => receipt.runId === runId); + } + + readExperimentRecords(runId: string): ExperimentRecord[] { + return this.experiments.filter((record) => record.runId === runId); + } + + readAnalysisAttachments(runId: string): StoredAnalysisAttachment[] { + return this.attachments.filter((attachment) => { + const binding = attachment.descriptor.boundBy; + if (binding === undefined) { + return false; + } + return this.readEvents(runId, binding.stream).some( + (event) => event.entryHash === binding.entryHash, + ); + }); + } + + /** Every committed channel event of a run, in sequence order. */ + channelEvents(runId: string): ChannelEvent[] { + return this.readEvents(runId, 'channel').map( + (record) => ChannelEventSchema.parse(JSON.parse(record.canonicalJson)), + ); + } + + /** Every committed intervention event of a run, in sequence order. */ + interventionEvents(runId: string): InterventionEvent[] { + return this.readEvents(runId, 'intervention').map( + (record) => InterventionEventSchema.parse(JSON.parse(record.canonicalJson)), + ); + } + + /** Every committed ledger event of one Baby, in sequence order. */ + ledgerEvents(runId: string, babyId: BabyId): LedgerEvent[] { + return this.readEvents( + runId, + ledgerStreamOf(roleForBabyId(babyId)), + ).map((record) => LedgerEventSchema.parse(JSON.parse(record.canonicalJson))); + } + + // ------------------------------------------------------------------------- + // Write side + // ------------------------------------------------------------------------- + + registerRun(config: RunConfig): { configurationHash: Sha256Hash } { + const parsed = RunConfigSchema.parse(config); + if (parsed.runId !== this.signers.runId) { + throw new InMemoryEvidenceError( + 'invalid-request', + `Signer registry is bound to run ${this.signers.runId}, not ${parsed.runId}`, + ); + } + if (this.runs.has(parsed.runId)) { + throw new InMemoryEvidenceError( + 'duplicate-run', + `Run ${parsed.runId} is already registered`, + ); + } + + const configurationHash = hashCanonical(HASH_DOMAINS.runConfig, parsed); + this.runs.set(parsed.runId, { + runId: parsed.runId, + createdAt: this.clock.now(), + deploymentMode: parsed.deploymentMode, + configurationHash, + configurationJson: canonicalJson(parsed), + parentRunId: parsed.parentRunId ?? null, + derivedFromCheckpointHash: parsed.derivedFromCheckpointHash ?? null, + }); + return { configurationHash }; + } + + commitTurn(request: TurnCommitRequest): Promise { + const draft = validateLedgerEventDraft(request.intentionDraft); + if (draft.eventType !== 'intention.recorded') { + throw new InMemoryEvidenceError( + 'invalid-request', + `A turn commit requires an intention.recorded draft, received ${draft.eventType}`, + ); + } + + return this.serialize(async () => { + this.assertKnownRun(request.runId); + + const ledgerStream = ledgerStreamOf(request.sender); + const ledgerHead = this.chainHead(request.runId, ledgerStream); + const senderLedgerEvent = await this.signLedgerEvent({ + runId: request.runId, + stream: ledgerStream, + babyId: babyIdForRole(request.sender), + sequence: ledgerHead.size + 1, + turn: request.turn, + draft, + previousEntryHash: ledgerHead.lastEntryHash, + }); + + const channelHead = this.chainHead(request.runId, 'channel'); + const publicArtifactHash = hashCarrierMark( + request.carrier, + request.deliveredArtifact, + ); + const channelEvent = await this.signChannelEvent({ + version: 1, + runId: request.runId, + sequence: channelHead.size + 1, + turn: request.turn, + logicalSender: request.sender, + origin: 'baby', + carrier: request.carrier, + communicationCondition: request.communicationCondition, + babyProposalHash: hashCanonical( + HASH_DOMAINS.babyProposal, + request.proposal, + ), + senderLedgerSequence: senderLedgerEvent.sequence, + senderEntryHash: senderLedgerEvent.entryHash, + publicArtifactHash, + previousChannelHash: channelHead.lastEntryHash, + gatewayValidationResult: 'accepted', + ...(request.deliveredArtifact === null + ? {} + : { + deliveryReceipt: { + recipient: request.recipient, + deliveredArtifactHash: publicArtifactHash, + deliveredAt: this.clock.now(), + }, + }), + recordedAt: this.clock.now(), + }); + + this.append(request.runId, ledgerStream, senderLedgerEvent); + this.append(request.runId, 'channel', channelEvent); + this.channelEventsByHash.set(channelEvent.entryHash, channelEvent); + + const delivery: DeliveredChannelArtifact | null = + request.deliveredArtifact === null + ? null + : { + runId: request.runId, + turn: request.turn, + logicalSender: request.sender, + carrier: request.carrier, + publicArtifact: request.deliveredArtifact, + channelEventHash: channelEvent.entryHash, + }; + + return { senderLedgerEvent, channelEvent, delivery }; + }); + } + + commitRejection(request: RejectionCommitRequest): Promise { + return this.serialize(async () => { + this.assertKnownRun(request.runId); + const head = this.chainHead(request.runId, 'channel'); + const channelEvent = await this.signChannelEvent({ + version: 1, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + logicalSender: request.sender, + origin: 'baby', + carrier: request.carrier, + communicationCondition: request.communicationCondition, + publicArtifactHash: request.rejectedPayloadHash, + previousChannelHash: head.lastEntryHash, + gatewayValidationResult: 'rejected', + reasonCode: request.reasonCode, + recordedAt: this.clock.now(), + }); + this.append(request.runId, 'channel', channelEvent); + this.channelEventsByHash.set(channelEvent.entryHash, channelEvent); + return channelEvent; + }); + } + + commitControlArtifact( + request: ControlArtifactCommitRequest, + ): Promise<{ channelEvent: ChannelEvent; delivery: DeliveredChannelArtifact }> { + return this.serialize(async () => { + this.assertKnownRun(request.runId); + const head = this.chainHead(request.runId, 'channel'); + const publicArtifactHash = hashCarrierMark( + request.carrier, + request.deliveredArtifact, + ); + const channelEvent = await this.signChannelEvent({ + version: 1, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + logicalSender: request.logicalSender, + origin: 'gateway-control', + carrier: request.carrier, + communicationCondition: 'oracle', + publicArtifactHash, + previousChannelHash: head.lastEntryHash, + gatewayValidationResult: 'accepted', + deliveryReceipt: { + recipient: request.recipient, + deliveredArtifactHash: publicArtifactHash, + deliveredAt: this.clock.now(), + }, + recordedAt: this.clock.now(), + }); + this.append(request.runId, 'channel', channelEvent); + this.channelEventsByHash.set(channelEvent.entryHash, channelEvent); + return { + channelEvent, + delivery: { + runId: request.runId, + turn: request.turn, + logicalSender: request.logicalSender, + carrier: request.carrier, + publicArtifact: request.deliveredArtifact, + channelEventHash: channelEvent.entryHash, + }, + }; + }); + } + + appendLedgerEvent(request: LedgerAppendRequest): Promise { + const draft = validateLedgerEventDraft(request.draft); + if ( + draft.eventType === 'interpretation.recorded' && + request.channelEventHash === undefined + ) { + throw new InMemoryEvidenceError( + 'interpretation-binding', + 'interpretation.recorded events must reference the delivered channelEventHash', + ); + } + + return this.serialize(async () => { + this.assertKnownRun(request.runId); + if (request.channelEventHash !== undefined) { + this.assertDeliveryBinding(request.babyId, request.channelEventHash); + } + const stream = ledgerStreamOf(roleForBabyId(request.babyId)); + const head = this.chainHead(request.runId, stream); + const event = await this.signLedgerEvent({ + runId: request.runId, + stream, + babyId: request.babyId, + sequence: head.size + 1, + turn: request.turn, + draft, + previousEntryHash: head.lastEntryHash, + ...(request.channelEventHash === undefined + ? {} + : { channelEventHash: request.channelEventHash }), + }); + this.append(request.runId, stream, event); + return event; + }); + } + + appendTurnRecord(request: TurnRecordAppendRequest): Promise { + return this.serialize(async () => { + this.assertKnownRun(request.runId); + const head = this.chainHead(request.runId, 'turns'); + const signer = this.signers.signer(STREAM_SIGNER.turns); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + phase: request.phase, + roles: request.roles, + communicationCondition: request.communicationCondition, + scenarioRef: request.scenarioRef, + scenarioStateHash: request.scenarioStateHash, + observationHashes: request.observationHashes, + ...(request.repairAttempt === undefined + ? {} + : { repairAttempt: request.repairAttempt }), + ...(request.probeHash === undefined + ? {} + : { probeHash: request.probeHash }), + babyProposalHash: request.babyProposalHash, + deliveredArtifactHash: request.deliveredArtifactHash, + channelEventHash: request.channelEventHash, + actionHash: request.actionHash, + outcomeHash: request.outcomeHash, + outcome: request.outcome, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash('turns', unsigned); + const record = TurnRecordSchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + this.append(request.runId, 'turns', record); + return record; + }); + } + + appendInterventionEvent( + request: InterventionAppendRequest, + ): Promise { + return this.serialize(async () => { + this.assertKnownRun(request.runId); + const head = this.chainHead(request.runId, 'intervention'); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + eventType: request.eventType, + actorId: request.actorId, + reasonCode: request.reasonCode, + details: request.details ?? {}, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + }; + const event = InterventionEventSchema.parse({ + ...unsigned, + entryHash: computeEntryHash('intervention', unsigned), + }); + this.append(request.runId, 'intervention', event); + return event; + }); + } + + appendAnalysisAttachment( + request: AnalysisAttachmentAppendRequest, + ): Promise { + return this.serialize(async () => { + this.assertKnownRun(request.runId); + const canonical = canonicalJson(request.value); + const sha256 = encodeHash( + sha256Bytes(Buffer.from(`${canonical}\n`, 'utf8')), + ); + const producedAt = this.clock.now(); + const head = this.chainHead(request.runId, 'intervention'); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + eventType: 'analysis-attached' as const, + actorId: request.actorId, + reasonCode: request.reasonCode, + details: { + path: request.path, + sha256, + kind: request.kind, + analysisVersion: request.analysisVersion, + }, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + }; + const event = InterventionEventSchema.parse({ + ...unsigned, + entryHash: computeEntryHash('intervention', unsigned), + }); + const descriptor = BundleAttachmentSchema.parse({ + path: request.path, + sha256, + kind: request.kind, + analysisVersion: request.analysisVersion, + producedAt, + boundBy: { stream: 'intervention', entryHash: event.entryHash }, + }); + const stored = { descriptor, canonicalJson: canonical }; + this.append(request.runId, 'intervention', event); + this.attachments.push(stored); + return stored; + }); + } + + appendAuditLedgerEntry( + request: AuditLedgerAppendRequest, + ): Promise { + return this.serialize(async () => { + this.assertKnownRun(request.runId); + const head = this.chainHead(request.runId, 'audit'); + const signer = this.signers.signer(STREAM_SIGNER.audit); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + babyId: request.babyId, + source: 'generated-analysis' as const, + sourceEntryHash: request.sourceEntryHash, + interpreterVersion: request.interpreterVersion, + content: request.content, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash('audit', unsigned); + const entry = AuditLedgerEntrySchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + this.append(request.runId, 'audit', entry); + return entry; + }); + } + + appendAffectEvent(request: AffectAppendRequest): Promise { + return this.serialize(async () => { + this.assertKnownRun(request.runId); + const head = this.chainHead(request.runId, 'affect'); + const signer = this.signers.signer(STREAM_SIGNER.affect); + const unsigned = { + version: 1 as const, + runId: request.runId, + sequence: head.size + 1, + turn: request.turn, + windowId: request.windowId, + sender: request.sender, + displayId: request.displayId, + affectMode: request.affectMode, + deliveredAt: request.deliveredAt, + previousEntryHash: head.lastEntryHash, + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash('affect', unsigned); + const event = AffectEventSchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + this.append(request.runId, 'affect', event); + return event; + }); + } + + /** Public keys of the single registry this double is bound to. */ + readRunSigners(runId: string): SignerPublicKey[] { + this.assertKnownRun(runId); + return this.signers.publicKeys(); + } + + insertCheckpointManifest(manifest: CheckpointManifest): void { + this.checkpoints.push(CheckpointManifestSchema.parse(manifest)); + } + + insertAnchorReceipt(receipt: AnchorReceipt): void { + this.anchors.push(AnchorReceiptSchema.parse(receipt)); + } + + appendExperimentRecord(record: ExperimentRecord): void { + const parsed = ExperimentRecordSchema.parse(record); + const previous = this.readExperimentRecords(parsed.runId); + const expected = (previous[previous.length - 1]?.recordVersion ?? 0) + 1; + if (parsed.recordVersion !== expected) { + throw new InMemoryEvidenceError( + 'experiment-record-version', + `Experiment record version must be ${expected}, received ${parsed.recordVersion}`, + ); + } + this.experiments.push(parsed); + } + + /** + * LEDGER §15 recovery: recompute the chain of every stream and report + * violations. The in-memory store cannot fork, so `forks` is always empty. + */ + recover(runId: string): Promise { + const chainViolations: string[] = []; + const heads: ChainHead[] = []; + const forks: ForkReport[] = []; + + for (const stream of EVENT_STREAMS) { + const records = this.streamRecords(runId, stream); + let previous = GENESIS_HASH; + records.forEach((record, index) => { + if (record.sequence !== index + 1) { + chainViolations.push( + `${stream}: expected sequence ${index + 1}, found ${record.sequence}`, + ); + } + if (record.previousEntryHash !== previous) { + chainViolations.push( + `${stream}#${record.sequence}: previous hash mismatch`, + ); + } + previous = record.entryHash; + }); + heads.push(this.chainHead(runId, stream)); + } + + return Promise.resolve({ + runId, + ok: chainViolations.length === 0, + heads, + forks, + chainViolations, + }); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private serialize(action: () => Promise): Promise { + const next = this.tail.then(action, action); + this.tail = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + private streamRecords(runId: string, stream: EventStream): StoredRecord[] { + return this.records.filter( + (record) => record.runId === runId && record.stream === stream, + ); + } + + private assertKnownRun(runId: string): void { + if (!this.runs.has(runId)) { + throw new InMemoryEvidenceError( + 'unknown-run', + `Run ${runId} is not registered`, + ); + } + } + + private assertDeliveryBinding( + babyId: BabyId, + channelEventHash: Sha256Hash, + ): void { + const event = this.channelEventsByHash.get(channelEventHash); + if (!event) { + throw new InMemoryEvidenceError( + 'interpretation-binding', + `No channel event ${channelEventHash} is committed`, + ); + } + const expected = roleForBabyId(babyId); + if (event.deliveryReceipt?.recipient !== expected) { + throw new InMemoryEvidenceError( + 'interpretation-binding', + `Channel event ${channelEventHash} was not delivered to ${expected}`, + ); + } + } + + private append( + runId: string, + stream: EventStream, + event: { sequence: number; entryHash: string; recordedAt: string }, + ): void { + const canonical = canonicalJson(event); + const previous = this.chainHead(runId, stream).lastEntryHash; + this.records.push({ + runId, + stream, + sequence: event.sequence, + entryHash: event.entryHash, + previousEntryHash: previous, + recordedAt: event.recordedAt, + canonicalJson: canonical, + }); + } + + private async signLedgerEvent(input: { + runId: string; + stream: LedgerStream; + babyId: BabyId; + sequence: number; + turn: number; + draft: LedgerEventDraft; + previousEntryHash: Sha256Hash; + channelEventHash?: Sha256Hash; + }): Promise { + const signer = this.signers.signer(STREAM_SIGNER[input.stream]); + const unsigned = { + version: 1 as const, + runId: input.runId, + babyId: input.babyId, + sequence: input.sequence, + turn: input.turn, + eventType: input.draft.eventType, + contentSchema: input.draft.contentSchema, + subjectId: input.draft.subjectId, + content: + input.draft.evidenceRefs.length > 0 + ? { ...input.draft.content, evidenceRefs: input.draft.evidenceRefs } + : { ...input.draft.content }, + blindingNonce: input.draft.blindingNonce, + previousEntryHash: input.previousEntryHash, + ...(input.channelEventHash === undefined + ? {} + : { channelEventHash: input.channelEventHash }), + recordedAt: this.clock.now(), + writerKeyId: signer.keyId, + }; + const entryHash = computeEntryHash(input.stream, unsigned); + return LedgerEventSchema.parse({ + ...unsigned, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + } + + private async signChannelEvent( + unsigned: Omit, + ): Promise { + const signer = this.signers.signer(STREAM_SIGNER.channel); + const withKey = { ...unsigned, writerKeyId: signer.keyId }; + const entryHash = computeEntryHash('channel', withKey); + return ChannelEventSchema.parse({ + ...withKey, + entryHash, + writerSignature: await signer.sign(entryHash), + }); + } +} + +/** Signer domains an `InMemoryEvidenceWriter` needs to be provisioned with. */ +export const REQUIRED_SIGNER_DOMAINS: readonly SignerDomain[] = [ + 'baby-a-ledger', + 'baby-b-ledger', + 'channel', + 'audit', + 'witness', +]; diff --git a/packages/gateway/tsconfig.json b/packages/gateway/tsconfig.json new file mode 100644 index 0000000..48fd4c2 --- /dev/null +++ b/packages/gateway/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../types" }, + { "path": "../hashing" }, + { "path": "../evidence" } + ] +} diff --git a/packages/hashing/__tests__/canonical.test.ts b/packages/hashing/__tests__/canonical.test.ts new file mode 100644 index 0000000..e52f973 --- /dev/null +++ b/packages/hashing/__tests__/canonical.test.ts @@ -0,0 +1,174 @@ +/** + * Regression tests for the canonical-JSON input contract + * (docs/evidence-bundle-format.md §1 "every *.jsonl line is canonical JSON", + * §3 entry hashing; LEDGER §4 RFC 8785 canonicalization). + * + * `canonicalize` v4 neither drops nor reports non-JSON values: a `Map`, `Set`, + * `Buffer`, or class instance serializes to `{}` (silently hashing an empty + * object), and a function- or symbol-valued property is interpolated as the + * bare token `undefined` (an evidence line no verifier can parse). Both must + * be refused before a hash exists. + */ +import { describe, expect, it } from 'vitest'; + +import { HASH_DOMAINS } from '@ald/types'; + +import { + canonicalJson, + computeEntryHash, + hashCanonical, + parseCanonicalJson, +} from '../src/index.js'; + +const REJECTED = /cannot be represented as canonical JSON/u; + +describe('canonicalJson input validation', () => { + it('still serializes every JSON-native value', () => { + expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe( + '{"a":{"c":3,"d":2},"b":1}', + ); + expect(canonicalJson(null)).toBe('null'); + expect(canonicalJson(0)).toBe('0'); + expect(canonicalJson(-1.5)).toBe('-1.5'); + expect(canonicalJson(false)).toBe('false'); + expect(canonicalJson('x')).toBe('"x"'); + expect(canonicalJson([1, [2, { a: null }], 'three'])).toBe( + '[1,[2,{"a":null}],"three"]', + ); + expect(canonicalJson(Object.create(null) as Record)).toBe( + '{}', + ); + expect(canonicalJson(Object.freeze({ a: 1 }))).toBe('{"a":1}'); + }); + + it('rejects undefined, bigint, and non-finite numbers', () => { + expect(() => canonicalJson(undefined)).toThrow(REJECTED); + expect(() => canonicalJson(10n)).toThrow(REJECTED); + expect(() => canonicalJson({ a: 1n })).toThrow(REJECTED); + expect(() => canonicalJson(Number.NaN)).toThrow(REJECTED); + expect(() => canonicalJson({ a: Number.POSITIVE_INFINITY })).toThrow( + REJECTED, + ); + expect(() => canonicalJson([Number.NEGATIVE_INFINITY])).toThrow(REJECTED); + }); + + it('rejects functions and symbols instead of emitting invalid JSON', () => { + // Before the fix these returned '{"redact":undefined,"version":1}' and + // '[1,]' — strings JSON.parse cannot read. + const withMethod: Record = { + version: 1, + redact: () => undefined, + }; + expect(() => canonicalJson(withMethod)).toThrow(REJECTED); + expect(() => canonicalJson([1, () => undefined])).toThrow(REJECTED); + expect(() => canonicalJson({ a: Symbol('x'), b: 1 })).toThrow(REJECTED); + expect(() => canonicalJson([Symbol('x')])).toThrow(REJECTED); + expect(() => canonicalJson(() => undefined)).toThrow(REJECTED); + }); + + it('rejects containers and class instances that canonicalize to {}', () => { + expect(() => canonicalJson(new Map([['a', 1]]))).toThrow(REJECTED); + expect(() => canonicalJson(new Set([1, 2]))).toThrow(REJECTED); + expect(() => canonicalJson(new Date(0))).toThrow(REJECTED); + expect(() => canonicalJson(Buffer.from([1, 2, 3]))).toThrow(REJECTED); + expect(() => canonicalJson(new Uint8Array([1, 2]))).toThrow(REJECTED); + expect(() => canonicalJson(new Error('boom'))).toThrow(REJECTED); + + class Policy { + constructor(public readonly theta: number) {} + } + expect(() => canonicalJson(new Policy(1))).toThrow(REJECTED); + expect(() => canonicalJson({ nested: { hypothesis: new Map() } })).toThrow( + REJECTED, + ); + expect(() => canonicalJson([{ seeds: new Set([1]) }])).toThrow(REJECTED); + + class Symbols extends Array {} + expect(() => canonicalJson(Symbols.from(['S01']))).toThrow(REJECTED); + }); + + it('names the offending path in the rejection', () => { + expect(() => canonicalJson({ content: { hypothesis: new Map() } })).toThrow( + /content\.hypothesis/u, + ); + expect(() => canonicalJson({ rows: [{ fn: () => 1 }] })).toThrow( + /rows\[0\]\.fn/u, + ); + expect(() => canonicalJson(undefined)).toThrow(//u); + }); + + it('rejects circular references', () => { + const cyclic: Record = { a: 1 }; + cyclic['self'] = cyclic; + expect(() => canonicalJson(cyclic)).toThrow(REJECTED); + + // Repeated (non-circular) references are still fine. + const shared = { a: 1 }; + expect(canonicalJson({ x: shared, y: shared })).toBe( + '{"x":{"a":1},"y":{"a":1}}', + ); + }); + + it('pins JSON semantics for undefined members', () => { + // An undefined-valued property is omitted (JCS/JSON.stringify semantics)… + const omitted: Record = { a: 1, b: undefined }; + expect(canonicalJson(omitted)).toBe('{"a":1}'); + expect(parseCanonicalJson(canonicalJson(omitted))).toEqual({ a: 1 }); + // …and an explicitly undefined array element becomes null. + expect(canonicalJson([1, undefined, 2])).toBe('[1,null,2]'); + }); + + it('rejects sparse arrays, which canonicalize to invalid JSON', () => { + // `canonicalize` v4 emits '[1,,2]' where JSON.stringify emits + // '[1,null,2]', so a hole would hash an unparseable evidence line. + const holed: unknown[] = [1, , 2]; + expect(() => canonicalJson(holed)).toThrow(REJECTED); + expect(() => canonicalJson({ rows: [, 1] })).toThrow(/rows\[0\]/u); + const trailing = [1, 2]; + trailing.length = 4; + expect(() => canonicalJson(trailing)).toThrow(REJECTED); + }); + + it('only ever returns parseable JSON', () => { + const value = { + runId: 'r', + rows: [1, undefined, { a: null, b: [true, 'x'] }], + omitted: undefined, + }; + const serialized = canonicalJson(value); + expect(() => JSON.parse(serialized) as unknown).not.toThrow(); + expect(canonicalJson(JSON.parse(serialized) as unknown)).toBe(serialized); + }); + + it('never produces a hash for a value it cannot represent', () => { + // Mode (a): a nested Map used to hash identically to an empty object. + expect(() => + hashCanonical( + HASH_DOMAINS.policyCheckpoint, + new Map([['thetaSender', [[1, 2]]]]), + ), + ).toThrow(REJECTED); + expect(() => + computeEntryHash('baby-a-ledger', { + runId: 'r', + babyId: 'A', + sequence: 1, + content: { hypothesis: new Map([['S01', 'means-circle']]) }, + }), + ).toThrow(REJECTED); + // Mode (b): an own function-valued property used to hash a non-JSON line. + expect(() => + computeEntryHash('baby-a-ledger', { + runId: 'r', + babyId: 'A', + sequence: 1, + content: { artifactRef: 'a', redact: () => undefined }, + }), + ).toThrow(REJECTED); + }); + + it('leaves parseCanonicalJson behaviour unchanged', () => { + expect(parseCanonicalJson('{"a":1}')).toEqual({ a: 1 }); + expect(() => parseCanonicalJson('{ "a": 1 }')).toThrow(/canonical/u); + }); +}); diff --git a/packages/hashing/__tests__/chain.test.ts b/packages/hashing/__tests__/chain.test.ts new file mode 100644 index 0000000..c89fc0d --- /dev/null +++ b/packages/hashing/__tests__/chain.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from 'vitest'; + +import { + GENESIS_HASH, + LedgerEventSchema, + SIGNER_KEY_IDS, + type ChainHead, +} from '@ald/types'; + +import { + InMemorySignerRegistry, + buildSignedEvent, + canonicalJson, + computeEntryHash, + formatChainViolation, + isSignedStream, + nextSequence, + parseJsonlEvents, + previousHashFor, + validateChain, + type ChainValidationResult, +} from '../src/index.js'; + +const RUN_ID = 'run-2026-08-24-001'; +const STREAM = 'baby-a-ledger'; + +interface ChainOptions { + count?: number; + runId?: string; + /** Sequence whose `previousEntryHash` is replaced by the genesis hash. */ + brokenLinkAt?: number; +} + +/** + * Build a real chain of signed Baby A ledger events: sequences from 1, the + * first link genesis, every later link the predecessor's entry hash + * (LEDGER §4). `brokenLinkAt` corrupts exactly one link while keeping the + * rest of the chain internally consistent. + */ +async function buildChain( + registry: InMemorySignerRegistry, + options: ChainOptions = {}, +): Promise[]> { + const { count = 10, runId = RUN_ID, brokenLinkAt } = options; + const signer = registry.signer('baby-a-ledger'); + const events: Record[] = []; + let previousEntryHash = GENESIS_HASH; + for (let sequence = 1; sequence <= count; sequence += 1) { + const event = await buildSignedEvent( + STREAM, + { + version: 1, + runId, + babyId: 'A', + sequence, + turn: sequence, + eventType: 'hypothesis.created', + contentSchema: 'agent-native-ledger', + subjectId: `sha256:${String(sequence).padStart(64, '0')}`, + content: { hypothesisRef: `h-${sequence}`, confidence: 0.5 }, + blindingNonce: `base64:nonce-${sequence}`, + previousEntryHash: + sequence === brokenLinkAt ? GENESIS_HASH : previousEntryHash, + recordedAt: `2026-08-24T21:${String(sequence).padStart(2, '0')}:00.000Z`, + writerKeyId: SIGNER_KEY_IDS['baby-a-ledger'], + }, + signer, + ); + events.push(event); + previousEntryHash = event.entryHash; + } + return events; +} + +function babyAKey(registry: InMemorySignerRegistry): string { + return registry.signer('baby-a-ledger').publicKey; +} + +function codes(result: ChainValidationResult): string[] { + return result.violations.map((violation) => violation.code); +} + +function clone(event: Record): Record { + return structuredClone(event); +} + +const registry = InMemorySignerRegistry.generate(RUN_ID); +const validated = { + runId: RUN_ID, + babyId: 'A' as const, + requireSignatures: true, + publicKey: babyAKey(registry), +}; + +describe('validateChain — accepting an unchanged chain (LEDGER §17)', () => { + it('accepts ten signed events and reports the head', async () => { + const events = await buildChain(registry); + const result = validateChain(STREAM, events, validated); + expect(result.violations).toEqual([]); + expect(result.ok).toBe(true); + expect(result.size).toBe(10); + expect(result.lastEntryHash).toBe(events[9]?.['entryHash']); + }); + + it('produces events that satisfy the shared LedgerEvent schema', async () => { + const events = await buildChain(registry, { count: 3 }); + for (const event of events) { + expect(() => LedgerEventSchema.parse(event)).not.toThrow(); + } + }); + + it('accepts an empty chain with the genesis head', () => { + const result = validateChain(STREAM, [], validated); + expect(result.ok).toBe(true); + expect(result.size).toBe(0); + expect(result.lastEntryHash).toBe(GENESIS_HASH); + }); + + it('accepts chains of every small size with and without options', async () => { + for (let count = 1; count <= 8; count += 1) { + const events = await buildChain(registry, { count }); + expect(validateChain(STREAM, events, validated).ok).toBe(true); + expect(validateChain(STREAM, events).ok).toBe(true); + } + }); +}); + +describe('validateChain — mutation matrix (LEDGER §17)', () => { + it('detects modified event content as an entry-hash mismatch', async () => { + const events = await buildChain(registry); + const mutated = events.map(clone); + (mutated[4] as { content: Record }).content = { + hypothesisRef: 'h-5', + confidence: 0.99, + }; + const result = validateChain(STREAM, mutated, validated); + expect(codes(result)).toEqual(['entry-hash-mismatch']); + expect(result.violations[0]?.sequence).toBe(5); + expect(result.ok).toBe(false); + }); + + it('detects a modified content field at every position', async () => { + const events = await buildChain(registry, { count: 6 }); + for (let index = 0; index < events.length; index += 1) { + const mutated = events.map(clone); + (mutated[index] as { turn: number }).turn = 999; + const result = validateChain(STREAM, mutated, validated); + expect(codes(result)).toEqual(['entry-hash-mismatch']); + expect(result.violations[0]?.sequence).toBe(index + 1); + } + }); + + it('detects a changed sequence number', async () => { + const events = await buildChain(registry); + const mutated = events.map(clone); + (mutated[9] as { sequence: number }).sequence = 99; + const result = validateChain(STREAM, mutated, validated); + expect(codes(result)).toEqual(['sequence-gap', 'entry-hash-mismatch']); + expect(result.violations[0]?.message).toContain('expected sequence 10'); + }); + + it('detects a chain that does not start at sequence 1', async () => { + const events = await buildChain(registry); + const result = validateChain(STREAM, events.slice(1), validated); + expect(codes(result)).toContain('sequence-start'); + expect(result.violations[0]?.sequence).toBe(2); + }); + + it('detects a deleted middle entry', async () => { + const events = await buildChain(registry); + const mutated = [...events.slice(0, 4), ...events.slice(5)]; + const result = validateChain(STREAM, mutated, validated); + expect(codes(result)).toContain('sequence-gap'); + expect(codes(result)).toContain('previous-hash-mismatch'); + expect( + result.violations.filter((violation) => violation.code === 'sequence-gap'), + ).toHaveLength(1); + expect(result.violations[0]?.sequence).toBe(6); + }); + + it('detects an inserted (replayed) entry', async () => { + const events = await buildChain(registry); + const mutated = [...events.slice(0, 5), clone(events[2] as Record), ...events.slice(5)]; + const result = validateChain(STREAM, mutated, validated); + expect(codes(result)).toContain('duplicate-sequence'); + expect(codes(result)).toContain('previous-hash-mismatch'); + }); + + it('detects reordered entries', async () => { + const events = await buildChain(registry); + const mutated = [...events]; + const fourth = mutated[3] as Record; + mutated[3] = mutated[4] as Record; + mutated[4] = fourth; + const result = validateChain(STREAM, mutated, validated); + expect(codes(result)).toContain('sequence-gap'); + expect(codes(result)).toContain('previous-hash-mismatch'); + }); + + it('detects an incorrect previous-entry hash even when re-signed', async () => { + const events = await buildChain(registry, { brokenLinkAt: 7 }); + const result = validateChain(STREAM, events, validated); + expect(codes(result)).toEqual(['previous-hash-mismatch']); + expect(result.violations[0]?.sequence).toBe(7); + }); + + it('detects a writer signature made by another domain key', async () => { + const events = await buildChain(registry); + const mutated = events.map(clone); + const target = mutated[3] as Record; + target['writerSignature'] = await registry + .signer('baby-b-ledger') + .sign(target['entryHash'] as string); + const result = validateChain(STREAM, mutated, validated); + expect(codes(result)).toEqual(['signature-invalid']); + expect(result.violations[0]?.sequence).toBe(4); + }); + + it('detects a corrupted signature', async () => { + const events = await buildChain(registry); + const mutated = events.map(clone); + (mutated[0] as Record)['writerSignature'] = + 'ed25519:not-a-signature'; + expect(codes(validateChain(STREAM, mutated, validated))).toEqual([ + 'signature-invalid', + ]); + }); + + it('rejects the whole chain under the wrong public key', async () => { + const events = await buildChain(registry); + const result = validateChain(STREAM, events, { + ...validated, + publicKey: registry.signer('channel').publicKey, + }); + expect(codes(result)).toEqual(Array(10).fill('signature-invalid')); + }); + + it('reports a missing signature only when signatures are required', async () => { + const events = await buildChain(registry); + const mutated = events.map(clone); + delete (mutated[2] as Record)['writerSignature']; + expect(codes(validateChain(STREAM, mutated, validated))).toEqual([ + 'signature-missing', + ]); + expect( + validateChain(STREAM, mutated, { ...validated, requireSignatures: false }) + .ok, + ).toBe(true); + }); + + it('detects a run-id mismatch against the expected run', async () => { + const events = await buildChain(registry); + const result = validateChain(STREAM, events, { + ...validated, + runId: 'run-other-001', + }); + expect(codes(result)).toEqual(Array(10).fill('run-id-mismatch')); + + const mutated = events.map(clone); + (mutated[1] as Record)['runId'] = 'run-other-001'; + expect(codes(validateChain(STREAM, mutated, validated))).toEqual([ + 'run-id-mismatch', + 'entry-hash-mismatch', + ]); + }); + + it('detects a baby-id mismatch against the expected ledger owner', async () => { + const events = await buildChain(registry, { count: 2 }); + const result = validateChain(STREAM, events, { + ...validated, + babyId: 'B', + }); + expect(codes(result)).toEqual(['baby-id-mismatch', 'baby-id-mismatch']); + }); + + it('collects every violation instead of stopping at the first', async () => { + const events = await buildChain(registry); + const mutated = events.map(clone); + (mutated[1] as { turn: number }).turn = 42; + (mutated[6] as { turn: number }).turn = 43; + const result = validateChain(STREAM, mutated, validated); + expect(result.violations).toHaveLength(2); + expect(result.violations.map((violation) => violation.sequence)).toEqual([ + 2, 7, + ]); + }); +}); + +describe('validateChain — malformed input is reported, never thrown', () => { + it('reports non-objects and unusable chain fields', () => { + const events = [ + null, + { sequence: 1 }, + { sequence: 'two', entryHash: GENESIS_HASH, previousEntryHash: GENESIS_HASH }, + ] as unknown as Record[]; + let result: ChainValidationResult | undefined; + expect(() => { + result = validateChain(STREAM, events, validated); + }).not.toThrow(); + expect(codes(result as ChainValidationResult)).toContain('malformed-event'); + expect((result as ChainValidationResult).ok).toBe(false); + }); + + it('reports values that cannot be canonicalized', () => { + const cyclic: Record = { + sequence: 1, + entryHash: GENESIS_HASH, + previousEntryHash: GENESIS_HASH, + }; + cyclic['self'] = cyclic; + const result = validateChain(STREAM, [cyclic]); + expect(codes(result)).toContain('malformed-event'); + }); + + it('renders violations as single lines for string-only report fields', async () => { + const events = await buildChain(registry, { brokenLinkAt: 2 }); + const [violation] = validateChain(STREAM, events, validated).violations; + const line = formatChainViolation(STREAM, violation as never); + expect(line.startsWith('baby-a-ledger#2 previous-hash-mismatch:')).toBe( + true, + ); + expect(line.includes('\n')).toBe(false); + }); +}); + +describe('unsigned intervention chain', () => { + it('validates without signatures even when signatures are required', () => { + expect(isSignedStream('intervention')).toBe(false); + expect(isSignedStream(STREAM)).toBe(true); + + const events: Record[] = []; + let previousEntryHash = GENESIS_HASH; + for (let sequence = 1; sequence <= 4; sequence += 1) { + const unsigned = { + version: 1, + runId: RUN_ID, + sequence, + eventType: 'run.paused', + actorId: 'researcher-1', + reasonCode: 'manual-pause', + previousEntryHash, + recordedAt: `2026-08-24T22:0${sequence}:00.000Z`, + }; + const event = { + ...unsigned, + entryHash: computeEntryHash('intervention', unsigned), + }; + events.push(event); + previousEntryHash = event.entryHash; + } + const result = validateChain('intervention', events, { + runId: RUN_ID, + requireSignatures: true, + }); + expect(result.ok).toBe(true); + expect(result.lastEntryHash).toBe(events[3]?.['entryHash']); + + const mutated = events.map(clone); + (mutated[1] as { reasonCode: string }).reasonCode = 'other'; + expect(codes(validateChain('intervention', mutated))).toEqual([ + 'entry-hash-mismatch', + ]); + }); +}); + +describe('link helpers', () => { + it('derives the next sequence and previous hash from a chain head', () => { + const empty: ChainHead = { + stream: STREAM, + size: 0, + lastEntryHash: GENESIS_HASH, + }; + expect(nextSequence(empty)).toBe(1); + expect(previousHashFor(empty)).toBe(GENESIS_HASH); + + const head: ChainHead = { + stream: STREAM, + size: 10, + lastEntryHash: `sha256:${'a'.repeat(64)}`, + }; + expect(nextSequence(head)).toBe(11); + expect(previousHashFor(head)).toBe(head.lastEntryHash); + }); + + it('signs with the stream domain and refuses cross-domain signers', async () => { + const unsigned = { version: 1, runId: RUN_ID, sequence: 1, previousChannelHash: GENESIS_HASH }; + const signed = await buildSignedEvent( + 'channel', + unsigned, + registry.signer('channel'), + ); + expect(signed.entryHash).toBe(computeEntryHash('channel', unsigned)); + expect(validateChain('channel', [signed], { + requireSignatures: true, + publicKey: registry.signer('channel').publicKey, + }).ok).toBe(true); + + await expect( + buildSignedEvent('channel', unsigned, registry.signer('baby-a-ledger')), + ).rejects.toThrow(/must be signed by the channel domain/u); + await expect( + buildSignedEvent('turns', unsigned, registry.signer('audit')), + ).rejects.toThrow(/witness/u); + }); + + it('overwrites any pre-existing signature fields', async () => { + const signed = await buildSignedEvent( + 'channel', + { + sequence: 1, + previousChannelHash: GENESIS_HASH, + entryHash: `sha256:${'b'.repeat(64)}`, + writerSignature: 'ed25519:stale', + }, + registry.signer('channel'), + ); + expect(signed.entryHash).not.toBe(`sha256:${'b'.repeat(64)}`); + expect(signed.writerSignature).not.toBe('ed25519:stale'); + expect(validateChain('channel', [signed], { + requireSignatures: true, + publicKey: registry.signer('channel').publicKey, + }).ok).toBe(true); + }); +}); + +describe('parseJsonlEvents', () => { + it('round-trips a canonical JSONL export into a valid chain', async () => { + const events = await buildChain(registry, { count: 5 }); + const text = `${events.map((event) => canonicalJson(event)).join('\n')}\n`; + const parsed = parseJsonlEvents(text); + expect(parsed).toEqual(events); + expect(validateChain(STREAM, parsed, validated).ok).toBe(true); + }); + + it('accepts a file with no trailing newline', () => { + expect(parseJsonlEvents('{"a":1}\n{"b":2}')).toEqual([{ a: 1 }, { b: 2 }]); + expect(parseJsonlEvents('')).toEqual([]); + // Only a *trailing* newline is ignored; a file holding a blank line is not + // a valid export. + expect(() => parseJsonlEvents('\n')).toThrow(/line 1/u); + }); + + it('rejects non-canonical lines unless canonicalization is waived', () => { + expect(() => parseJsonlEvents('{ "a": 1 }\n')).toThrow(/line 1/u); + expect(() => parseJsonlEvents('{"b":1,"a":2}\n')).toThrow(/canonical/u); + expect( + parseJsonlEvents('{ "a": 1 }\n', { requireCanonical: false }), + ).toEqual([{ a: 1 }]); + }); + + it('rejects blank interior lines and non-object lines', () => { + expect(() => parseJsonlEvents('{"a":1}\n\n{"b":2}\n')).toThrow(/line 2/u); + expect(() => parseJsonlEvents('[1,2]\n')).toThrow(/not a JSON object/u); + expect(() => parseJsonlEvents('7\n')).toThrow(/not a JSON object/u); + }); +}); diff --git a/packages/hashing/__tests__/keystore.test.ts b/packages/hashing/__tests__/keystore.test.ts new file mode 100644 index 0000000..4457b3a --- /dev/null +++ b/packages/hashing/__tests__/keystore.test.ts @@ -0,0 +1,245 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + GENESIS_HASH, + SIGNER_DOMAINS, + SIGNER_KEY_IDS, + type SignerDomain, +} from '@ald/types'; + +import { + FileKeyStore, + SIGNER_SEED_FILE, + buildSignedEvent, + validateChain, + verifyHashSignature, +} from '../src/index.js'; + +const skipModes = process.platform === 'win32'; + +let keyDir: string; +let store: FileKeyStore; + +beforeAll(() => { + keyDir = mkdtempSync(join(tmpdir(), 'ald-keystore-')); + store = new FileKeyStore(join(keyDir, 'keys')); +}); + +afterAll(() => { + rmSync(keyDir, { recursive: true, force: true }); +}); + +function seedsOf(runId: string): string[] { + const raw: unknown = JSON.parse( + readFileSync(store.seedFile(runId), 'utf8'), + ); + const seeds = (raw as { seeds: Record }).seeds; + return Object.values(seeds); +} + +describe('FileKeyStore provisioning (ALD-009, LEDGER §11)', () => { + it('provisions one key per signer domain and reports the run', () => { + expect(store.hasRun('run-p1')).toBe(false); + const registry = store.provisionRun('run-p1'); + expect(store.hasRun('run-p1')).toBe(true); + expect(registry.runId).toBe('run-p1'); + expect(registry.domains().sort()).toEqual([...SIGNER_DOMAINS].sort()); + expect(registry.publicKeys().map((key) => key.keyId).sort()).toEqual( + SIGNER_DOMAINS.map((domain) => SIGNER_KEY_IDS[domain]).sort(), + ); + expect(new Set(registry.publicKeys().map((key) => key.publicKey)).size).toBe( + SIGNER_DOMAINS.length, + ); + }); + + it('refuses to overwrite an existing run and never rotates in place', () => { + const registry = store.provisionRun('run-p2'); + expect(() => store.provisionRun('run-p2')).toThrow(/already exist/u); + expect(store.publicKeys('run-p2')).toEqual(registry.publicKeys()); + }); + + it('reproduces the same public keys and signatures after loadRun', async () => { + const provisioned = store.provisionRun('run-p3'); + const loaded = store.loadRun('run-p3'); + expect(loaded.publicKeys()).toEqual(provisioned.publicKeys()); + expect(store.publicKeys('run-p3')).toEqual(provisioned.publicKeys()); + + const hash = GENESIS_HASH; + for (const domain of SIGNER_DOMAINS) { + const signature = await loaded.signer(domain).sign(hash); + const publicKey = provisioned.signer(domain).publicKey; + expect(verifyHashSignature(hash, signature, publicKey)).toBe(true); + expect(await provisioned.signer(domain).sign(hash)).toBe(signature); + } + }); + + it('produces distinct public keys for every run (per-run rotation)', () => { + const first = store.provisionRun('run-r1'); + const second = store.provisionRun('run-r2'); + for (const domain of SIGNER_DOMAINS) { + expect(second.signer(domain).publicKey).not.toBe( + first.signer(domain).publicKey, + ); + // Same stable key identifier, different per-run key material. + expect(second.signer(domain).keyId).toBe(first.signer(domain).keyId); + } + }); + + it('can provision a subset of domains', () => { + const domains: readonly SignerDomain[] = ['channel', 'witness']; + const registry = store.provisionRun('run-subset', domains); + expect(registry.domains().sort()).toEqual(['channel', 'witness']); + expect(store.loadRun('run-subset').domains().sort()).toEqual([ + 'channel', + 'witness', + ]); + expect(() => registry.signer('baby-a-ledger')).toThrow(/No signer/u); + expect(() => store.provisionRun('run-empty', [])).toThrow(/at least one/iu); + }); +}); + +describe('FileKeyStore on-disk protection', () => { + it('stores only runId and hex seeds, never keys or evidence fields', () => { + store.provisionRun('run-file'); + const text = readFileSync(store.seedFile('run-file'), 'utf8'); + expect(text.endsWith('\n')).toBe(true); + const parsed: unknown = JSON.parse(text); + expect(Object.keys(parsed as object)).toEqual(['runId', 'seeds']); + const seeds = (parsed as { seeds: Record }).seeds; + expect(Object.keys(seeds).sort()).toEqual([...SIGNER_DOMAINS].sort()); + for (const seed of Object.values(seeds)) { + expect(seed).toMatch(/^[0-9a-f]{64}$/u); + } + expect(text).not.toContain('ed25519-pub:'); + expect(text).not.toContain('keyId'); + expect(text).not.toContain('publicKey'); + expect(store.seedFile('run-file').endsWith(SIGNER_SEED_FILE)).toBe(true); + }); + + it('keeps the run directory 0o700 and the seed file 0o600', () => { + store.provisionRun('run-modes'); + if (skipModes) { + return; + } + const file = store.seedFile('run-modes'); + expect(statSync(file).mode & 0o777).toBe(0o600); + expect(statSync(join(file, '..')).mode & 0o777).toBe(0o700); + }); + + it('never exposes seed material through the public-key surface', () => { + const registry = store.provisionRun('run-secrets'); + const seeds = seedsOf('run-secrets'); + const exposed = [ + JSON.stringify(registry.publicKeys()), + JSON.stringify(store.publicKeys('run-secrets')), + registry + .publicKeys() + .map((key) => `${key.domain}${key.keyId}${key.publicKey}`) + .join(''), + ].join('|'); + expect(seeds).toHaveLength(SIGNER_DOMAINS.length); + for (const seed of seeds) { + expect(exposed).not.toContain(seed); + } + for (const key of registry.publicKeys()) { + expect(Object.keys(key).sort()).toEqual(['domain', 'keyId', 'publicKey']); + } + }); + + it('rejects unsafe run identifiers instead of escaping the key directory', () => { + for (const runId of ['../escape', 'a/b', '', '.', './x', '-leading']) { + expect(() => store.provisionRun(runId)).toThrow(/runId/u); + expect(() => store.hasRun(runId)).toThrow(/runId/u); + } + }); +}); + +describe('FileKeyStore loading failures', () => { + it('throws for a run that was never provisioned', () => { + expect(() => store.loadRun('run-missing')).toThrow(/No signer keys/u); + expect(() => store.publicKeys('run-missing')).toThrow(/No signer keys/u); + }); + + it('rejects a tampered or foreign seed file', () => { + store.provisionRun('run-tampered'); + const file = store.seedFile('run-tampered'); + const original: unknown = JSON.parse(readFileSync(file, 'utf8')); + const rewrite = (value: unknown): void => { + writeFileSync(file, `${JSON.stringify(value)}\n`, 'utf8'); + }; + + rewrite({ ...(original as object), runId: 'run-other' }); + expect(() => store.loadRun('run-tampered')).toThrow(/refusing to load/u); + + rewrite({ runId: 'run-tampered', seeds: { channel: 'not-hex' } }); + expect(() => store.loadRun('run-tampered')).toThrow(/64 lowercase hex/u); + + rewrite({ runId: 'run-tampered', seeds: { 'anchor-wallet': 'a'.repeat(64) } }); + expect(() => store.loadRun('run-tampered')).toThrow(/Unknown signer domain/u); + + rewrite({ runId: 'run-tampered', seeds: {} }); + expect(() => store.loadRun('run-tampered')).toThrow(/no seeds/u); + + rewrite([1, 2, 3]); + expect(() => store.loadRun('run-tampered')).toThrow(/JSON object/u); + }); +}); + +describe('provisioned keys signing a real chain end to end', () => { + it('signs and verifies Baby A events under the manifest public key', async () => { + const registry = store.provisionRun('run-chain'); + const manifestKey = store + .publicKeys('run-chain') + .find((key) => key.domain === 'baby-a-ledger')?.publicKey; + + const events: Record[] = []; + let previousEntryHash = GENESIS_HASH; + for (let sequence = 1; sequence <= 3; sequence += 1) { + const event = await buildSignedEvent( + 'baby-a-ledger', + { + version: 1, + runId: 'run-chain', + babyId: 'A', + sequence, + turn: sequence, + eventType: 'intention.recorded', + contentSchema: 'agent-native-ledger', + subjectId: `subject-${sequence}`, + content: { artifactRef: `a-${sequence}` }, + blindingNonce: `base64:n${sequence}`, + previousEntryHash, + recordedAt: `2026-08-24T21:0${sequence}:00.000Z`, + writerKeyId: SIGNER_KEY_IDS['baby-a-ledger'], + }, + registry.signer('baby-a-ledger'), + ); + events.push(event); + previousEntryHash = event.entryHash; + } + + expect( + validateChain('baby-a-ledger', events, { + runId: 'run-chain', + babyId: 'A', + requireSignatures: true, + publicKey: manifestKey, + }).ok, + ).toBe(true); + + // A key from a different run cannot validate this run's events. + const otherRunKey = store + .publicKeys('run-p1') + .find((key) => key.domain === 'baby-a-ledger')?.publicKey; + expect( + validateChain('baby-a-ledger', events, { + requireSignatures: true, + publicKey: otherRunKey, + }).violations.map((violation) => violation.code), + ).toEqual(['signature-invalid', 'signature-invalid', 'signature-invalid']); + }); +}); diff --git a/packages/hashing/__tests__/primitives.test.ts b/packages/hashing/__tests__/primitives.test.ts new file mode 100644 index 0000000..d6f3b97 --- /dev/null +++ b/packages/hashing/__tests__/primitives.test.ts @@ -0,0 +1,199 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import { + GENESIS_HASH, + HASH_DOMAINS, + SIGNER_DOMAINS, +} from '@ald/types'; + +import { + InMemorySignerRegistry, + SeededPrng, + canonicalJson, + computeEntryHash, + decodeHash, + deriveSeedHex, + domainHash, + encodeHash, + generateEd25519KeyPair, + hashCarrierMark, + isSha256Hash, + parseCanonicalJson, + privateKeyFromSeed, + publicKeyFromPrivate, + signHash, + uint64BE, + verifyHashSignature, +} from '../src/index.js'; + +describe('domain-separated hashing', () => { + it('encodes as sha256:<64 hex> and round-trips bytes', () => { + const hash = domainHash('dtsf-test-v1', 'payload'); + expect(isSha256Hash(hash)).toBe(true); + expect(encodeHash(decodeHash(hash))).toBe(hash); + expect(isSha256Hash(GENESIS_HASH)).toBe(true); + }); + + it('is deterministic and sensitive to domain, separator, and payload', () => { + const base = domainHash('dtsf-a-v1', 'x'); + expect(domainHash('dtsf-a-v1', 'x')).toBe(base); + expect(domainHash('dtsf-b-v1', 'x')).not.toBe(base); + expect(domainHash('dtsf-a-v1', 'x', 0x01)).not.toBe(base); + expect(domainHash('dtsf-a-v1', 'y')).not.toBe(base); + expect(domainHash('dtsf-a-v1', ['x'])).toBe(base); + }); + + it('matches a hand-computed construction', () => { + // sha256("d" || 0x00 || "p") computed with node:crypto directly. + const expected = `sha256:${createHash('sha256') + .update(Buffer.from([0x64, 0x00, 0x70])) + .digest('hex')}`; + expect(domainHash('d', 'p')).toBe(expected); + }); + + it('encodes uint64 big-endian', () => { + expect(uint64BE(1).toString('hex')).toBe('0000000000000001'); + expect(uint64BE(258).toString('hex')).toBe('0000000000000102'); + expect(() => uint64BE(-1)).toThrow(); + }); + + it('content-addresses carrier marks with the carrier mode', () => { + const a = hashCarrierMark('fixed-token', { symbols: ['S01'] }); + expect(a).toBe(hashCarrierMark('fixed-token', { symbols: ['S01'] })); + expect(a).not.toBe(hashCarrierMark('fixed-glyph', { symbols: ['S01'] })); + expect(a).not.toBe(hashCarrierMark('fixed-token', { symbols: ['S02'] })); + }); + + it('recomputes an entry hash ignoring signature fields', () => { + const unsigned = { a: 1, b: { c: [1, 2] } }; + const hash = computeEntryHash('channel', unsigned); + expect( + computeEntryHash('channel', { + ...unsigned, + entryHash: hash, + writerSignature: 'ed25519:AAAA', + }), + ).toBe(hash); + expect(computeEntryHash('baby-a-ledger', unsigned)).not.toBe(hash); + expect(hash).toBe( + domainHash(HASH_DOMAINS.channelEvent, canonicalJson(unsigned)), + ); + }); +}); + +describe('canonical JSON', () => { + it('is insertion-order independent and rejects non-canonical input', () => { + expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe( + '{"a":{"c":3,"d":2},"b":1}', + ); + expect(parseCanonicalJson('{"a":1}')).toEqual({ a: 1 }); + expect(() => parseCanonicalJson('{ "a": 1 }')).toThrow(/canonical/u); + expect(() => canonicalJson(undefined)).toThrow(); + }); +}); + +describe('Ed25519', () => { + it('signs and verifies a hash, and rejects tampering', () => { + const pair = generateEd25519KeyPair(); + const hash = domainHash('dtsf-test-v1', 'event'); + const signature = signHash(hash, pair.privateKey); + expect(signature.startsWith('ed25519:')).toBe(true); + expect(verifyHashSignature(hash, signature, pair.publicKey)).toBe(true); + + const otherHash = domainHash('dtsf-test-v1', 'tampered'); + expect(verifyHashSignature(otherHash, signature, pair.publicKey)).toBe( + false, + ); + const otherKey = generateEd25519KeyPair().publicKey; + expect(verifyHashSignature(hash, signature, otherKey)).toBe(false); + expect(verifyHashSignature(hash, 'ed25519:notbase64!!', pair.publicKey)).toBe( + false, + ); + expect(verifyHashSignature(hash, signature, 'garbage')).toBe(false); + }); + + it('restores the same key from its seed', () => { + const pair = generateEd25519KeyPair(); + const restored = privateKeyFromSeed(pair.seed); + expect(publicKeyFromPrivate(restored)).toBe(pair.publicKey); + const hash = domainHash('dtsf-test-v1', 'seeded'); + expect(signHash(hash, restored)).toBe(signHash(hash, pair.privateKey)); + }); +}); + +describe('seeded PRNG', () => { + it('replays exactly from the same seed and diverges by label', () => { + const a = new SeededPrng('run-seed'); + const b = new SeededPrng('run-seed'); + const values = Array.from({ length: 50 }, () => a.nextUint32()); + expect(Array.from({ length: 50 }, () => b.nextUint32())).toEqual(values); + + const c = new SeededPrng('run-seed').derive('scenario'); + const d = new SeededPrng('run-seed').derive('gateway'); + expect(c.nextUint32()).not.toBe(d.nextUint32()); + }); + + it('produces bounded integers, unit floats, and permutations', () => { + const prng = new SeededPrng('bounds'); + for (let index = 0; index < 1000; index += 1) { + const value = prng.nextInt(7); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(7); + const float = prng.nextFloat(); + expect(float).toBeGreaterThanOrEqual(0); + expect(float).toBeLessThan(1); + } + const items = [1, 2, 3, 4, 5, 6, 7, 8]; + const shuffled = prng.shuffle(items); + expect([...shuffled].sort((x, y) => x - y)).toEqual(items); + expect(items).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(() => prng.nextInt(0)).toThrow(); + }); + + it('samples indices proportionally to weights', () => { + const prng = new SeededPrng('weights'); + const counts = [0, 0, 0]; + for (let index = 0; index < 3000; index += 1) { + counts[prng.sampleIndex([0, 1, 3])] += 1; + } + expect(counts[0]).toBe(0); + expect(counts[2]).toBeGreaterThan(counts[1] as number); + }); + + it('derives Appendix D seeds as sha256 over 0x00-joined parts', () => { + const expected = createHash('sha256') + .update(Buffer.concat([Buffer.from('ald-e03-v1'), Buffer.from([0]), Buffer.from('7')])) + .digest('hex'); + expect(deriveSeedHex('ald-e03-v1', '7')).toBe(expected); + }); +}); + +describe('signer registry', () => { + it('provisions one distinct key per domain and restores from seeds', async () => { + const registry = InMemorySignerRegistry.generate('run-1'); + const keys = registry.publicKeys(); + expect(keys.map((key) => key.domain).sort()).toEqual( + [...SIGNER_DOMAINS].sort(), + ); + expect(new Set(keys.map((key) => key.publicKey)).size).toBe(keys.length); + + const hash = domainHash('dtsf-test-v1', 'signed'); + const signature = await registry.signer('channel').sign(hash); + const channelKey = keys.find((key) => key.domain === 'channel')?.publicKey; + const babyKey = keys.find((key) => key.domain === 'baby-a-ledger')?.publicKey; + expect(verifyHashSignature(hash, signature, channelKey as string)).toBe(true); + expect(verifyHashSignature(hash, signature, babyKey as string)).toBe(false); + + const restored = InMemorySignerRegistry.fromSeeds( + 'run-1', + registry.exportSeeds(), + ); + expect(restored.publicKeys()).toEqual(keys); + expect(() => restored.signer('witness').keyId).not.toThrow(); + expect( + InMemorySignerRegistry.generate('run-2').publicKeys()[0]?.publicKey, + ).not.toBe(keys[0]?.publicKey); + }); +}); diff --git a/packages/hashing/__tests__/signers.test.ts b/packages/hashing/__tests__/signers.test.ts new file mode 100644 index 0000000..ce90990 --- /dev/null +++ b/packages/hashing/__tests__/signers.test.ts @@ -0,0 +1,91 @@ +/** + * Regression tests for the signer projection boundary (LEDGER §11 key + * management; SPEC §13.5 — signing keys are never exposed to model context or + * tools; `DomainSigner` in packages/types/src/contracts.ts). + * + * A `DomainSigner` must carry exactly `{ domain, keyId, publicKey, sign }`. + * `provision()` previously also set an own enumerable `seed` property, so any + * spread, `Object.keys`, or `JSON.stringify` of a signer emitted the 32-byte + * per-run Ed25519 private seed. Seeds now live in a private registry map that + * only `exportSeeds()` reads. + */ +import { describe, expect, it } from 'vitest'; + +import { SIGNER_DOMAINS, SIGNER_KEY_IDS } from '@ald/types'; + +import { + InMemorySignerRegistry, + domainHash, + verifyHashSignature, +} from '../src/index.js'; + +const RUN_ID = 'run-signer-shape'; + +describe('DomainSigner never carries private key material', () => { + it('exposes exactly the four contract members', () => { + const registry = InMemorySignerRegistry.generate(RUN_ID); + for (const domain of SIGNER_DOMAINS) { + const signer = registry.signer(domain); + expect(Object.keys(signer).sort()).toEqual([ + 'domain', + 'keyId', + 'publicKey', + 'sign', + ]); + expect(Object.keys({ ...signer })).not.toContain('seed'); + expect('seed' in signer).toBe(false); + } + }); + + it('never serializes a seed, in any encoding', () => { + // A fixed seed per domain makes every encoding below a deterministic + // needle, so the assertions cannot pass or fail by chance. + const fixed = Object.fromEntries( + SIGNER_DOMAINS.map((domain, index) => [ + domain, + Buffer.alloc(32, index + 1).toString('hex'), + ]), + ) as Record<(typeof SIGNER_DOMAINS)[number], string>; + const registry = InMemorySignerRegistry.fromSeeds(RUN_ID, fixed); + expect(registry.exportSeeds()).toEqual(fixed); + + for (const domain of SIGNER_DOMAINS) { + const hex = fixed[domain]; + const bytes = Buffer.from(hex, 'hex'); + const serialized = JSON.stringify(registry.signer(domain)); + expect(serialized).not.toContain('seed'); + expect(serialized).not.toContain(hex); + expect(serialized).not.toContain(bytes.toString('base64')); + // The `{"type":"Buffer","data":[…]}` form a Buffer property would take. + expect(serialized).not.toContain('Buffer'); + expect(serialized).not.toContain(JSON.stringify([...bytes]).slice(1, -1)); + expect(JSON.stringify(registry)).not.toContain(hex); + // Also true of the generated registry, which never sees these seeds. + const generated = InMemorySignerRegistry.generate(RUN_ID); + expect(JSON.stringify(generated.signer(domain))).not.toContain( + generated.exportSeeds()[domain] ?? 'unreachable', + ); + } + }); + + it('still signs, restores from seeds, and exports the same seeds', async () => { + const registry = InMemorySignerRegistry.generate(RUN_ID); + const seeds = registry.exportSeeds(); + expect(Object.keys(seeds).sort()).toEqual([...SIGNER_DOMAINS].sort()); + + const restored = InMemorySignerRegistry.fromSeeds(RUN_ID, seeds); + expect(restored.exportSeeds()).toEqual(seeds); + expect(restored.domains().sort()).toEqual([...SIGNER_DOMAINS].sort()); + expect(restored.publicKeys()).toEqual(registry.publicKeys()); + + const hash = domainHash('dtsf-test-v1', 'event'); + for (const domain of SIGNER_DOMAINS) { + const signer = registry.signer(domain); + expect(signer.domain).toBe(domain); + expect(signer.keyId).toBe(SIGNER_KEY_IDS[domain]); + const signature = await signer.sign(hash); + expect(verifyHashSignature(hash, signature, signer.publicKey)).toBe(true); + expect(await restored.signer(domain).sign(hash)).toBe(signature); + } + }); +}); diff --git a/packages/hashing/package.json b/packages/hashing/package.json new file mode 100644 index 0000000..7297fd0 --- /dev/null +++ b/packages/hashing/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ald/hashing", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "canonicalize": "4.0.0" + } +} diff --git a/packages/hashing/src/canonical.ts b/packages/hashing/src/canonical.ts new file mode 100644 index 0000000..b6635bb --- /dev/null +++ b/packages/hashing/src/canonical.ts @@ -0,0 +1,143 @@ +/** + * RFC 8785 (JCS) canonical JSON, the single serialization every hash in the + * system is computed over (LEDGER §4; docs/evidence-bundle-format.md §1, §3). + * + * Because `entryHash` binds the canonical form and every exported `*.jsonl` + * line *is* that canonical form, a value the serializer cannot represent must + * never produce a hash: the alternatives are a signed entry whose content is + * silently empty, or an evidence line no independent verifier can parse. + * `canonicalize` v4 signals neither case — a `Map`, `Set`, `Buffer`, or class + * instance serializes to `{}`, and a function-valued property is interpolated + * as the bare token `undefined` — so {@link canonicalJson} validates the value + * itself before serializing. + */ +import canonicalize from 'canonicalize'; + +/** Message prefix of every rejection thrown by {@link canonicalJson}. */ +const REJECTION = 'Value cannot be represented as canonical JSON'; + +function reject(reason: string, path: string): never { + throw new Error(`${REJECTION}: ${reason} at ${path === '' ? '' : path}`); +} + +function describe(value: object): string { + const name: unknown = (value as { constructor?: { name?: unknown } }) + .constructor?.name; + return typeof name === 'string' && name.length > 0 + ? `a non-plain object (${name})` + : 'a non-plain object'; +} + +/** + * Reject anything JSON cannot represent, before `canonicalize` can drop it or + * emit a non-JSON token. + * + * Accepted: `null`, booleans, finite numbers, strings, plain `Array`s, and + * objects whose prototype is `Object.prototype` or `null`. + * + * Rejected: `undefined` at the root, `bigint`, functions, symbols, `NaN`, + * `±Infinity`, circular references, sparse arrays (see below), `Array` + * subclasses, and every other non-plain object (`Map`, `Set`, `Date`, + * `Buffer`, typed arrays, boxed primitives, class instances) — each of which + * either loses its contents or produces invalid JSON. + * + * Two JSON/JCS semantics are deliberately preserved rather than rejected, and + * are pinned by tests: an object property whose value is `undefined` is + * **omitted** from the canonical form, and an *explicitly* `undefined` array + * element becomes `null`. Both match `JSON.stringify`, so a caller reading the + * canonical form back gets exactly what was hashed. + * + * An array *hole* is not the same case and is rejected: `canonicalize` v4 + * emits `[1,,2]` for `[1, , 2]` where `JSON.stringify` emits `[1,null,2]`, so + * a hole would produce an evidence line that is not JSON at all. + */ +function assertJsonValue(value: unknown, path: string, seen: Set): void { + if (value === null) { + return; + } + switch (typeof value) { + case 'boolean': + case 'string': + return; + case 'number': + if (!Number.isFinite(value)) { + reject(`the non-finite number ${String(value)}`, path); + } + return; + case 'undefined': + reject('undefined', path); + break; + case 'bigint': + reject('a bigint', path); + break; + case 'function': + reject('a function', path); + break; + case 'symbol': + reject('a symbol', path); + break; + default: + break; + } + const object = value as object; + if (seen.has(object)) { + reject('a circular reference', path); + } + const prototype: unknown = Object.getPrototypeOf(object); + seen.add(object); + if (Array.isArray(object)) { + if (prototype !== Array.prototype) { + reject(describe(object), path); + } + const elements: readonly unknown[] = object; + for (let index = 0; index < elements.length; index += 1) { + // A hole serializes as an empty slot (`[1,,2]`), which is not JSON. + if (!Object.hasOwn(elements, index)) { + reject('an array hole', `${path}[${index}]`); + } + const element = elements[index]; + // An explicit `undefined` element canonicalizes to `null` (JSON + // semantics), matching `JSON.stringify`. + if (element !== undefined) { + assertJsonValue(element, `${path}[${index}]`, seen); + } + } + } else { + if (prototype !== Object.prototype && prototype !== null) { + reject(describe(object), path); + } + const record = object as Record; + for (const key of Object.keys(record)) { + const property = record[key]; + // An `undefined` property is omitted from the canonical form (JCS). + if (property !== undefined) { + assertJsonValue(property, path === '' ? key : `${path}.${key}`, seen); + } + } + } + seen.delete(object); +} + +/** + * RFC 8785 (JCS) serialization. Throws for values JSON cannot represent — + * see {@link assertJsonValue} for the exact accept/reject rules — so a hash + * is never computed over a form that lost data or is not valid JSON + * (docs/evidence-bundle-format.md §1, §3). + */ +export function canonicalJson(value: unknown): string { + assertJsonValue(value, '', new Set()); + const serialized = canonicalize(value); + if (serialized === undefined) { + throw new Error(REJECTION); + } + return serialized; +} + +/** Parse JSON and require that it is already in canonical form. */ +export function parseCanonicalJson(text: string): T { + const parsed: unknown = JSON.parse(text); + if (canonicalJson(parsed) !== text) { + throw new Error('Input is valid JSON but is not RFC 8785 canonical JSON'); + } + return parsed as T; +} diff --git a/packages/hashing/src/chain.ts b/packages/hashing/src/chain.ts new file mode 100644 index 0000000..51698e4 --- /dev/null +++ b/packages/hashing/src/chain.ts @@ -0,0 +1,372 @@ +/** + * Hash-chain construction and the chain-walk validator (ALD-008). + * + * Every event stream is an independent previous-hash chain whose sequences + * start at `1` (LEDGER §4, §6). This module holds the two halves of that + * rule: the small helpers a writer uses to link the next event onto a chain + * head, and the validator an independent verifier uses to walk a chain and + * report every deviation. + * + * The validator implements steps 1-5 of the independent-verification + * procedure in LEDGER §14 (canonical JSON, run/baby/sequence consistency, + * rebuilt entry hashes, previous-entry links, writer signatures) and is the + * detector required by the LEDGER §17 acceptance tests for modified content, + * changed sequence numbers, deleted, inserted, and reordered entries, + * incorrect previous-entry hashes, and invalid writer signatures. Merkle, + * checkpoint, and anchoring checks (steps 6-11) belong to `@ald/merkle`, + * `@ald/checkpoint`, and `@ald/anchor`. + */ +import { + GENESIS_HASH, + STREAM_SIGNER, + type ChainHead, + type DomainSigner, + type EventStream, +} from '@ald/types'; + +import { parseCanonicalJson } from './canonical.js'; +import { verifyHashSignature } from './ed25519.js'; +import { computeEntryHash, isSha256Hash } from './sha256.js'; + +/** Streams whose events carry a `writerSignature` (`intervention` does not). */ +export type SignedEventStream = Exclude; + +/** + * Integrity failure codes reported by {@link validateChain}. One code per + * distinct rule so a verifier report can name the rule that failed rather + * than a free-text reason. + */ +export type ChainViolationCode = + /** The first event of the chain does not have `sequence: 1` (LEDGER §4). */ + | 'sequence-start' + /** A sequence is not exactly one greater than its predecessor. */ + | 'sequence-gap' + /** A sequence value repeats within the walked chain (LEDGER §15 fork). */ + | 'duplicate-sequence' + /** `previousEntryHash` does not equal the preceding `entryHash`. */ + | 'previous-hash-mismatch' + /** The recomputed entry hash differs from the stored `entryHash`. */ + | 'entry-hash-mismatch' + /** A signed stream event has no `writerSignature`. */ + | 'signature-missing' + /** `writerSignature` does not verify under the expected public key. */ + | 'signature-invalid' + /** The event's `runId` differs from the run being verified. */ + | 'run-id-mismatch' + /** The event's `babyId` differs from the ledger owner being verified. */ + | 'baby-id-mismatch' + /** The event is not an object, or lacks usable chain fields. */ + | 'malformed-event'; + +export interface ChainViolation { + /** The event's own `sequence`, or `null` when it is unusable. */ + sequence: number | null; + code: ChainViolationCode; + message: string; +} + +export interface ChainValidationResult { + ok: boolean; + /** Number of events walked, comparable with `ChainHead.size`. */ + size: number; + /** Head of the walked chain: the last usable `entryHash`, else genesis. */ + lastEntryHash: string; + violations: ChainViolation[]; +} + +export interface ChainValidationOptions { + /** When given, every event's `runId` must equal this value (LEDGER §14). */ + runId?: string; + /** When given, `writerSignature` is verified against this public key. */ + publicKey?: string; + /** When true, a signed-stream event without a signature is a violation. */ + requireSignatures?: boolean; + /** When given, every event's `babyId` must equal this value (LEDGER §14). */ + babyId?: 'A' | 'B'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** `true` for every stream except the unsigned `intervention` log. */ +export function isSignedStream(stream: EventStream): stream is SignedEventStream { + return Object.prototype.hasOwnProperty.call(STREAM_SIGNER, stream); +} + +/** + * Walk one stream's events in storage order and report every integrity + * violation found. Never throws: malformed input is reported, not raised, so + * a verifier can produce a complete report for a corrupted bundle. + * + * Checks are independent by design — a mutated event body is reported once as + * `entry-hash-mismatch` and does not cascade into the link or signature + * checks, because the stored `entryHash` (which the link and signature + * commit to) is unchanged. + */ +/** + * Name of the previous-hash link field in each stream's events. Every stream + * uses `previousEntryHash` except the channel transcript, whose schema + * (SPEC §11.5) names the link `previousChannelHash`. + */ +export const LINK_FIELDS: Record = { + 'baby-a-ledger': 'previousEntryHash', + 'baby-b-ledger': 'previousEntryHash', + channel: 'previousChannelHash', + affect: 'previousEntryHash', + audit: 'previousEntryHash', + turns: 'previousEntryHash', + intervention: 'previousEntryHash', +}; + +export function linkFieldFor(stream: EventStream): 'previousEntryHash' | 'previousChannelHash' { + return LINK_FIELDS[stream]; +} + +export function validateChain( + stream: EventStream, + events: readonly Record[], + options: ChainValidationOptions = {}, +): ChainValidationResult { + const violations: ChainViolation[] = []; + const signed = isSignedStream(stream); + const linkField = linkFieldFor(stream); + const requireSignatures = options.requireSignatures ?? false; + const seen = new Set(); + let previousEntryHash = GENESIS_HASH; + let expectedSequence = 1; + + const report = ( + sequence: number | null, + code: ChainViolationCode, + message: string, + ): void => { + violations.push({ sequence, code, message }); + }; + + events.forEach((event, index) => { + if (!isRecord(event)) { + report(null, 'malformed-event', `event at index ${index} is not an object`); + expectedSequence += 1; + return; + } + + const rawSequence = event['sequence']; + const sequence = + typeof rawSequence === 'number' && + Number.isSafeInteger(rawSequence) && + rawSequence > 0 + ? rawSequence + : null; + const entryHash = event['entryHash']; + const linkHash = event[linkField]; + + const malformed: string[] = []; + if (sequence === null) { + malformed.push('sequence must be a positive integer'); + } + if (!isSha256Hash(entryHash)) { + malformed.push('entryHash must be sha256:<64 hex>'); + } + if (!isSha256Hash(linkHash)) { + malformed.push(`${linkField} must be sha256:<64 hex>`); + } + if (malformed.length > 0) { + report( + sequence, + 'malformed-event', + `event at index ${index}: ${malformed.join('; ')}`, + ); + } + + if (options.runId !== undefined && event['runId'] !== options.runId) { + report( + sequence, + 'run-id-mismatch', + `expected runId ${options.runId}, found ${String(event['runId'])}`, + ); + } + if (options.babyId !== undefined && event['babyId'] !== options.babyId) { + report( + sequence, + 'baby-id-mismatch', + `expected babyId ${options.babyId}, found ${String(event['babyId'])}`, + ); + } + + if (sequence === null) { + expectedSequence += 1; + } else { + if (seen.has(sequence)) { + report( + sequence, + 'duplicate-sequence', + `sequence ${sequence} appears more than once`, + ); + } else if (index === 0 && sequence !== 1) { + report( + sequence, + 'sequence-start', + `chain must start at sequence 1, found ${sequence}`, + ); + } else if (sequence !== expectedSequence) { + report( + sequence, + 'sequence-gap', + `expected sequence ${expectedSequence}, found ${sequence}`, + ); + } + seen.add(sequence); + // Resynchronize so a single break reports once instead of cascading. + expectedSequence = sequence + 1; + } + + if (isSha256Hash(linkHash) && linkHash !== previousEntryHash) { + report( + sequence, + 'previous-hash-mismatch', + `${linkField} ${linkHash} does not match ${previousEntryHash}`, + ); + } + + let recomputed: string | undefined; + try { + recomputed = computeEntryHash(stream, event); + } catch { + report( + sequence, + 'malformed-event', + `event at index ${index} is not representable as canonical JSON`, + ); + } + if ( + recomputed !== undefined && + isSha256Hash(entryHash) && + recomputed !== entryHash + ) { + report( + sequence, + 'entry-hash-mismatch', + `stored entryHash ${entryHash} does not match recomputed ${recomputed}`, + ); + } + + if (signed) { + const signature = event['writerSignature']; + if (typeof signature !== 'string' || signature.length === 0) { + if (requireSignatures) { + report(sequence, 'signature-missing', 'writerSignature is absent'); + } + } else if ( + options.publicKey !== undefined && + isSha256Hash(entryHash) && + !verifyHashSignature(entryHash, signature, options.publicKey) + ) { + report( + sequence, + 'signature-invalid', + `writerSignature does not verify under ${options.publicKey}`, + ); + } + } + + if (isSha256Hash(entryHash)) { + previousEntryHash = entryHash; + } + }); + + return { + ok: violations.length === 0, + size: events.length, + lastEntryHash: previousEntryHash, + violations, + }; +} + +/** + * One-line rendering of a violation, for `RecoveryReport.chainViolations` and + * `VerificationReport.gaps`, which are contracted as `string[]`. + */ +export function formatChainViolation( + stream: EventStream, + violation: ChainViolation, +): string { + const at = violation.sequence === null ? '-' : String(violation.sequence); + return `${stream}#${at} ${violation.code}: ${violation.message}`; +} + +/** Next sequence to assign on a chain head (LEDGER §15: never reuse one). */ +export function nextSequence(head: ChainHead): number { + return head.size + 1; +} + +/** `previousEntryHash` for the next event: genesis for an empty chain. */ +export function previousHashFor(head: ChainHead): string { + return head.size === 0 ? GENESIS_HASH : head.lastEntryHash; +} + +/** + * Hash and sign one assembled unsigned event (LEDGER §4): compute + * `entryHash` over the canonical event without the signature fields, then + * have the stream's own domain signer sign the raw digest bytes. + * + * The signer domain is checked against `STREAM_SIGNER`, so a cross-domain + * signing attempt (for example Baby A's signer on the channel stream) fails + * here rather than producing evidence that only fails later at verification + * time (LEDGER §11, ALD-009). + */ +export async function buildSignedEvent>( + stream: SignedEventStream, + unsignedEvent: T, + signer: DomainSigner, +): Promise { + const expectedDomain = STREAM_SIGNER[stream]; + if (signer.domain !== expectedDomain) { + throw new Error( + `Stream ${stream} must be signed by the ${expectedDomain} domain, not ${signer.domain}`, + ); + } + const entryHash = computeEntryHash(stream, unsignedEvent); + const writerSignature = await signer.sign(entryHash); + return { ...unsignedEvent, entryHash, writerSignature }; +} + +export interface JsonlParseOptions { + /** Require every line to already be RFC 8785 canonical (bundle default). */ + requireCanonical?: boolean; +} + +/** + * Parse one JSONL stream file into events. Each line must be a single JSON + * object; a single trailing newline is allowed (every `*.jsonl` line in an + * evidence bundle ends with `\n`), but a blank line inside the file is an + * error, as is any line that is not already canonical when + * `requireCanonical` is set — step 1 of LEDGER §14. + * + * Throws on unparseable input; use {@link validateChain} on the result for + * non-throwing integrity reporting. + */ +export function parseJsonlEvents( + text: string, + options: JsonlParseOptions = {}, +): Record[] { + const requireCanonical = options.requireCanonical ?? true; + const lines = text.split('\n'); + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + return lines.map((line, index) => { + const lineNumber = index + 1; + let parsed: unknown; + try { + parsed = requireCanonical ? parseCanonicalJson(line) : JSON.parse(line); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`JSONL line ${lineNumber} is invalid: ${reason}`); + } + if (!isRecord(parsed)) { + throw new Error(`JSONL line ${lineNumber} is not a JSON object`); + } + return parsed; + }); +} diff --git a/packages/hashing/src/ed25519.ts b/packages/hashing/src/ed25519.ts new file mode 100644 index 0000000..191a5d5 --- /dev/null +++ b/packages/hashing/src/ed25519.ts @@ -0,0 +1,104 @@ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign, + verify, + type KeyObject, +} from 'node:crypto'; + +import { decodeHash } from './sha256.js'; + +// DER prefixes for raw Ed25519 keys (RFC 8410). +const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); +const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); +const PUBLIC_KEY_PATTERN = /^ed25519-pub:([A-Za-z0-9+/]+=*)$/u; +const SIGNATURE_PATTERN = /^ed25519:([A-Za-z0-9+/]+=*)$/u; + +export interface Ed25519KeyPair { + /** `ed25519-pub:` */ + publicKey: string; + privateKey: KeyObject; + /** 32-byte private seed; persist only inside an isolated key store. */ + seed: Buffer; +} + +export function generateEd25519KeyPair(): Ed25519KeyPair { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + return { + publicKey: encodePublicKey(publicKey), + privateKey, + seed: privateKeySeed(privateKey), + }; +} + +export function encodePublicKey(key: KeyObject): string { + const der = key.export({ type: 'spki', format: 'der' }); + const raw = der.subarray(der.length - 32); + return `ed25519-pub:${Buffer.from(raw).toString('base64')}`; +} + +export function decodePublicKey(encoded: string): KeyObject { + const match = PUBLIC_KEY_PATTERN.exec(encoded); + const base64 = match?.[1]; + if (!base64) { + throw new Error('Invalid Ed25519 public key encoding'); + } + const raw = Buffer.from(base64, 'base64'); + if (raw.length !== 32) { + throw new Error('Ed25519 public key must be 32 bytes'); + } + return createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, raw]), + format: 'der', + type: 'spki', + }); +} + +export function publicKeyFromPrivate(privateKey: KeyObject): string { + return encodePublicKey(createPublicKey(privateKey)); +} + +export function privateKeySeed(key: KeyObject): Buffer { + const der = key.export({ type: 'pkcs8', format: 'der' }); + return Buffer.from(der.subarray(der.length - 32)); +} + +export function privateKeyFromSeed(seed: Uint8Array): KeyObject { + if (seed.length !== 32) { + throw new Error('Ed25519 seed must be 32 bytes'); + } + return createPrivateKey({ + key: Buffer.concat([PKCS8_PREFIX, Buffer.from(seed)]), + format: 'der', + type: 'pkcs8', + }); +} + +/** Sign the 32 raw bytes of an encoded hash. Returns `ed25519:`. */ +export function signHash(hash: string, privateKey: KeyObject): string { + const signature = sign(null, decodeHash(hash), privateKey); + return `ed25519:${signature.toString('base64')}`; +} + +/** Returns false (never throws) for malformed input or a bad signature. */ +export function verifyHashSignature( + hash: string, + signature: string, + publicKey: string, +): boolean { + const match = SIGNATURE_PATTERN.exec(signature); + const base64 = match?.[1]; + if (!base64) { + return false; + } + try { + const raw = Buffer.from(base64, 'base64'); + if (raw.length !== 64) { + return false; + } + return verify(null, decodeHash(hash), decodePublicKey(publicKey), raw); + } catch { + return false; + } +} diff --git a/packages/hashing/src/index.ts b/packages/hashing/src/index.ts new file mode 100644 index 0000000..b8efeeb --- /dev/null +++ b/packages/hashing/src/index.ts @@ -0,0 +1,58 @@ +/** + * @ald/hashing — cryptographic primitives shared by every evidence component: + * domain-separated SHA-256, RFC 8785 canonical JSON, Ed25519 signing, per-run + * signer registries, and the deterministic seeded PRNG. + * + * Hash-chain construction/validation (ALD-008) lives in `chain.ts`; the + * file-backed per-run key store (ALD-009) lives in `keystore.ts`. + */ +export { + buildSignedEvent, + formatChainViolation, + isSignedStream, + LINK_FIELDS, + linkFieldFor, + nextSequence, + parseJsonlEvents, + previousHashFor, + validateChain, + type ChainValidationOptions, + type ChainValidationResult, + type ChainViolation, + type ChainViolationCode, + type JsonlParseOptions, + type SignedEventStream, +} from './chain.js'; +export { + canonicalJson, + parseCanonicalJson, +} from './canonical.js'; +export { + computeEntryHash, + decodeHash, + domainHash, + encodeHash, + hashCanonical, + hashCarrierMark, + hashRunId, + isSha256Hash, + omitFields, + sha256Bytes, + toBytes, + uint64BE, + type HashSeparator, +} from './sha256.js'; +export { + decodePublicKey, + encodePublicKey, + generateEd25519KeyPair, + privateKeyFromSeed, + privateKeySeed, + publicKeyFromPrivate, + signHash, + verifyHashSignature, + type Ed25519KeyPair, +} from './ed25519.js'; +export { FileKeyStore, SIGNER_SEED_FILE } from './keystore.js'; +export { SeededPrng, deriveSeedHex } from './prng.js'; +export { InMemorySignerRegistry } from './signers.js'; diff --git a/packages/hashing/src/keystore.ts b/packages/hashing/src/keystore.ts new file mode 100644 index 0000000..73df53b --- /dev/null +++ b/packages/hashing/src/keystore.ts @@ -0,0 +1,179 @@ +/** + * Per-run key provisioning for the isolated signer services (ALD-009). + * + * LEDGER §11 requires a separate Ed25519 key per event-writer domain and for + * the Nursery checkpoint witness, generated **per run** inside an isolated + * signer service, with only public keys recorded in the run manifest and no + * signing key ever exposed to model context or tools. + * + * This store keeps that private material outside the evidence store + * altogether: one directory per run under `keyDir`, holding a single + * `signers.json` of hex seeds. Because the file contains *only* + * `{ runId, seeds }`, it can never be mistaken for an evidence artifact — + * there are no public keys, key ids, hashes, or signatures in it, and nothing + * in the evidence bundle format has that shape. Public keys are always + * derived on load (LEDGER §11: "the verifier requires only public keys"). + */ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + SIGNER_DOMAINS, + type SignerDomain, + type SignerPublicKey, +} from '@ald/types'; + +import { canonicalJson } from './canonical.js'; +import { InMemorySignerRegistry } from './signers.js'; + +/** File name of the seed file inside `//`. */ +export const SIGNER_SEED_FILE = 'signers.json'; + +/** Owner-only key directory permissions. */ +const DIR_MODE = 0o700; +/** Owner-only seed file permissions. */ +const FILE_MODE = 0o600; + +const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const SEED_HEX_PATTERN = /^[0-9a-f]{64}$/u; + +/** On-disk shape of `signers.json`: private seeds and nothing else. */ +interface SeedFile { + runId: string; + seeds: Record; +} + +function isSignerDomain(value: string): value is SignerDomain { + return (SIGNER_DOMAINS as readonly string[]).includes(value); +} + +/** + * Reject run identifiers that could escape `keyDir` or collide with the + * directory itself. Implementation-defined: the specification does not fix a + * `runId` grammar, so the key store accepts only path-safe identifiers. + */ +function assertSafeRunId(runId: string): void { + if (!RUN_ID_PATTERN.test(runId)) { + throw new Error( + 'runId must be 1-128 characters of [A-Za-z0-9._-] and start alphanumeric', + ); + } +} + +/** + * File-backed per-run signer provisioning. + * + * ```ts + * const store = new FileKeyStore('/var/lib/ald/keys'); + * const registry = store.provisionRun(config.runId); // fresh keys + * const manifestSigners = registry.publicKeys(); // manifest input + * const resumed = store.loadRun(config.runId); // after restart + * ``` + * + * `keyDir` must live outside the repository and outside every evidence + * bundle; this class enforces owner-only permissions but cannot choose the + * location for the caller. + */ +export class FileKeyStore { + constructor(private readonly keyDir: string) {} + + /** Directory holding one run's key material. */ + private runDir(runId: string): string { + assertSafeRunId(runId); + return join(this.keyDir, runId); + } + + /** Absolute path of a run's seed file. */ + seedFile(runId: string): string { + return join(this.runDir(runId), SIGNER_SEED_FILE); + } + + hasRun(runId: string): boolean { + return existsSync(this.seedFile(runId)); + } + + /** + * Generate a fresh key per domain for `runId` and persist the seeds. Throws + * if the run already has keys: per-run rotation must never silently replace + * the keys that already-committed signatures were made with (LEDGER §11, + * §15). + */ + provisionRun( + runId: string, + domains: readonly SignerDomain[] = SIGNER_DOMAINS, + ): InMemorySignerRegistry { + if (domains.length === 0) { + throw new Error('At least one signer domain must be provisioned'); + } + const directory = this.runDir(runId); + if (this.hasRun(runId)) { + throw new Error(`Signer keys already exist for run ${runId}`); + } + const registry = InMemorySignerRegistry.generate(runId, domains); + const payload: SeedFile = { runId, seeds: registry.exportSeeds() }; + + mkdirSync(directory, { recursive: true, mode: DIR_MODE }); + chmodSync(directory, DIR_MODE); + writeFileSync(this.seedFile(runId), `${canonicalJson(payload)}\n`, { + encoding: 'utf8', + mode: FILE_MODE, + flag: 'wx', + }); + chmodSync(this.seedFile(runId), FILE_MODE); + return registry; + } + + /** Restore a run's signers from its persisted seeds. */ + loadRun(runId: string): InMemorySignerRegistry { + const path = this.seedFile(runId); + if (!existsSync(path)) { + throw new Error(`No signer keys provisioned for run ${runId}`); + } + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); + const file = parseSeedFile(parsed); + if (file.runId !== runId) { + throw new Error( + `Seed file for ${runId} declares runId ${file.runId}; refusing to load`, + ); + } + const seeds: Partial> = {}; + for (const [domain, seed] of Object.entries(file.seeds)) { + if (!isSignerDomain(domain)) { + throw new Error(`Unknown signer domain in seed file: ${domain}`); + } + seeds[domain] = seed; + } + return InMemorySignerRegistry.fromSeeds(runId, seeds); + } + + /** Public keys for the run manifest. Never returns private material. */ + publicKeys(runId: string): SignerPublicKey[] { + return this.loadRun(runId).publicKeys(); + } +} + +function parseSeedFile(value: unknown): SeedFile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Seed file must be a JSON object'); + } + const record = value as Record; + const runId = record['runId']; + const seeds = record['seeds']; + if (typeof runId !== 'string' || runId.length === 0) { + throw new Error('Seed file is missing a runId'); + } + if (typeof seeds !== 'object' || seeds === null || Array.isArray(seeds)) { + throw new Error('Seed file is missing a seeds object'); + } + const parsedSeeds: Record = {}; + for (const [domain, seed] of Object.entries(seeds as Record)) { + if (typeof seed !== 'string' || !SEED_HEX_PATTERN.test(seed)) { + throw new Error(`Seed for ${domain} must be 64 lowercase hex characters`); + } + parsedSeeds[domain] = seed; + } + if (Object.keys(parsedSeeds).length === 0) { + throw new Error('Seed file contains no seeds'); + } + return { runId, seeds: parsedSeeds }; +} diff --git a/packages/hashing/src/prng.ts b/packages/hashing/src/prng.ts new file mode 100644 index 0000000..ed3fc32 --- /dev/null +++ b/packages/hashing/src/prng.ts @@ -0,0 +1,124 @@ +import { HASH_DOMAINS } from '@ald/types'; + +import { sha256Bytes, uint64BE } from './sha256.js'; + +/** + * Deterministic, dependency-free PRNG: SHA-256 in counter mode over a + * domain-separated key derived from a string seed. Every consumer that needs + * randomness (scenario generation, gateway control substitution, learner + * sampling) derives its own labeled stream so runs replay exactly from + * `RunConfig.randomSeed` (SPEC §14.3). + * + * Not a cryptographic RNG for key material; keys use `node:crypto`. + */ +export class SeededPrng { + private readonly key: Buffer; + private counter = 0; + private block: Buffer = Buffer.alloc(0); + private offset = 0; + + constructor(public readonly seed: string) { + this.key = sha256Bytes( + Buffer.from(HASH_DOMAINS.seed, 'utf8'), + Buffer.from([0]), + Buffer.from(seed, 'utf8'), + ); + } + + /** Independent child stream; the same label always yields the same stream. */ + derive(label: string): SeededPrng { + return new SeededPrng(`${this.key.toString('hex')}/${label}`); + } + + private refill(): void { + this.block = sha256Bytes(this.key, uint64BE(this.counter)); + this.counter += 1; + this.offset = 0; + } + + nextUint32(): number { + if (this.offset + 4 > this.block.length) { + this.refill(); + } + const value = this.block.readUInt32BE(this.offset); + this.offset += 4; + return value; + } + + /** Uniform float in [0, 1) with 53 bits of precision. */ + nextFloat(): number { + const high = this.nextUint32() >>> 5; + const low = this.nextUint32() >>> 6; + return (high * 67108864 + low) / 9007199254740992; + } + + /** Uniform integer in [0, maxExclusive) by rejection sampling. */ + nextInt(maxExclusive: number): number { + if (!Number.isInteger(maxExclusive) || maxExclusive <= 0) { + throw new Error('maxExclusive must be a positive integer'); + } + const range = 0x1_0000_0000; + const limit = range - (range % maxExclusive); + let value = this.nextUint32(); + while (value >= limit) { + value = this.nextUint32(); + } + return value % maxExclusive; + } + + pick(items: readonly T[]): T { + if (items.length === 0) { + throw new Error('Cannot pick from an empty list'); + } + return items[this.nextInt(items.length)] as T; + } + + /** Fisher-Yates shuffle of a copy; the input is not mutated. */ + shuffle(items: readonly T[]): T[] { + const copy = [...items]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const swap = this.nextInt(index + 1); + const held = copy[index] as T; + copy[index] = copy[swap] as T; + copy[swap] = held; + } + return copy; + } + + /** Sample an index proportionally to non-negative weights. */ + sampleIndex(weights: readonly number[]): number { + let total = 0; + for (const weight of weights) { + if (!(weight >= 0)) { + throw new Error('weights must be non-negative numbers'); + } + total += weight; + } + if (total <= 0) { + return this.nextInt(weights.length); + } + let threshold = this.nextFloat() * total; + for (let index = 0; index < weights.length; index += 1) { + threshold -= weights[index] as number; + if (threshold < 0) { + return index; + } + } + return weights.length - 1; + } +} + +/** + * RESEARCH.md Appendix D seed derivation: lowercase hex SHA-256 of the parts + * joined by a single 0x00 byte, e.g. `deriveSeedHex('ald-e03-v1', '7')`. + */ +export function deriveSeedHex(...parts: string[]): string { + const buffers: Buffer[] = []; + parts.forEach((part, index) => { + if (index > 0) { + buffers.push(Buffer.from([0])); + } + buffers.push(Buffer.from(part, 'utf8')); + }); + return sha256Bytes(...buffers).toString('hex'); +} diff --git a/packages/hashing/src/sha256.ts b/packages/hashing/src/sha256.ts new file mode 100644 index 0000000..b2e2c61 --- /dev/null +++ b/packages/hashing/src/sha256.ts @@ -0,0 +1,125 @@ +import { createHash } from 'node:crypto'; + +import { + HASH_DOMAINS, + SIGNATURE_FIELDS, + STREAM_HASH_DOMAIN, + type EventStream, +} from '@ald/types'; + +import { canonicalJson } from './canonical.js'; + +const HASH_PATTERN = /^sha256:[a-f0-9]{64}$/u; + +export type HashSeparator = 0x00 | 0x01; + +export function isSha256Hash(value: unknown): value is string { + return typeof value === 'string' && HASH_PATTERN.test(value); +} + +export function sha256Bytes(...parts: Uint8Array[]): Buffer { + const hash = createHash('sha256'); + for (const part of parts) { + hash.update(part); + } + return hash.digest(); +} + +/** Encode a 32-byte digest as `sha256:<64 lowercase hex>`. */ +export function encodeHash(digest: Uint8Array): string { + if (digest.length !== 32) { + throw new Error('SHA-256 digest must be exactly 32 bytes'); + } + return `sha256:${Buffer.from(digest).toString('hex')}`; +} + +/** Decode `sha256:` into its 32 raw bytes. */ +export function decodeHash(hash: string): Buffer { + if (!isSha256Hash(hash)) { + throw new Error( + `Invalid SHA-256 hash encoding: ${String(hash).slice(0, 80)}`, + ); + } + return Buffer.from(hash.slice('sha256:'.length), 'hex'); +} + +/** Big-endian unsigned 64-bit encoding used for Merkle leaf sequences. */ +export function uint64BE(value: number): Buffer { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error('uint64 value must be a non-negative safe integer'); + } + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64BE(BigInt(value)); + return buffer; +} + +export function toBytes(payload: Uint8Array | string): Buffer { + return typeof payload === 'string' + ? Buffer.from(payload, 'utf8') + : Buffer.from(payload); +} + +/** + * `SHA-256(utf8(domain) || separator || payload...)` encoded as `sha256:`. + * Strings are UTF-8 encoded; byte arrays are used verbatim. + */ +export function domainHash( + domain: string, + payload: Array | Uint8Array | string, + separator: HashSeparator = 0x00, +): string { + const parts = Array.isArray(payload) ? payload : [payload]; + return encodeHash( + sha256Bytes( + Buffer.from(domain, 'utf8'), + Buffer.from([separator]), + ...parts.map(toBytes), + ), + ); +} + +/** Domain-separated hash of the RFC 8785 canonical form of `value`. */ +export function hashCanonical(domain: string, value: unknown): string { + return domainHash(domain, canonicalJson(value)); +} + +/** SPEC §9.2 content address for any delivered public artifact. */ +export function hashCarrierMark(carrierMode: string, artifact: unknown): string { + return domainHash(HASH_DOMAINS.carrierMark, [ + carrierMode, + new Uint8Array([0]), + canonicalJson(artifact), + ]); +} + +export function hashRunId(runId: string): string { + return domainHash(HASH_DOMAINS.runId, runId); +} + +/** Copy of `record` without the given top-level keys. */ +export function omitFields>( + record: T, + fields: readonly string[], +): Record { + const copy: Record = { ...record }; + for (const field of fields) { + delete copy[field]; + } + return copy; +} + +/** + * Recompute the entry hash of a (signed or unsigned) stream event by removing + * `entryHash` and `writerSignature`, canonicalizing, and hashing under the + * stream's domain. Used by writers to create hashes and by verifiers to check + * them. + */ +export function computeEntryHash( + stream: EventStream, + event: Record, +): string { + return hashCanonical( + STREAM_HASH_DOMAIN[stream], + omitFields(event, SIGNATURE_FIELDS), + ); +} diff --git a/packages/hashing/src/signers.ts b/packages/hashing/src/signers.ts new file mode 100644 index 0000000..7b42216 --- /dev/null +++ b/packages/hashing/src/signers.ts @@ -0,0 +1,117 @@ +import type { KeyObject } from 'node:crypto'; + +import { + SIGNER_DOMAINS, + SIGNER_KEY_IDS, + type DomainSigner, + type SignerDomain, + type SignerPublicKey, + type SignerRegistry, +} from '@ald/types'; + +import { + generateEd25519KeyPair, + privateKeyFromSeed, + publicKeyFromPrivate, + signHash, +} from './ed25519.js'; + +/** + * Build the public face of a domain signer. + * + * The returned object carries exactly the four members of the + * `DomainSigner` contract (`packages/types/src/contracts.ts`) — no seed and no + * key object — so spreading, `Object.keys`, `JSON.stringify`, or any + * structured log of a signer can never emit private key material + * (LEDGER §11; SPEC §13.5: signing keys are never exposed to model context or + * tools). The private key lives only in the `sign` closure; the seed lives + * only in {@link InMemorySignerRegistry}'s private seed map, which + * {@link InMemorySignerRegistry.exportSeeds} is the sole reader of. + */ +function provision(domain: SignerDomain, privateKey: KeyObject): DomainSigner { + const publicKey = publicKeyFromPrivate(privateKey); + return { + domain, + keyId: SIGNER_KEY_IDS[domain], + publicKey, + sign: async (hash: string) => signHash(hash, privateKey), + }; +} + +/** + * Per-run Ed25519 signers, one key per domain (LEDGER §11). Each + * `DomainSigner` holds exactly one private key and can therefore only produce + * signatures for its own domain. The file-backed key store (ALD-009) persists + * `exportSeeds()` outside the repository and restores with `fromSeeds()`. + */ +export class InMemorySignerRegistry implements SignerRegistry { + private readonly signers = new Map(); + + /** Private per-domain seeds, read only by {@link exportSeeds}. */ + private readonly seeds = new Map(); + + private constructor(public readonly runId: string) {} + + static generate( + runId: string, + domains: readonly SignerDomain[] = SIGNER_DOMAINS, + ): InMemorySignerRegistry { + const registry = new InMemorySignerRegistry(runId); + for (const domain of domains) { + const pair = generateEd25519KeyPair(); + registry.signers.set(domain, provision(domain, pair.privateKey)); + registry.seeds.set(domain, pair.seed); + } + return registry; + } + + static fromSeeds( + runId: string, + seeds: Partial>, + ): InMemorySignerRegistry { + const registry = new InMemorySignerRegistry(runId); + for (const domain of SIGNER_DOMAINS) { + const material = seeds[domain]; + if (material === undefined) { + continue; + } + const seed = + typeof material === 'string' + ? Buffer.from(material, 'hex') + : Buffer.from(material); + registry.signers.set(domain, provision(domain, privateKeyFromSeed(seed))); + registry.seeds.set(domain, seed); + } + return registry; + } + + domains(): SignerDomain[] { + return [...this.signers.keys()]; + } + + signer(domain: SignerDomain): DomainSigner { + const signer = this.signers.get(domain); + if (!signer) { + throw new Error(`No signer provisioned for domain ${domain}`); + } + return signer; + } + + publicKeys(): SignerPublicKey[] { + return [...this.signers.values()].map(({ domain, keyId, publicKey }) => ({ + domain, + keyId, + publicKey, + })); + } + + /** Hex seeds for persistence by an isolated key store. Never export into evidence. */ + exportSeeds(): Record { + return Object.fromEntries( + [...this.seeds.entries()].map(([domain, seed]) => [ + domain, + seed.toString('hex'), + ]), + ); + } +} diff --git a/packages/hashing/tsconfig.json b/packages/hashing/tsconfig.json new file mode 100644 index 0000000..9f29396 --- /dev/null +++ b/packages/hashing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../types" } + ] +} diff --git a/packages/interventions/__tests__/.gitkeep b/packages/interventions/__tests__/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/interventions/__tests__/fixtures.ts b/packages/interventions/__tests__/fixtures.ts new file mode 100644 index 0000000..227ffcb --- /dev/null +++ b/packages/interventions/__tests__/fixtures.ts @@ -0,0 +1,355 @@ +/** + * Deterministic fixtures for the `@ald/interventions` tests. + * + * `playTabularGame` drives two real `scratch-rl` adapters through a small + * referential game with the real `RecordingLedgerClient` from + * `@ald/learners`, so the claim index and the probe planner are tested + * against the ledger events the reference track actually writes + * (`hypothesis.created`/`revised` with `argmaxTypeCode` and `confidence`, + * `intention.recorded`/`interpretation.recorded` with `symbols`) rather than + * against hand-written content this package invented. + * + * Everything is seeded: the same options always produce the same events, + * policies and episodes. + */ +import { + RecordingLedgerClient, + attributesFromTypeCode, + buildConformanceRunConfig, + createTabularReinforceAdapterFactory, + loadLearnerContract, + typeCodeFromAttributes, +} from '@ald/learners'; +import { SeededPrng, deriveSeedHex, domainHash, hashCanonical } from '@ald/hashing'; +import { + HASH_DOMAINS, + babyIdForRole, + fixedTokenInventory, + type BabyRole, + type LedgerEvent, + type LedgerEventDraft, + type PrivateLedgerClient, + type RunConfig, + type Sha256Hash, +} from '@ald/types'; + +/** Captures the `LedgerEvent`s the writer returns, not just the drafts. */ +class CapturingLedger implements PrivateLedgerClient { + readonly events: LedgerEvent[] = []; + + constructor(private readonly inner: RecordingLedgerClient) {} + + setTurn(turn: number): void { + this.inner.turn = turn; + } + + async append( + draft: LedgerEventDraft, + options?: { channelEventHash?: Sha256Hash }, + ): Promise { + const event = await this.inner.append(draft, options); + this.events.push(event); + return event; + } +} + +export interface PlayedEpisode { + readonly turn: number; + readonly sender: BabyRole; + readonly receiver: BabyRole; + readonly symbols: readonly string[]; + readonly candidateRefs: readonly string[]; + /** Researcher-only ground truth per candidate, in the receiver's order. */ + readonly candidateTypeCodes: readonly number[]; + readonly targetRef: string; + readonly targetTypeCode: number; + readonly chosenRef: string; + readonly success: boolean; +} + +export interface GamePlay { + readonly config: RunConfig; + readonly symbolInventory: readonly string[]; + readonly events: Record; + readonly policies: Record; + readonly episodes: readonly PlayedEpisode[]; +} + +export interface GameOptions { + readonly episodes?: number; + readonly seed?: string; + readonly messageLength?: number; + readonly symbolInventorySize?: number; + readonly attributeCount?: number; + readonly valuesPerAttribute?: number; + readonly candidateCount?: number; +} + +/** Two `scratch-rl` adapters playing a seeded referential game. */ +export async function playTabularGame( + options: GameOptions = {}, +): Promise { + const episodes = options.episodes ?? 60; + const seed = options.seed ?? 'ald-interventions-fixture'; + const messageLength = options.messageLength ?? 2; + const symbolInventorySize = options.symbolInventorySize ?? 8; + const attributeCount = options.attributeCount ?? 2; + const valuesPerAttribute = options.valuesPerAttribute ?? 4; + const candidateCount = options.candidateCount ?? 4; + + const config = buildConformanceRunConfig('scratch-rl', { + seed, + episodes, + messageLength, + symbolInventorySize, + attributeCount, + valuesPerAttribute, + candidateCount, + }); + const symbolInventory = fixedTokenInventory(symbolInventorySize); + const factory = createTabularReinforceAdapterFactory({ + attributeCount, + valuesPerAttribute, + messageLength, + }); + const contract = loadLearnerContract('scratch-rl'); + + const roles: BabyRole[] = ['baby-a', 'baby-b']; + const adapters = {} as Record>; + const ledgers = {} as Record; + + for (const role of roles) { + const ledger = new CapturingLedger( + new RecordingLedgerClient(config.runId, role, true, false), + ); + const adapter = factory.create(); + await adapter.init({ + runId: config.runId, + role, + babyId: babyIdForRole(role), + config, + learnerContract: contract, + seed: deriveSeedHex(seed, role), + symbolInventory: [...symbolInventory], + ledger, + }); + adapters[role] = adapter; + ledgers[role] = ledger; + } + + const played: PlayedEpisode[] = []; + const typeCount = valuesPerAttribute ** attributeCount; + + for (let index = 0; index < episodes; index += 1) { + const prng = new SeededPrng(seed).derive(`fixture-episode/${index}`); + const typeCodes = prng + .shuffle(Array.from({ length: typeCount }, (_unused, code) => code)) + .slice(0, candidateCount); + const attributes = typeCodes.map((typeCode) => + attributesFromTypeCode(typeCode, attributeCount, valuesPerAttribute), + ); + const targetPosition = prng.nextInt(candidateCount); + const receiverOrder = prng.shuffle( + Array.from({ length: candidateCount }, (_unused, position) => position), + ); + const senderPayload = attributes.map((codes, position) => [ + ...codes, + position === targetPosition ? 1 : 0, + ]); + const receiverPayload = receiverOrder.map((senderPosition) => [ + ...(attributes[senderPosition] as number[]), + ]); + const candidateRefs = receiverOrder.map( + (_unused, position) => + `object:${domainHash(HASH_DOMAINS.observation, `${seed}/${index}/${position}`)}`, + ); + const candidateTypeCodes = receiverPayload.map((row) => + typeCodeFromAttributes(row, valuesPerAttribute), + ); + const targetRef = candidateRefs[ + receiverOrder.indexOf(targetPosition) + ] as string; + const turn = index; + const sender: BabyRole = index % 2 === 0 ? 'baby-a' : 'baby-b'; + const receiver: BabyRole = sender === 'baby-a' ? 'baby-b' : 'baby-a'; + const senderAdapter = adapters[sender]; + const receiverAdapter = adapters[receiver]; + ledgers[sender].setTurn(turn); + ledgers[receiver].setTurn(turn); + + await senderAdapter.observe({ + runId: config.runId, + turn, + recipient: sender, + encoding: 'opaque-numeric', + payload: senderPayload, + scenarioRef: `scenario:${index}`, + }); + await receiverAdapter.observe({ + runId: config.runId, + turn, + recipient: receiver, + encoding: 'opaque-numeric', + payload: receiverPayload, + scenarioRef: `scenario:${index}`, + }); + + const senderEnvelope = await senderAdapter.act({ + turn, + role: 'sender', + responseBudgetMs: config.turnResponseBudgetMs, + availableActions: ['emit_symbols'], + }); + await ledgers[sender].append(senderEnvelope.privateLedgerDraft); + const symbols = ( + senderEnvelope.proposal.publicArtifact as { symbols: string[] } + ).symbols; + + const channelEventHash = hashCanonical(HASH_DOMAINS.channelEvent, { + turn, + logicalSender: sender, + publicArtifact: senderEnvelope.proposal.publicArtifact, + }); + const interpretation = await receiverAdapter.receive({ + runId: config.runId, + turn, + logicalSender: sender, + carrier: config.carrierMode, + publicArtifact: senderEnvelope.proposal.publicArtifact, + channelEventHash, + }); + await ledgers[receiver].append(interpretation.privateLedgerDraft, { + channelEventHash, + }); + + const receiverEnvelope = await receiverAdapter.act({ + turn, + role: 'receiver', + responseBudgetMs: config.turnResponseBudgetMs, + availableActions: ['select_object'], + candidateRefs: [...candidateRefs], + }); + await ledgers[receiver].append(receiverEnvelope.privateLedgerDraft); + const chosenRef = ( + receiverEnvelope.proposal.publicArtifact as { objectRef: string } + ).objectRef; + const success = chosenRef === targetRef; + + for (const role of [sender, receiver] as const) { + const adapter = adapters[role]; + await adapter.onOutcome({ + runId: config.runId, + turn, + role: role === sender ? 'sender' : 'receiver', + success, + reward: success ? 1 : 0, + payload: [success ? 1 : 0], + }); + await adapter.updatePolicy?.({ + runId: config.runId, + turns: [turn], + learningSignal: config.learningSignal, + }); + } + + played.push({ + turn, + sender, + receiver, + symbols: [...symbols], + candidateRefs: [...candidateRefs], + candidateTypeCodes, + targetRef, + targetTypeCode: typeCodes[targetPosition] as number, + chosenRef, + success, + }); + } + + return { + config, + symbolInventory, + events: { + 'baby-a': ledgers['baby-a'].events, + 'baby-b': ledgers['baby-b'].events, + }, + policies: { + 'baby-a': adapters['baby-a'].exportPolicy(), + 'baby-b': adapters['baby-b'].exportPolicy(), + }, + episodes: played, + }; +} + +/** A minimal schema-valid `LedgerEvent` for hand-built claim fixtures. */ +export function ledgerEvent(input: { + readonly sequence: number; + readonly turn?: number; + readonly eventType: string; + readonly subjectId: string; + readonly content: Record; + readonly babyId?: 'A' | 'B'; +}): LedgerEvent { + const digest = domainHash( + HASH_DOMAINS.ledgerEntry, + `fixture/${input.sequence}/${input.eventType}`, + ); + return { + version: 1, + runId: 'run-fixture', + babyId: input.babyId ?? 'A', + sequence: input.sequence, + turn: input.turn ?? input.sequence, + eventType: input.eventType, + contentSchema: 'agent-native-ledger', + subjectId: input.subjectId, + content: input.content, + blindingNonce: digest.slice('sha256:'.length, 'sha256:'.length + 24), + previousEntryHash: `sha256:${'0'.repeat(64)}`, + recordedAt: new Date(input.sequence * 1_000).toISOString(), + writerKeyId: 'baby-a-ledger-writer-v1', + entryHash: digest, + writerSignature: `ed25519:${Buffer.from(digest).toString('base64')}`, + }; +} + +/** A `hypothesis.created` event for form `form` claiming `typeCode`. */ +export function claimEvent(input: { + readonly sequence: number; + readonly form: string; + readonly typeCode: number; + readonly confidence: number; + readonly version?: number; + readonly babyId?: 'A' | 'B'; +}): LedgerEvent { + return ledgerEvent({ + sequence: input.sequence, + eventType: 'hypothesis.created', + subjectId: `symbol:${input.form}`, + ...(input.babyId === undefined ? {} : { babyId: input.babyId }), + content: { + termRef: `symbol:${input.form}`, + hypothesisRef: `hyp:${input.form}:${input.version ?? 1}`, + argmaxTypeCode: input.typeCode, + confidence: input.confidence, + }, + }); +} + +/** An `interpretation.recorded` event that puts `symbols` on the record. */ +export function messageEvent(input: { + readonly sequence: number; + readonly symbols: readonly string[]; + readonly babyId?: 'A' | 'B'; +}): LedgerEvent { + return ledgerEvent({ + sequence: input.sequence, + eventType: 'interpretation.recorded', + subjectId: `artifact:${input.sequence}`, + ...(input.babyId === undefined ? {} : { babyId: input.babyId }), + content: { + artifactRef: `artifact:${input.sequence}`, + symbols: [...input.symbols], + }, + }); +} diff --git a/packages/interventions/__tests__/interventions.test.ts b/packages/interventions/__tests__/interventions.test.ts new file mode 100644 index 0000000..857a7aa --- /dev/null +++ b/packages/interventions/__tests__/interventions.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest'; +import { buildConformanceRunConfig } from '@ald/learners'; +import type { RunConfig } from '@ald/types'; + +import { + CurriculumExecutor, + assertStagesSupported, + buildInterventionRunPlan, + evaluateInterventionSuite, + evaluateRepair, + planEvaluationProbes, + validateCurriculumStages, +} from '../src/index.js'; +import { claimEvent, messageEvent } from './fixtures.js'; + +describe('configuration-driven intervention planning', () => { + it('assembles every enabled intervention from RunConfig alone', () => { + const base = buildConformanceRunConfig('scratch-rl', { + episodes: 12, + messageLength: 2, + }); + const config: RunConfig = { + ...base, + interventionPlan: { + version: 1, + evaluationSuite: { + ablation: true, + substitution: true, + scramblingControl: true, + probeShare: 0.5, + }, + repair: { enabled: true, maxExtraTurns: 1 }, + heldOutTypeCodes: [3, 7], + curriculum: { + stages: [ + { stageIndex: 0, startTurn: 0, maxSymbolsPerMessage: 1 }, + { stageIndex: 1, startTurn: 6, maxSymbolsPerMessage: 2 }, + ], + }, + driftEvaluationInterval: 4, + }, + }; + + const plan = buildInterventionRunPlan({ + config, + evaluationTurns: [8, 9, 10, 11], + ledgers: { babyA: [], babyB: [] }, + symbolInventory: ['s0', 's1', 's2', 's3'], + seed: 'pre-registered-analysis-seed', + }); + + expect(plan.configurationBasis).toBe('run-config-intervention-plan'); + expect(plan.enabled).toEqual({ + evaluationSuite: true, + ablation: true, + substitution: true, + scramblingControl: true, + repair: true, + heldOutTypeCodes: true, + curriculum: true, + driftEvaluation: true, + }); + expect(plan.driftEvaluationTurns).toEqual([4, 8]); + expect(plan.scramblingControl.appliesTo).toBe('offline-analysis-only'); + expect(plan.heldOutTypeCodes).toEqual([3, 7]); + }); + + it('produces an inert plan when the configuration has no intervention plan', () => { + const config = buildConformanceRunConfig('scratch-rl', { episodes: 4 }); + const plan = buildInterventionRunPlan({ + config, + evaluationTurns: [2, 3], + ledgers: { babyA: [], babyB: [] }, + symbolInventory: ['s0', 's1'], + }); + + expect(plan.planPresent).toBe(false); + expect(Object.values(plan.enabled)).toEqual(Array(8).fill(false)); + expect(plan.probeSchedule.probes).toEqual([]); + expect(plan.curriculum).toBeNull(); + }); +}); + +describe('causal intervention suite', () => { + it('plans from prior ledger claims and scores the registered directions', () => { + const ledger = [ + claimEvent({ sequence: 1, form: 'S01', typeCode: 0, confidence: 0.9 }), + claimEvent({ sequence: 2, form: 'S02', typeCode: 1, confidence: 0.8 }), + messageEvent({ sequence: 3, symbols: ['S01'] }), + messageEvent({ sequence: 4, symbols: ['S02'] }), + ]; + const schedule = planEvaluationProbes({ + plan: { + version: 1, + evaluationSuite: { + ablation: true, + substitution: true, + scramblingControl: true, + probeShare: 1, + }, + }, + evaluationTurns: [20, 21, 22, 23], + ledgers: { babyA: ledger, babyB: ledger }, + symbolInventory: ['S01', 'S02'], + seed: 'probe-plan', + roleReversalPeriod: 1, + messageLength: 1, + }); + + expect(schedule.probes).toHaveLength(4); + expect(schedule.baselineTurns).toEqual([]); + expect(schedule.probes.map((probe) => probe.kind)).toEqual([ + 'ablation', + 'substitution', + 'ablation', + 'substitution', + ]); + expect(schedule.probes.every((probe) => probe.predictedDirection.predictedBy === 'agent-native-ledger')).toBe( + true, + ); + + const candidateRefs = ['object:zero', 'object:one']; + const candidateTypeCodes = [0, 1]; + const observed = schedule.probes.map((planned) => { + const shift = planned.predictedDirection.predictedCandidateShift; + const predicted = + planned.kind === 'ablation' + ? (shift.awayFromTypeCode as number) + : (shift.towardTypeCode as number); + const other = predicted === 0 ? 1 : 0; + return { + turn: planned.turn, + probeId: planned.probe.probeId, + candidateRefs, + candidateTypeCodes, + receiverActionRef: + candidateRefs[planned.kind === 'ablation' ? other : predicted] as string, + unprobedBaselineActionRef: + candidateRefs[planned.kind === 'ablation' ? predicted : other] as string, + success: true, + }; + }); + const result = evaluateInterventionSuite({ + schedule, + observed, + threshold: 0.7, + }); + + expect(result.descriptiveAgreement).toMatchObject({ + probes: 4, + scored: 4, + agreements: 4, + agreementRate: 1, + }); + expect(result.meetsDescriptiveReadinessThreshold).toBe(true); + expect(result.readinessThresholdKind).toBe('descriptive-not-inferential'); + expect(result.confirmatory).toBeNull(); + expect(result.claimBoundary).toBe('software-readiness-only'); + }); +}); + +describe('fixed curriculum execution', () => { + const stages = [ + { + stageIndex: 0, + startTurn: 0, + learnerOptions: { learningRate: 0.1 }, + maxSymbolsPerMessage: 1, + }, + { stageIndex: 1, startTurn: 5, consolidation: true }, + { + stageIndex: 2, + startTurn: 9, + learnerOptions: { explorationRate: 0.05 }, + maxSymbolsPerMessage: 2, + }, + ] as const; + + it('selects stages and transitions solely by turn', () => { + const executor = new CurriculumExecutor({ + stages, + maxSymbolsPerMessage: 2, + maxTurnsPerRun: 12, + }); + + expect(executor.stageForTurn(4).stageIndex).toBe(0); + expect(executor.stageForTurn(5).stageIndex).toBe(1); + expect(executor.consolidating(8)).toBe(true); + expect(executor.maxSymbolsPerMessageAt(10)).toBe(2); + expect(executor.transitionsBetween(-1, 9).map((entry) => entry.turn)).toEqual([ + 0, 5, 9, + ]); + }); + + it('rejects schedules that widen capacity or use unsupported knobs', () => { + expect(() => + validateCurriculumStages({ + stages: [{ stageIndex: 0, startTurn: 0, maxSymbolsPerMessage: 3 }], + maxSymbolsPerMessage: 2, + }), + ).toThrow(/above the run ceiling/u); + + expect(() => + assertStagesSupported(stages, { + learningRate: true, + maxSymbolsPerMessage: true, + consolidation: true, + }), + ).toThrow(/explorationRate/u); + }); +}); + +describe('repair metrics', () => { + it('measures bounded repair, role reversal, held-out success, and form reuse', () => { + const result = evaluateRepair({ + maxExtraTurns: 1, + maxSymbolsPerMessage: 2, + episodes: [ + { + episodeId: 'seen-resolved', + ambiguous: true, + split: 'train', + attempts: [ + { + turn: 0, + attempt: 0, + sender: 'baby-a', + receiver: 'baby-b', + success: false, + formHash: 'first-a', + messageLength: 2, + }, + { + turn: 1, + attempt: 1, + sender: 'baby-b', + receiver: 'baby-a', + success: true, + formHash: 'repair-x', + messageLength: 2, + }, + ], + }, + { + episodeId: 'held-out-unresolved', + ambiguous: true, + split: 'held-out', + attempts: [ + { + turn: 2, + attempt: 0, + sender: 'baby-b', + receiver: 'baby-a', + success: false, + formHash: 'first-b', + }, + { + turn: 3, + attempt: 1, + sender: 'baby-a', + receiver: 'baby-b', + success: false, + formHash: 'repair-x', + }, + ], + }, + { + episodeId: 'clear-first-try', + ambiguous: false, + split: 'train', + attempts: [ + { + turn: 4, + attempt: 0, + sender: 'baby-a', + receiver: 'baby-b', + success: true, + }, + ], + }, + ], + }); + + expect(result.repairRate?.summary).toMatchObject({ successes: 1, n: 2, proportion: 0.5 }); + expect(result.turnsPerResolvedAmbiguity).toBe(2); + expect(result.repairInitiation).toEqual({ originalReceiver: 2, originalSender: 0 }); + expect(result.reuse).toMatchObject({ + repairAttemptsWithForm: 2, + distinctRepairForms: 1, + reusedAttempts: 1, + reuseRate: 0.5, + repairSpecificForms: 1, + }); + expect(result.seenRepairRate?.summary.proportion).toBe(1); + expect(result.heldOutRepairRate?.summary.proportion).toBe(0); + expect(result.violations).toEqual([]); + expect(result.claimBoundary).toBe('software-readiness-only'); + }); +}); diff --git a/packages/interventions/__tests__/readiness-gates.test.ts b/packages/interventions/__tests__/readiness-gates.test.ts new file mode 100644 index 0000000..69f4191 --- /dev/null +++ b/packages/interventions/__tests__/readiness-gates.test.ts @@ -0,0 +1,98 @@ +/** Cross-capability readiness checks for ALD-075 E21 and ALD-077 E50. */ +import { describe, expect, it } from 'vitest'; + +import { hashCanonical } from '@ald/hashing'; +import { createDerivedRunConfig } from '@ald/lifecycle'; +import { buildConformanceRunConfig } from '@ald/learners'; +import { HASH_DOMAINS } from '@ald/types'; + +import { aggregateAcrossSeeds } from '../src/index.js'; + +describe('experiment readiness gates', () => { + it('runs every E21 learner condition against the identical scenario configuration', () => { + const seed = 'e21-shared-scenario'; + const options = { seed, experimentId: 'E21', episodes: 24 } as const; + const configs = [ + buildConformanceRunConfig('no-learning', options), + buildConformanceRunConfig('frozen-llm', options), + buildConformanceRunConfig('scratch-rl', { + ...options, + learningSignal: 'extrinsic-task', + }), + buildConformanceRunConfig('scratch-rl', { + ...options, + learningSignal: 'intrinsic-curiosity', + }), + buildConformanceRunConfig('self-supervised', { + ...options, + learningSignal: 'self-supervised', + }), + ]; + + expect(new Set(configs.map((config) => config.scenarioBundleHash))).toEqual( + new Set([configs[0]?.scenarioBundleHash]), + ); + expect(new Set(configs.map((config) => config.randomSeed))).toEqual( + new Set([seed]), + ); + expect(new Set(configs.map((config) => config.experimentId))).toEqual( + new Set(['E21']), + ); + }); + + it('creates E50 independent-seed children from one pre-registered parent and aggregates every seed', () => { + const parent = buildConformanceRunConfig('no-learning', { + runId: 'e50-parent', + experimentId: 'E50', + seed: 'e50-parent-seed', + }); + const checkpointHash = hashCanonical(HASH_DOMAINS.checkpoint, { + runId: parent.runId, + checkpoint: 1, + }); + const seeds = Array.from({ length: 10 }, (_, index) => `e50-seed-${index}`); + const children = seeds.map((randomSeed, index) => + createDerivedRunConfig(parent, checkpointHash, `e50-child-${index}`, { + babyAInitialPolicyRef: 'policies/baby-a-policy-initial.json', + babyBInitialPolicyRef: 'policies/baby-b-policy-initial.json', + overrides: { randomSeed }, + }), + ); + + expect(new Set(children.map((child) => child.parentRunId))).toEqual( + new Set([parent.runId]), + ); + expect(new Set(children.map((child) => child.preRegistrationHash))).toEqual( + new Set([parent.preRegistrationHash]), + ); + expect(new Set(children.map((child) => child.randomSeed)).size).toBe(10); + + const result = aggregateAcrossSeeds({ + seed: 'e50-aggregate', + findings: [ + { + findingId: 'primary-success', + metricLabel: 'success proportion', + original: { + label: 'original', + perSeedValues: [0.7, 0.72, 0.71, 0.73, 0.69, 0.74, 0.7, 0.72, 0.71, 0.73], + }, + replication: { + label: 'replication', + perSeedValues: [0.68, 0.7, 0.71, 0.69, 0.72, 0.7, 0.73, 0.71, 0.69, 0.72], + }, + nullValue: 0.25, + rule: { type: 'ci-excludes-null' }, + expectedDirection: 'greater', + }, + ], + bootstrapIterations: 200, + }); + + expect(result.findings[0]?.original.seeds).toBe(children.length); + expect(result.findings[0]?.replication.seeds).toBe(children.length); + expect(result.summary).toMatchObject({ findings: 1, replicated: 1 }); + expect(result.negativeResultsIncluded).toBe(true); + expect(result.claimBoundary).toBe('software-readiness-only'); + }); +}); diff --git a/packages/interventions/package.json b/packages/interventions/package.json new file mode 100644 index 0000000..63a7364 --- /dev/null +++ b/packages/interventions/package.json @@ -0,0 +1,24 @@ +{ + "name": "@ald/interventions", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/analysis": "*", + "@ald/learners": "*", + "zod": "4.4.3" + } +} diff --git a/packages/interventions/src/attachment.ts b/packages/interventions/src/attachment.ts new file mode 100644 index 0000000..5a98243 --- /dev/null +++ b/packages/interventions/src/attachment.ts @@ -0,0 +1,99 @@ +/** + * Bundle-attachment packaging for this package's readouts + * (docs/evidence-bundle-format.md §10, `BundleAttachmentSchema` in + * `@ald/types`). + * + * An attachment is a file under `analysis/` in the evidence bundle whose + * `sha256` is the plain SHA-256 of its bytes — no domain separator, because a + * third party hashes the file with any tool. This module produces the exact + * bytes and that hash. It does not write anything: the bundle writer is the + * integrator's, and binding an attachment to a chained evidence entry + * (`boundBy`) is the runtime's, since only the runtime holds the entry hash. + * + * Two rules keep an attachment honest: + * + * 1. **A statistic that is undefined for the sample serializes as `null`.** + * RFC 8785 has no representation for `NaN` or `±Infinity` + * (`canonicalJson` rejects them outright), and substituting `0` for "no + * variance estimate exists" would turn a missing number into a claim. + * {@link toCanonicalJsonValue} therefore maps every non-finite number to + * `null` and records nothing else. + * 2. **The attachment carries its own `analysisVersion`.** The kind + * (`intervention-suite`, `curriculum-transitions`, `drift-evaluation`, …) + * says what the file is; the version says which rules produced it. + */ +import { canonicalJson, encodeHash, sha256Bytes } from '@ald/hashing'; +import type { Sha256Hash } from '@ald/types'; + +/** Attachment kinds this package produces (`BundleAttachmentSchema.kind`). */ +export type InterventionAttachmentKind = + | 'intervention-suite' + | 'curriculum-transitions' + | 'drift-evaluation' + /** E14 repair and E50 replication readouts have no dedicated kind yet. */ + | 'other'; + +export interface AttachmentFile { + readonly kind: InterventionAttachmentKind; + readonly analysisVersion: string; + /** Canonical (RFC 8785) bytes of the attachment, as a UTF-8 string. */ + readonly canonicalJson: string; + /** Plain SHA-256 of those bytes; `BundleAttachmentSchema.sha256`. */ + readonly sha256: Sha256Hash; + /** The value that was serialized, after non-finite numbers became `null`. */ + readonly value: unknown; +} + +/** + * Deep-convert a readout into a canonical-JSON-representable value: + * non-finite numbers become `null`, `undefined` properties are dropped, + * arrays and plain objects are rebuilt, and anything else is passed through + * for `canonicalJson` to reject loudly. + */ +export function toCanonicalJsonValue(value: unknown): unknown { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (Array.isArray(value)) { + return value.map((element) => toCanonicalJsonValue(element)); + } + if (value === null || typeof value !== 'object') { + return value; + } + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return value; + } + const record = value as Record; + const output: Record = {}; + for (const key of Object.keys(record).sort()) { + const property = record[key]; + if (property === undefined) { + continue; + } + output[key] = toCanonicalJsonValue(property); + } + return output; +} + +/** Plain SHA-256 of a UTF-8 string, as `sha256:` (no domain). */ +export function fileSha256(text: string): Sha256Hash { + return encodeHash(sha256Bytes(Buffer.from(text, 'utf8'))); +} + +/** Package one readout as bundle-attachment bytes plus its file hash. */ +export function buildAttachment(input: { + readonly kind: InterventionAttachmentKind; + readonly analysisVersion: string; + readonly value: unknown; +}): AttachmentFile { + const value = toCanonicalJsonValue(input.value); + const text = canonicalJson(value); + return { + kind: input.kind, + analysisVersion: input.analysisVersion, + canonicalJson: text, + sha256: fileSha256(text), + value, + }; +} diff --git a/packages/interventions/src/claims.ts b/packages/interventions/src/claims.ts new file mode 100644 index 0000000..f04bd6a --- /dev/null +++ b/packages/interventions/src/claims.ts @@ -0,0 +1,270 @@ +/** + * The agent-native ledger claim index the §15.2 probe planner reads. + * + * SPEC §15.2 is explicit that "ledger meanings MUST be validated + * behaviorally, not accepted from ledger prose alone", and E16's procedure + * fixes the order of operations: "Select ledger hypotheses before viewing + * intervention outcomes". This module is the read side of that rule. It walks + * a Baby's own hash-chained ledger events (SPEC §11.4, LEDGER §5) and reduces + * them to the claims a probe can target: + * + * - `hypothesis.created` / `hypothesis.revised` establish the current claim + * for a form: which referent type code the Baby's own state puts the + * argmax on, and with what confidence (the tabular reference track writes + * `argmaxTypeCode`, `confidence`, and `associationOverTypeCodes` — + * `packages/learners/src/tabular-reinforce.ts`); + * - `hypothesis.contradicted` is counted, never used to delete a claim + * (CONCEPT-IDEA.md §11.2 rule 5: contradictory evidence is preserved as its + * own event); + * - `intention.recorded` / `interpretation.recorded` carry the message the + * Baby emitted or interpreted, which is the only evidence in the ledger of + * *which position* a form occupied. + * + * The index is content-schema tolerant on purpose: a track whose + * `agent-native-ledger` content does not carry a parseable claim is counted + * in `ignoredEvents` rather than rejected, because SPEC §11.4 lets + * `agent-native-ledger` content be any canonicalizable object and this + * package must not dictate a track's private state shape. + * + * Nothing here reads an outcome, a reward, or a success flag. That is what + * makes a schedule built from this index verifiably pre-outcome (E16). + */ +import type { LedgerEvent } from '@ald/types'; +import { z } from 'zod'; + +/** Subject prefix the reference tracks use for a form (`symbol:S07`). */ +const FORM_SUBJECT_PREFIX = 'symbol:'; + +const hypothesisContentSchema = z.object({ + hypothesisRef: z.string().min(1), + argmaxTypeCode: z.number().int().min(0), + confidence: z.number().min(0).max(1), + termRef: z.string().min(1).optional(), +}); + +const messageContentSchema = z.object({ + symbols: z.array(z.string().min(1)).min(1), +}); + +/** The current ledger-claimed meaning of one form. */ +export interface LedgerFormClaim { + /** + * The form the claim is about: a fixed-token/fixed-glyph inventory id, or a + * generative carrier's `markHash`. Taken from `content.termRef` when + * present, else from `subjectId`, with the `symbol:` prefix stripped. + */ + readonly form: string; + /** The claim's own reference, e.g. `hyp:S07:2` (LEDGER §5 revision graph). */ + readonly hypothesisRef: string; + /** Referent type code the Baby's state puts its argmax on. */ + readonly claimedTypeCode: number; + /** The Baby's own confidence in that argmax, as recorded. */ + readonly confidence: number; + /** Ledger sequence of the event that established the current claim. */ + readonly sequence: number; + readonly turn: number; + /** `hypothesis.revised` events seen for this form. */ + readonly revisions: number; + /** `hypothesis.contradicted` events seen for this form (never deletes it). */ + readonly contradictions: number; + /** + * Message positions this form was recorded in, most frequent first, ties + * broken by the lower position. Empty when the ledger records no message + * containing the form. + */ + readonly positions: readonly { readonly position: number; readonly count: number }[]; +} + +export interface LedgerClaimIndex { + /** Claims ranked by confidence (desc), fewer contradictions, then form. */ + readonly claims: readonly LedgerFormClaim[]; + readonly claimsByForm: ReadonlyMap; + /** Longest message length seen in the ledger's own records; 0 if none. */ + readonly observedMessageLength: number; + /** Events whose content carried no parseable claim or message. */ + readonly ignoredEvents: number; + readonly eventsRead: number; +} + +interface MutableClaim { + form: string; + hypothesisRef: string; + claimedTypeCode: number; + confidence: number; + sequence: number; + turn: number; + revisions: number; + contradictions: number; + positionCounts: Map; +} + +function formOf( + content: { termRef?: string | undefined }, + subjectId: string, +): string { + const raw = content.termRef ?? subjectId; + return raw.startsWith(FORM_SUBJECT_PREFIX) + ? raw.slice(FORM_SUBJECT_PREFIX.length) + : raw; +} + +/** + * Reduce one Baby's ledger events to the claim index the planner selects + * from. Events are read in `sequence` order, so the last + * `hypothesis.created`/`hypothesis.revised` for a form wins regardless of the + * order the caller passes them in. + */ +export function indexLedgerClaims( + events: readonly LedgerEvent[], +): LedgerClaimIndex { + const ordered = [...events].sort((left, right) => left.sequence - right.sequence); + const claims = new Map(); + let ignoredEvents = 0; + let observedMessageLength = 0; + + const positionsFor = (form: string): Map => { + const existing = claims.get(form); + if (existing !== undefined) { + return existing.positionCounts; + } + // A message may name a form that has no hypothesis event yet. Its + // position evidence is kept so a later claim for the same form can use + // it; a form that never gets a claim never becomes a probe target. + const placeholder: MutableClaim = { + form, + hypothesisRef: '', + claimedTypeCode: -1, + confidence: -1, + sequence: -1, + turn: -1, + revisions: 0, + contradictions: 0, + positionCounts: new Map(), + }; + claims.set(form, placeholder); + return placeholder.positionCounts; + }; + + for (const event of ordered) { + if ( + event.eventType === 'hypothesis.created' || + event.eventType === 'hypothesis.revised' || + event.eventType === 'hypothesis.contradicted' || + event.eventType === 'hypothesis.abandoned' + ) { + const parsed = hypothesisContentSchema.safeParse(event.content); + if (!parsed.success) { + ignoredEvents += 1; + continue; + } + const form = formOf(parsed.data, event.subjectId); + const existing = claims.get(form); + const positionCounts = + existing?.positionCounts ?? new Map(); + if (event.eventType === 'hypothesis.contradicted') { + claims.set(form, { + form, + hypothesisRef: existing?.hypothesisRef ?? parsed.data.hypothesisRef, + claimedTypeCode: + existing?.claimedTypeCode ?? parsed.data.argmaxTypeCode, + confidence: existing?.confidence ?? parsed.data.confidence, + sequence: existing?.sequence ?? event.sequence, + turn: existing?.turn ?? event.turn, + revisions: existing?.revisions ?? 0, + contradictions: (existing?.contradictions ?? 0) + 1, + positionCounts, + }); + continue; + } + if (event.eventType === 'hypothesis.abandoned') { + // An abandoned meaning is no longer a claim the ledger stands behind, + // so it stops being a probe target; the position evidence stays. + claims.set(form, { + form, + hypothesisRef: '', + claimedTypeCode: -1, + confidence: -1, + sequence: -1, + turn: -1, + revisions: existing?.revisions ?? 0, + contradictions: existing?.contradictions ?? 0, + positionCounts, + }); + continue; + } + claims.set(form, { + form, + hypothesisRef: parsed.data.hypothesisRef, + claimedTypeCode: parsed.data.argmaxTypeCode, + confidence: parsed.data.confidence, + sequence: event.sequence, + turn: event.turn, + revisions: + (existing?.revisions ?? 0) + + (event.eventType === 'hypothesis.revised' ? 1 : 0), + contradictions: existing?.contradictions ?? 0, + positionCounts, + }); + continue; + } + + if ( + event.eventType === 'intention.recorded' || + event.eventType === 'interpretation.recorded' + ) { + const parsed = messageContentSchema.safeParse(event.content); + if (!parsed.success) { + ignoredEvents += 1; + continue; + } + observedMessageLength = Math.max( + observedMessageLength, + parsed.data.symbols.length, + ); + parsed.data.symbols.forEach((symbol, position) => { + const counts = positionsFor(symbol); + counts.set(position, (counts.get(position) ?? 0) + 1); + }); + continue; + } + + ignoredEvents += 1; + } + + const materialized: LedgerFormClaim[] = [...claims.values()] + .filter((claim) => claim.hypothesisRef.length > 0) + .map((claim) => ({ + form: claim.form, + hypothesisRef: claim.hypothesisRef, + claimedTypeCode: claim.claimedTypeCode, + confidence: claim.confidence, + sequence: claim.sequence, + turn: claim.turn, + revisions: claim.revisions, + contradictions: claim.contradictions, + positions: [...claim.positionCounts.entries()] + .map(([position, count]) => ({ position, count })) + .sort((left, right) => + right.count === left.count + ? left.position - right.position + : right.count - left.count, + ), + })) + .sort((left, right) => { + if (right.confidence !== left.confidence) { + return right.confidence - left.confidence; + } + if (left.contradictions !== right.contradictions) { + return left.contradictions - right.contradictions; + } + return left.form < right.form ? -1 : left.form > right.form ? 1 : 0; + }); + + return { + claims: materialized, + claimsByForm: new Map(materialized.map((claim) => [claim.form, claim])), + observedMessageLength, + ignoredEvents, + eventsRead: ordered.length, + }; +} diff --git a/packages/interventions/src/curriculum.ts b/packages/interventions/src/curriculum.ts new file mode 100644 index 0000000..f5ea574 --- /dev/null +++ b/packages/interventions/src/curriculum.ts @@ -0,0 +1,336 @@ +/** + * E22 staged-curriculum executor (EXPERIMENT-NOTEBOOK.md E22; SPEC §18 + * `curriculumMode: fixed-schedule`, `CurriculumStageSchema` in `@ald/types`; + * BACKLOG ALD-072, ALD-075 acceptance criterion 3 — "`ALD-072`'s scaffold + * supports a staged/curriculum sequence of interventions within a single + * run"). + * + * E22's procedure is what this module enforces and records: + * + * - "Pre-register every schedule" — the stages come from + * `RunConfig.interventionPlan.curriculum.stages`, which is inside the + * hashed run configuration, so a curriculum is a configuration change and + * never a code change (ALD-072 acceptance criterion 1); + * - "Define competence gates without age labels" — a stage is keyed by + * `startTurn` only; this module has no notion of age, and + * `curriculumMode: 'adaptive-guided'` (which would let a schedule react to + * performance) is rejected here rather than silently treated as fixed, + * because SPEC §18 requires an adaptive curriculum to be labeled; + * - "Prevent adaptive BabySitter teaching" — `stageForTurn` is a pure + * function of the turn index, so nothing about a run's outcomes can move a + * transition; + * - "Record exact transition points and policy hashes" — + * {@link CurriculumTransition} carries `policyHashBefore`/`policyHashAfter` + * for the runtime to fill from `exportPolicy()` around the + * `applyCurriculumStage` call, and {@link curriculumTransitionsAttachment} + * packages the log as the `curriculum-transitions` bundle attachment. + * + * A stage whose knob the target adapter cannot honour is rejected *before the + * run starts* by {@link assertStagesSupported}, mirroring the adapter-side + * rule that an adapter must reject a knob it cannot apply rather than ignore + * it (SPEC §6.2 note on `applyCurriculumStage`). + */ +import type { CurriculumStage, InterventionPlan, RunConfig } from '@ald/types'; + +import { buildAttachment, type AttachmentFile } from './attachment.js'; +import { InterventionError } from './errors.js'; + +/** Analysis version of the `curriculum-transitions` attachment. */ +export const CURRICULUM_TRANSITIONS_VERSION = 'curriculum-transitions/v1'; + +/** SPEC §9.1 hard ceiling on marks per message, whatever a stage asks for. */ +export const MAX_SYMBOLS_PER_MESSAGE_CEILING = 16; + +/** Knobs a `CurriculumStage` can carry (`CurriculumStageSchema`). */ +export const CURRICULUM_KNOBS = [ + 'learningRate', + 'temperature', + 'explorationRate', + 'memoryCapacity', + 'maxSymbolsPerMessage', + 'consolidation', +] as const; + +export type CurriculumKnob = (typeof CURRICULUM_KNOBS)[number]; + +/** Which knobs a target adapter declares it can apply. */ +export type CurriculumCapabilities = Readonly< + Partial> +>; + +export interface CurriculumTransition { + readonly stageIndex: number; + /** Turn at which the stage becomes active (`stage.startTurn`). */ + readonly turn: number; + readonly stage: CurriculumStage; + /** `exportPolicy()` hash before `applyCurriculumStage`; runtime-filled. */ + readonly policyHashBefore?: string; + /** `exportPolicy()` hash after `applyCurriculumStage`; runtime-filled. */ + readonly policyHashAfter?: string; +} + +export interface CurriculumValidationInput { + readonly stages: readonly CurriculumStage[]; + /** The run's own per-message ceiling; a stage may lower it, never raise it. */ + readonly maxSymbolsPerMessage?: number; + readonly maxTurnsPerRun?: number; +} + +/** + * Validate a stage list against SPEC §18 and E22: + * + * 1. at least one stage, and the first starts at turn 0 — a run must have a + * defined configuration from its first turn; + * 2. `startTurn` strictly increasing, so `stageForTurn` is single-valued; + * 3. `stageIndex` equal to the position in the list, because transitions and + * the attachment identify a stage by that index; + * 4. `maxSymbolsPerMessage` at or below both the run's configured value and + * the §9.1 ceiling of 16 — staged bandwidth may narrow the channel, never + * widen it beyond what was pre-registered; + * 5. every `startTurn` inside `maxTurnsPerRun` when it is given, so a + * schedule cannot silently contain a stage the run never reaches. + */ +export function validateCurriculumStages( + input: CurriculumValidationInput, +): readonly CurriculumStage[] { + const { stages } = input; + if (stages.length === 0) { + throw new InterventionError( + 'invalid-curriculum', + 'a curriculum must declare at least one stage', + ); + } + const first = stages[0] as CurriculumStage; + if (first.startTurn !== 0) { + throw new InterventionError( + 'invalid-curriculum', + 'the first curriculum stage must start at turn 0', + ); + } + const ceiling = Math.min( + input.maxSymbolsPerMessage ?? MAX_SYMBOLS_PER_MESSAGE_CEILING, + MAX_SYMBOLS_PER_MESSAGE_CEILING, + ); + let previousTurn = -1; + stages.forEach((stage, index) => { + if (stage.stageIndex !== index) { + throw new InterventionError( + 'invalid-curriculum', + `stage at position ${index} declares stageIndex ${stage.stageIndex}`, + ); + } + if (stage.startTurn <= previousTurn) { + throw new InterventionError( + 'invalid-curriculum', + 'curriculum stages must have strictly increasing startTurn', + ); + } + previousTurn = stage.startTurn; + if ( + stage.maxSymbolsPerMessage !== undefined && + stage.maxSymbolsPerMessage > ceiling + ) { + throw new InterventionError( + 'invalid-curriculum', + `stage ${index} raises maxSymbolsPerMessage to ${stage.maxSymbolsPerMessage} above the run ceiling ${ceiling}`, + ); + } + if ( + input.maxTurnsPerRun !== undefined && + stage.startTurn >= input.maxTurnsPerRun + ) { + throw new InterventionError( + 'invalid-curriculum', + `stage ${index} starts at turn ${stage.startTurn}, at or beyond maxTurnsPerRun ${input.maxTurnsPerRun}`, + ); + } + }); + return stages; +} + +/** Knobs a stage actually sets, in `CURRICULUM_KNOBS` order. */ +export function knobsOf(stage: CurriculumStage): CurriculumKnob[] { + const options = stage.learnerOptions ?? {}; + return CURRICULUM_KNOBS.filter((knob) => { + if (knob === 'maxSymbolsPerMessage') { + return stage.maxSymbolsPerMessage !== undefined; + } + if (knob === 'consolidation') { + return stage.consolidation !== undefined; + } + return options[knob] !== undefined; + }); +} + +/** + * Reject a schedule whose stages set knobs the target adapter declared it + * cannot apply. SPEC §6.2 requires the adapter itself to reject such a stage + * at run time; checking it here means a pre-registered schedule fails before + * a run consumes turns, with the offending stage and knob named. + */ +export function assertStagesSupported( + stages: readonly CurriculumStage[], + capabilities: CurriculumCapabilities, +): void { + stages.forEach((stage, index) => { + for (const knob of knobsOf(stage)) { + if (capabilities[knob] !== true) { + throw new InterventionError( + 'unsupported-curriculum-knob', + `stage ${index} sets ${knob}, which the adapter does not support`, + ); + } + } + }); +} + +/** + * The staged executor. Construction validates the schedule, so an instance + * always describes a schedule the run may legally follow. + */ +export class CurriculumExecutor { + readonly stages: readonly CurriculumStage[]; + + constructor(input: CurriculumValidationInput) { + this.stages = validateCurriculumStages(input); + } + + /** Build an executor from a run configuration, or `null` when it has none. */ + static fromRunConfig(config: RunConfig): CurriculumExecutor | null { + const stages = config.interventionPlan?.curriculum?.stages; + if (stages === undefined) { + return null; + } + if (config.curriculumMode !== 'fixed-schedule') { + // SPEC §18: `adaptive-guided` "must be labeled"; executing it as if it + // were a fixed schedule would drop that label silently. + throw new InterventionError( + 'invalid-curriculum', + `curriculumMode ${config.curriculumMode} is not executable by the fixed-schedule executor`, + ); + } + return new CurriculumExecutor({ + stages, + ...(config.maxSymbolsPerMessage === undefined + ? {} + : { maxSymbolsPerMessage: config.maxSymbolsPerMessage }), + maxTurnsPerRun: config.maxTurnsPerRun, + }); + } + + /** Build an executor from a bare plan (no run configuration available). */ + static fromPlan( + plan: InterventionPlan | undefined, + options: Omit = {}, + ): CurriculumExecutor | null { + const stages = plan?.curriculum?.stages; + return stages === undefined + ? null + : new CurriculumExecutor({ stages, ...options }); + } + + /** The stage active at `turn`: the last one whose `startTurn` is at most it. */ + stageForTurn(turn: number): { stageIndex: number; stage: CurriculumStage } { + if (!Number.isInteger(turn) || turn < 0) { + throw new InterventionError( + 'invalid-input', + 'turn must be a non-negative integer', + ); + } + let active = this.stages[0] as CurriculumStage; + for (const stage of this.stages) { + if (stage.startTurn <= turn) { + active = stage; + } else { + break; + } + } + return { stageIndex: active.stageIndex, stage: active }; + } + + /** + * Transitions the runtime must apply when advancing from `fromTurn` to + * `toTurn`: every stage whose `startTurn` is in `(fromTurn, toTurn]`. + * + * The lower bound is exclusive and the upper inclusive, so a run starts by + * calling `transitionsBetween(-1, 0)` (which returns the turn-0 stage) and + * then `transitionsBetween(previousTurn, turn)` before each later turn. + * Ranges never overlap and never skip a stage, however large the step. + */ + transitionsBetween(fromTurn: number, toTurn: number): CurriculumTransition[] { + if (!Number.isInteger(fromTurn) || fromTurn < -1) { + throw new InterventionError( + 'invalid-input', + 'fromTurn must be an integer of at least -1', + ); + } + if (!Number.isInteger(toTurn) || toTurn < fromTurn) { + throw new InterventionError( + 'invalid-input', + 'toTurn must be an integer at or after fromTurn', + ); + } + return this.stages + .filter((stage) => stage.startTurn > fromTurn && stage.startTurn <= toTurn) + .map((stage) => ({ + stageIndex: stage.stageIndex, + turn: stage.startTurn, + stage, + })); + } + + /** + * E22 "consolidation intervals": true while the active stage forbids model + * learning. Adapters use the applied stage to discard rather than defer the + * affected turns when `updatePolicy` is called. + */ + consolidating(turn: number): boolean { + return this.stageForTurn(turn).stage.consolidation === true; + } + + /** The per-message mark ceiling in force at `turn`, when a stage sets one. */ + maxSymbolsPerMessageAt(turn: number): number | null { + return this.stageForTurn(turn).stage.maxSymbolsPerMessage ?? null; + } + + /** Every transition of a whole run, for pre-run inspection. */ + plannedTransitions(maxTurnsPerRun: number): CurriculumTransition[] { + return this.transitionsBetween(-1, Math.max(0, maxTurnsPerRun - 1)); + } +} + +export interface CurriculumTransitionLog { + readonly analysisVersion: string; + readonly runId: string; + readonly curriculumMode: RunConfig['curriculumMode']; + readonly stages: number; + readonly transitions: readonly CurriculumTransition[]; + readonly claimBoundary: 'software-readiness-only'; +} + +/** + * Package the applied transitions as the `curriculum-transitions` bundle + * attachment. The runtime supplies the transitions it actually applied, + * including the policy hashes it observed around each `applyCurriculumStage` + * call (E22 "Record exact transition points and policy hashes"). + */ +export function curriculumTransitionsAttachment(input: { + readonly runId: string; + readonly curriculumMode: RunConfig['curriculumMode']; + readonly stages: number; + readonly transitions: readonly CurriculumTransition[]; +}): AttachmentFile { + const log: CurriculumTransitionLog = { + analysisVersion: CURRICULUM_TRANSITIONS_VERSION, + runId: input.runId, + curriculumMode: input.curriculumMode, + stages: input.stages, + transitions: input.transitions, + claimBoundary: 'software-readiness-only', + }; + return buildAttachment({ + kind: 'curriculum-transitions', + analysisVersion: CURRICULUM_TRANSITIONS_VERSION, + value: log, + }); +} diff --git a/packages/interventions/src/errors.ts b/packages/interventions/src/errors.ts new file mode 100644 index 0000000..38da7b2 --- /dev/null +++ b/packages/interventions/src/errors.ts @@ -0,0 +1,73 @@ +/** + * Error type for the intervention planner and its readiness scaffolds + * (BACKLOG ALD-072). + * + * Every module in this package is pure: a failure is always an input-domain + * failure — a plan that violates SPEC §15.2/§18, an observation set that does + * not match the schedule it claims to belong to, or a curriculum stage an + * adapter cannot honour. Callers get a stable `code` so a harness can record + * the failure in evidence without matching on message text. + * + * These errors are researcher-facing. Nothing in this package runs inside a + * Baby's isolation boundary, so no message here can reach a Baby context + * (SPEC §10.3). + */ +export type InterventionErrorCode = + /** The `InterventionPlan` is not usable for the requested operation. */ + | 'invalid-plan' + /** Evaluation turns, ledgers, inventory, or seed are missing or malformed. */ + | 'invalid-input' + /** Observed probe outcomes do not line up with the schedule. */ + | 'schedule-mismatch' + /** A curriculum stage list violates SPEC §18 / E22 ordering rules. */ + | 'invalid-curriculum' + /** A stage sets a knob the target adapter declared it cannot apply. */ + | 'unsupported-curriculum-knob' + /** Not enough probes, seeds, or episodes for the requested statistic. */ + | 'insufficient-data'; + +export class InterventionError extends Error { + constructor( + public readonly code: InterventionErrorCode, + message: string, + ) { + super(message); + this.name = 'InterventionError'; + } +} + +export function assertNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new InterventionError( + 'invalid-input', + `${label} must be a non-empty string`, + ); + } + return value; +} + +export function assertTurnList( + turns: readonly number[], + label: string, +): readonly number[] { + if (turns.length === 0) { + throw new InterventionError('invalid-input', `${label} must not be empty`); + } + let previous = -1; + turns.forEach((turn, index) => { + if (!Number.isInteger(turn) || turn < 0) { + throw new InterventionError( + 'invalid-input', + `${label}[${index}] must be a non-negative integer`, + ); + } + if (turn <= previous) { + throw new InterventionError( + 'invalid-input', + `${label} must be strictly increasing`, + ); + } + previous = turn; + }); + return turns; +} diff --git a/packages/interventions/src/index.ts b/packages/interventions/src/index.ts new file mode 100644 index 0000000..cc02211 --- /dev/null +++ b/packages/interventions/src/index.ts @@ -0,0 +1,165 @@ +/** + * `@ald/interventions` — the SPEC §15.2 intervention test suite and the + * experiment-readiness scaffolds behind E14, E15, E16, E22, E31 and E50. + * + * Owning BACKLOG items: **ALD-072** (acceptance criterion 1: an intervention + * is toggled by configuration with no code change per intervention; + * criterion 3: the output is a data structure for a researcher's downstream + * analysis and draws no conclusions), and the software readiness underlying + * **ALD-074** criterion 3 (E14 role reversal/repair, E15 held-out + * composition, E16 causal interventions), **ALD-075** criterion 3 (E22 staged + * curriculum), **ALD-076** criterion 2 (E31 drift across checkpoints) and + * **ALD-077** criterion 2 (E50 multi-seed aggregation). + * + * SPEC sections implemented: §8.1 (turn phases and role reversal, as the + * schedule this package plans against), §9.6 (the control conditions a probe + * is scored beside), §11.4 (agent-native ledger content the claim index + * reads), §14.3 (seeded, replayable analyses), §15.2 (the mandatory suite and + * its descriptive-versus-confirmatory split), §15.3 (α, Holm-Bonferroni, + * mandatory effect sizes, seed minimums), §18 (`interventionPlan`, + * `interventionSuiteThreshold`, `curriculumMode`, `roleReversalPeriod`). + * + * Module map: + * + * - `run-plan.ts` — {@link buildInterventionRunPlan}: the one + * configuration-driven entry point (ALD-072 criterion 1). + * - `claims.ts` — the agent-native ledger claim index a probe targets. + * - `plan.ts` — {@link planEvaluationProbes}: the ordered probe schedule with + * its ledger-predicted directions, fixed before outcomes exist (E16). + * - `suite.ts` — {@link evaluateInterventionSuite}: per-probe agreement, the + * descriptive 70% readiness readout, and the seed-clustered confirmatory + * analyses. + * - `scrambling.ts` — the offline-only §15.2 scrambling control. + * - `repair.ts` — E14 repair, role-symmetry and reuse metrics. + * - `curriculum.ts` — the E22 staged executor and its transition log. + * - `replication.ts` — E50 multi-seed aggregation with failed and partial + * replications always included. + * - `attachment.ts` — canonical-JSON packaging for the bundle attachments + * (`intervention-suite`, `curriculum-transitions`, `drift-evaluation`). + * + * The statistics themselves live in `@ald/analysis` (`hierarchical.ts`, + * `composition.ts`, `drift.ts` are this workstream's additions there), so a + * researcher can use them without pulling in the planner. + * + * Everything here is pure, deterministic and seeded: no evidence store, no + * runtime, no clock, no network. **Nothing in this package draws a research + * conclusion.** A field named `decision`, `meetsDescriptiveReadinessThreshold` + * or `replicationStatus` reports the mechanical outcome of a pre-registered + * rule applied to the numbers, and every readout carries + * `claimBoundary: 'software-readiness-only'` to keep that explicit in the + * artifact itself. + */ +export { + InterventionError, + type InterventionErrorCode, +} from './errors.js'; +export { + indexLedgerClaims, + type LedgerClaimIndex, + type LedgerFormClaim, +} from './claims.js'; +export { + readPolicyFormClaims, + type PolicyFormClaims, +} from './policy-claims.js'; +export { + LEDGER_PREDICTION_FUNCTION_VERSION, + PROBE_KINDS, + PROBE_SCHEDULE_VERSION, + evaluationTurnRange, + hashProbe, + planEvaluationProbes, + probeCountFor, + receiverForTurn, + type LedgerPredictedDirection, + type PlannedProbe, + type PredictedCandidateShift, + type ProbeKind, + type ProbePlanInput, + type ProbePositionBasis, + type ProbeSchedule, + type ProbeShortfallCode, +} from './plan.js'; +export { + DEFAULT_CHANCE_RATE, + DEFAULT_INTERVENTION_SUITE_THRESHOLD, + INTERVENTION_SUITE_ANALYSIS_VERSION, + evaluateInterventionSuite, + interventionSuiteAttachment, + scoreProbeAgreement, + type AgreementBlock, + type InterventionSuiteInput, + type InterventionSuiteResult, + type ObservedProbeOutcome, + type ProbeScoring, + type ProbeScoringBasis, + type ProbeUnscoredReasonCode, + type ScoredProbe, +} from './suite.js'; +export { + SCRAMBLING_CONTROL_VERSION, + evaluateScramblingControl, + scramblingControlAttachment, + type ScramblingDecision, + type ScramblingInput, + type ScramblingResult, +} from './scrambling.js'; +export { + REPAIR_ANALYSIS_VERSION, + evaluateRepair, + repairMetricsAttachment, + type ProportionWithWilson, + type RepairAttempt, + type RepairEpisode, + type RepairInput, + type RepairResult, + type RepairReuseResult, + type RepairViolationCode, + type RoleSymmetryResult, +} from './repair.js'; +export { + CURRICULUM_KNOBS, + CURRICULUM_TRANSITIONS_VERSION, + CurriculumExecutor, + MAX_SYMBOLS_PER_MESSAGE_CEILING, + assertStagesSupported, + curriculumTransitionsAttachment, + knobsOf, + validateCurriculumStages, + type CurriculumCapabilities, + type CurriculumKnob, + type CurriculumTransition, + type CurriculumTransitionLog, + type CurriculumValidationInput, +} from './curriculum.js'; +export { + PUBLICATION_MINIMUM_SEEDS, + REPLICATION_ANALYSIS_VERSION, + aggregateAcrossSeeds, + replicationAttachment, + type ReplicationArm, + type ReplicationArmSummary, + type ReplicationFindingInput, + type ReplicationFindingResult, + type ReplicationInput, + type ReplicationResult, + type ReplicationRule, + type ReplicationStatus, + type ReplicationStatusReasonCode, +} from './replication.js'; +export { + PROBE_SEED_DOMAIN, + buildInterventionRunPlan, + deriveInterventionSeed, + driftEvaluationTurns, + type InterventionRunPlan, + type InterventionRunPlanInput, + type InterventionToggleState, +} from './run-plan.js'; +export { + buildAttachment, + fileSha256, + toCanonicalJsonValue, + type AttachmentFile, + type InterventionAttachmentKind, +} from './attachment.js'; diff --git a/packages/interventions/src/plan.ts b/packages/interventions/src/plan.ts new file mode 100644 index 0000000..7809d1d --- /dev/null +++ b/packages/interventions/src/plan.ts @@ -0,0 +1,493 @@ +/** + * The SPEC §15.2 causal-probe planner (BACKLOG ALD-072, ALD-074 acceptance + * criterion 3 for E16). + * + * §15.2 makes two of the three suite tests live interventions on a delivery: + * + * | Test | Procedure | Pass criterion (default) | + * |---|---|---| + * | Ablation | Drop a symbol/stroke-feature the ledger claims is meaningful | Receiver behavior changes in the ledger-predicted direction | + * | Substitution | Swap a symbol for another in-inventory symbol | Receiver behavior shifts toward the substituted symbol's ledger-claimed meaning | + * + * (the third, scrambling, is offline only and lives in `scrambling.ts`). + * + * This module turns a pre-registered `InterventionPlan` (SPEC §18, + * `RunConfig.interventionPlan`) plus the Babies' own ledgers into an ordered + * `ProbeSchedule`: which evaluation turns carry a probe, which + * `ArtifactProbe` each one applies, and — recorded *with* the probe, before + * any outcome exists — the direction the ledger predicts the receiver's + * behaviour will move in. E16's procedure requires exactly that order: + * "Select ledger hypotheses before viewing intervention outcomes." The + * planner therefore reads claims (`claims.ts`) and never an outcome, a + * reward, or a success flag; `evaluateInterventionSuite` in `suite.ts` is the + * only place observed behaviour enters. + * + * Determinism: which turns are probed and every tie-break come from a + * `SeededPrng` derived from the caller's seed, so a schedule replays + * bit-for-bit from the recorded seed manifest (SPEC §14.3) and + * `hashProbe` is stable across processes. + * + * No intervention is hard-coded here: `plan.evaluationSuite.{ablation, + * substitution,probeShare}` are the only switches, and they are part of the + * hashed `RunConfig`, so turning a probe kind on or off is a pre-registered + * configuration change and never a code change (ALD-072 acceptance + * criterion 1). + */ +import { hashCanonical, SeededPrng } from '@ald/hashing'; +import { + HASH_DOMAINS, + type ArtifactProbe, + type BabyRole, + type InterventionPlan, + type LedgerEvent, + type Sha256Hash, +} from '@ald/types'; + +import { + indexLedgerClaims, + type LedgerClaimIndex, + type LedgerFormClaim, +} from './claims.js'; +import { + InterventionError, + assertNonEmptyString, + assertTurnList, +} from './errors.js'; +import { readPolicyFormClaims, type PolicyFormClaims } from './policy-claims.js'; + +/** Version of the schedule structure and of the selection rules below. */ +export const PROBE_SCHEDULE_VERSION = 'intervention-probe-schedule/v1'; + +/** + * Version of the ledger-to-prediction function (RESEARCH.md §6.8): the rule + * that turns a ledger claim plus a probe into a directional prediction + * *without* observing the outcome. It is recorded with every prediction so a + * re-analysis can tell which rule produced it. + */ +export const LEDGER_PREDICTION_FUNCTION_VERSION = + 'ledger-direction:claimed-type-code:v1'; + +/** Probe kinds in the fixed order the planner alternates through. */ +export const PROBE_KINDS = ['ablation', 'substitution'] as const; + +export type ProbeKind = (typeof PROBE_KINDS)[number]; + +export type ProbeShortfallCode = + /** `plan.evaluationSuite` is absent: the run pre-registered no live probes. */ + | 'suite-absent' + /** Both probe kinds are switched off in the plan. */ + | 'no-probe-kind-enabled' + /** `probeShare` is 0: pre-registered as an unprobed evaluation phase. */ + | 'probe-share-zero' + /** No ledger claim was available to target. */ + | 'no-ledger-claim' + /** No second claim with a different meaning existed for a substitution. */ + | 'no-substitute-with-distinct-claim' + /** The substitute a claim named is not in the run's inventory. */ + | 'substitute-not-in-inventory'; + +/** + * How the probe position was chosen. `ledger-observed` means the ledger's own + * `intention.recorded`/`interpretation.recorded` events put the form at that + * position; `default-first` means the ledger recorded no message for the form + * and the planner fell back to position 0. The distinction is recorded + * because a `default-first` probe tests a weaker claim. + */ +export type ProbePositionBasis = 'ledger-observed' | 'default-first'; + +export interface PredictedCandidateShift { + /** + * `away-from-type-code` (ablation): removing the mark should move the + * receiver off candidates of the claimed type code. + * `toward-type-code` (substitution): the receiver should move toward + * candidates of the substitute's claimed type code. + */ + readonly kind: 'away-from-type-code' | 'toward-type-code'; + /** The type code the receiver is predicted to move toward, if any. */ + readonly towardTypeCode: number | null; + /** The type code the receiver is predicted to move away from, if any. */ + readonly awayFromTypeCode: number | null; +} + +export interface LedgerPredictedDirection { + /** The ledger hypothesis under test (SPEC §15.2, `ArtifactProbe.hypothesisRef`). */ + readonly hypothesisRef: string; + /** SPEC §11.4: the prediction comes from agent-native state, not an audit gloss. */ + readonly predictedBy: 'agent-native-ledger'; + readonly predictionFunctionVersion: string; + readonly claimedTypeCode: number; + readonly claimedConfidence: number; + readonly predictedCandidateShift: PredictedCandidateShift; + /** + * Optional consistency check of the same claim against the Baby's own + * exported policy. `null` when no policy was supplied or its shape is not + * one this module can read. It is a diagnostic, never part of the + * prediction (SPEC §15.2 validates ledgers behaviourally). + */ + readonly policyConsistency: PolicyFormClaims | null; +} + +export interface PlannedProbe { + readonly turn: number; + /** The Baby that receives the perturbed delivery on this turn. */ + readonly receiver: BabyRole; + /** + * The form the probe removes or replaces. Recorded next to the probe + * because the offline scrambling control (`scrambling.ts`) has to re-derive + * the prediction under a shuffled form-to-meaning map, and `ArtifactProbe` + * itself carries only the position. + */ + readonly targetForm: string; + /** The in-inventory form delivered instead; `null` for an ablation. */ + readonly substituteForm: string | null; + readonly probe: ArtifactProbe; + /** `hashProbe(probe)`; the value `TurnRecord.probeHash` records. */ + readonly probeHash: Sha256Hash; + readonly kind: ProbeKind; + readonly positionBasis: ProbePositionBasis; + readonly predictedDirection: LedgerPredictedDirection; +} + +export interface ProbeSchedule { + readonly scheduleVersion: string; + /** Fixes the order of operations E16 requires; a constant, recorded as data. */ + readonly selectionBasis: 'ledger-claims-selected-before-outcomes'; + readonly seed: string; + readonly probeShare: number; + readonly evaluationTurns: readonly number[]; + /** Turns the planner intended to probe, before per-turn shortfalls. */ + readonly targetProbeCount: number; + readonly probes: readonly PlannedProbe[]; + /** Evaluation turns with no probe: the within-run unprobed baseline. */ + readonly baselineTurns: readonly number[]; + /** Why the schedule carries fewer probes than `targetProbeCount`. */ + readonly shortfalls: readonly { + readonly turn: number; + readonly code: ProbeShortfallCode; + }[]; + readonly enabledKinds: readonly ProbeKind[]; + readonly claimsConsidered: number; +} + +export interface ProbePlanInput { + /** `RunConfig.interventionPlan`; `undefined` yields an empty schedule. */ + readonly plan?: InterventionPlan | undefined; + /** Turn numbers of the evaluation phase, strictly increasing. */ + readonly evaluationTurns: readonly number[]; + /** Each Baby's own ledger events, as far as they exist when planning runs. */ + readonly ledgers: { + readonly babyA: readonly LedgerEvent[]; + readonly babyB: readonly LedgerEvent[]; + }; + /** Each Baby's `exportPolicy()` output, for the optional consistency check. */ + readonly policies?: { + readonly babyA?: unknown; + readonly babyB?: unknown; + }; + /** The run's declared inventory (SPEC §9.1); substitutes must be in it. */ + readonly symbolInventory: readonly string[]; + readonly seed: string; + /** SPEC §18 `roleReversalPeriod`; used by the default `receiverForTurn`. */ + readonly roleReversalPeriod?: number; + /** Override the role schedule when the runtime uses a different one. */ + readonly receiverForTurn?: (turn: number) => BabyRole; + /** Marks per delivered message; defaults to what the ledgers recorded. */ + readonly messageLength?: number; +} + +/** Canonical probe hash (`HASH_DOMAINS.causalProbe`; `TurnRecord.probeHash`). */ +export function hashProbe(probe: ArtifactProbe): Sha256Hash { + return hashCanonical(HASH_DOMAINS.causalProbe, probe); +} + +/** + * SPEC §8.1 step 9 role schedule, mirrored from the Nursery runtime's + * `senderForTurn`: `baby-a` sends while `floor(turn / period)` is even, so the + * receiver is the other Baby. Pass `receiverForTurn` to override. + */ +export function receiverForTurn( + turn: number, + roleReversalPeriod: number, +): BabyRole { + if (!Number.isInteger(roleReversalPeriod) || roleReversalPeriod < 1) { + throw new InterventionError( + 'invalid-input', + 'roleReversalPeriod must be a positive integer', + ); + } + return Math.floor(turn / roleReversalPeriod) % 2 === 0 ? 'baby-b' : 'baby-a'; +} + +/** Contiguous evaluation turn numbers, the shape the runtime produces. */ +export function evaluationTurnRange( + firstEvaluationTurn: number, + evaluationTurns: number, +): number[] { + if (!Number.isInteger(firstEvaluationTurn) || firstEvaluationTurn < 0) { + throw new InterventionError( + 'invalid-input', + 'firstEvaluationTurn must be a non-negative integer', + ); + } + if (!Number.isInteger(evaluationTurns) || evaluationTurns < 1) { + throw new InterventionError( + 'invalid-input', + 'evaluationTurns must be a positive integer', + ); + } + return Array.from( + { length: evaluationTurns }, + (_unused, index) => firstEvaluationTurn + index, + ); +} + +/** + * How many evaluation turns a `probeShare` buys. + * + * `round(share * n)`, clamped to `[0, n]`, with one documented exception: a + * pre-registered non-zero share on a short evaluation phase always buys at + * least one probe, because rounding a deliberately registered intervention + * away to zero would silently drop it from the run (BACKLOG §15 decision). + */ +export function probeCountFor(probeShare: number, turns: number): number { + if (!Number.isFinite(probeShare) || probeShare < 0 || probeShare > 1) { + throw new InterventionError( + 'invalid-plan', + 'probeShare must be within [0, 1]', + ); + } + if (probeShare === 0 || turns === 0) { + return 0; + } + return Math.min(turns, Math.max(1, Math.round(probeShare * turns))); +} + +function claimPosition( + claim: LedgerFormClaim, + messageLength: number, +): { position: number; basis: ProbePositionBasis } { + for (const entry of claim.positions) { + if (entry.position < messageLength) { + return { position: entry.position, basis: 'ledger-observed' }; + } + } + return { position: 0, basis: 'default-first' }; +} + +function emptySchedule( + input: ProbePlanInput, + probeShare: number, + enabledKinds: readonly ProbeKind[], + code: ProbeShortfallCode, + claimsConsidered: number, +): ProbeSchedule { + return { + scheduleVersion: PROBE_SCHEDULE_VERSION, + selectionBasis: 'ledger-claims-selected-before-outcomes', + seed: input.seed, + probeShare, + evaluationTurns: [...input.evaluationTurns], + targetProbeCount: 0, + probes: [], + baselineTurns: [...input.evaluationTurns], + shortfalls: [{ turn: -1, code }], + enabledKinds, + claimsConsidered, + }; +} + +/** + * Build the ordered probe schedule for one run's evaluation phase. + * + * Selection rules, all fixed before any outcome exists: + * + * 1. `probeCountFor(probeShare, evaluationTurns.length)` turns are probed, + * drawn from the evaluation turns by a seeded shuffle and then sorted, so + * probes are spread over the phase without a systematic position. + * 2. Probe kinds alternate through the enabled ones in `PROBE_KINDS` order, + * which balances ablation and substitution across the phase. + * 3. The target claim rotates through the receiver's claims ranked by the + * Baby's own recorded confidence (`claims.ts`), so successive probes test + * successive hypotheses rather than the same one repeatedly. §15.2's + * "a symbol the ledger claims is meaningful" is the highest-confidence + * claim, which is where the rotation starts. + * 4. A substitution needs a second in-inventory form whose ledger-claimed + * meaning differs from the target's; the highest-confidence such form is + * used. When none exists the turn falls back to an ablation, and the + * fallback is recorded in `shortfalls`. + */ +export function planEvaluationProbes(input: ProbePlanInput): ProbeSchedule { + assertNonEmptyString(input.seed, 'seed'); + assertTurnList(input.evaluationTurns, 'evaluationTurns'); + if (input.symbolInventory.length === 0) { + throw new InterventionError( + 'invalid-input', + 'symbolInventory must not be empty', + ); + } + const suite = input.plan?.evaluationSuite; + const probeShare = suite?.probeShare ?? 0; + const enabledKinds = PROBE_KINDS.filter((kind) => + kind === 'ablation' ? suite?.ablation === true : suite?.substitution === true, + ); + + const indexes: Record = { + 'baby-a': indexLedgerClaims(input.ledgers.babyA), + 'baby-b': indexLedgerClaims(input.ledgers.babyB), + }; + const claimsConsidered = + indexes['baby-a'].claims.length + indexes['baby-b'].claims.length; + + if (suite === undefined) { + return emptySchedule(input, probeShare, enabledKinds, 'suite-absent', claimsConsidered); + } + if (enabledKinds.length === 0) { + return emptySchedule( + input, + probeShare, + enabledKinds, + 'no-probe-kind-enabled', + claimsConsidered, + ); + } + const targetProbeCount = probeCountFor( + probeShare, + input.evaluationTurns.length, + ); + if (targetProbeCount === 0) { + return emptySchedule( + input, + probeShare, + enabledKinds, + 'probe-share-zero', + claimsConsidered, + ); + } + + const prng = new SeededPrng(`${input.seed}/probe-schedule`); + const probedTurns = prng + .shuffle(input.evaluationTurns) + .slice(0, targetProbeCount) + .sort((left, right) => left - right); + + const roleFor = + input.receiverForTurn ?? + ((turn: number) => receiverForTurn(turn, input.roleReversalPeriod ?? 1)); + + const inventory = new Set(input.symbolInventory); + const probes: PlannedProbe[] = []; + const shortfalls: { turn: number; code: ProbeShortfallCode }[] = []; + const rotation: Record = { 'baby-a': 0, 'baby-b': 0 }; + + probedTurns.forEach((turn, index) => { + const receiver = roleFor(turn); + const claims = indexes[receiver].claims; + if (claims.length === 0) { + shortfalls.push({ turn, code: 'no-ledger-claim' }); + return; + } + const target = claims[rotation[receiver] % claims.length] as LedgerFormClaim; + rotation[receiver] += 1; + + const messageLength = Math.max( + 1, + input.messageLength ?? indexes[receiver].observedMessageLength ?? 1, + ); + const { position, basis } = claimPosition(target, messageLength); + + let kind = enabledKinds[index % enabledKinds.length] as ProbeKind; + let substitute: LedgerFormClaim | undefined; + if (kind === 'substitution') { + substitute = claims.find( + (claim) => + claim.form !== target.form && + claim.claimedTypeCode !== target.claimedTypeCode && + inventory.has(claim.form), + ); + if (substitute === undefined) { + const distinct = claims.find( + (claim) => + claim.form !== target.form && + claim.claimedTypeCode !== target.claimedTypeCode, + ); + shortfalls.push({ + turn, + code: + distinct === undefined + ? 'no-substitute-with-distinct-claim' + : 'substitute-not-in-inventory', + }); + if (!enabledKinds.includes('ablation')) { + return; + } + kind = 'ablation'; + } + } + + const probe: ArtifactProbe = { + probeId: `probe:t${turn}:${kind}`, + kind, + position, + ...(kind === 'substitution' && substitute !== undefined + ? { substitute: substitute.form } + : {}), + hypothesisRef: target.hypothesisRef, + }; + + const shift: PredictedCandidateShift = + kind === 'ablation' || substitute === undefined + ? { + kind: 'away-from-type-code', + towardTypeCode: null, + awayFromTypeCode: target.claimedTypeCode, + } + : { + kind: 'toward-type-code', + towardTypeCode: substitute.claimedTypeCode, + awayFromTypeCode: target.claimedTypeCode, + }; + + probes.push({ + turn, + receiver, + targetForm: target.form, + substituteForm: substitute?.form ?? null, + probe, + probeHash: hashProbe(probe), + kind, + positionBasis: basis, + predictedDirection: { + hypothesisRef: target.hypothesisRef, + predictedBy: 'agent-native-ledger', + predictionFunctionVersion: LEDGER_PREDICTION_FUNCTION_VERSION, + claimedTypeCode: target.claimedTypeCode, + claimedConfidence: target.confidence, + predictedCandidateShift: shift, + policyConsistency: readPolicyFormClaims({ + policy: + receiver === 'baby-a' + ? input.policies?.babyA + : input.policies?.babyB, + form: target.form, + claimedTypeCode: target.claimedTypeCode, + symbolInventory: input.symbolInventory, + }), + }, + }); + }); + + const probedSet = new Set(probes.map((probe) => probe.turn)); + return { + scheduleVersion: PROBE_SCHEDULE_VERSION, + selectionBasis: 'ledger-claims-selected-before-outcomes', + seed: input.seed, + probeShare, + evaluationTurns: [...input.evaluationTurns], + targetProbeCount, + probes, + baselineTurns: input.evaluationTurns.filter((turn) => !probedSet.has(turn)), + shortfalls, + enabledKinds, + claimsConsidered, + }; +} diff --git a/packages/interventions/src/policy-claims.ts b/packages/interventions/src/policy-claims.ts new file mode 100644 index 0000000..053d533 --- /dev/null +++ b/packages/interventions/src/policy-claims.ts @@ -0,0 +1,92 @@ +/** + * Ledger-claim versus exported-policy consistency check. + * + * RESEARCH.md §6.8 requires each track to supply a versioned + * ledger-to-prediction function at a locked commit, and the reference tabular + * track supplies exactly that in `@ald/learners` + * (`predictSenderSymbol`/`predictReceiverChoice`, which read nothing but an + * exported policy checkpoint). The probe planner's prediction comes from the + * Baby's ledger events; this module answers a *separate*, diagnostic + * question: does the same Baby's exported policy put its argmax on the same + * form for the type code its ledger claims? + * + * The check is deliberately non-fatal. SPEC §6.2 lets `exportPolicy()` return + * any canonicalizable shape, so a policy this module cannot read simply + * yields `null` (`available: false` inside the schedule) instead of failing a + * plan. And it is deliberately not part of the prediction: SPEC §15.2 + * validates ledger meanings *behaviourally*, so a policy that disagrees with + * its own ledger is a finding for the researcher, not a reason for the + * planner to substitute the policy's opinion. + */ +import { + parseExportedTabularPolicy, + predictSenderSymbol, + tabularPolicyShape, +} from '@ald/learners'; + +export interface PolicyFormClaims { + /** True when the policy shape could be read at all. */ + readonly available: boolean; + /** Which reader matched; `none` when the policy was unreadable. */ + readonly policyShape: 'exported-tabular-policy' | 'none'; + /** + * Type codes whose sender-side argmax symbol is the claimed form. Empty + * when the policy never prefers the form for any referent. + */ + readonly argmaxTypeCodes: readonly number[]; + /** + * `true` when the ledger's claimed type code is one of them, `false` when + * it is not, `null` when the policy was unreadable. + */ + readonly agreesWithLedgerClaim: boolean | null; +} + +const UNAVAILABLE: PolicyFormClaims = { + available: false, + policyShape: 'none', + argmaxTypeCodes: [], + agreesWithLedgerClaim: null, +}; + +/** + * Read the type codes an exported tabular policy associates with `form`. + * + * Uses only the public `@ald/learners` surface: the policy is parsed and + * shape-checked, then `predictSenderSymbol` is asked, for every referent type + * code the policy covers, which form it would emit. Any parse or shape + * failure — including a policy from a track with a different state shape — + * returns the unavailable result. + */ +export function readPolicyFormClaims(input: { + readonly policy: unknown; + readonly form: string; + readonly claimedTypeCode: number; + readonly symbolInventory: readonly string[]; +}): PolicyFormClaims | null { + if (input.policy === undefined || input.policy === null) { + return null; + } + try { + const parsed = parseExportedTabularPolicy(input.policy); + const shape = tabularPolicyShape(parsed); + const argmaxTypeCodes: number[] = []; + for (let typeCode = 0; typeCode < shape.typeCount; typeCode += 1) { + const prediction = predictSenderSymbol( + parsed, + typeCode, + input.symbolInventory, + ); + if (prediction.symbol === input.form) { + argmaxTypeCodes.push(typeCode); + } + } + return { + available: true, + policyShape: 'exported-tabular-policy', + argmaxTypeCodes, + agreesWithLedgerClaim: argmaxTypeCodes.includes(input.claimedTypeCode), + }; + } catch { + return UNAVAILABLE; + } +} diff --git a/packages/interventions/src/repair.ts b/packages/interventions/src/repair.ts new file mode 100644 index 0000000..0333d28 --- /dev/null +++ b/packages/interventions/src/repair.ts @@ -0,0 +1,399 @@ +/** + * E14 turn-taking, role-reversal and repair metrics + * (EXPERIMENT-NOTEBOOK.md E14; SPEC §8.1 step 9, §15.3; BACKLOG ALD-072, + * ALD-074 acceptance criterion 3). + * + * E14's result table names five metrics — successful repair rate, turns per + * resolved ambiguity, role symmetry, reused repair constructions, held-out + * repair success — and its procedure fixes what a "repair" is allowed to be: + * "Permit a bounded second turn without adding new channel capacity" + * (`InterventionPlanSchema.repair = { enabled, maxExtraTurns: 1 }` in + * `@ald/types`). This module computes those five metrics from episodes the + * runtime already records, and it *checks the bound* rather than assuming it: + * an episode that took more extra turns than the plan permits, or whose + * repair message carried more marks than the run's `maxSymbolsPerMessage`, + * is reported as a budget violation. A violation invalidates the E14 metric, + * not the run, and this module says so in codes rather than prose. + * + * The input is deliberately carrier-agnostic: a form is identified by an + * opaque `formHash` (a `markHash` for a generative carrier, or the joined + * symbol sequence for `fixed-token`), so "reused repair constructions" is + * measurable for all five SPEC §9.2 carriers. + * + * Pure counting: no PRNG, no I/O, and no conclusion. Whether the numbers show + * dialogue is the researcher's call (ALD-072 acceptance criterion 3). + */ +import { + cohensH, + proportion, + wilsonInterval, + type ProportionSummary, + type WilsonInterval, +} from '@ald/analysis'; +import type { BabyRole } from '@ald/types'; + +import { buildAttachment, type AttachmentFile } from './attachment.js'; +import { InterventionError } from './errors.js'; + +/** Analysis version of the E14 readout. */ +export const REPAIR_ANALYSIS_VERSION = 'repair-metrics/v1'; + +/** One delivered turn inside an episode. `attempt` 0 is the first try. */ +export interface RepairAttempt { + readonly turn: number; + /** 0 for the episode's first attempt, 1.. for bounded repair turns. */ + readonly attempt: number; + readonly sender: BabyRole; + readonly receiver: BabyRole; + readonly success: boolean; + /** Opaque identity of the delivered form (`markHash` or symbol sequence). */ + readonly formHash?: string; + /** Marks in the delivered message, for the channel-capacity check. */ + readonly messageLength?: number; +} + +export interface RepairEpisode { + readonly episodeId: string; + /** E14 "Pre-register ambiguous and unambiguous scenarios". */ + readonly ambiguous: boolean; + /** E15/E14 "Test whether repair forms generalize to new referents". */ + readonly split: 'train' | 'held-out'; + readonly attempts: readonly RepairAttempt[]; +} + +export interface RepairInput { + readonly episodes: readonly RepairEpisode[]; + /** `InterventionPlan.repair.maxExtraTurns`; SPEC's literal default is 1. */ + readonly maxExtraTurns?: number; + /** `RunConfig.maxSymbolsPerMessage`; a repair must not exceed it. */ + readonly maxSymbolsPerMessage?: number; + readonly confidence?: number; +} + +export type RepairViolationCode = + /** More extra turns than `maxExtraTurns` permits. */ + | 'repair-budget-exceeded' + /** A repair message carried more marks than the run's per-message ceiling. */ + | 'repair-added-channel-capacity' + /** Attempt indices are not 0,1,2,… without gaps. */ + | 'attempt-sequence-invalid' + /** No attempt with index 0. */ + | 'missing-first-attempt' + /** An attempt claims a sender that is also its receiver. */ + | 'sender-equals-receiver'; + +export interface ProportionWithWilson { + readonly summary: ProportionSummary; + readonly wilson: WilsonInterval; +} + +export interface RoleSymmetryResult { + readonly perRole: Readonly>; + /** `baby-a` minus `baby-b` sender success; `NaN` when a role has no turns. */ + readonly successGap: number; + /** §15.3 mandatory effect size for the role contrast. */ + readonly cohensH: number; + /** Turns each Baby spent as sender: the §8.1 step 9 reversal check. */ + readonly senderTurns: Readonly>; +} + +export interface RepairReuseResult { + readonly repairAttemptsWithForm: number; + readonly distinctRepairForms: number; + /** Repair attempts whose form had already been used in an earlier repair. */ + readonly reusedAttempts: number; + /** `reusedAttempts / repairAttemptsWithForm`; `NaN` without forms. */ + readonly reuseRate: number; + /** Forms that appear in repair turns but never in a first attempt. */ + readonly repairSpecificForms: number; +} + +export interface RepairResult { + readonly analysisVersion: string; + readonly episodes: number; + readonly ambiguousEpisodes: number; + readonly heldOutEpisodes: number; + readonly maxExtraTurns: number; + /** Episodes whose first attempt failed: the repair opportunities. */ + readonly repairOpportunities: number; + /** Opportunities in which at least one extra turn was taken. */ + readonly repairAttempted: number; + /** Attempted repairs that ended in a success. */ + readonly repairResolved: number; + /** E14 "Successful repair rate": resolved over attempted. */ + readonly repairRate: ProportionWithWilson | null; + /** The same numerator over every opportunity, attempted or not. */ + readonly repairRateOverOpportunities: ProportionWithWilson | null; + readonly firstAttemptSuccess: ProportionWithWilson | null; + /** E14 "Turns per resolved ambiguity": mean attempts in resolved ambiguous episodes. */ + readonly turnsPerResolvedAmbiguity: number; + readonly resolvedAmbiguousEpisodes: number; + /** E14 "Role symmetry". */ + readonly roleSymmetry: RoleSymmetryResult; + /** + * Who took the repair turn. `original-receiver` means the Baby that failed + * to act on the first message initiated the repair, which is E14's + * "receiver requests or elicits repair"; `original-sender` is a + * re-formulation by the same speaker. + */ + readonly repairInitiation: { + readonly originalReceiver: number; + readonly originalSender: number; + }; + /** E14 "Reused repair constructions". */ + readonly reuse: RepairReuseResult; + /** E14 "Held-out repair success". */ + readonly heldOutRepairRate: ProportionWithWilson | null; + readonly seenRepairRate: ProportionWithWilson | null; + readonly violations: readonly { + readonly episodeId: string; + readonly code: RepairViolationCode; + readonly turn: number | null; + }[]; + readonly claimBoundary: 'software-readiness-only'; +} + +function withWilson( + successes: number, + n: number, + confidence: number, +): ProportionWithWilson | null { + if (n === 0) { + return null; + } + return { + summary: proportion(successes, n), + wilson: wilsonInterval(successes, n, confidence), + }; +} + +/** Attempts of one episode in `attempt` order. */ +function orderedAttempts(episode: RepairEpisode): RepairAttempt[] { + return [...episode.attempts].sort((left, right) => left.attempt - right.attempt); +} + +/** + * Compute the five E14 metrics plus the repair-budget checks. + * + * An episode with no attempts at all is an input error: E14 measures + * dialogue, and an episode that never delivered a turn is not a data point + * with a missing field but a malformed record. + */ +export function evaluateRepair(input: RepairInput): RepairResult { + if (input.episodes.length === 0) { + throw new InterventionError('invalid-input', 'episodes must not be empty'); + } + const confidence = input.confidence ?? 0.95; + const maxExtraTurns = input.maxExtraTurns ?? 1; + if (!Number.isInteger(maxExtraTurns) || maxExtraTurns < 0) { + throw new InterventionError( + 'invalid-plan', + 'maxExtraTurns must be a non-negative integer', + ); + } + + const violations: RepairResult['violations'][number][] = []; + let repairOpportunities = 0; + let repairAttempted = 0; + let repairResolved = 0; + let firstAttempts = 0; + let firstAttemptSuccesses = 0; + let resolvedAmbiguous = 0; + let resolvedAmbiguousTurns = 0; + let heldOutAttempted = 0; + let heldOutResolved = 0; + let seenAttempted = 0; + let seenResolved = 0; + let originalReceiverRepairs = 0; + let originalSenderRepairs = 0; + + const senderTurns: Record = { 'baby-a': 0, 'baby-b': 0 }; + const senderSuccesses: Record = { + 'baby-a': 0, + 'baby-b': 0, + }; + const firstAttemptForms = new Set(); + const repairForms = new Map(); + let repairAttemptsWithForm = 0; + let reusedAttempts = 0; + + for (const episode of input.episodes) { + const attempts = orderedAttempts(episode); + if (attempts.length === 0) { + throw new InterventionError( + 'invalid-input', + `episode ${episode.episodeId} has no attempts`, + ); + } + attempts.forEach((attempt, index) => { + if (attempt.attempt !== index) { + violations.push({ + episodeId: episode.episodeId, + code: + index === 0 ? 'missing-first-attempt' : 'attempt-sequence-invalid', + turn: attempt.turn, + }); + } + if (attempt.sender === attempt.receiver) { + violations.push({ + episodeId: episode.episodeId, + code: 'sender-equals-receiver', + turn: attempt.turn, + }); + } + senderTurns[attempt.sender] += 1; + if (attempt.success) { + senderSuccesses[attempt.sender] += 1; + } + }); + + const first = attempts[0] as RepairAttempt; + const extras = attempts.slice(1); + if (first.attempt === 0) { + firstAttempts += 1; + if (first.success) { + firstAttemptSuccesses += 1; + } + if (first.formHash !== undefined) { + firstAttemptForms.add(first.formHash); + } + } + if (extras.length > maxExtraTurns) { + violations.push({ + episodeId: episode.episodeId, + code: 'repair-budget-exceeded', + turn: (extras[maxExtraTurns] as RepairAttempt).turn, + }); + } + for (const extra of extras) { + if ( + input.maxSymbolsPerMessage !== undefined && + extra.messageLength !== undefined && + extra.messageLength > input.maxSymbolsPerMessage + ) { + violations.push({ + episodeId: episode.episodeId, + code: 'repair-added-channel-capacity', + turn: extra.turn, + }); + } + if (extra.sender === first.receiver) { + originalReceiverRepairs += 1; + } else { + originalSenderRepairs += 1; + } + if (extra.formHash !== undefined) { + repairAttemptsWithForm += 1; + const seen = repairForms.get(extra.formHash) ?? 0; + if (seen > 0) { + reusedAttempts += 1; + } + repairForms.set(extra.formHash, seen + 1); + } + } + + if (first.success) { + continue; + } + repairOpportunities += 1; + if (extras.length === 0) { + continue; + } + repairAttempted += 1; + const resolved = extras.some((extra) => extra.success); + if (resolved) { + repairResolved += 1; + if (episode.ambiguous) { + resolvedAmbiguous += 1; + resolvedAmbiguousTurns += attempts.length; + } + } + if (episode.split === 'held-out') { + heldOutAttempted += 1; + if (resolved) { + heldOutResolved += 1; + } + } else { + seenAttempted += 1; + if (resolved) { + seenResolved += 1; + } + } + } + + const roleRate = (role: BabyRole): ProportionWithWilson | null => + withWilson(senderSuccesses[role], senderTurns[role], confidence); + const rateA = roleRate('baby-a'); + const rateB = roleRate('baby-b'); + + return { + analysisVersion: REPAIR_ANALYSIS_VERSION, + episodes: input.episodes.length, + ambiguousEpisodes: input.episodes.filter((episode) => episode.ambiguous) + .length, + heldOutEpisodes: input.episodes.filter( + (episode) => episode.split === 'held-out', + ).length, + maxExtraTurns, + repairOpportunities, + repairAttempted, + repairResolved, + repairRate: withWilson(repairResolved, repairAttempted, confidence), + repairRateOverOpportunities: withWilson( + repairResolved, + repairOpportunities, + confidence, + ), + firstAttemptSuccess: withWilson( + firstAttemptSuccesses, + firstAttempts, + confidence, + ), + turnsPerResolvedAmbiguity: + resolvedAmbiguous === 0 + ? NaN + : resolvedAmbiguousTurns / resolvedAmbiguous, + resolvedAmbiguousEpisodes: resolvedAmbiguous, + roleSymmetry: { + perRole: { 'baby-a': rateA, 'baby-b': rateB }, + successGap: + rateA === null || rateB === null + ? NaN + : rateA.summary.proportion - rateB.summary.proportion, + cohensH: + rateA === null || rateB === null + ? NaN + : cohensH(rateA.summary.proportion, rateB.summary.proportion), + senderTurns: { ...senderTurns }, + }, + repairInitiation: { + originalReceiver: originalReceiverRepairs, + originalSender: originalSenderRepairs, + }, + reuse: { + repairAttemptsWithForm, + distinctRepairForms: repairForms.size, + reusedAttempts, + reuseRate: + repairAttemptsWithForm === 0 + ? NaN + : reusedAttempts / repairAttemptsWithForm, + repairSpecificForms: [...repairForms.keys()].filter( + (form) => !firstAttemptForms.has(form), + ).length, + }, + heldOutRepairRate: withWilson(heldOutResolved, heldOutAttempted, confidence), + seenRepairRate: withWilson(seenResolved, seenAttempted, confidence), + violations, + claimBoundary: 'software-readiness-only', + }; +} + +/** Package the E14 readout as an `other`-kind bundle attachment. */ +export function repairMetricsAttachment(result: RepairResult): AttachmentFile { + return buildAttachment({ + kind: 'other', + analysisVersion: result.analysisVersion, + value: result, + }); +} diff --git a/packages/interventions/src/replication.ts b/packages/interventions/src/replication.ts new file mode 100644 index 0000000..01b0d14 --- /dev/null +++ b/packages/interventions/src/replication.ts @@ -0,0 +1,387 @@ +/** + * E50 multi-seed replication aggregation (EXPERIMENT-NOTEBOOK.md E50; + * SPEC §15.3; BACKLOG ALD-072, ALD-077 acceptance criterion 2 — "`ALD-028` + * derived-run support and `ALD-072`'s scaffold together launch the same + * pre-registered configuration across independent seeds and aggregate + * baseline statistics"). + * + * E50's procedure fixes both what is aggregated and what must be said about + * it: "Select independent seeds before viewing results", "Re-run all primary + * comparisons", "Aggregate effect sizes and uncertainty", "Report failed and + * partial replications". The last one is not optional here — + * `negativeResultsIncluded` is a literal `true` in the result, and a finding + * whose rule fails is reported with the same detail as one that passes. There + * is no code path that drops a finding. + * + * A replication rule is pre-registered per finding and evaluated + * mechanically: + * + * - `ci-excludes-null` — the replication's seed-level bootstrap interval + * excludes the null value *and* lies on the same side of it as the original + * effect. This is the strict rule: a significant effect in the opposite + * direction is a failed replication, not a successful one. + * - `direction-and-alpha` — the replication's effect has the same sign as the + * original and its one-sided seed-level t test against the null is below + * `alpha`. Holm-Bonferroni across the findings of one experiment is + * reported alongside the raw p values (§15.3), so a family-adjusted reading + * is available without recomputation. + * + * `replicationStatus` distinguishes `replicated`, `partial` (the effect points + * the same way but the rule did not fire), `failed` (opposite direction or a + * rule violation), and `insufficient-seeds` (fewer seeds than the + * pre-registered floor, where no status can honestly be assigned). None of + * these is a scientific conclusion: they are the pre-registered rule applied + * to the numbers (ALD-072 acceptance criterion 3). + */ +import { + bootstrapMeanCi, + cohensH, + holmBonferroni, + oneSampleTTest, + summarize, + type BootstrapCi, + type OneSampleTTestResult, +} from '@ald/analysis'; + +import { buildAttachment, type AttachmentFile } from './attachment.js'; +import { InterventionError, assertNonEmptyString } from './errors.js'; + +/** Analysis version of the E50 readout. */ +export const REPLICATION_ANALYSIS_VERSION = 'replication-aggregate/v1'; + +/** §15.3: 10 seeds per condition for any publication-facing claim. */ +export const PUBLICATION_MINIMUM_SEEDS = 10; + +export type ReplicationRule = + | { readonly type: 'ci-excludes-null' } + | { readonly type: 'direction-and-alpha'; readonly alpha?: number }; + +/** One arm of a finding: the per-seed metric values, one per independent seed. */ +export interface ReplicationArm { + readonly label: string; + /** One value per seed — a success proportion, an effect, a rate. */ + readonly perSeedValues: readonly number[]; + /** Optional per-seed episode counts, used only for the pooled Cohen's h. */ + readonly perSeedEpisodes?: readonly number[]; +} + +export interface ReplicationFindingInput { + /** Stable id of the primary finding (E50's result table rows). */ + readonly findingId: string; + /** What the metric is, e.g. `held-out success proportion`. */ + readonly metricLabel: string; + readonly original: ReplicationArm; + readonly replication: ReplicationArm; + /** Value the effect is compared against, e.g. the E03 chance rate. */ + readonly nullValue: number; + readonly rule: ReplicationRule; + /** Seeds required before a status other than `insufficient-seeds`. */ + readonly minimumSeeds?: number; + /** + * Direction the pre-registration expected. `greater` and `less` require the + * replication to move that way; `either` accepts the original's direction. + */ + readonly expectedDirection?: 'greater' | 'less' | 'either'; +} + +export interface ReplicationInput { + readonly findings: readonly ReplicationFindingInput[]; + /** Seed for every bootstrap in the aggregation. */ + readonly seed: string; + readonly alpha?: number; + readonly confidence?: number; + readonly bootstrapIterations?: number; +} + +export type ReplicationStatus = + | 'replicated' + | 'partial' + | 'failed' + | 'insufficient-seeds'; + +export interface ReplicationArmSummary { + readonly label: string; + readonly seeds: number; + readonly mean: number; + readonly sd: number; + /** Mean minus `nullValue`: the effect the rule is evaluated on. */ + readonly effect: number; + readonly bootstrap: BootstrapCi | null; + readonly tTest: OneSampleTTestResult | null; +} + +export interface ReplicationFindingResult { + readonly findingId: string; + readonly metricLabel: string; + readonly nullValue: number; + readonly rule: ReplicationRule; + readonly minimumSeeds: number; + readonly original: ReplicationArmSummary; + readonly replication: ReplicationArmSummary; + /** `replication.effect - original.effect`. */ + readonly effectDifference: number; + /** §15.3 effect size when both arms are proportions in [0, 1]. */ + readonly cohensHOriginalVersusReplication: number; + readonly sameDirection: boolean; + readonly ruleMet: boolean; + readonly replicationStatus: ReplicationStatus; + /** Codes explaining the status; empty for a clean `replicated`. */ + readonly statusReasonCodes: readonly ReplicationStatusReasonCode[]; + /** Raw one-sided p of the replication arm, when the rule uses one. */ + readonly replicationP: number | null; +} + +export type ReplicationStatusReasonCode = + | 'below-seed-minimum-original' + | 'below-seed-minimum-replication' + | 'opposite-direction' + | 'interval-includes-null' + | 'p-at-or-above-alpha' + | 'unexpected-direction' + | 'degenerate-sample'; + +export interface ReplicationResult { + readonly analysisVersion: string; + readonly seed: string; + readonly alpha: number; + readonly confidence: number; + readonly findings: readonly ReplicationFindingResult[]; + readonly summary: { + readonly findings: number; + readonly replicated: number; + readonly partial: number; + readonly failed: number; + readonly insufficientSeeds: number; + }; + /** + * Holm-Bonferroni across the replication arms' one-sided p values, in + * `findings` order (§15.3 "correction across the primary metrics of a + * single experiment"). `null` when no finding produced a usable p. + */ + readonly holmAcrossFindings: { + readonly pValues: readonly number[]; + readonly adjusted: readonly number[]; + readonly rejected: readonly boolean[]; + } | null; + /** E50 "Report failed and partial replications" — never conditional. */ + readonly negativeResultsIncluded: true; + readonly claimBoundary: 'software-readiness-only'; +} + +function summarizeArm( + arm: ReplicationArm, + nullValue: number, + seed: string, + confidence: number, + iterations: number, +): ReplicationArmSummary { + if (arm.perSeedValues.length === 0) { + throw new InterventionError( + 'insufficient-data', + `arm ${arm.label} has no per-seed values`, + ); + } + const stats = summarize(arm.perSeedValues); + const canInfer = arm.perSeedValues.length >= 2; + return { + label: arm.label, + seeds: stats.n, + mean: stats.mean, + sd: stats.sd, + effect: stats.mean - nullValue, + bootstrap: canInfer + ? bootstrapMeanCi(arm.perSeedValues, { + seed: `${seed}/${arm.label}`, + iterations, + confidence, + }) + : null, + tTest: canInfer + ? oneSampleTTest( + arm.perSeedValues, + nullValue, + stats.mean >= nullValue ? 'greater' : 'less', + ) + : null, + }; +} + +function isProportion(value: number): boolean { + return Number.isFinite(value) && value >= 0 && value <= 1; +} + +/** + * Aggregate the primary findings of a replication against their + * pre-registered rules. + * + * Every finding is evaluated and returned, including the ones that fail: E50 + * requires failed and partial replications in the report, so dropping them + * here would make the aggregate unusable for its own experiment record. + */ +export function aggregateAcrossSeeds( + input: ReplicationInput, +): ReplicationResult { + assertNonEmptyString(input.seed, 'seed'); + if (input.findings.length === 0) { + throw new InterventionError('invalid-input', 'findings must not be empty'); + } + const alpha = input.alpha ?? 0.05; + const confidence = input.confidence ?? 0.95; + const iterations = input.bootstrapIterations ?? 2_000; + + const findings = input.findings.map((finding) => { + const minimumSeeds = finding.minimumSeeds ?? PUBLICATION_MINIMUM_SEEDS; + const original = summarizeArm( + finding.original, + finding.nullValue, + `${input.seed}/${finding.findingId}/original`, + confidence, + iterations, + ); + const replication = summarizeArm( + finding.replication, + finding.nullValue, + `${input.seed}/${finding.findingId}/replication`, + confidence, + iterations, + ); + const reasons: ReplicationStatusReasonCode[] = []; + if (original.seeds < minimumSeeds) { + reasons.push('below-seed-minimum-original'); + } + if (replication.seeds < minimumSeeds) { + reasons.push('below-seed-minimum-replication'); + } + + const sameDirection = + Math.sign(original.effect) === Math.sign(replication.effect) && + original.effect !== 0 && + replication.effect !== 0; + if (!sameDirection) { + reasons.push('opposite-direction'); + } + const expected = finding.expectedDirection ?? 'either'; + const directionOk = + expected === 'either' + ? true + : expected === 'greater' + ? replication.effect > 0 + : replication.effect < 0; + if (!directionOk) { + reasons.push('unexpected-direction'); + } + + let ruleMet = false; + let replicationP: number | null = null; + if (finding.rule.type === 'ci-excludes-null') { + const interval = replication.bootstrap; + if (interval === null) { + reasons.push('degenerate-sample'); + } else { + const excludesNull = + interval.lower > finding.nullValue || interval.upper < finding.nullValue; + if (!excludesNull) { + reasons.push('interval-includes-null'); + } + ruleMet = excludesNull && sameDirection && directionOk; + } + } else { + const ruleAlpha = finding.rule.alpha ?? alpha; + const test = replication.tTest; + if (test === null || Number.isNaN(test.p)) { + reasons.push('degenerate-sample'); + } else { + replicationP = test.p; + if (!(test.p < ruleAlpha)) { + reasons.push('p-at-or-above-alpha'); + } + ruleMet = test.p < ruleAlpha && sameDirection && directionOk; + } + } + + let status: ReplicationStatus; + if ( + reasons.includes('below-seed-minimum-original') || + reasons.includes('below-seed-minimum-replication') + ) { + status = 'insufficient-seeds'; + } else if (ruleMet) { + status = 'replicated'; + } else if (sameDirection && directionOk) { + status = 'partial'; + } else { + status = 'failed'; + } + + return { + findingId: finding.findingId, + metricLabel: finding.metricLabel, + nullValue: finding.nullValue, + rule: finding.rule, + minimumSeeds, + original, + replication, + effectDifference: replication.effect - original.effect, + cohensHOriginalVersusReplication: + isProportion(original.mean) && isProportion(replication.mean) + ? cohensH(original.mean, replication.mean) + : NaN, + sameDirection, + ruleMet, + replicationStatus: status, + statusReasonCodes: reasons, + replicationP, + } satisfies ReplicationFindingResult; + }); + + const usableP = findings + .map((finding) => finding.replicationP) + .filter((value): value is number => value !== null); + const holm = + usableP.length === 0 + ? null + : (() => { + const result = holmBonferroni(usableP, alpha); + return { + pValues: usableP, + adjusted: result.adjusted, + rejected: result.rejected, + }; + })(); + + return { + analysisVersion: REPLICATION_ANALYSIS_VERSION, + seed: input.seed, + alpha, + confidence, + findings, + summary: { + findings: findings.length, + replicated: findings.filter( + (finding) => finding.replicationStatus === 'replicated', + ).length, + partial: findings.filter( + (finding) => finding.replicationStatus === 'partial', + ).length, + failed: findings.filter((finding) => finding.replicationStatus === 'failed') + .length, + insufficientSeeds: findings.filter( + (finding) => finding.replicationStatus === 'insufficient-seeds', + ).length, + }, + holmAcrossFindings: holm, + negativeResultsIncluded: true, + claimBoundary: 'software-readiness-only', + }; +} + +/** Package the E50 aggregate as an `other`-kind bundle attachment. */ +export function replicationAttachment( + result: ReplicationResult, +): AttachmentFile { + return buildAttachment({ + kind: 'other', + analysisVersion: result.analysisVersion, + value: result, + }); +} diff --git a/packages/interventions/src/run-plan.ts b/packages/interventions/src/run-plan.ts new file mode 100644 index 0000000..591ccde --- /dev/null +++ b/packages/interventions/src/run-plan.ts @@ -0,0 +1,202 @@ +/** + * The single configuration-driven entry point for every intervention this + * package supports — BACKLOG ALD-072 acceptance criterion 1: + * + * > An intervention defined in a pre-registration reference (`ALD-071`) can be + * > toggled on/off for a run via configuration, with no code change per + * > intervention. + * + * {@link buildInterventionRunPlan} reads *only* `RunConfig` (its + * `interventionPlan`, `interventionSuiteThreshold`, `maxSymbolsPerMessage`, + * `maxTurnsPerRun`, `roleReversalPeriod`, `curriculumMode`, `randomSeed`) plus + * the run's ledgers and inventory, and returns every scheduled intervention + * the runtime has to apply. Adding a probe kind, a repair turn, a held-out + * split, a curriculum, or a drift-evaluation cadence to a run is therefore a + * change to the hashed configuration and to nothing else; the call site in the + * runtime is identical for every combination. That is exactly what the + * acceptance criterion asks for, and the table test in + * `__tests__/interventions.test.ts` walks the toggle combinations through the + * planner to demonstrate it. + * + * Seed derivation: the planner runs inside the Nursery trust zone (SPEC §4.2) + * and may read `RunConfig.randomSeed`, but a probe schedule ends up in a + * public bundle attachment, so the default planner seed is the one-way + * derivation `deriveSeedHex(PROBE_SEED_DOMAIN, runId, randomSeed)` rather than + * the run seed itself. The derivation is implementation-defined and belongs in + * BACKLOG §15; a caller that pre-registered its own analysis seed passes + * `seed` instead. + */ +import { deriveSeedHex } from '@ald/hashing'; +import type { BabyRole, LedgerEvent, RunConfig } from '@ald/types'; + +import { CurriculumExecutor } from './curriculum.js'; +import { InterventionError } from './errors.js'; +import { + planEvaluationProbes, + type ProbeSchedule, +} from './plan.js'; + +/** + * Implementation-defined domain for the derived analysis seed (see the module + * comment). It is not a hash domain of an evidence artifact, so it lives here + * rather than in `HASH_DOMAINS`. + */ +export const PROBE_SEED_DOMAIN = 'dtsf-intervention-probe-seed-v1'; + +export interface InterventionToggleState { + readonly evaluationSuite: boolean; + readonly ablation: boolean; + readonly substitution: boolean; + readonly scramblingControl: boolean; + readonly repair: boolean; + readonly heldOutTypeCodes: boolean; + readonly curriculum: boolean; + readonly driftEvaluation: boolean; +} + +export interface InterventionRunPlan { + readonly runId: string; + readonly experimentId: string; + /** Where every switch came from; a constant, recorded as data. */ + readonly configurationBasis: 'run-config-intervention-plan'; + readonly planPresent: boolean; + readonly enabled: InterventionToggleState; + readonly seed: string; + /** `RunConfig.interventionSuiteThreshold` (SPEC §18, default 0.70). */ + readonly threshold: number; + readonly probeShare: number; + readonly probeSchedule: ProbeSchedule; + /** E14: the bounded extra turn, or `null` when not pre-registered. */ + readonly repair: { readonly enabled: boolean; readonly maxExtraTurns: 1 } | null; + /** E15: type codes withheld from training. */ + readonly heldOutTypeCodes: readonly number[]; + /** E22: the staged executor, or `null` when no curriculum is registered. */ + readonly curriculum: CurriculumExecutor | null; + /** E31: turns at which a frozen drift evaluation is scheduled. */ + readonly driftEvaluationTurns: readonly number[]; + /** + * SPEC §15.2: the scrambling control is an offline re-analysis and is never + * applied to a live delivery, whatever the toggle says. + */ + readonly scramblingControl: { + readonly enabled: boolean; + readonly appliesTo: 'offline-analysis-only'; + }; +} + +export interface InterventionRunPlanInput { + readonly config: RunConfig; + /** Evaluation-phase turn numbers, strictly increasing. */ + readonly evaluationTurns: readonly number[]; + readonly ledgers: { + readonly babyA: readonly LedgerEvent[]; + readonly babyB: readonly LedgerEvent[]; + }; + readonly policies?: { + readonly babyA?: unknown; + readonly babyB?: unknown; + }; + readonly symbolInventory: readonly string[]; + /** Overrides the derived analysis seed. */ + readonly seed?: string; + readonly receiverForTurn?: (turn: number) => BabyRole; + readonly messageLength?: number; +} + +/** + * E31 frozen-evaluation turns: every multiple of `driftEvaluationInterval` + * inside the training phase. Turn 0 is excluded (there is nothing to compare + * an initial-state evaluation against inside the same run), and the last turn + * of the phase is excluded because the run's own evaluation phase follows it. + */ +export function driftEvaluationTurns( + interval: number | undefined, + maxTurnsPerRun: number, +): number[] { + if (interval === undefined) { + return []; + } + if (!Number.isInteger(interval) || interval < 1) { + throw new InterventionError( + 'invalid-plan', + 'driftEvaluationInterval must be a positive integer', + ); + } + const turns: number[] = []; + for (let turn = interval; turn < maxTurnsPerRun; turn += interval) { + turns.push(turn); + } + return turns; +} + +/** Derived analysis seed for one run (see the module comment). */ +export function deriveInterventionSeed(config: RunConfig): string { + return deriveSeedHex(PROBE_SEED_DOMAIN, config.runId, config.randomSeed); +} + +/** + * Assemble every intervention a run's configuration asks for. + * + * The function never inspects an outcome, so it is safe to call before the + * evaluation phase begins — which E16 requires, since the probe schedule must + * be fixed before outcomes are seen. + */ +export function buildInterventionRunPlan( + input: InterventionRunPlanInput, +): InterventionRunPlan { + const { config } = input; + const plan = config.interventionPlan; + const suite = plan?.evaluationSuite; + const seed = input.seed ?? deriveInterventionSeed(config); + + const probeSchedule = planEvaluationProbes({ + plan, + evaluationTurns: input.evaluationTurns, + ledgers: input.ledgers, + ...(input.policies === undefined ? {} : { policies: input.policies }), + symbolInventory: input.symbolInventory, + seed, + roleReversalPeriod: config.roleReversalPeriod, + ...(input.receiverForTurn === undefined + ? {} + : { receiverForTurn: input.receiverForTurn }), + ...(input.messageLength === undefined + ? {} + : { messageLength: input.messageLength }), + }); + + return { + runId: config.runId, + experimentId: config.experimentId, + configurationBasis: 'run-config-intervention-plan', + planPresent: plan !== undefined, + enabled: { + evaluationSuite: suite !== undefined, + ablation: suite?.ablation === true, + substitution: suite?.substitution === true, + scramblingControl: suite?.scramblingControl === true, + repair: plan?.repair?.enabled === true, + heldOutTypeCodes: (plan?.heldOutTypeCodes ?? []).length > 0, + curriculum: plan?.curriculum !== undefined, + driftEvaluation: plan?.driftEvaluationInterval !== undefined, + }, + seed, + threshold: config.interventionSuiteThreshold, + probeShare: suite?.probeShare ?? 0, + probeSchedule, + repair: + plan?.repair === undefined + ? null + : { enabled: plan.repair.enabled, maxExtraTurns: plan.repair.maxExtraTurns }, + heldOutTypeCodes: [...(plan?.heldOutTypeCodes ?? [])], + curriculum: CurriculumExecutor.fromRunConfig(config), + driftEvaluationTurns: driftEvaluationTurns( + plan?.driftEvaluationInterval, + config.maxTurnsPerRun, + ), + scramblingControl: { + enabled: suite?.scramblingControl === true, + appliesTo: 'offline-analysis-only', + }, + }; +} diff --git a/packages/interventions/src/scrambling.ts b/packages/interventions/src/scrambling.ts new file mode 100644 index 0000000..22bbacf --- /dev/null +++ b/packages/interventions/src/scrambling.ts @@ -0,0 +1,314 @@ +/** + * The SPEC §15.2 scrambling control — offline only. + * + * > | Scrambling control | Replay with a shuffled post-hoc symbol-to-meaning + * > mapping (offline analysis only, never live) | Ledger-predicted accuracy + * > collapses toward chance, confirming the ledger is not a post-hoc + * > rationalization | + * + * "Offline analysis only, never live" is a hard property of this module, not + * a convention: it takes an already-observed probe set and re-scores it, and + * it has no way to reach a Gateway, a delivery, or a run. `ArtifactProbe` + * (SPEC §15.2 / `@ald/types`) deliberately has no `scrambling` kind for the + * same reason, so a scrambled mapping can never become a delivered + * perturbation. + * + * The control answers one question: if the ledger's form-to-meaning map were + * replaced by a random permutation of the same meanings, how well would the + * *same* pre-registered scoring rules (`scoreProbeAgreement`) predict the + * *same* observed behaviour? If the observed agreement rate is inside the + * scrambled distribution, the run's agreement is consistent with post-hoc + * rationalization; if it sits above it, the ledger's specific claims carried + * the prediction. The module reports both numbers and the gap; it does not + * decide which conclusion the researcher may draw. + * + * Determinism: the permutations come from a `SeededPrng` derived from the + * caller's seed, so a scrambling control replays exactly (SPEC §14.3). + */ +import { percentileInterval, type ConfidenceInterval } from '@ald/analysis'; +import { SeededPrng } from '@ald/hashing'; + +import { buildAttachment, type AttachmentFile } from './attachment.js'; +import { InterventionError, assertNonEmptyString } from './errors.js'; +import type { PlannedProbe, ProbeSchedule } from './plan.js'; +import { + scoreProbeAgreement, + type ObservedProbeOutcome, +} from './suite.js'; + +/** Analysis version of the scrambling-control readout. */ +export const SCRAMBLING_CONTROL_VERSION = 'scrambling-control/v1'; + +const DEFAULT_PERMUTATIONS = 1_000; + +export type ScramblingDecision = + /** Observed agreement is above the scrambled `confidence` interval. */ + | 'observed-above-scrambled-interval' + /** Observed agreement lies inside the scrambled interval. */ + | 'observed-within-scrambled-interval' + /** Observed agreement is below the scrambled interval. */ + | 'observed-below-scrambled-interval' + /** Fewer than two scored probes, or fewer than two distinct meanings. */ + | 'insufficient-probes'; + +export interface ScramblingInput { + readonly schedule: ProbeSchedule; + readonly observed: readonly ObservedProbeOutcome[]; + /** Number of shuffled meaning maps to draw (default 1000). */ + readonly permutations?: number; + readonly confidence?: number; + /** Chance rate the collapse is measured against (E03 baseline). */ + readonly chanceRate?: number; + /** Seed for the permutations; defaults to the schedule's own seed. */ + readonly seed?: string; +} + +export interface ScramblingResult { + readonly analysisVersion: string; + readonly seed: string; + readonly permutations: number; + readonly confidence: number; + readonly chanceRate: number; + /** Distinct forms the schedule's probes referred to. */ + readonly formsRemapped: number; + /** Distinct claimed meanings that were permuted among those forms. */ + readonly meaningsPermuted: number; + readonly scoredProbes: number; + /** Agreement rate under the ledger's own claims (the observed run). */ + readonly observedAgreementRate: number; + /** Mean agreement rate over the shuffled meaning maps. */ + readonly scrambledMeanAgreementRate: number; + /** Central `confidence` interval of the shuffled agreement rates. */ + readonly scrambledInterval: ConfidenceInterval; + /** `observed - scrambled mean`: how much the ledger's specificity bought. */ + readonly collapseTowardChance: number; + /** Distance of the scrambled mean from the chance rate. */ + readonly scrambledMinusChance: number; + /** Share of shuffled maps that scored at least as well as the ledger's. */ + readonly permutationP: number; + readonly decision: ScramblingDecision; + readonly claimBoundary: 'software-readiness-only'; +} + +interface PairedProbe { + readonly planned: PlannedProbe; + readonly observed: ObservedProbeOutcome; +} + +/** + * Re-score the observed probes under a permuted form-to-meaning map. + * + * The permutation is applied to the *meanings*: the distinct claimed type + * codes of the schedule's probes are shuffled among the distinct forms, so + * the scrambled map has the same forms and the same multiset of meanings as + * the ledger's map and differs only in their pairing. That is what makes the + * comparison a control rather than a different analysis. + */ +function scoreUnderMap( + pairs: readonly PairedProbe[], + map: ReadonlyMap, +): { scored: number; agreements: number } { + let scored = 0; + let agreements = 0; + for (const pair of pairs) { + const targetMeaning = map.get(pair.planned.targetForm); + const substituteMeaning = + pair.planned.substituteForm === null + ? null + : (map.get(pair.planned.substituteForm) ?? null); + if (targetMeaning === undefined) { + continue; + } + const shift = + pair.planned.kind === 'substitution' && substituteMeaning !== null + ? { + kind: 'toward-type-code' as const, + towardTypeCode: substituteMeaning, + awayFromTypeCode: targetMeaning, + } + : { + kind: 'away-from-type-code' as const, + towardTypeCode: null, + awayFromTypeCode: targetMeaning, + }; + const scoring = scoreProbeAgreement({ shift, observed: pair.observed }); + if (!scoring.scored) { + continue; + } + scored += 1; + if (scoring.agrees) { + agreements += 1; + } + } + return { scored, agreements }; +} + +/** + * Run the offline scrambling control over a scored probe set. + * + * Only probes that the schedule planned *and* the run observed take part; a + * probe with no observation cannot be re-scored under any map. + */ +export function evaluateScramblingControl( + input: ScramblingInput, +): ScramblingResult { + const seed = input.seed ?? input.schedule.seed; + assertNonEmptyString(seed, 'seed'); + const permutations = input.permutations ?? DEFAULT_PERMUTATIONS; + if (!Number.isInteger(permutations) || permutations < 1) { + throw new InterventionError( + 'invalid-input', + 'permutations must be a positive integer', + ); + } + const confidence = input.confidence ?? 0.95; + if (!(confidence > 0) || !(confidence < 1)) { + throw new InterventionError( + 'invalid-input', + 'confidence must be within (0, 1)', + ); + } + const chanceRate = input.chanceRate ?? 0.25; + + const observedById = new Map( + input.observed.map((observation) => [observation.probeId, observation]), + ); + const pairs: PairedProbe[] = []; + for (const planned of input.schedule.probes) { + const observed = observedById.get(planned.probe.probeId); + if (observed !== undefined) { + pairs.push({ planned, observed }); + } + } + + const forms = [ + ...new Set( + pairs.flatMap((pair) => + pair.planned.substituteForm === null + ? [pair.planned.targetForm] + : [pair.planned.targetForm, pair.planned.substituteForm], + ), + ), + ].sort(); + const ledgerMap = new Map(); + for (const pair of pairs) { + ledgerMap.set( + pair.planned.targetForm, + pair.planned.predictedDirection.claimedTypeCode, + ); + const shift = pair.planned.predictedDirection.predictedCandidateShift; + if (pair.planned.substituteForm !== null && shift.towardTypeCode !== null) { + ledgerMap.set(pair.planned.substituteForm, shift.towardTypeCode); + } + } + const meanings = forms.map((form) => ledgerMap.get(form) as number); + const distinctMeanings = new Set(meanings.filter((value) => value !== undefined)); + + const ledgerScore = scoreUnderMap(pairs, ledgerMap); + const observedRate = + ledgerScore.scored === 0 ? NaN : ledgerScore.agreements / ledgerScore.scored; + + if (ledgerScore.scored < 2 || distinctMeanings.size < 2) { + return { + analysisVersion: SCRAMBLING_CONTROL_VERSION, + seed, + permutations, + confidence, + chanceRate, + formsRemapped: forms.length, + meaningsPermuted: distinctMeanings.size, + scoredProbes: ledgerScore.scored, + observedAgreementRate: observedRate, + scrambledMeanAgreementRate: NaN, + scrambledInterval: { lower: NaN, upper: NaN, level: confidence }, + collapseTowardChance: NaN, + scrambledMinusChance: NaN, + permutationP: NaN, + decision: 'insufficient-probes', + claimBoundary: 'software-readiness-only', + }; + } + + const prng = new SeededPrng(`${seed}/scrambling-control`); + const rates: number[] = []; + let atLeastAsGood = 0; + for (let replicate = 0; replicate < permutations; replicate += 1) { + const shuffled = prng.shuffle(meanings); + const map = new Map(); + forms.forEach((form, index) => { + map.set(form, shuffled[index] as number); + }); + const score = scoreUnderMap(pairs, map); + if (score.scored === 0) { + continue; + } + const rate = score.agreements / score.scored; + rates.push(rate); + if (rate >= observedRate) { + atLeastAsGood += 1; + } + } + + if (rates.length === 0) { + return { + analysisVersion: SCRAMBLING_CONTROL_VERSION, + seed, + permutations, + confidence, + chanceRate, + formsRemapped: forms.length, + meaningsPermuted: distinctMeanings.size, + scoredProbes: ledgerScore.scored, + observedAgreementRate: observedRate, + scrambledMeanAgreementRate: NaN, + scrambledInterval: { lower: NaN, upper: NaN, level: confidence }, + collapseTowardChance: NaN, + scrambledMinusChance: NaN, + permutationP: NaN, + decision: 'insufficient-probes', + claimBoundary: 'software-readiness-only', + }; + } + + const scrambledMean = + rates.reduce((total, rate) => total + rate, 0) / rates.length; + const interval = percentileInterval(rates, confidence); + let decision: ScramblingDecision; + if (observedRate > interval.upper) { + decision = 'observed-above-scrambled-interval'; + } else if (observedRate < interval.lower) { + decision = 'observed-below-scrambled-interval'; + } else { + decision = 'observed-within-scrambled-interval'; + } + + return { + analysisVersion: SCRAMBLING_CONTROL_VERSION, + seed, + permutations, + confidence, + chanceRate, + formsRemapped: forms.length, + meaningsPermuted: distinctMeanings.size, + scoredProbes: ledgerScore.scored, + observedAgreementRate: observedRate, + scrambledMeanAgreementRate: scrambledMean, + scrambledInterval: interval, + collapseTowardChance: observedRate - scrambledMean, + scrambledMinusChance: scrambledMean - chanceRate, + permutationP: (atLeastAsGood + 1) / (rates.length + 1), + decision, + claimBoundary: 'software-readiness-only', + }; +} + +/** Package a scrambling result inside an `intervention-suite` attachment. */ +export function scramblingControlAttachment( + result: ScramblingResult, +): AttachmentFile { + return buildAttachment({ + kind: 'intervention-suite', + analysisVersion: result.analysisVersion, + value: result, + }); +} diff --git a/packages/interventions/src/suite.ts b/packages/interventions/src/suite.ts new file mode 100644 index 0000000..e45323f --- /dev/null +++ b/packages/interventions/src/suite.ts @@ -0,0 +1,476 @@ +/** + * The SPEC §15.2 intervention test suite readout (BACKLOG ALD-072; ALD-074 + * acceptance criterion 3 for E16). + * + * §15.2 states the pass criteria and, in the same paragraph, the boundary + * around them: + * + * > Default descriptive readiness threshold: within each run, ledger-predicted + * > direction matches observed behavior change in at least 70% of probed + * > instances. **This is not an inferential test.** Confirmatory inference MUST + * > account for probe clustering within run/seed using a hierarchical + * > Bernoulli model or a pre-registered seed-level equivalent against the E03 + * > chance baseline at alpha = 0.05. + * + * So this module reports two clearly separated things, and its field names + * carry the distinction rather than leaving it to a footnote: + * `descriptiveAgreement.*` and `meetsDescriptiveReadinessThreshold` are the + * within-run descriptive readout, and `confirmatory.*` holds the two + * seed-clustered analyses from `@ald/analysis` (`seedLevelAgreement` and + * `betaBinomialAgreement`). A readout never combines them into a verdict: + * ALD-072 acceptance criterion 3 requires the output to be "a data structure + * ready for a researcher's downstream analysis" that "does not itself draw or + * store scientific conclusions". + * + * Scoring rules are pre-registered here, not chosen per run. For each probe: + * + * - **substitution** (predicted shift *toward* the substitute's claimed type + * code `S`): the probe agrees when the receiver selected a candidate of type + * code `S`. It is *unscored* when no candidate of type `S` was on offer + * (nothing to shift toward) or when the unprobed baseline already selected + * type `S` (no shift is observable). + * - **ablation** (predicted shift *away from* the claimed type code `T`): the + * probe agrees when the receiver did **not** select a candidate of type code + * `T`. It is *unscored* when no candidate of type `T` was on offer, or when + * an unprobed baseline was replayed and did not select `T` — because then + * the ledger-predicted behaviour was not there to be changed. + * - a probe whose recorded action is not one of the candidate refs (a + * forfeited turn, SPEC §8.3) is unscored with its own code. + * + * Unscored probes are never silently counted as agreements or disagreements: + * `descriptiveAgreement` is over scored probes, and + * `conservativeAgreementRate` additionally reports the rate that treats every + * unscored probe as a non-agreement, so a pre-registration can name which one + * is primary and a reader can see both. + */ +import { + seedLevelAgreement, + betaBinomialAgreement, + proportion, + wilsonInterval, + type BetaBinomialAgreementResult, + type ProportionSummary, + type SeedAgreementCount, + type SeedLevelAgreementResult, + type WilsonInterval, +} from '@ald/analysis'; + +import { buildAttachment, type AttachmentFile } from './attachment.js'; +import { InterventionError, assertNonEmptyString } from './errors.js'; +import type { + PlannedProbe, + PredictedCandidateShift, + ProbeKind, + ProbeSchedule, +} from './plan.js'; + +/** Analysis version stamped on the `intervention-suite` attachment. */ +export const INTERVENTION_SUITE_ANALYSIS_VERSION = 'intervention-suite/v1'; + +/** SPEC §18 default `interventionSuiteThreshold`. */ +export const DEFAULT_INTERVENTION_SUITE_THRESHOLD = 0.7; + +/** Appendix D §D.3 chance rate for the four-candidate referential task. */ +export const DEFAULT_CHANCE_RATE = 0.25; + +export type ProbeUnscoredReasonCode = + | 'action-not-among-candidates' + | 'predicted-type-code-not-among-candidates' + | 'baseline-not-at-predicted-type-code' + | 'baseline-already-at-predicted-type-code' + | 'no-predicted-type-code'; + +/** What the agreement decision could be based on for one probe. */ +export type ProbeScoringBasis = 'baseline-contrast' | 'probed-only'; + +/** + * One observed probe delivery. `candidateTypeCodes` is researcher-only ground + * truth aligned with `candidateRefs` (the receiver's own candidate order, + * SPEC §11.2): the refs identify what the receiver chose, and the type codes + * are what a ledger claim is about. Without them a directional prediction + * cannot be scored at all, which is why they are required here even though + * the Baby never sees them. + */ +export interface ObservedProbeOutcome { + readonly turn: number; + readonly probeId: string; + /** The candidate the receiver selected under the probe. */ + readonly receiverActionRef: string; + /** Candidate refs in the receiver's own order. */ + readonly candidateRefs: readonly string[]; + /** Referent type code per candidate ref, same order. */ + readonly candidateTypeCodes: readonly number[]; + /** Selection on an unprobed replay of the same observation, when run. */ + readonly unprobedBaselineActionRef?: string; + /** Task outcome of the probed turn (reported, never used for scoring). */ + readonly success: boolean; +} + +export interface ScoredProbe { + readonly probeId: string; + readonly turn: number; + readonly kind: ProbeKind; + readonly hypothesisRef: string; + readonly predictedCandidateShift: PredictedCandidateShift; + readonly probeHash: string; + /** Type code the receiver actually selected; `null` when unresolvable. */ + readonly observedTypeCode: number | null; + /** Type code the unprobed baseline selected, when a baseline was replayed. */ + readonly baselineTypeCode: number | null; + readonly scored: boolean; + readonly scoringBasis: ProbeScoringBasis | null; + readonly agreesWithLedgerPrediction: boolean; + readonly unscoredReasonCode: ProbeUnscoredReasonCode | null; + readonly success: boolean; +} + +export interface AgreementBlock { + readonly probes: number; + readonly scored: number; + readonly agreements: number; + /** Agreements over scored probes; `NaN` when nothing was scored. */ + readonly agreementRate: number; + /** Wilson interval over the scored probes. Descriptive only (§D.7). */ + readonly wilsonDescriptive: WilsonInterval | null; + readonly pooledDescriptive: ProportionSummary | null; +} + +export interface InterventionSuiteInput { + readonly schedule: ProbeSchedule; + readonly observed: readonly ObservedProbeOutcome[]; + /** `RunConfig.interventionSuiteThreshold` (SPEC §18 default 0.70). */ + readonly threshold?: number; + /** E03 chance baseline for the confirmatory comparison. */ + readonly chanceRate?: number; + /** α per primary hypothesis (§15.3 default 0.05). */ + readonly alpha?: number; + /** + * Seed-level tallies for the confirmatory analyses, one entry per + * independent seed of the condition — this run's own tally is one of them. + * Omit for a single-run readout; the confirmatory block is then absent. + */ + readonly seedLevel?: readonly SeedAgreementCount[]; + /** §15.3 seed floor for the claim this analysis backs. */ + readonly minimumSeeds?: number; + /** Bootstrap iterations for the beta-binomial cluster bootstrap. */ + readonly bootstrapIterations?: number; +} + +export interface InterventionSuiteResult { + readonly analysisVersion: string; + readonly scheduleVersion: string; + readonly seed: string; + readonly probes: readonly ScoredProbe[]; + /** Scored-probe agreement: the §15.2 descriptive readiness readout. */ + readonly descriptiveAgreement: AgreementBlock; + /** Same rate with every unscored probe counted as a non-agreement. */ + readonly conservativeAgreementRate: number; + readonly perKind: Readonly>; + readonly unscoredByReasonCode: Readonly< + Partial> + >; + readonly threshold: number; + /** Mechanical comparison of `descriptiveAgreement.agreementRate` to `threshold`. */ + readonly meetsDescriptiveReadinessThreshold: boolean; + /** SPEC §15.2 verbatim: the threshold is not an inferential test. */ + readonly readinessThresholdKind: 'descriptive-not-inferential'; + readonly confirmatory: { + readonly alpha: number; + readonly chanceRate: number; + readonly seedLevelEquivalent: SeedLevelAgreementResult; + readonly hierarchicalBernoulli: BetaBinomialAgreementResult; + } | null; + /** Probes the schedule planned but for which no observation was supplied. */ + readonly missingObservations: readonly string[]; + /** Observations that name a probe the schedule does not contain. */ + readonly unexpectedObservations: readonly string[]; + /** What this readout is: software readiness, never a research finding. */ + readonly claimBoundary: 'software-readiness-only'; +} + +function typeCodeOf( + ref: string | undefined, + observed: ObservedProbeOutcome, +): number | null { + if (ref === undefined) { + return null; + } + const index = observed.candidateRefs.indexOf(ref); + if (index < 0) { + return null; + } + const typeCode = observed.candidateTypeCodes[index]; + return typeCode === undefined ? null : typeCode; +} + +/** Outcome of applying the pre-registered scoring rules to one probe. */ +export interface ProbeScoring { + readonly scored: boolean; + readonly agrees: boolean; + readonly basis: ProbeScoringBasis | null; + readonly reason: ProbeUnscoredReasonCode | null; +} + +const UNSCORED = (reason: ProbeUnscoredReasonCode): ProbeScoring => ({ + scored: false, + agrees: false, + basis: null, + reason, +}); + +/** + * Apply the pre-registered scoring rules of this module's doc comment to one + * probe. Exported so the offline scrambling control re-scores the identical + * rules under a shuffled meaning map (`scrambling.ts`). + */ +export function scoreProbeAgreement(input: { + readonly shift: PredictedCandidateShift; + readonly observed: ObservedProbeOutcome; +}): ProbeScoring { + const { shift, observed } = input; + if (observed.candidateRefs.length !== observed.candidateTypeCodes.length) { + throw new InterventionError( + 'invalid-input', + `probe ${observed.probeId}: candidateRefs and candidateTypeCodes must have equal length`, + ); + } + const observedTypeCode = typeCodeOf(observed.receiverActionRef, observed); + if (observedTypeCode === null) { + return UNSCORED('action-not-among-candidates'); + } + const baselineTypeCode = typeCodeOf( + observed.unprobedBaselineActionRef, + observed, + ); + const hasBaseline = + observed.unprobedBaselineActionRef !== undefined && baselineTypeCode !== null; + + if (shift.kind === 'toward-type-code') { + const target = shift.towardTypeCode; + if (target === null) { + return UNSCORED('no-predicted-type-code'); + } + if (!observed.candidateTypeCodes.includes(target)) { + return UNSCORED('predicted-type-code-not-among-candidates'); + } + if (hasBaseline && baselineTypeCode === target) { + return UNSCORED('baseline-already-at-predicted-type-code'); + } + return { + scored: true, + agrees: observedTypeCode === target, + basis: hasBaseline ? 'baseline-contrast' : 'probed-only', + reason: null, + }; + } + + const away = shift.awayFromTypeCode; + if (away === null) { + return UNSCORED('no-predicted-type-code'); + } + if (!observed.candidateTypeCodes.includes(away)) { + return UNSCORED('predicted-type-code-not-among-candidates'); + } + if (hasBaseline && baselineTypeCode !== away) { + return UNSCORED('baseline-not-at-predicted-type-code'); + } + return { + scored: true, + agrees: observedTypeCode !== away, + basis: hasBaseline ? 'baseline-contrast' : 'probed-only', + reason: null, + }; +} + +function agreementBlock( + probes: readonly ScoredProbe[], + confidence: number, +): AgreementBlock { + const scored = probes.filter((probe) => probe.scored); + const agreements = scored.filter( + (probe) => probe.agreesWithLedgerPrediction, + ).length; + return { + probes: probes.length, + scored: scored.length, + agreements, + agreementRate: scored.length === 0 ? NaN : agreements / scored.length, + wilsonDescriptive: + scored.length === 0 + ? null + : wilsonInterval(agreements, scored.length, confidence), + pooledDescriptive: + scored.length === 0 ? null : proportion(agreements, scored.length), + }; +} + +/** + * Score a run's observed probes against the schedule that planned them, and + * attach the seed-clustered confirmatory analyses when seed-level tallies are + * supplied. + * + * Every probe in `schedule.probes` must have at most one observation, matched + * by `probeId`; mismatches are reported in `missingObservations` / + * `unexpectedObservations` rather than throwing, because a run that forfeited + * a turn (SPEC §8.3) legitimately has a planned probe with no delivery. + */ +export function evaluateInterventionSuite( + input: InterventionSuiteInput, +): InterventionSuiteResult { + const threshold = input.threshold ?? DEFAULT_INTERVENTION_SUITE_THRESHOLD; + if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) { + throw new InterventionError( + 'invalid-plan', + 'threshold must be within [0, 1]', + ); + } + const chanceRate = input.chanceRate ?? DEFAULT_CHANCE_RATE; + if (!Number.isFinite(chanceRate) || chanceRate < 0 || chanceRate > 1) { + throw new InterventionError( + 'invalid-input', + 'chanceRate must be within [0, 1]', + ); + } + const alpha = input.alpha ?? 0.05; + const confidence = 0.95; + assertNonEmptyString(input.schedule.seed, 'schedule.seed'); + + const observedById = new Map(); + for (const observation of input.observed) { + if (observedById.has(observation.probeId)) { + throw new InterventionError( + 'schedule-mismatch', + `duplicate observation for probe ${observation.probeId}`, + ); + } + observedById.set(observation.probeId, observation); + } + + const plannedById = new Map( + input.schedule.probes.map((probe) => [probe.probe.probeId, probe]), + ); + const missingObservations: string[] = []; + const probes: ScoredProbe[] = []; + + for (const planned of input.schedule.probes) { + const observation = observedById.get(planned.probe.probeId); + if (observation === undefined) { + missingObservations.push(planned.probe.probeId); + continue; + } + if (observation.turn !== planned.turn) { + throw new InterventionError( + 'schedule-mismatch', + `probe ${planned.probe.probeId} was planned for turn ${planned.turn} but observed at turn ${observation.turn}`, + ); + } + const shift = planned.predictedDirection.predictedCandidateShift; + const scoring = scoreProbeAgreement({ shift, observed: observation }); + probes.push({ + probeId: planned.probe.probeId, + turn: planned.turn, + kind: planned.kind, + hypothesisRef: planned.predictedDirection.hypothesisRef, + predictedCandidateShift: shift, + probeHash: planned.probeHash, + observedTypeCode: typeCodeOf(observation.receiverActionRef, observation), + baselineTypeCode: typeCodeOf( + observation.unprobedBaselineActionRef, + observation, + ), + scored: scoring.scored, + scoringBasis: scoring.basis, + agreesWithLedgerPrediction: scoring.agrees, + unscoredReasonCode: scoring.reason, + success: observation.success, + }); + } + + const unexpectedObservations = input.observed + .map((observation) => observation.probeId) + .filter((probeId) => !plannedById.has(probeId)); + + const overall = agreementBlock(probes, confidence); + const unscoredByReasonCode: Partial< + Record + > = {}; + for (const probe of probes) { + if (probe.unscoredReasonCode !== null) { + unscoredByReasonCode[probe.unscoredReasonCode] = + (unscoredByReasonCode[probe.unscoredReasonCode] ?? 0) + 1; + } + } + + const confirmatory = + input.seedLevel === undefined || input.seedLevel.length === 0 + ? null + : { + alpha, + chanceRate, + seedLevelEquivalent: seedLevelAgreement({ + seeds: input.seedLevel, + chanceRate, + alpha, + confidence, + ...(input.minimumSeeds === undefined + ? {} + : { minimumSeeds: input.minimumSeeds }), + bootstrap: { seed: `${input.schedule.seed}/seed-level`, iterations: 2_000 }, + }), + hierarchicalBernoulli: betaBinomialAgreement({ + seeds: input.seedLevel, + chanceRate, + alpha, + confidence, + ...(input.minimumSeeds === undefined + ? {} + : { minimumSeeds: input.minimumSeeds }), + bootstrap: { + seed: `${input.schedule.seed}/beta-binomial`, + ...(input.bootstrapIterations === undefined + ? {} + : { iterations: input.bootstrapIterations }), + }, + }), + }; + + return { + analysisVersion: INTERVENTION_SUITE_ANALYSIS_VERSION, + scheduleVersion: input.schedule.scheduleVersion, + seed: input.schedule.seed, + probes, + descriptiveAgreement: overall, + conservativeAgreementRate: + probes.length === 0 ? NaN : overall.agreements / probes.length, + perKind: { + ablation: agreementBlock( + probes.filter((probe) => probe.kind === 'ablation'), + confidence, + ), + substitution: agreementBlock( + probes.filter((probe) => probe.kind === 'substitution'), + confidence, + ), + }, + unscoredByReasonCode, + threshold, + meetsDescriptiveReadinessThreshold: + overall.scored > 0 && overall.agreementRate >= threshold, + readinessThresholdKind: 'descriptive-not-inferential', + confirmatory, + missingObservations, + unexpectedObservations, + claimBoundary: 'software-readiness-only', + }; +} + +/** Package a suite result as an `intervention-suite` bundle attachment. */ +export function interventionSuiteAttachment( + result: InterventionSuiteResult, +): AttachmentFile { + return buildAttachment({ + kind: 'intervention-suite', + analysisVersion: result.analysisVersion, + value: result, + }); +} diff --git a/packages/interventions/tsconfig.json b/packages/interventions/tsconfig.json new file mode 100644 index 0000000..8df0cd9 --- /dev/null +++ b/packages/interventions/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../types" + }, + { + "path": "../hashing" + }, + { + "path": "../analysis" + }, + { + "path": "../learners" + } + ] +} diff --git a/packages/isolation/__tests__/.gitkeep b/packages/isolation/__tests__/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/isolation/__tests__/isolation.test.ts b/packages/isolation/__tests__/isolation.test.ts new file mode 100644 index 0000000..a02b170 --- /dev/null +++ b/packages/isolation/__tests__/isolation.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { runLearnerAdapterConformance } from '@ald/learners'; + +import { + FrameAssembler, + FrameConnection, + MIN_FRAME_SIZE, + createIsolatedAdapterFactory, + createLoopbackChannelPair, + decodeFrameLine, + encodeFrames, +} from '../src/index.js'; + +describe('fixed-size canonical framing', () => { + it('round-trips a chunked payload through equally sized frames', () => { + const payload = { alpha: 'x'.repeat(2_500), count: 7 }; + const lines = encodeFrames(payload, { + kind: 'req', + id: 'r-000000000001', + frameSize: MIN_FRAME_SIZE, + }); + const assembler = new FrameAssembler(); + let completed: ReturnType; + + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(Buffer.byteLength(line, 'utf8')).toBe(MIN_FRAME_SIZE); + completed = assembler.push( + decodeFrameLine(line.slice(0, -1), MIN_FRAME_SIZE), + ); + } + + expect(completed).toEqual({ + kind: 'req', + id: 'r-000000000001', + payload, + }); + }); + + it('rejects live object references instead of silently serializing them', () => { + expect(() => + encodeFrames( + { sharedState: new Map([['secret', 1]]) }, + { kind: 'req', id: 'r-000000000001' }, + ), + ).toThrow(); + }); + + it('uses the same response envelope size for success and failure', async () => { + const pair = createLoopbackChannelPair(); + const runtime = new FrameConnection({ + channel: pair.runtime, + originator: 'r', + }); + const host = new FrameConnection({ + channel: pair.host, + originator: 'h', + handler: async (method) => { + if (method === 'fail') { + throw new Error('private adapter detail'); + } + return { accepted: true }; + }, + }); + + await expect(runtime.request('succeed', {})).resolves.toEqual({ accepted: true }); + await expect(runtime.request('fail', {})).rejects.toMatchObject({ + code: 'host-error', + hostCode: 'internal', + }); + + expect(pair.host.written).toHaveLength(2); + expect(pair.host.written.map((line) => Buffer.byteLength(line, 'utf8'))).toEqual([ + 8192, 8192, + ]); + expect(pair.host.written.join('')).not.toContain('private adapter detail'); + runtime.close(); + host.close(); + }); +}); + +describe('separate-process learner host', () => { + it('runs the ordinary learner contract in two distinct child processes', async () => { + const factory = createIsolatedAdapterFactory({ + track: 'no-learning', + timing: 'immediate', + deadlineMs: 5_000, + process: { stderr: 'count' }, + }); + + try { + const result = await runLearnerAdapterConformance(factory, { + episodes: 2, + seed: 'isolation-conformance', + }); + const processIds = factory.adapters.map((adapter) => adapter.isolation.processId); + + expect(result.proposals).toBe(4); + expect(processIds).toHaveLength(2); + expect(processIds.every((id) => id !== undefined && id !== process.pid)).toBe(true); + expect(new Set(processIds).size).toBe(2); + + const probe = await factory.adapters[0]?.probeIsolation({ + readPath: new URL('../../../SPECIFICATION.md', import.meta.url).pathname, + }); + expect(probe?.permissionModel).toBe(true); + expect(probe?.fsRead).toBe('denied'); + expect(probe?.clipboard).toBe('denied'); + expect(probe?.childProcess).toBe('denied'); + expect(probe?.worker).toBe('denied'); + } finally { + await factory.dispose(); + } + }, 20_000); +}); diff --git a/packages/isolation/bin/ald-learner-host.js b/packages/isolation/bin/ald-learner-host.js new file mode 100755 index 0000000..5bd558d --- /dev/null +++ b/packages/isolation/bin/ald-learner-host.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** + * Learner host entry point (SPEC §4.1 item 5, §5.2; ALD-055). + * + * Launched by `@ald/isolation`'s process transport under Node's permission + * model, and by `deploy/mode-r/Dockerfile` as a container's TCP server. It + * deliberately does nothing but hand argv to `runLearnerHostCli`: every read + * this process is allowed to make is the module graph below, so the file must + * stay a one-liner that adds no capability of its own. + * + * It loads the built package (`dist/`), not the TypeScript sources: a child + * running under `--permission` cannot be given a loader, so `tsc --build + * packages/isolation` is a prerequisite for the process transport. + */ +import { runLearnerHostCli } from '../dist/host.js'; + +await runLearnerHostCli(process.argv.slice(2)); diff --git a/packages/isolation/package.json b/packages/isolation/package.json new file mode 100644 index 0000000..f499387 --- /dev/null +++ b/packages/isolation/package.json @@ -0,0 +1,27 @@ +{ + "name": "@ald/isolation", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/learners": "*", + "@ald/scenario": "*", + "zod": "4.4.3" + }, + "bin": { + "ald-learner-host": "./bin/ald-learner-host.js" + } +} diff --git a/packages/isolation/src/channel.ts b/packages/isolation/src/channel.ts new file mode 100644 index 0000000..492510a --- /dev/null +++ b/packages/isolation/src/channel.ts @@ -0,0 +1,370 @@ +/** + * The duplex request/response multiplexer that runs over one isolation + * boundary (SPEC §4.1 item 5, §4.2, §12.7). + * + * The boundary is symmetric on purpose. The runtime calls the host to drive + * the `LearnerAdapter` methods of SPEC §6.2, and the host calls *back* for + * exactly one thing: `ledger.append`. That reverse direction is not a + * convenience — the Ledger Writer and the Evidence Store live in the Nursery + * trust zone (SPEC §4.1 item 7, §4.2 "Symbol Gateway ⇄ Evidence Writer"), so + * a Baby host must never hold a database handle, a signer, or a file path. It + * holds one RPC that the runtime binds to the authenticated Baby identity and + * the current turn, precisely as `PrivateLedgerClient` promises. + * + * Everything else about this file is bookkeeping in service of two + * properties: one frame sequence is written synchronously so messages never + * interleave, and a closed channel fails every in-flight call with a typed + * error instead of hanging a turn. + */ +import { z } from 'zod'; + +import { + HOST_ERROR_CODES, + IsolationError, + type HostErrorCode, + type IsolationErrorCode, +} from './errors.js'; +import { + DEFAULT_FRAME_SIZE, + DEFAULT_MAX_PAYLOAD_BYTES, + FrameAssembler, + LineReader, + assertValidFrameSize, + correlationId, + decodeFrameLine, + encodeFrames, + type FrameOriginator, +} from './frames.js'; +import { systemTimer, type IsolationTimer } from './timer.js'; + +/** One byte-stream to a learner host: a child's stdio, or a TCP socket. */ +export interface FrameChannel { + readonly kind: 'process' | 'tcp' | 'loopback'; + /** OS process id of the peer when the transport knows it. */ + readonly processId?: number; + /** Write a complete frame sequence. Implementations must not reorder. */ + write(lines: readonly string[]): void; + onLine(handler: (line: string) => void): void; + /** Called once when the peer or transport goes away. */ + onClose(handler: () => void): void; + /** + * Why the channel closed, when the transport can tell: a child that exited + * is `host-exited`, a dropped socket or a closed pipe is `host-unavailable`. + * Both are `adapter-crash` under SPEC §14.5; the distinction is for the + * operator's audit record, not for the handling path. + */ + closeCode?(): IsolationErrorCode; + close(): void; +} + +const RequestPayloadSchema = z.strictObject({ + m: z.string().min(1).max(64), + p: z.unknown().optional(), +}); + +const ResponsePayloadSchema = z.union([ + z.strictObject({ ok: z.literal(1), r: z.unknown().optional() }), + z.strictObject({ + error: z.strictObject({ code: z.enum(HOST_ERROR_CODES) }), + }), +]); + +/** Serves an inbound request. Rejecting with a `HostProtocolError` is typed. */ +export type FrameRequestHandler = ( + method: string, + params: unknown, +) => Promise; + +export interface FrameConnectionOptions { + channel: FrameChannel; + /** Namespace for this side's correlation ids. */ + originator: FrameOriginator; + frameSize?: number; + maxPayloadBytes?: number; + handler?: FrameRequestHandler; + timer?: IsolationTimer; + /** Maps a thrown handler error to the wire code. Default: `internal`. */ + errorCodeFor?: (error: unknown) => HostErrorCode; +} + +export interface ConnectionStats { + framesIn: number; + framesOut: number; + bytesIn: number; + bytesOut: number; + requestsOut: number; + requestsIn: number; + responsesDropped: number; + timeouts: number; + protocolViolations: number; +} + +interface PendingRequest { + method: string; + settle: (outcome: { ok: true; value: unknown } | { ok: false; error: unknown }) => void; +} + +/** + * Request/response correlation over one {@link FrameChannel}. + * + * A response for an id that is no longer pending (a late answer to a call + * that already hit its deadline) is counted and dropped: re-delivering it + * would put the caller's turn out of order, and reporting it would leak the + * host's timing after the fact. + */ +export class FrameConnection { + readonly stats: ConnectionStats = { + framesIn: 0, + framesOut: 0, + bytesIn: 0, + bytesOut: 0, + requestsOut: 0, + requestsIn: 0, + responsesDropped: 0, + timeouts: 0, + protocolViolations: 0, + }; + + private readonly channel: FrameChannel; + private readonly originator: FrameOriginator; + private readonly frameSize: number; + private readonly maxPayloadBytes: number; + private readonly handler: FrameRequestHandler | undefined; + private readonly timer: IsolationTimer; + private readonly errorCodeFor: (error: unknown) => HostErrorCode; + private readonly pending = new Map(); + private readonly assembler: FrameAssembler; + private readonly reader: LineReader; + private counter = 0; + private closed = false; + private closeReason: IsolationError | undefined; + + constructor(options: FrameConnectionOptions) { + this.channel = options.channel; + this.originator = options.originator; + this.frameSize = assertValidFrameSize(options.frameSize ?? DEFAULT_FRAME_SIZE); + this.maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES; + this.handler = options.handler; + this.timer = options.timer ?? systemTimer; + this.errorCodeFor = options.errorCodeFor ?? ((): HostErrorCode => 'internal'); + this.assembler = new FrameAssembler(this.maxPayloadBytes); + this.reader = new LineReader( + this.frameSize, + (line) => { + this.acceptLine(line); + }, + (error) => { + this.fail(error); + }, + ); + + this.channel.onLine((chunk) => { + this.stats.bytesIn += Buffer.byteLength(chunk, 'utf8'); + this.reader.push(chunk); + }); + this.channel.onClose(() => { + this.fail(new IsolationError(this.channel.closeCode?.() ?? 'host-unavailable')); + }); + } + + get isClosed(): boolean { + return this.closed; + } + + /** + * Call `method` on the peer. + * + * `deadlineMs` is the SPEC §8.3 per-call budget. On expiry the call rejects + * with `deadline-exceeded` and the id is abandoned; the channel stays usable + * so the runtime's §14.5 retry can decide what to do next. + */ + async request( + method: string, + params: unknown, + deadlineMs?: number, + ): Promise { + if (this.closed) { + throw this.closeReason ?? new IsolationError('host-unavailable', { method }); + } + this.counter += 1; + const id = correlationId(this.originator, this.counter); + const lines = encodeFrames( + params === undefined ? { m: method } : { m: method, p: params }, + { + kind: 'req', + id, + frameSize: this.frameSize, + maxPayloadBytes: this.maxPayloadBytes, + method, + }, + ); + + const outcome = new Promise<{ ok: true; value: unknown } | { ok: false; error: unknown }>( + (resolve) => { + this.pending.set(id, { method, settle: resolve }); + }, + ); + + try { + this.writeLines(lines); + } catch (error) { + this.pending.delete(id); + throw error; + } + this.stats.requestsOut += 1; + + const budget = + deadlineMs === undefined || !Number.isFinite(deadlineMs) || deadlineMs <= 0 + ? undefined + : this.timer.delay(deadlineMs); + const settled = + budget === undefined + ? await outcome + : await Promise.race([ + outcome, + budget.promise.then( + () => ({ ok: false as const, error: 'deadline' as const }), + ), + ]); + budget?.cancel(); + + if (settled.ok) { + return settled.value as T; + } + if (settled.error === 'deadline') { + this.pending.delete(id); + this.stats.timeouts += 1; + throw new IsolationError('deadline-exceeded', { method }); + } + throw settled.error; + } + + /** Reject every in-flight call and close the channel. */ + close(reason?: IsolationError): void { + this.fail(reason ?? new IsolationError('host-unavailable')); + this.channel.close(); + } + + private writeLines(lines: readonly string[]): void { + this.channel.write(lines); + this.stats.framesOut += lines.length; + for (const line of lines) { + this.stats.bytesOut += Buffer.byteLength(line, 'utf8'); + } + } + + private fail(error: IsolationError): void { + if (this.closed) { + return; + } + this.closed = true; + this.closeReason = error; + const inflight = [...this.pending.values()]; + this.pending.clear(); + for (const entry of inflight) { + entry.settle({ + ok: false, + error: new IsolationError(error.code, { + method: entry.method, + cause: error, + }), + }); + } + } + + private acceptLine(line: string): void { + this.stats.framesIn += 1; + let message: { kind: 'req' | 'res'; id: string; payload: unknown } | undefined; + try { + message = this.assembler.push(decodeFrameLine(line, this.frameSize)); + } catch (error) { + this.stats.protocolViolations += 1; + this.fail( + error instanceof IsolationError + ? error + : new IsolationError('protocol-violation', { cause: error }), + ); + return; + } + if (message === undefined) { + return; + } + if (message.kind === 'req') { + void this.serve(message.id, message.payload); + return; + } + this.resolve(message.id, message.payload); + } + + private resolve(id: string, payload: unknown): void { + const entry = this.pending.get(id); + const parsed = ResponsePayloadSchema.safeParse(payload); + if (!parsed.success) { + this.stats.protocolViolations += 1; + this.fail(new IsolationError('protocol-violation', { cause: parsed.error })); + return; + } + if (entry === undefined) { + this.stats.responsesDropped += 1; + return; + } + this.pending.delete(id); + if ('error' in parsed.data) { + entry.settle({ + ok: false, + error: new IsolationError('host-error', { + method: entry.method, + hostCode: parsed.data.error.code, + }), + }); + return; + } + entry.settle({ ok: true, value: parsed.data.r }); + } + + private async serve(id: string, payload: unknown): Promise { + this.stats.requestsIn += 1; + let response: unknown; + const parsed = RequestPayloadSchema.safeParse(payload); + if (!parsed.success) { + response = { error: { code: 'invalid-frame' satisfies HostErrorCode } }; + } else if (this.handler === undefined) { + response = { error: { code: 'unknown-method' satisfies HostErrorCode } }; + } else { + try { + const value = await this.handler(parsed.data.m, parsed.data.p); + response = value === undefined ? { ok: 1 } : { ok: 1, r: value }; + } catch (error) { + response = { error: { code: this.errorCodeFor(error) } }; + } + } + + if (this.closed) { + return; + } + try { + this.writeLines( + encodeFrames(response, { + kind: 'res', + id, + frameSize: this.frameSize, + maxPayloadBytes: this.maxPayloadBytes, + }), + ); + } catch { + // The result itself could not be represented (a `Map`, a function, a + // class instance): answer with the text-free code instead, so the + // caller sees a typed refusal rather than a hang. + try { + this.writeLines( + encodeFrames( + { error: { code: 'non-serializable-result' satisfies HostErrorCode } }, + { kind: 'res', id, frameSize: this.frameSize }, + ), + ); + } catch { + this.fail(new IsolationError('protocol-violation')); + } + } + } +} diff --git a/packages/isolation/src/errors.ts b/packages/isolation/src/errors.ts new file mode 100644 index 0000000..af48f2e --- /dev/null +++ b/packages/isolation/src/errors.ts @@ -0,0 +1,193 @@ +/** + * Error taxonomy for the Mode R isolation boundary (SPEC §5.2, §10.3, §14.5). + * + * Two closed enums live here and they are deliberately different: + * + * - {@link HOST_ERROR_CODES} is the *wire* enum. It is the only thing a + * learner host process ever says about a failure: an error response frame + * carries exactly `{ error: { code } }` — no message, no stack, no payload, + * no adapter text (SPEC §10.3 "normalized message envelope size and error + * behavior"). Adapter error text is withheld on purpose: it is + * model-influenced content on a path the runtime records, and a + * distinguishable error body is the error-message side channel §10.3 + * enumerates. + * - {@link ISOLATION_ERROR_CODES} is the *runtime-side* enum carried by + * {@link IsolationError}. It never crosses the boundary and never reaches a + * Baby context; it exists so the Nursery runtime can classify a failure + * without string matching, exactly as `@ald/orchestrator`'s + * `AdapterFailureError` does for in-process adapters. + * + * {@link ISOLATION_FAILURE_CLASSES} is the mapping the integrator needs: it + * says, per code, which SPEC §14.5 handling path the failure belongs to. This + * package deliberately does not import `@ald/orchestrator` (the dependency + * runs the other way), so the mapping is data the runtime reads. + */ + +/** Failure codes reported by a learner host on the wire. Text-free by design. */ +export const HOST_ERROR_CODES = [ + /** A frame did not decode, was not canonical JSON, or broke the framing. */ + 'invalid-frame', + /** Method parameters failed their zod schema (unknown keys included). */ + 'invalid-params', + /** No such method in this protocol version. */ + 'unknown-method', + /** A turn method arrived before `init`. */ + 'not-initialized', + /** A second `init` arrived for the same host. */ + 'already-initialized', + /** The hosted adapter does not implement this optional method (SPEC §6.2). */ + 'unsupported-method', + /** The hosted adapter threw. Its message is withheld (§10.3). */ + 'adapter-error', + /** An inbound frame exceeded the negotiated frame size. */ + 'frame-too-large', + /** A payload exceeded the configured maximum. */ + 'payload-too-large', + /** The adapter returned a value canonical JSON cannot represent. */ + 'non-serializable-result', + /** Anything else. Never carries detail. */ + 'internal', +] as const; + +export type HostErrorCode = (typeof HOST_ERROR_CODES)[number]; + +const HOST_ERROR_CODE_SET: ReadonlySet = new Set(HOST_ERROR_CODES); + +export function isHostErrorCode(value: unknown): value is HostErrorCode { + return typeof value === 'string' && HOST_ERROR_CODE_SET.has(value); +} + +/** Runtime-side classification of an isolation failure. */ +export const ISOLATION_ERROR_CODES = [ + /** The factory/transport was configured in a way that cannot work. */ + 'configuration', + /** `child_process.spawn` failed or the host exited before the handshake. */ + 'spawn-failed', + /** A container/TCP host could not be reached. */ + 'connect-failed', + /** The channel is closed: the host exited, or the socket dropped. */ + 'host-unavailable', + /** The host process exited while a call was in flight. */ + 'host-exited', + /** No response inside the per-call deadline (SPEC §8.3). */ + 'deadline-exceeded', + /** The peer sent something this protocol does not allow. */ + 'protocol-violation', + /** An inbound frame was longer than the negotiated frame size. */ + 'frame-too-large', + /** An outbound payload exceeded the configured maximum. */ + 'payload-too-large', + /** + * The value handed to the transport cannot be represented as canonical + * JSON — a function, `Map`, `Set`, class instance, or circular graph. This + * is the code an attempt to smuggle a live object reference across the + * boundary produces (ALD-055 acceptance criterion 2). + */ + 'non-serializable-payload', + /** The host answered with a typed `{ error: { code } }` frame. */ + 'host-error', + /** A turn method was called before `init` completed. */ + 'not-initialized', + /** `init` was called twice on one adapter. */ + 'already-initialized', + /** The adapter was disposed; it cannot be used again. */ + 'disposed', +] as const; + +export type IsolationErrorCode = (typeof ISOLATION_ERROR_CODES)[number]; + +/** + * How the Nursery runtime should treat each code under SPEC §14.5. + * + * - `adapter-crash` — the §14.5 bullet 4 path: retry within the run's retry + * budget, then forfeit the turn, audit, and pause. `@ald/orchestrator` + * already implements exactly this for a thrown adapter error, so an + * `IsolationError` needs no new runtime path; it only needs to be wrapped in + * `AdapterFailureError` like any other adapter throw. + * - `adapter-timeout` — the §8.3 deadline path: the turn is forfeited with a + * `timeout` rejection rather than retried with unbounded latency. A retry + * here would itself be the retry-count side channel §10.3 forbids. + * - `operator-error` — a misconfiguration or misuse that no retry can fix; the + * run must not start (or must abort) and the operator must act. + */ +export const ISOLATION_FAILURE_CLASSES: Readonly< + Record +> = Object.freeze({ + configuration: 'operator-error', + 'spawn-failed': 'operator-error', + 'connect-failed': 'operator-error', + 'host-unavailable': 'adapter-crash', + 'host-exited': 'adapter-crash', + 'deadline-exceeded': 'adapter-timeout', + 'protocol-violation': 'adapter-crash', + 'frame-too-large': 'adapter-crash', + 'payload-too-large': 'adapter-crash', + 'non-serializable-payload': 'adapter-crash', + 'host-error': 'adapter-crash', + 'not-initialized': 'operator-error', + 'already-initialized': 'operator-error', + disposed: 'operator-error', +}); + +export interface IsolationErrorContext { + /** Protocol method the failure happened in, when there was one. */ + method?: string; + /** The host's own wire code, when the failure was a `host-error`. */ + hostCode?: HostErrorCode; + /** Underlying cause, kept for the operator's own logging. Never reported. */ + cause?: unknown; +} + +/** + * A failure of the isolation boundary itself. + * + * The message is assembled from the code, the method name, and the host's + * wire code only. It never interpolates the peer's error text, a payload, a + * path, or any model-influenced string, so recording it verbatim in the audit + * stream cannot become a content channel (SPEC §10.3, §14.2). + */ +export class IsolationError extends Error { + readonly code: IsolationErrorCode; + readonly method?: string; + readonly hostCode?: HostErrorCode; + readonly failureClass: 'adapter-crash' | 'adapter-timeout' | 'operator-error'; + + constructor(code: IsolationErrorCode, context: IsolationErrorContext = {}) { + const method = context.method === undefined ? '' : ` in ${context.method}()`; + const hostCode = + context.hostCode === undefined ? '' : ` (host code ${context.hostCode})`; + super(`isolation boundary failed${method}: ${code}${hostCode}`); + this.name = new.target.name; + this.code = code; + this.failureClass = ISOLATION_FAILURE_CLASSES[code]; + if (context.method !== undefined) { + this.method = context.method; + } + if (context.hostCode !== undefined) { + this.hostCode = context.hostCode; + } + if (context.cause !== undefined) { + this.cause = context.cause; + } + } +} + +export function isIsolationError(value: unknown): value is IsolationError { + return value instanceof IsolationError; +} + +/** + * Host-side failure. Only its {@link HostErrorCode} is ever serialized; the + * class exists so host code can throw something typed without inventing a + * message that would then have to be suppressed at the boundary. + */ +export class HostProtocolError extends Error { + constructor(readonly code: HostErrorCode) { + super(`learner host refused the request: ${code}`); + this.name = new.target.name; + } +} + +export function isHostProtocolError(value: unknown): value is HostProtocolError { + return value instanceof HostProtocolError; +} diff --git a/packages/isolation/src/factory.ts b/packages/isolation/src/factory.ts new file mode 100644 index 0000000..efafbb3 --- /dev/null +++ b/packages/isolation/src/factory.ts @@ -0,0 +1,203 @@ +/** + * `LearnerAdapterFactory` for an isolated learner (SPEC §5.2, §5.3; ALD-053 + * support, ALD-055). + * + * The Nursery runtime already has the seam this plugs into: its + * `adapterFactoryFor(config, role)` option decides how each Baby's adapter is + * built, and `LearnerAdapterFactory.isolation` is what a Mode R run is checked + * against. So Mode R needs no new runtime concept — it needs a factory whose + * `isolation` is `separate-process` or `separate-container` and whose + * `create()` returns a proxy. Both are here. + * + * One factory is one Baby: a container factory carries one endpoint, and the + * runtime asks for one factory per role. `dispose()` exists because a factory + * that spawns processes owns them, and a test or an aborted run must be able + * to reclaim them deterministically. + */ +import { + type LearnerAdapterFactory, + type LearnerTrackId, +} from '@ald/types'; + +import { IsolationError } from './errors.js'; +import { + ProcessHostChannel, + type ProcessTransportOptions, +} from './process-transport.js'; +import { + RemoteLearnerAdapter, + type HostTransport, + type RemoteLearnerAdapterOptions, +} from './remote-adapter.js'; +import { + connectTcpFrameChannel, + type TcpConnectOptions, + type TcpFrameChannel, +} from './tcp-transport.js'; +import type { FrameChannel } from './channel.js'; +import { canonicalPayload } from './frames.js'; + +/** A learner host in its own OS process, spawned on construction. */ +export class ProcessHostTransport implements HostTransport { + readonly boundary = 'separate-process' as const; + + readonly hostLabel: string | undefined; + + private readonly channel: ProcessHostChannel; + + constructor(options: ProcessTransportOptions = {}) { + this.hostLabel = options.hostLabel; + // Spawned eagerly so `IsolationDescriptor.processId` is available before + // `init` — ALD-055 criterion 1 compares process ids, and a descriptor + // that only appears after a handshake is harder to audit. + this.channel = new ProcessHostChannel(options); + } + + current(): FrameChannel { + return this.channel; + } + + open(): Promise { + return Promise.resolve(this.channel); + } + + /** Counters (stderr bytes/lines, exit code); never stderr content. */ + get diagnostics(): ProcessHostChannel['diagnostics'] { + return this.channel.diagnostics; + } + + get alive(): boolean { + return this.channel.alive; + } + + async terminate(): Promise { + this.channel.kill(); + await this.channel.waitForExit(); + this.channel.close(); + } +} + +/** A learner host in another container, reached over the internal network. */ +export class ContainerHostTransport implements HostTransport { + readonly boundary = 'separate-container' as const; + + readonly hostLabel: string | undefined; + + private channel: TcpFrameChannel | undefined; + + constructor(private readonly options: TcpConnectOptions & { hostLabel?: string }) { + this.hostLabel = options.hostLabel ?? `${options.host}:${String(options.port)}`; + } + + current(): FrameChannel | undefined { + return this.channel; + } + + async open(): Promise { + this.channel ??= await connectTcpFrameChannel(this.options); + return this.channel; + } + + async terminate(): Promise { + this.channel?.close(); + return Promise.resolve(); + } +} + +export interface IsolatedAdapterFactoryOptions + extends Omit { + track: LearnerTrackId; + /** `process` spawns a child; `container` connects to one (SPEC §5.3). */ + transport?: 'process' | 'container'; + /** Process transport knobs (permission model, entry, env, cwd, stderr). */ + process?: ProcessTransportOptions; + /** Container endpoint. Required when `transport` is `container`. */ + endpoint?: TcpConnectOptions & { hostLabel?: string }; +} + +export interface IsolatedAdapterFactory extends LearnerAdapterFactory { + readonly isolation: 'separate-process' | 'separate-container'; + create(): RemoteLearnerAdapter; + /** Every adapter this factory has created, in creation order. */ + readonly adapters: readonly RemoteLearnerAdapter[]; + /** Dispose every adapter (and therefore every host) this factory created. */ + dispose(): Promise; +} + +/** + * Build an isolated factory for one Baby. + * + * `learnerOptions` must be JSON-safe: it is serialized into the host's `init` + * frame so the host can call `createLearnerAdapterFactory(track, options)` + * itself. That is checked here rather than at the first call, because a track + * whose options carry a live object (a model client, a file handle) cannot be + * configured *by value* across a boundary — such a host has to build the + * object from its own environment, and finding that out at run start is much + * better than finding it out on turn one. + */ +export function createIsolatedAdapterFactory( + options: IsolatedAdapterFactoryOptions, +): IsolatedAdapterFactory { + const transportKind = options.transport ?? 'process'; + if (transportKind === 'container' && options.endpoint === undefined) { + throw new IsolationError('configuration'); + } + if (options.learnerOptions !== undefined) { + canonicalPayload(options.learnerOptions, 'learnerOptions'); + } + + const adapters: RemoteLearnerAdapter[] = []; + const isolation = + transportKind === 'process' + ? ('separate-process' as const) + : ('separate-container' as const); + + return { + track: options.track, + isolation, + adapters, + create(): RemoteLearnerAdapter { + const transport: HostTransport = + transportKind === 'process' + ? new ProcessHostTransport({ + ...options.process, + // The host is pinned to this track unless the caller passed its + // own arguments: a host built for one Baby's track must refuse + // an `init` that names another (`invalid-params`). + hostArgs: options.process?.hostArgs ?? [`--track=${options.track}`], + }) + : new ContainerHostTransport( + options.endpoint as TcpConnectOptions & { hostLabel?: string }, + ); + const adapter = new RemoteLearnerAdapter({ + track: options.track, + transport, + ...(options.learnerOptions === undefined + ? {} + : { learnerOptions: options.learnerOptions }), + ...(options.frameSize === undefined ? {} : { frameSize: options.frameSize }), + ...(options.maxPayloadBytes === undefined + ? {} + : { maxPayloadBytes: options.maxPayloadBytes }), + ...(options.timing === undefined ? {} : { timing: options.timing }), + ...(options.deadlineMs === undefined + ? {} + : { deadlineMs: options.deadlineMs }), + ...(options.timer === undefined ? {} : { timer: options.timer }), + }); + adapters.push(adapter); + return adapter; + }, + async dispose(): Promise { + const pending = adapters.map(async (adapter) => { + try { + await adapter.dispose(); + } catch { + // Teardown is best-effort: a host that already died is disposed. + } + }); + await Promise.all(pending); + adapters.length = 0; + }, + }; +} diff --git a/packages/isolation/src/frames.ts b/packages/isolation/src/frames.ts new file mode 100644 index 0000000..3a81692 --- /dev/null +++ b/packages/isolation/src/frames.ts @@ -0,0 +1,324 @@ +/** + * Fixed-size, newline-delimited canonical-JSON framing for the learner host + * protocol (SPEC §10.3: "normalized message envelope size and error + * behavior"; ALD-040 transport half). + * + * Every line written to the boundary is **exactly** `frameSize` bytes, + * newline included, whatever it carries: an `init` request, an accepted + * `act()` envelope, a rejected turn, or a text-free error. An observer of the + * pipe or socket therefore learns the frame *count* and nothing else about + * the content — and for every turn-path method the count is one frame in each + * direction, so an accepted and a failed turn are byte-identical in size. + * + * The payload of a frame is RFC 8785 canonical JSON, base64-encoded and split + * into fixed-capacity chunks. Base64 rather than raw JSON so that one frame's + * capacity is a constant that does not depend on how the payload happens to + * escape, and canonical JSON because: + * + * - `canonicalJson` from `@ald/hashing` *rejects* anything JSON cannot + * represent — a function, `Symbol`, `Map`, `Set`, `Buffer`, class instance, + * or circular graph — instead of silently dropping it. That rejection is + * the mechanism behind ALD-055 acceptance criterion 2: an attempt to smuggle + * a live object reference between two Baby hosts fails loudly at the + * boundary rather than arriving as `{}`; + * - `parseCanonicalJson` on receipt requires the peer to have sent the + * canonical form, so the framing cannot be used to hide bytes (trailing + * junk, alternative escapes, key reordering) inside an otherwise valid + * message. + * + * The frame envelope's own key set is fixed and its numeric fields are + * bounded, so the envelope overhead is a constant computed once from the + * worst case rather than measured per message. + */ +import { z } from 'zod'; +import { canonicalJson, parseCanonicalJson } from '@ald/hashing'; + +import { IsolationError } from './errors.js'; + +export const FRAME_VERSION = 1; + +/** Default wire frame size in bytes, newline included (8 KiB). */ +export const DEFAULT_FRAME_SIZE = 8192; + +/** Smallest frame that still leaves room for a useful chunk. */ +export const MIN_FRAME_SIZE = 1024; + +/** Largest frame this implementation will negotiate (1 MiB). */ +export const MAX_FRAME_SIZE = 1_048_576; + +/** Default cap on one payload before chunking (4 MiB of canonical JSON). */ +export const DEFAULT_MAX_PAYLOAD_BYTES = 4_194_304; + +/** Hard ceiling on the chunk count of one payload. */ +export const MAX_CHUNKS = 999_999; + +/** `r-` for the runtime side, `h-` for the host side. */ +export type FrameOriginator = 'r' | 'h'; + +export type FrameKind = 'req' | 'res'; + +const CORRELATION_ID = /^[rh]-[0-9]{12}$/u; + +/** + * Frame envelope. Short keys because every byte of overhead is a byte of + * payload capacity, and the shape is documented here rather than in the + * names: `v` version, `t` kind, `i` correlation id, `q` chunk index, `n` + * chunk count, `d` base64 chunk. + */ +export const FrameSchema = z.strictObject({ + v: z.literal(FRAME_VERSION), + t: z.enum(['req', 'res']), + i: z.string().regex(CORRELATION_ID), + q: z.number().int().min(0).max(MAX_CHUNKS - 1), + n: z.number().int().min(1).max(MAX_CHUNKS), + d: z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/u), +}); + +export type Frame = z.infer; + +/** + * Bytes an envelope costs at its worst case (six-digit chunk numbers, the + * longest correlation id), plus the trailing newline. Computed from the + * serializer rather than counted by hand so it cannot drift. + */ +export const FRAME_OVERHEAD_BYTES: number = + Buffer.byteLength( + canonicalJson({ + v: FRAME_VERSION, + t: 'req', + i: 'r-000000000000', + q: MAX_CHUNKS - 1, + n: MAX_CHUNKS, + d: '', + }), + 'utf8', + ) + 1; + +/** Base64 characters one frame of `frameSize` bytes can carry. */ +export function frameCapacity(frameSize: number): number { + return frameSize - FRAME_OVERHEAD_BYTES; +} + +export function assertValidFrameSize(frameSize: number): number { + if ( + !Number.isInteger(frameSize) || + frameSize < MIN_FRAME_SIZE || + frameSize > MAX_FRAME_SIZE + ) { + throw new IsolationError('configuration'); + } + return frameSize; +} + +/** Zero-padded correlation id, e.g. `r-000000000042`. */ +export function correlationId( + originator: FrameOriginator, + counter: number, +): string { + return `${originator}-${String(counter % 1_000_000_000_000).padStart(12, '0')}`; +} + +/** + * Canonical JSON for `value`, or {@link IsolationError} `non-serializable-payload`. + * + * The underlying rejection message from `@ald/hashing` names the offending + * *path and type* — key names on that path can be adapter-chosen, so the + * message is kept as `cause` for the operator and never interpolated into the + * error this throws (SPEC §10.3, §14.2). + */ +export function canonicalPayload(value: unknown, method?: string): string { + try { + return canonicalJson(value); + } catch (cause) { + throw new IsolationError('non-serializable-payload', { + ...(method === undefined ? {} : { method }), + cause, + }); + } +} + +export interface EncodeFramesOptions { + kind: FrameKind; + id: string; + frameSize?: number; + maxPayloadBytes?: number; + /** Method name, for error attribution only. Never written to the wire. */ + method?: string; +} + +/** + * Encode one payload as a complete, padded frame sequence. + * + * The whole sequence is returned at once and callers write it in a single + * synchronous burst, which is what lets the reader assume frames of one + * message are never interleaved with another message's frames. + */ +export function encodeFrames( + payload: unknown, + options: EncodeFramesOptions, +): string[] { + const frameSize = assertValidFrameSize(options.frameSize ?? DEFAULT_FRAME_SIZE); + const maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES; + const canonical = canonicalPayload(payload, options.method); + const bytes = Buffer.from(canonical, 'utf8'); + if (bytes.byteLength > maxPayloadBytes) { + throw new IsolationError('payload-too-large', { + ...(options.method === undefined ? {} : { method: options.method }), + }); + } + const base64 = bytes.toString('base64'); + const capacity = frameCapacity(frameSize); + const chunkCount = Math.max(1, Math.ceil(base64.length / capacity)); + if (chunkCount > MAX_CHUNKS) { + throw new IsolationError('payload-too-large', { + ...(options.method === undefined ? {} : { method: options.method }), + }); + } + + const lines: string[] = []; + for (let index = 0; index < chunkCount; index += 1) { + const frame: Frame = { + v: FRAME_VERSION, + t: options.kind, + i: options.id, + q: index, + n: chunkCount, + d: base64.slice(index * capacity, (index + 1) * capacity), + }; + const envelope = canonicalJson(frame); + const padding = frameSize - 1 - Buffer.byteLength(envelope, 'utf8'); + if (padding < 0) { + // Unreachable while FRAME_OVERHEAD_BYTES is the true worst case; kept + // as a hard stop so a future envelope change cannot silently emit a + // frame of a different size. + throw new IsolationError('frame-too-large', { + ...(options.method === undefined ? {} : { method: options.method }), + }); + } + lines.push(`${envelope}${' '.repeat(padding)}\n`); + } + return lines; +} + +/** + * Decode one received line (without its newline) into a frame. + * + * Padding is stripped, then the remainder must be *exactly* the canonical + * form of the frame envelope: a peer cannot hide bytes in whitespace, key + * order, or number formatting. + */ +export function decodeFrameLine(line: string, frameSize: number): Frame { + if (Buffer.byteLength(line, 'utf8') + 1 > frameSize) { + throw new IsolationError('frame-too-large'); + } + const trimmed = line.replace(/ +$/u, ''); + let parsed: unknown; + try { + parsed = parseCanonicalJson(trimmed); + } catch (cause) { + throw new IsolationError('protocol-violation', { cause }); + } + const frame = FrameSchema.safeParse(parsed); + if (!frame.success) { + throw new IsolationError('protocol-violation', { cause: frame.error }); + } + return frame.data; +} + +/** + * Reassembles chunked payloads for one direction of one channel. + * + * One message at a time: chunks must arrive in order, and a frame belonging + * to a different message while one is incomplete is a protocol violation + * rather than the start of an interleaved stream. Both sides write a whole + * frame sequence synchronously, so in-order, non-interleaved arrival is a + * property of the protocol and not an assumption about the transport. + */ +export class FrameAssembler { + private partial: + | { kind: FrameKind; id: string; count: number; next: number; chunks: string[] } + | undefined; + + constructor(private readonly maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES) {} + + /** Feed one frame; returns the decoded payload once a message completes. */ + push(frame: Frame): { kind: FrameKind; id: string; payload: unknown } | undefined { + if (this.partial === undefined) { + if (frame.q !== 0) { + throw new IsolationError('protocol-violation'); + } + this.partial = { + kind: frame.t, + id: frame.i, + count: frame.n, + next: 0, + chunks: [], + }; + } + const partial = this.partial; + if ( + frame.t !== partial.kind || + frame.i !== partial.id || + frame.n !== partial.count || + frame.q !== partial.next + ) { + this.partial = undefined; + throw new IsolationError('protocol-violation'); + } + partial.chunks.push(frame.d); + partial.next += 1; + if (partial.next < partial.count) { + return undefined; + } + + this.partial = undefined; + const bytes = Buffer.from(partial.chunks.join(''), 'base64'); + if (bytes.byteLength > this.maxPayloadBytes) { + throw new IsolationError('payload-too-large'); + } + let payload: unknown; + try { + payload = parseCanonicalJson(bytes.toString('utf8')); + } catch (cause) { + throw new IsolationError('protocol-violation', { cause }); + } + return { kind: partial.kind, id: partial.id, payload }; + } + + /** True while a multi-frame message is still incomplete. */ + get pending(): boolean { + return this.partial !== undefined; + } +} + +/** + * Splits an incoming byte stream into frame lines, refusing any line longer + * than the negotiated frame size before it can be buffered (a host that never + * writes a newline must not be able to grow the runtime's heap). + */ +export class LineReader { + private buffer = ''; + + constructor( + private readonly frameSize: number, + private readonly onLine: (line: string) => void, + private readonly onError: (error: IsolationError) => void, + ) {} + + push(chunk: string): void { + this.buffer += chunk; + for (;;) { + const newline = this.buffer.indexOf('\n'); + if (newline < 0) { + if (Buffer.byteLength(this.buffer, 'utf8') >= this.frameSize) { + this.buffer = ''; + this.onError(new IsolationError('frame-too-large')); + } + return; + } + const line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + this.onLine(line); + } + } +} diff --git a/packages/isolation/src/host.ts b/packages/isolation/src/host.ts new file mode 100644 index 0000000..0de2574 --- /dev/null +++ b/packages/isolation/src/host.ts @@ -0,0 +1,693 @@ +/** + * The learner host: the child process (or container) side of the boundary + * (SPEC §4.1 item 5, §5.2, §6.2, §6.3, §10.3; ALD-055, ALD-056). + * + * A host holds exactly one `LearnerAdapter` and one duplex frame channel. It + * has no Evidence Store handle, no signer, no scenario bundle, no run + * registry, and no address for the other Baby: its only outbound capability + * is the `ledger_append` reverse RPC, which the runtime binds to this Baby's + * authenticated identity and current turn (SPEC §4.2, §12.7). That is the + * whole point of ALD-056 — a training update can only read what this process + * itself accumulated, because nothing else is reachable from here. + * + * Nothing in this file writes to stdout except frames, and nothing writes a + * message to the wire: a failure becomes one {@link HostErrorCode} and the + * adapter's own text stays inside this process (SPEC §10.3). + */ +import { readFileSync } from 'node:fs'; +import { hostname } from 'node:os'; + +import { + HASH_DOMAINS, + type LearnerAdapter, + type LearnerAdapterFactory, + type LearnerContract, + type LearnerInitContext, + type LearnerProvenance, + type LearnerTrackId, + type LearnerVisibleRunConfig, + type LedgerEvent, + type LedgerEventDraft, + type OutcomeEvent, + type PrivateLedgerClient, + type Sha256Hash, +} from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { createLearnerAdapterFactory } from '@ald/learners'; + +import { FrameConnection, type FrameChannel } from './channel.js'; +import { + HostProtocolError, + isHostProtocolError, + type HostErrorCode, +} from './errors.js'; +import { DEFAULT_FRAME_SIZE } from './frames.js'; +import { + HOST_CAPABILITIES, + HOST_METHODS, + HOST_PARAM_SCHEMAS, + LedgerAppendResultSchema, + type HostCapability, + type HostMethod, + type InitParams, + type ProbeOutcome, +} from './protocol.js'; +import { createTcpFrameServer } from './tcp-transport.js'; + +const HOST_METHOD_SET: ReadonlySet = new Set(HOST_METHODS); + +/** Error text a withheld reward raises *inside the host*, never on the wire. */ +const WITHHELD_REWARD_MESSAGE = + 'outcome.reward was read under a reward-free learning signal'; + +export interface LearnerHostOptions { + channel: FrameChannel; + /** + * Boundary this host reports. A process host says `separate-process`; a + * container host says `separate-container` (SPEC §5.3). + */ + boundary?: 'separate-process' | 'separate-container'; + /** + * Pin the host to one track. When set, an `init` naming a different track + * is `invalid-params`: a container built for `baby-a`'s track cannot be + * repurposed mid-run. + */ + track?: LearnerTrackId; + frameSize?: number; + maxPayloadBytes?: number; + /** Deadline for the `ledger_append` reverse RPC. */ + ledgerDeadlineMs?: number; + /** Operator label (compose service name). Never Baby-visible. */ + hostLabel?: string; + /** Seam for tests: build the adapter factory without `@ald/learners`. */ + createFactory?: ( + track: LearnerTrackId, + options: Record, + ) => LearnerAdapterFactory; + /** Called after a `shutdown` request has been answered. */ + onShutdown?: () => void; +} + +/** Container id when this host can tell it is in one, else `undefined`. */ +export function detectContainerId(assumeContainer = false): string | undefined { + const declared = process.env.ALD_CONTAINER_ID; + if (declared !== undefined && declared.length > 0) { + return declared; + } + if (assumeContainer) { + const name = hostname(); + return name.length > 0 ? name : undefined; + } + try { + // Docker's default hostname is the short container id. Reading + // `/.dockerenv` is refused under the permission model, which is itself + // the answer: a process host reports no container id at all. + readFileSync('/.dockerenv'); + } catch { + return undefined; + } + const name = hostname(); + return name.length > 0 ? name : undefined; +} + +/** + * One hosted adapter behind the {@link FrameConnection}. + * + * Method dispatch validates parameters against `HOST_PARAM_SCHEMAS` *before* + * the adapter is touched, so a malformed or over-wide request never reaches + * learner code. + */ +export class LearnerHost { + readonly connection: FrameConnection; + + private readonly options: LearnerHostOptions; + private adapter: LearnerAdapter | undefined; + private ledger: PrivateLedgerClient | undefined; + + constructor(options: LearnerHostOptions) { + this.options = options; + this.connection = new FrameConnection({ + channel: options.channel, + originator: 'h', + frameSize: options.frameSize ?? DEFAULT_FRAME_SIZE, + ...(options.maxPayloadBytes === undefined + ? {} + : { maxPayloadBytes: options.maxPayloadBytes }), + handler: (method, params) => this.dispatch(method, params), + errorCodeFor: (error) => wireCodeFor(error), + }); + } + + async close(): Promise { + this.connection.close(); + return Promise.resolve(); + } + + private async dispatch(method: string, params: unknown): Promise { + if (!HOST_METHOD_SET.has(method)) { + throw new HostProtocolError('unknown-method'); + } + const name = method as HostMethod; + const parsed = HOST_PARAM_SCHEMAS[name].safeParse(params ?? {}); + if (!parsed.success) { + throw new HostProtocolError('invalid-params'); + } + + switch (name) { + case 'init': + return this.handleInit(parsed.data as InitParams); + case 'observe': { + const adapter = this.requireAdapter(); + await adapter.observe(parsed.data as Parameters[0]); + return { policyDigest: this.policyDigest() }; + } + case 'act': { + const adapter = this.requireAdapter(); + const envelope = await adapter.act( + parsed.data as Parameters[0], + ); + return { envelope, policyDigest: this.policyDigest() }; + } + case 'receive': { + const adapter = this.requireAdapter(); + const envelope = await adapter.receive( + parsed.data as Parameters[0], + ); + return { envelope, policyDigest: this.policyDigest() }; + } + case 'on_outcome': { + const adapter = this.requireAdapter(); + await adapter.onOutcome( + outcomeFromWire(parsed.data as Record), + ); + return { policyDigest: this.policyDigest() }; + } + case 'update_policy': { + const adapter = this.requireAdapter(); + if (adapter.updatePolicy === undefined) { + throw new HostProtocolError('unsupported-method'); + } + const checkpoint = await adapter.updatePolicy( + parsed.data as Parameters>[0], + ); + return { checkpoint, policyDigest: this.policyDigest() }; + } + case 'measure_affect': { + const adapter = this.requireAdapter(); + if (adapter.measureAffect === undefined) { + throw new HostProtocolError('unsupported-method'); + } + const measurement = await adapter.measureAffect(); + return { measurement, policyDigest: this.policyDigest() }; + } + case 'apply_curriculum_stage': { + const adapter = this.requireAdapter(); + if (adapter.applyCurriculumStage === undefined) { + throw new HostProtocolError('unsupported-method'); + } + const { stage } = parsed.data as { + stage: Parameters>[0]; + }; + await adapter.applyCurriculumStage(stage); + return { policyDigest: this.policyDigest() }; + } + case 'describe_provenance': { + const adapter = this.requireAdapter(); + if (adapter.describeProvenance === undefined) { + throw new HostProtocolError('unsupported-method'); + } + return { provenance: adapter.describeProvenance() }; + } + case 'export_policy': { + const adapter = this.requireAdapter(); + const policy = adapter.exportPolicy(); + return { policy, policyDigest: policyDigestOf(policy) }; + } + case 'describe_isolation': + return this.describeIsolation(); + case 'isolation_probe': + return isolationProbe( + parsed.data as { + readPath?: string; + connect?: { host: string; port: number; timeoutMs: number }; + }, + ); + case 'shutdown': + setImmediate(() => { + this.options.onShutdown?.(); + }); + return {}; + default: { + const exhaustive: never = name; + throw new HostProtocolError(exhaustive); + } + } + } + + private async handleInit(params: InitParams): Promise { + if (this.adapter !== undefined) { + throw new HostProtocolError('already-initialized'); + } + if (this.options.track !== undefined && this.options.track !== params.track) { + throw new HostProtocolError('invalid-params'); + } + + const build = + this.options.createFactory ?? + ((track: LearnerTrackId, options: Record) => + createLearnerAdapterFactory(track, options)); + const factory = build(params.track, params.learnerOptions ?? {}); + const adapter = factory.create(); + const ledger = new HostLedgerClient( + this.connection, + this.options.ledgerDeadlineMs ?? 30_000, + ); + + const context: LearnerInitContext = { + runId: params.runId, + role: params.role, + babyId: params.babyId, + config: params.config as LearnerVisibleRunConfig, + learnerContract: params.learnerContract as LearnerContract, + seed: params.seed, + symbolInventory: params.symbolInventory, + ledger, + ...(params.initialPolicy === undefined + ? {} + : { initialPolicy: params.initialPolicy }), + }; + await adapter.init(context); + this.adapter = adapter; + this.ledger = ledger; + + const capabilities: HostCapability[] = HOST_CAPABILITIES.filter( + (capability) => adapter[capability] !== undefined, + ); + let provenance: LearnerProvenance | undefined; + if (adapter.describeProvenance !== undefined) { + provenance = adapter.describeProvenance(); + } + return { + capabilities, + isolation: this.describeIsolation(), + policyDigest: this.policyDigest(), + protocolVersion: 1, + ...(provenance === undefined ? {} : { provenance }), + }; + } + + private describeIsolation(): Record { + const boundary = this.options.boundary ?? 'separate-process'; + const containerId = detectContainerId(boundary === 'separate-container'); + return { + boundary, + processId: process.pid, + ...(containerId === undefined ? {} : { containerId }), + ...(this.options.hostLabel === undefined + ? {} + : { hostLabel: this.options.hostLabel }), + }; + } + + private requireAdapter(): LearnerAdapter { + if (this.adapter === undefined || this.ledger === undefined) { + throw new HostProtocolError('not-initialized'); + } + return this.adapter; + } + + private policyDigest(): Sha256Hash { + return policyDigestOf(this.requireAdapter().exportPolicy()); + } +} + +/** `PrivateLedgerClient` implemented as the one reverse RPC (SPEC §6.3). */ +class HostLedgerClient implements PrivateLedgerClient { + constructor( + private readonly connection: FrameConnection, + private readonly deadlineMs: number, + ) {} + + async append( + draft: LedgerEventDraft, + options?: { channelEventHash?: Sha256Hash }, + ): Promise { + const result = await this.connection.request( + 'ledger_append', + { + draft, + ...(options?.channelEventHash === undefined + ? {} + : { channelEventHash: options.channelEventHash }), + }, + this.deadlineMs, + ); + return LedgerAppendResultSchema.parse(result).event; + } +} + +export function policyDigestOf(policy: unknown): Sha256Hash { + return hashCanonical(HASH_DOMAINS.policyCheckpoint, policy); +} + +/** + * Rebuild the `OutcomeEvent` the runtime described. + * + * `rewardWithheld` reinstalls a *throwing* accessor rather than a value, so a + * reward-free track that peeks at `reward` fails inside the hosted adapter + * exactly as it does in-process (SPEC §6.1, §11.1 `learningSignal`). + */ +export function outcomeFromWire(wire: Record): OutcomeEvent { + const { rewardWithheld, ...rest } = wire; + const outcome = { ...rest } as Record; + if (rewardWithheld === true) { + delete outcome.reward; + Object.defineProperty(outcome, 'reward', { + enumerable: true, + configurable: true, + get(): never { + throw new Error(WITHHELD_REWARD_MESSAGE); + }, + }); + } else if (outcome.reward === undefined) { + outcome.reward = null; + } + return outcome as unknown as OutcomeEvent; +} + +function wireCodeFor(error: unknown): HostErrorCode { + return isHostProtocolError(error) ? error.code : 'adapter-error'; +} + +interface IsolationProbeRequest { + readPath?: string; + connect?: { host: string; port: number; timeoutMs: number }; +} + +/** + * Attempt each access SPEC §10.3 claims a Baby process does not have, and + * report only an outcome code per attempt. + * + * This is the inside-the-Baby half of E01's "attempt filesystem, clipboard, + * environment, and process access" categories. It returns no file content, no + * error message, and no path. + */ +export async function isolationProbe( + request: IsolationProbeRequest = {}, +): Promise> { + const permissionModel = hasPermissionModel(); + return { + permissionModel, + fsRead: probeFsRead(request.readPath), + clipboard: await probeClipboard(), + childProcess: await probeChildProcess(), + worker: await probeWorker(), + network: await probeNetwork(request.connect), + envKeys: Object.keys(process.env).sort(), + argvCount: process.argv.length, + processId: process.pid, + }; +} + +/** Attempt the platform clipboard command without retaining any output. */ +async function probeClipboard(): Promise { + try { + const childProcess = await import('node:child_process'); + const [command, args] = + process.platform === 'darwin' + ? ['pbpaste', []] + : process.platform === 'win32' + ? ['powershell.exe', ['-NoProfile', '-Command', 'Get-Clipboard']] + : ['xclip', ['-selection', 'clipboard', '-o']]; + const result = childProcess.spawnSync(command, args, { stdio: 'ignore' }); + if (result.error !== undefined) { + return classify(result.error); + } + return result.status === 0 ? 'allowed' : 'refused'; + } catch (error) { + return classify(error); + } +} + +function hasPermissionModel(): boolean { + const permission = ( + process as unknown as { permission?: { has?: (scope: string) => boolean } } + ).permission; + if (permission?.has === undefined) { + return false; + } + // `fs.read` is granted only for the module graph, so the unrestricted + // capability is absent whenever the model is on. + return permission.has('fs.write') === false; +} + +function classify(error: unknown): ProbeOutcome { + const code = (error as { code?: unknown } | undefined)?.code; + return code === 'ERR_ACCESS_DENIED' ? 'denied' : 'refused'; +} + +function probeFsRead(path: string | undefined): ProbeOutcome { + if (path === undefined) { + return 'skipped'; + } + try { + readFileSync(path); + return 'allowed'; + } catch (error) { + return classify(error); + } +} + +async function probeChildProcess(): Promise { + try { + const childProcess = await import('node:child_process'); + const result = childProcess.spawnSync(process.execPath, ['--version']); + if (result.error !== undefined) { + return classify(result.error); + } + return result.status === null ? 'refused' : 'allowed'; + } catch (error) { + return classify(error); + } +} + +async function probeWorker(): Promise { + try { + const workers = await import('node:worker_threads'); + const worker = new workers.Worker(new URL('data:text/javascript,')); + await worker.terminate(); + return 'allowed'; + } catch (error) { + return classify(error); + } +} + +async function probeNetwork( + target: { host: string; port: number; timeoutMs: number } | undefined, +): Promise { + if (target === undefined) { + return 'skipped'; + } + try { + const net = await import('node:net'); + return await new Promise((resolve) => { + const socket = new net.Socket(); + const finish = (outcome: ProbeOutcome): void => { + socket.destroy(); + resolve(outcome); + }; + socket.setTimeout(target.timeoutMs, () => { + finish('refused'); + }); + socket.once('error', (error: unknown) => { + finish(classify(error)); + }); + socket.connect(target.port, target.host, () => { + finish('allowed'); + }); + }); + } catch (error) { + return classify(error); + } +} + +/** Frame channel over this process's own stdio (the process transport). */ +export class StdioFrameChannel implements FrameChannel { + readonly kind = 'process' as const; + + private lineHandler: ((line: string) => void) | undefined; + private closeHandler: (() => void) | undefined; + private closed = false; + + constructor( + private readonly input: NodeJS.ReadableStream = process.stdin, + private readonly output: NodeJS.WritableStream = process.stdout, + ) { + this.input.setEncoding('utf8'); + this.input.on('data', (chunk: string) => { + this.lineHandler?.(chunk); + }); + this.input.on('end', () => { + this.settle(); + }); + this.input.on('error', () => { + this.settle(); + }); + } + + get processId(): number { + return process.pid; + } + + write(lines: readonly string[]): void { + for (const line of lines) { + this.output.write(line); + } + } + + onLine(handler: (line: string) => void): void { + this.lineHandler = handler; + } + + onClose(handler: () => void): void { + this.closeHandler = handler; + if (this.closed) { + handler(); + } + } + + close(): void { + this.settle(); + } + + private settle(): void { + if (this.closed) { + return; + } + this.closed = true; + this.closeHandler?.(); + } +} + +export interface HostCliOptions { + transport: 'process' | 'tcp'; + port?: number; + bindHost?: string; + track?: LearnerTrackId; + frameSize?: number; + hostLabel?: string; + maxConnections?: number; +} + +/** Parse the host's own argv. Unknown flags are a configuration failure. */ +export function parseHostCliOptions(argv: readonly string[]): HostCliOptions { + const options: HostCliOptions = { transport: 'process' }; + for (const argument of argv) { + const [flag, rawValue] = splitFlag(argument); + switch (flag) { + case '--transport': + options.transport = rawValue === 'tcp' ? 'tcp' : 'process'; + break; + case '--port': + options.port = Number(rawValue); + break; + case '--bind': + options.bindHost = rawValue; + break; + case '--track': + options.track = rawValue as LearnerTrackId; + break; + case '--frame-size': + options.frameSize = Number(rawValue); + break; + case '--host-label': + options.hostLabel = rawValue; + break; + case '--max-connections': + options.maxConnections = Number(rawValue); + break; + default: + throw new Error(`unknown learner-host flag: ${flag}`); + } + } + return options; +} + +function splitFlag(argument: string): [string, string | undefined] { + const equals = argument.indexOf('='); + return equals < 0 + ? [argument, undefined] + : [argument.slice(0, equals), argument.slice(equals + 1)]; +} + +/** + * Entry point used by `bin/ald-learner-host.js`. + * + * The process transport exits when its stdin closes: when the runtime goes + * away, so does the Baby. + */ +export async function runLearnerHostCli(argv: readonly string[]): Promise { + const options = parseHostCliOptions(argv); + const shared = { + ...(options.track === undefined ? {} : { track: options.track }), + ...(options.frameSize === undefined ? {} : { frameSize: options.frameSize }), + ...(options.hostLabel === undefined ? {} : { hostLabel: options.hostLabel }), + }; + + if (options.transport === 'process') { + const channel = new StdioFrameChannel(); + const host = new LearnerHost({ + ...shared, + channel, + boundary: 'separate-process', + onShutdown: () => { + void host.close(); + process.exit(0); + }, + }); + channel.onClose(() => { + process.exit(0); + }); + await new Promise(() => { + // Runs until stdin closes or `shutdown` arrives. + }); + return; + } + + if (options.port === undefined || !Number.isInteger(options.port)) { + throw new Error('--port is required for --transport=tcp'); + } + const hosts = new Set(); + const server = await createTcpFrameServer({ + port: options.port, + ...(options.bindHost === undefined ? {} : { host: options.bindHost }), + ...(options.maxConnections === undefined + ? {} + : { maxConnections: options.maxConnections }), + onChannel: (channel) => { + const host = new LearnerHost({ + ...shared, + channel, + boundary: 'separate-container', + onShutdown: () => { + void host.close(); + hosts.delete(host); + }, + }); + hosts.add(host); + channel.onClose(() => { + hosts.delete(host); + }); + }, + }); + const stop = (): void => { + void server.close().then(() => { + process.exit(0); + }); + }; + process.once('SIGTERM', stop); + process.once('SIGINT', stop); + await new Promise(() => { + // Runs until the container is stopped. + }); +} diff --git a/packages/isolation/src/index.ts b/packages/isolation/src/index.ts new file mode 100644 index 0000000..0fa9af7 --- /dev/null +++ b/packages/isolation/src/index.ts @@ -0,0 +1,163 @@ +/** + * `@ald/isolation` — Research-Grade Mode (Mode R) learner isolation: + * separate-process and separate-container `LearnerAdapter`s behind the + * ordinary SPEC §6.2 interface. + * + * Specification sections implemented here: §5.2 (Mode R), §5.3 (the mode + * comparison table's process-boundary and timing-normalization rows), §6.2 + * (the adapter interface, unchanged across the boundary), §6.3 (tool-only + * interaction and the private-ledger tool as the host's single reverse RPC), + * §8.3 (per-turn response deadline), §10.3 (side-channel controls: normalized + * envelope size, constant-shape error behavior, fixed turn schedule, no + * filesystem/process access from a Baby beyond the Gateway RPC), §10.4 + * (training isolation — an update step can only reach this process's own + * buffers), §12.7 (the private deterministic-service boundary the reverse RPC + * respects), §13.5 (no key material in a Baby host). + * + * Owning backlog items: ALD-055 (separate-container isolation for Mode R), + * ALD-056 isolation half (training isolation verified under separation), + * ALD-053 support (the `IsolationDescriptor`/`isolation` boundary the runtime + * enforces), ALD-040 transport half (normalized envelope, constant-shape + * errors, fixed timing on the host protocol). + * + * What this package does **not** claim: the process transport does not deny + * network access (Node's permission model has no network dimension), and no + * transport here is a formally verified isolation proof. The container + * topology in `deploy/mode-r/` is the SPEC §5.2 shape; `README.md` maps every + * §10.3 threat-model item to the transport that actually covers it, and says + * which ones neither covers. + */ +export { + FrameConnection, + type ConnectionStats, + type FrameChannel, + type FrameConnectionOptions, + type FrameRequestHandler, +} from './channel.js'; +export { + HOST_ERROR_CODES, + HostProtocolError, + ISOLATION_ERROR_CODES, + ISOLATION_FAILURE_CLASSES, + IsolationError, + isHostErrorCode, + isHostProtocolError, + isIsolationError, + type HostErrorCode, + type IsolationErrorCode, + type IsolationErrorContext, +} from './errors.js'; +export { + ContainerHostTransport, + ProcessHostTransport, + createIsolatedAdapterFactory, + type IsolatedAdapterFactory, + type IsolatedAdapterFactoryOptions, +} from './factory.js'; +export { + DEFAULT_FRAME_SIZE, + DEFAULT_MAX_PAYLOAD_BYTES, + FRAME_OVERHEAD_BYTES, + FRAME_VERSION, + FrameAssembler, + FrameSchema, + LineReader, + MAX_CHUNKS, + MAX_FRAME_SIZE, + MIN_FRAME_SIZE, + assertValidFrameSize, + canonicalPayload, + correlationId, + decodeFrameLine, + encodeFrames, + frameCapacity, + type EncodeFramesOptions, + type Frame, + type FrameKind, + type FrameOriginator, +} from './frames.js'; +export { + LearnerHost, + StdioFrameChannel, + detectContainerId, + isolationProbe, + outcomeFromWire, + parseHostCliOptions, + policyDigestOf, + runLearnerHostCli, + type HostCliOptions, + type LearnerHostOptions, +} from './host.js'; +export { + defaultHostEntry, + defaultReadAllowList, + isolationPackageRoot, + nodePermissionArgs, + ProcessHostChannel, + workspaceRoot, + type HostDiagnostics, + type NodePermissionOptions, + type ProcessTransportOptions, +} from './process-transport.js'; +export { + AffectWindowSchema, + CheckpointResultSchema, + EnvelopeResultSchema, + ExportPolicyResultSchema, + HOST_CAPABILITIES, + HOST_METHODS, + HOST_PARAM_SCHEMAS, + InitParamsSchema, + InitResultSchema, + IsolationDescriptorSchema, + IsolationProbeParamsSchema, + IsolationProbeResultSchema, + LearnerProvenanceSchema, + LearnerVisibleRunConfigSchema, + LedgerAppendParamsSchema, + LedgerAppendResultSchema, + MeasureAffectResultSchema, + OutcomeEventSchema, + PROBE_OUTCOMES, + PolicyStateResultSchema, + ProvenanceResultSchema, + RUNTIME_METHODS, + TURN_PATH_METHODS, + TurnBudgetSchema, + UpdateBatchSchema, + type HostCapability, + type HostMethod, + type InitParams, + type IsolationProbeResult, + type ProbeOutcome, + type RuntimeMethod, +} from './protocol.js'; +export { + DEFAULT_CALL_DEADLINE_MS, + RemoteLearnerAdapter, + TRACKS_WITHOUT_POLICY_UPDATES, + outcomeToWire, + type HostTransport, + type RemoteAdapterDiagnostics, + type RemoteLearnerAdapterOptions, +} from './remote-adapter.js'; +export { + TcpFrameChannel, + connectTcpFrameChannel, + createTcpFrameServer, + type TcpConnectOptions, + type TcpFrameServer, + type TcpFrameServerOptions, +} from './tcp-transport.js'; +export { + DirectHostTransport, + LoopbackChannel, + createLoopbackChannelPair, + type LoopbackPair, +} from './testing.js'; +export { + RecordingTimer, + systemTimer, + type IsolationDelay, + type IsolationTimer, +} from './timer.js'; diff --git a/packages/isolation/src/process-transport.ts b/packages/isolation/src/process-transport.ts new file mode 100644 index 0000000..f1d3c53 --- /dev/null +++ b/packages/isolation/src/process-transport.ts @@ -0,0 +1,311 @@ +/** + * Separate-process transport: one learner host per Baby, launched under + * Node's permission model (SPEC §5.2, §5.3, §10.3; ALD-055). + * + * What the launch actually denies, and how: + * + * - `--permission` with **no** `--allow-child-process`, `--allow-worker`, + * `--allow-wasi`, `--allow-addons`, `--allow-inspector` and **no** + * `--allow-fs-write` at all: the host cannot spawn a process, start a + * worker, load a native addon, open an inspector, or write a single file. + * - `--allow-fs-read` naming *only* the module graph the host needs: this + * package's `bin`/`dist`, the workspace packages' `dist` directories, + * `package.json` manifests, pnpm package-local dependency links, and the + * root `node_modules`. The dependency links are required for ESM package + * resolution under pnpm; their targets remain the installed module graph. + * The evidence + * database, the key store, the scenario bundles, `contracts/`, and the + * repository sources are all outside the list — see the fs-denial test, + * which hands the host the evidence path as a decoy and asserts `denied`. + * - an empty environment and a fresh empty working directory: no + * `ALD_*` variable, no peer address, no database URL, no `PATH`. A host + * therefore has no *name* for anything outside itself, which is the other + * half of "no in-memory object reference is shared" (ALD-055 criterion 2): + * there is no handle and no address to reach for. + * - `stdio: ['pipe', 'pipe', 'ignore']`: stderr is discarded, because an + * adapter's error text is model-influenced content and a distinguishable + * error body is precisely the error-message side channel of SPEC §10.3. + * `stderr: 'count'` is available for operators who want a fault *count* + * without content; it counts bytes and lines and stores neither. + * + * What this transport does **not** do: it does not close the network. Node's + * permission model has no network dimension, so a hosted adapter can still + * open a socket. Denying the Baby-to-Baby route is the container transport's + * job (`deploy/mode-r/docker-compose.yml`, internal networks) and the README + * states that boundary explicitly rather than implying process isolation + * covers it. + */ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { IsolationError, type IsolationErrorCode } from './errors.js'; +import type { FrameChannel } from './channel.js'; + +/** Absolute path of this package's root, resolved from the module itself. */ +export function isolationPackageRoot(): string { + return fileURLToPath(new URL('../', import.meta.url)); +} + +/** Absolute path of the workspace root that holds `packages/` and `node_modules/`. */ +export function workspaceRoot(): string { + return fileURLToPath(new URL('../../../', import.meta.url)); +} + +/** Default child entry point: the package's `bin` script. */ +export function defaultHostEntry(): string { + return join(isolationPackageRoot(), 'bin', 'ald-learner-host.js'); +} + +/** + * The read allowlist: the module graph and nothing else. + * + * Enumerated rather than globbed so the list is auditable in a test and so a + * new package directory cannot silently widen it. + */ +export function defaultReadAllowList(hostEntry = defaultHostEntry()): string[] { + const root = workspaceRoot(); + const allow = new Set([ + dirname(hostEntry), + join(isolationPackageRoot(), 'dist'), + join(root, 'node_modules'), + join(root, 'package.json'), + ]); + const packagesDir = join(root, 'packages'); + let entries: string[] = []; + try { + entries = readdirSync(packagesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + entries = []; + } + for (const name of entries) { + const dist = join(packagesDir, name, 'dist'); + if (existsSync(dist)) { + allow.add(dist); + } + const manifest = join(packagesDir, name, 'package.json'); + if (existsSync(manifest)) { + allow.add(manifest); + } + const dependencies = join(packagesDir, name, 'node_modules'); + if (existsSync(dependencies)) { + allow.add(dependencies); + } + } + return [...allow].sort(); +} + +export interface NodePermissionOptions { + /** Turn the permission model off. Only for diagnosing a launch failure. */ + permissionModel?: boolean; + /** Overrides the computed read allowlist entirely. */ + allowFsRead?: readonly string[]; +} + +/** + * Node CLI flags for a locked-down host. + * + * Every `--allow-*` flag Node 24 offers is listed in the comment above; the + * ones that are absent here are absent on purpose, and a test asserts that + * this function never emits them. + */ +export function nodePermissionArgs(options: NodePermissionOptions = {}): string[] { + if (options.permissionModel === false) { + return []; + } + const allow = options.allowFsRead ?? defaultReadAllowList(); + return ['--permission', ...allow.map((path) => `--allow-fs-read=${path}`)]; +} + +export interface ProcessTransportOptions extends NodePermissionOptions { + /** Child entry module. Defaults to this package's `bin` script. */ + hostEntry?: string; + /** Node binary. Defaults to the runtime's own `process.execPath`. */ + nodeExecPath?: string; + /** Extra arguments appended after the entry path. */ + hostArgs?: readonly string[]; + /** Environment for the child. Defaults to `{}` — nothing at all. */ + env?: Readonly>; + /** Working directory. Defaults to a fresh empty temporary directory. */ + cwd?: string; + /** `ignore` discards stderr (default); `count` counts it without storing it. */ + stderr?: 'ignore' | 'count'; + /** Operator label recorded in the `IsolationDescriptor`. Never Baby-visible. */ + hostLabel?: string; +} + +/** Counters an operator may read; never content (SPEC §10.3, §14.1). */ +export interface HostDiagnostics { + stderrBytes: number; + stderrLines: number; + exitCode: number | null; + signal: string | null; +} + +/** + * A learner host's stdio as a {@link FrameChannel}. + * + * The whole frame sequence of one message is written in a single `write` + * call, which is what makes non-interleaved framing a property of the + * transport rather than an assumption. + */ +export class ProcessHostChannel implements FrameChannel { + readonly kind = 'process' as const; + + readonly diagnostics: HostDiagnostics = { + stderrBytes: 0, + stderrLines: 0, + exitCode: null, + signal: null, + }; + + private readonly child: ChildProcessWithoutNullStreams; + private readonly temporaryCwd: string | undefined; + private lineHandler: ((line: string) => void) | undefined; + private closeHandler: (() => void) | undefined; + private closed = false; + + constructor(options: ProcessTransportOptions = {}) { + const hostEntry = options.hostEntry ?? defaultHostEntry(); + if (!existsSync(hostEntry)) { + throw new IsolationError('configuration'); + } + this.temporaryCwd = + options.cwd === undefined + ? mkdtempSync(join(tmpdir(), 'ald-learner-host-')) + : undefined; + const cwd = options.cwd ?? (this.temporaryCwd as string); + const args = [ + ...nodePermissionArgs(options), + hostEntry, + ...(options.hostArgs ?? []), + ]; + + try { + this.child = spawn(options.nodeExecPath ?? process.execPath, args, { + cwd, + env: { ...(options.env ?? {}) }, + stdio: ['pipe', 'pipe', options.stderr === 'count' ? 'pipe' : 'ignore'], + windowsHide: true, + }) as ChildProcessWithoutNullStreams; + } catch (cause) { + this.cleanupCwd(); + throw new IsolationError('spawn-failed', { cause }); + } + + this.child.stdout.setEncoding('utf8'); + this.child.stdout.on('data', (chunk: string) => { + this.lineHandler?.(chunk); + }); + this.child.stdout.on('error', () => { + this.settle(); + }); + this.child.stdin.on('error', () => { + this.settle(); + }); + if (options.stderr === 'count' && this.child.stderr !== null) { + this.child.stderr.on('data', (chunk: Buffer) => { + // Counted, never stored: an adapter's stderr is model-influenced text. + this.diagnostics.stderrBytes += chunk.byteLength; + this.diagnostics.stderrLines += chunk + .toString('utf8') + .split('\n').length - 1; + }); + } + this.child.on('error', () => { + this.settle(); + }); + this.child.on('exit', (code, signal) => { + this.diagnostics.exitCode = code; + this.diagnostics.signal = signal; + this.settle(); + }); + } + + get processId(): number | undefined { + return this.child.pid; + } + + /** Whether the child is still running. */ + get alive(): boolean { + return !this.closed && this.child.exitCode === null && !this.child.killed; + } + + write(lines: readonly string[]): void { + if (this.closed) { + throw new IsolationError('host-unavailable'); + } + for (const line of lines) { + this.child.stdin.write(line); + } + } + + onLine(handler: (line: string) => void): void { + this.lineHandler = handler; + } + + onClose(handler: () => void): void { + this.closeHandler = handler; + if (this.closed) { + handler(); + } + } + + closeCode(): IsolationErrorCode { + return this.diagnostics.exitCode !== null || this.diagnostics.signal !== null + ? 'host-exited' + : 'host-unavailable'; + } + + close(): void { + if (!this.closed) { + this.child.stdin.end(); + } + this.settle(); + } + + /** SIGKILL: used by the ALD-055 criterion 3 test and by `dispose`. */ + kill(signal: NodeJS.Signals = 'SIGKILL'): void { + try { + this.child.kill(signal); + } catch { + // Already gone; `exit` has fired or will fire. + } + } + + /** Resolves once the child has exited. */ + async waitForExit(): Promise { + if (this.child.exitCode !== null || this.child.signalCode !== null) { + return; + } + await new Promise((resolve) => { + this.child.once('exit', () => { + resolve(); + }); + }); + } + + private settle(): void { + if (this.closed) { + return; + } + this.closed = true; + this.cleanupCwd(); + this.closeHandler?.(); + } + + private cleanupCwd(): void { + if (this.temporaryCwd === undefined) { + return; + } + try { + rmSync(this.temporaryCwd, { recursive: true, force: true }); + } catch { + // A leftover empty temp directory is not worth failing a run over. + } + } +} diff --git a/packages/isolation/src/protocol.ts b/packages/isolation/src/protocol.ts new file mode 100644 index 0000000..185e006 --- /dev/null +++ b/packages/isolation/src/protocol.ts @@ -0,0 +1,348 @@ +/** + * `LearnerHostProtocol` — the wire contract between the runtime-side proxy + * and a learner host process (SPEC §5.2, §6.2, §6.3, §10.3; ALD-055). + * + * The method set mirrors `LearnerAdapter` exactly, plus three methods that + * exist only because the adapter is remote: + * + * - `describe_isolation` fills the `IsolationDescriptor` of SPEC §5.3 from + * inside the host, so `processId`/`containerId` are what the host itself + * reports rather than what the runtime assumed (ALD-055 criterion 1); + * - `export_policy` exists because `LearnerAdapter.exportPolicy()` is + * *synchronous* and a process boundary is not. Every method that can change + * policy state answers with a `policyDigest`; when the digest moves, the + * proxy pulls the policy once and caches it, so the synchronous contract is + * honoured without a synchronous RPC (see `remote-adapter.ts`); + * - `isolation_probe` is the audited self-test surface for SPEC §10.3's + * "no direct network, filesystem, clipboard, or process access from a Baby + * process/container beyond the Gateway RPC" and E01's filesystem/process + * attempt categories. It returns *outcome codes only* — never file + * contents, never a path, never an error message — and it is the runtime + * that asks; a hosted adapter can neither call it nor observe it. + * + * The reverse direction has exactly one method, `ledger_append`, because the + * Ledger Writer stays in the Nursery trust zone (SPEC §4.1 item 7, §4.2). + * + * Every schema here is strict: an unknown key anywhere in a request is + * `invalid-params`, not a silently stripped field. That is deliberate. A + * stripped key is a channel the schema hides (the same reasoning as + * `assertNoStrippedKeys` in `@ald/learners`' conformance harness), and on this + * boundary the two peers are a Baby host and the Nursery. + */ +import { z } from 'zod'; +import { + AffectStateMeasurementSchema, + AGENT_ACTION_KINDS, + CurriculumStageSchema, + DeliveredChannelArtifactSchema, + LearnerTrackIdSchema, + LearningSignalSchema, + LedgerEventDraftSchema, + LedgerEventSchema, + ObservationSchema, + RunConfigSchema, +} from '@ald/types'; + +/** Methods a learner host serves. */ +export const HOST_METHODS = [ + 'init', + 'observe', + 'act', + 'receive', + 'on_outcome', + 'update_policy', + 'measure_affect', + 'apply_curriculum_stage', + 'describe_provenance', + 'export_policy', + 'describe_isolation', + 'isolation_probe', + 'shutdown', +] as const; + +export type HostMethod = (typeof HOST_METHODS)[number]; + +/** Methods the runtime serves for a host (reverse RPC). */ +export const RUNTIME_METHODS = ['ledger_append'] as const; + +export type RuntimeMethod = (typeof RUNTIME_METHODS)[number]; + +/** + * Methods whose response is normalized to the fixed turn schedule in Mode R + * (SPEC §10.3, §8.3). They are exactly the methods a *turn* consists of: what + * the other Baby could in principle correlate with. `init`, + * `update_policy`, `export_policy` and the descriptive methods are + * runtime-initiated bookkeeping outside any turn and are not padded, which + * keeps a training run from taking one full deadline per policy update. + */ +export const TURN_PATH_METHODS: readonly HostMethod[] = [ + 'observe', + 'act', + 'receive', + 'on_outcome', + 'measure_affect', +]; + +const roleSchema = z.enum(['baby-a', 'baby-b']); +const hashSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u); + +/** + * `LearnerVisibleRunConfig` (`RunConfig` minus `randomSeed`, SPEC §6.2) as a + * schema. + * + * `RunConfigSchema` carries cross-field refinements, so zod v4 refuses + * `.omit()` on it; the shape is re-wrapped instead. The cross-field checks + * are not re-run here on purpose: the runtime validated the whole + * configuration before the run was created, and this boundary's job is to + * refuse a *shape* that does not belong on the wire (an extra key, a wrong + * type, or `randomSeed` itself, which a Baby must never receive). + */ +const visibleConfigShape = Object.fromEntries( + Object.entries(RunConfigSchema.shape).filter(([key]) => key !== 'randomSeed'), +) as Omit; + +export const LearnerVisibleRunConfigSchema = z.strictObject(visibleConfigShape); + +export const AffectWindowSchema = z.strictObject({ + windowId: z.string().min(1), + turn: z.number().int().min(0), + sender: roleSchema, + recipient: roleSchema, + opensAfter: z.literal('outcome'), +}); + +export const TurnBudgetSchema = z.strictObject({ + turn: z.number().int().min(0), + role: z.enum(['sender', 'receiver']), + responseBudgetMs: z.number().int().positive(), + availableActions: z.array(z.enum(AGENT_ACTION_KINDS)), + candidateRefs: z.array(z.string().min(1)).optional(), + window: AffectWindowSchema.optional(), +}); + +/** + * `OutcomeEvent` on the wire. + * + * `rewardWithheld` is how a reward-free learning signal survives the + * boundary. SPEC §6.1's `self-supervised` track and the intrinsic modes must + * never read a task reward, and `@ald/learners`' conformance harness proves + * that by making `outcome.reward` *throw* when read. A proxy that read the + * property to serialize it would defeat the very test it is meant to carry, + * so the proxy inspects the property descriptor instead: an accessor becomes + * `rewardWithheld: true` and the host reinstalls a throwing accessor. The + * violation then still happens inside the hosted adapter, where it belongs. + */ +export const OutcomeEventSchema = z.strictObject({ + runId: z.string().min(1), + turn: z.number().int().min(0), + role: z.enum(['sender', 'receiver']), + success: z.boolean(), + reward: z.number().nullable().optional(), + rewardWithheld: z.boolean().optional(), + payload: z.array(z.number()), +}); + +export const UpdateBatchSchema = z.strictObject({ + runId: z.string().min(1), + turns: z.array(z.number().int().min(0)), + learningSignal: LearningSignalSchema, +}); + +export const LearnerContractSchema = z.strictObject({ + version: z.string().min(1), + text: z.string().min(1), + track: LearnerTrackIdSchema.optional(), +}); + +export const InitParamsSchema = z.strictObject({ + track: LearnerTrackIdSchema, + /** JSON-safe factory options for `createLearnerAdapterFactory`. */ + learnerOptions: z.record(z.string(), z.unknown()).optional(), + runId: z.string().min(1), + role: roleSchema, + babyId: z.enum(['A', 'B']), + config: LearnerVisibleRunConfigSchema, + learnerContract: LearnerContractSchema, + seed: z.string().min(1), + symbolInventory: z.array(z.string().min(1)), + initialPolicy: z.unknown().optional(), +}); + +export type InitParams = z.infer; + +export const IsolationDescriptorSchema = z.strictObject({ + boundary: z.enum(['in-process', 'separate-process', 'separate-container']), + processId: z.number().int().positive().optional(), + containerId: z.string().min(1).optional(), + hostLabel: z.string().min(1).optional(), +}); + +export const LearnerComponentRecordSchema = z.strictObject({ + name: z.string().min(1), + kind: z.enum([ + 'sensory-encoder', + 'world-model', + 'communication-policy', + 'value-function', + 'language-model', + 'memory', + 'other', + ]), + provenance: z.enum([ + 'random-init', + 'frozen-open-weight', + 'frozen-visual-features', + 'derived-run-policy', + 'none', + ]), + hash: hashSchema, + textAligned: z.boolean(), +}); + +/** + * `LearnerProvenance` (SPEC §6.5) as a schema. `@ald/types` declares it as an + * interface only; the boundary needs a validator, so one lives here (see this + * package's `notesForIntegrator`: it belongs in `@ald/types` eventually). + */ +export const LearnerProvenanceSchema = z.strictObject({ + track: LearnerTrackIdSchema, + modelRef: z.string().min(1), + textTokenizerPresent: z.boolean(), + textAlignedEncoderPresent: z.boolean(), + weightUpdatePath: z.enum(['none', 'private-buffers-only', 'centralized']), + components: z.array(LearnerComponentRecordSchema), +}); + +/** Optional `LearnerAdapter` members a host reports after `init`. */ +export const HOST_CAPABILITIES = [ + 'updatePolicy', + 'measureAffect', + 'applyCurriculumStage', + 'describeProvenance', +] as const; + +export type HostCapability = (typeof HOST_CAPABILITIES)[number]; + +export const InitResultSchema = z.strictObject({ + capabilities: z.array(z.enum(HOST_CAPABILITIES)), + isolation: IsolationDescriptorSchema, + policyDigest: hashSchema, + provenance: LearnerProvenanceSchema.optional(), + /** Protocol version the host speaks; must equal the proxy's. */ + protocolVersion: z.literal(1), +}); + +/** + * Result of a method that may have changed policy state. + * + * `envelope` is `z.unknown()` and stays **verbatim**: the runtime must see + * exactly what the hosted adapter produced, including an illegal extra field + * or a malformed proposal, because judging it is the Symbol Gateway's job + * (SPEC §9.4) and a transport that quietly normalized it would hide the very + * violation the rejection framework exists to record. + */ +export const EnvelopeResultSchema = z.strictObject({ + envelope: z.unknown(), + policyDigest: hashSchema, +}); + +export const PolicyStateResultSchema = z.strictObject({ + policyDigest: hashSchema, +}); + +export const CheckpointResultSchema = z.strictObject({ + checkpoint: z.strictObject({ + policyCheckpointRef: z.string().min(1), + policyHash: hashSchema, + turn: z.number().int().min(0), + }), + policyDigest: hashSchema, +}); + +export const ExportPolicyResultSchema = z.strictObject({ + policy: z.unknown(), + policyDigest: hashSchema, +}); + +export const MeasureAffectResultSchema = z.strictObject({ + measurement: AffectStateMeasurementSchema, + policyDigest: hashSchema, +}); + +export const ProvenanceResultSchema = z.strictObject({ + provenance: LearnerProvenanceSchema, +}); + +/** Outcome of one attempted forbidden access inside the host. */ +export const PROBE_OUTCOMES = [ + /** The platform refused it (Node permission model, or the OS). */ + 'denied', + /** It succeeded — the channel is open and therefore NOT claimed closed. */ + 'allowed', + /** The API exists and was reachable but the peer refused the connection. */ + 'refused', + /** Not attempted in this probe. */ + 'skipped', +] as const; + +export type ProbeOutcome = (typeof PROBE_OUTCOMES)[number]; + +export const IsolationProbeParamsSchema = z.strictObject({ + /** A decoy path the host must not be able to read. */ + readPath: z.string().min(1).optional(), + /** A loopback endpoint used only to show whether sockets are reachable. */ + connect: z + .strictObject({ + host: z.string().min(1), + port: z.number().int().positive().max(65_535), + timeoutMs: z.number().int().positive().max(2000).default(300), + }) + .optional(), +}); + +export const IsolationProbeResultSchema = z.strictObject({ + /** Whether Node's permission model is active in this host. */ + permissionModel: z.boolean(), + fsRead: z.enum(PROBE_OUTCOMES), + clipboard: z.enum(PROBE_OUTCOMES), + childProcess: z.enum(PROBE_OUTCOMES), + worker: z.enum(PROBE_OUTCOMES), + network: z.enum(PROBE_OUTCOMES), + /** + * Environment variable *names* only. A Baby host's environment is the + * simplest place to leak a peer's address, a database path, or a key file, + * so the test asserts on this set; values are never returned. + */ + envKeys: z.array(z.string()), + /** Number of argv entries, so a test can assert nothing extra was passed. */ + argvCount: z.number().int().min(0), + processId: z.number().int().positive(), +}); + +export type IsolationProbeResult = z.infer; + +export const LedgerAppendParamsSchema = z.strictObject({ + draft: z.strictObject(LedgerEventDraftSchema.shape), + channelEventHash: hashSchema.optional(), +}); + +export const LedgerAppendResultSchema = z.strictObject({ + event: LedgerEventSchema, +}); + +/** Params schema per host method, so dispatch validates before it acts. */ +export const HOST_PARAM_SCHEMAS = { + init: InitParamsSchema, + observe: z.strictObject(ObservationSchema.shape), + act: TurnBudgetSchema, + receive: z.strictObject(DeliveredChannelArtifactSchema.shape), + on_outcome: OutcomeEventSchema, + update_policy: UpdateBatchSchema, + measure_affect: z.strictObject({}), + apply_curriculum_stage: z.strictObject({ stage: CurriculumStageSchema }), + describe_provenance: z.strictObject({}), + export_policy: z.strictObject({}), + describe_isolation: z.strictObject({}), + isolation_probe: IsolationProbeParamsSchema, + shutdown: z.strictObject({}), +} as const satisfies Record; diff --git a/packages/isolation/src/remote-adapter.ts b/packages/isolation/src/remote-adapter.ts new file mode 100644 index 0000000..6b74a73 --- /dev/null +++ b/packages/isolation/src/remote-adapter.ts @@ -0,0 +1,714 @@ +/** + * `RemoteLearnerAdapter` — a `LearnerAdapter` (SPEC §6.2) whose state lives in + * another process or container (SPEC §5.2, §5.3, §10.3; ALD-053, ALD-055, + * ALD-056, ALD-040 transport half). + * + * From the Nursery runtime's side nothing changes: it holds an object with + * `init`/`observe`/`act`/`receive`/`onOutcome`/`updatePolicy`/`exportPolicy` + * and hands it the same `PrivateLedgerClient` it hands an in-process adapter. + * Three things about that are not free across a process boundary, and this + * file is mostly about them. + * + * **1. `exportPolicy()` is synchronous.** A boundary is not. Every method that + * can move policy state answers with a `policyDigest`; when the digest + * changes, the proxy pulls the policy once (`export_policy`) *before* + * returning, recomputes the digest locally, and caches the value. So the + * synchronous accessor is always the state as of the last completed call, and + * the recomputation doubles as an integrity check: the digest the host + * computed over its own object must equal the digest the runtime computes + * after the round trip, which is what makes "identical policy hash to the + * in-process adapter for the same seed" a property of the transport rather + * than a coincidence. + * + * **2. Optional members must be visible before `init`.** SPEC §6.2 makes + * `updatePolicy` absent for `no-learning` and `frozen-llm`, and callers test + * for it (`@ald/learners`' conformance harness checks it before `init`). The + * proxy declares `updatePolicy` from the track alone — the one rule the SPEC + * fixes — and reconciles the rest of the optional members against the host's + * `init` handshake, installing or deleting them so the proxy's surface is the + * hosted adapter's surface. + * + * **3. Timing.** In Mode R turn timing normalization is a MUST (SPEC §5.3, + * §10.3): "a rejected/failed turn and an accepted turn MUST produce externally + * indistinguishable timing/size profiles where technically feasible". With + * `timing: 'normalized'` every turn-path call returns on a fixed schedule — + * padded to the deadline tick — whether the host answered in one millisecond, + * threw, or never answered at all. Frame padding does the same for size. + * `timing: 'immediate'` exists for Mode P and for tests; it is not admissible + * for a Mode R run and `@ald/lifecycle`'s mode policy is what enforces that. + */ +import { + HASH_DOMAINS, + type AffectStateMeasurement, + type CurriculumStage, + type DeliveredChannelArtifact, + type IsolationBoundary, + type IsolationDescriptor, + type LearnerAdapter, + type LearnerInitContext, + type LearnerProvenance, + type LearnerTrackId, + type LearnerVisibleRunConfig, + type LedgerDraftEnvelope, + type Observation, + type OutcomeEvent, + type PolicyCheckpointRef, + type PrivateLedgerClient, + type Sha256Hash, + type TurnBudget, + type TurnProposalEnvelope, + type UpdateBatch, +} from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; + +import { FrameConnection, type FrameChannel } from './channel.js'; +import { + HostProtocolError, + IsolationError, + isHostProtocolError, + type HostErrorCode, +} from './errors.js'; +import { DEFAULT_FRAME_SIZE } from './frames.js'; +import { + CheckpointResultSchema, + EnvelopeResultSchema, + ExportPolicyResultSchema, + HOST_CAPABILITIES, + InitResultSchema, + IsolationDescriptorSchema, + IsolationProbeResultSchema, + LedgerAppendParamsSchema, + MeasureAffectResultSchema, + PolicyStateResultSchema, + ProvenanceResultSchema, + TURN_PATH_METHODS, + type HostCapability, + type HostMethod, + type IsolationProbeResult, +} from './protocol.js'; +import { systemTimer, type IsolationTimer } from './timer.js'; + +/** Fallback per-call deadline when no configuration has been seen yet. */ +export const DEFAULT_CALL_DEADLINE_MS = 30_000; + +/** Grace period for the `shutdown` courtesy call in `dispose()`. */ +const SHUTDOWN_DEADLINE_MS = 1000; + +/** + * SPEC §6.2: "`updatePolicy` is absent for the no-learning and frozen-llm + * tracks". This is the only capability the proxy can know before the + * handshake, and it must know it: callers branch on the property. + */ +export const TRACKS_WITHOUT_POLICY_UPDATES: readonly LearnerTrackId[] = [ + 'no-learning', + 'frozen-llm', +]; + +const TURN_PATH: ReadonlySet = new Set(TURN_PATH_METHODS); + +/** One learner host, however it is reached. */ +export interface HostTransport { + readonly boundary: IsolationBoundary; + readonly hostLabel?: string; + /** The channel if the transport already has one (a spawned child does). */ + current(): FrameChannel | undefined; + /** Open (or return) the channel. Called once, from `init`. */ + open(): Promise; + /** Kill the host without a handshake. Used by `dispose` and kill tests. */ + terminate(): Promise; +} + +export interface RemoteLearnerAdapterOptions { + track: LearnerTrackId; + transport: HostTransport; + /** JSON-safe options for the host's `createLearnerAdapterFactory` call. */ + learnerOptions?: Record; + frameSize?: number; + maxPayloadBytes?: number; + /** Mode R requires `normalized` (SPEC §5.3). Default `normalized`. */ + timing?: 'normalized' | 'immediate'; + /** Overrides `RunConfig.turnResponseBudgetMs` as the per-call deadline. */ + deadlineMs?: number; + timer?: IsolationTimer; +} + +/** Runtime-side counters. Never sent anywhere; never Baby-visible. */ +export interface RemoteAdapterDiagnostics { + calls: number; + deadlineExceeded: number; + hostErrors: number; + policyRefreshes: number; + paddedCalls: number; + ledgerAppends: number; +} + +export class RemoteLearnerAdapter implements LearnerAdapter { + readonly track: LearnerTrackId; + + readonly diagnostics: RemoteAdapterDiagnostics = { + calls: 0, + deadlineExceeded: 0, + hostErrors: 0, + policyRefreshes: 0, + paddedCalls: 0, + ledgerAppends: 0, + }; + + /** Present exactly when the hosted adapter has it (SPEC §6.2). */ + updatePolicy?: (batch: UpdateBatch) => Promise; + measureAffect?: () => Promise; + applyCurriculumStage?: (stage: CurriculumStage) => Promise; + describeProvenance?: () => LearnerProvenance; + + private readonly options: RemoteLearnerAdapterOptions; + private readonly timer: IsolationTimer; + private readonly timing: 'normalized' | 'immediate'; + private connection: FrameConnection | undefined; + private ledger: PrivateLedgerClient | undefined; + private config: LearnerVisibleRunConfig | undefined; + private descriptor: IsolationDescriptor; + private provenance: LearnerProvenance | undefined; + private policy: { digest: Sha256Hash; value: unknown } | undefined; + private capabilities: readonly HostCapability[] = []; + private disposed = false; + private lastLedgerFailure: unknown; + + constructor(options: RemoteLearnerAdapterOptions) { + this.options = options; + this.track = options.track; + this.timer = options.timer ?? systemTimer; + this.timing = options.timing ?? 'normalized'; + + const processId = options.transport.current()?.processId; + this.descriptor = { + boundary: options.transport.boundary, + timingNormalization: this.timing, + ...(processId === undefined ? {} : { processId }), + ...(options.transport.hostLabel === undefined + ? {} + : { hostLabel: options.transport.hostLabel }), + }; + + if (!TRACKS_WITHOUT_POLICY_UPDATES.includes(options.track)) { + this.installCapability('updatePolicy'); + } + } + + /** SPEC §5.3: where this adapter executes. Enriched by the handshake. */ + get isolation(): IsolationDescriptor { + return { ...this.descriptor }; + } + + /** The host's own error, kept locally for the operator (never on the wire). */ + get lastLedgerError(): unknown { + return this.lastLedgerFailure; + } + + /** Frame/byte counters of the boundary, for the ALD-040 measurements. */ + get transportStats(): FrameConnection['stats'] | undefined { + return this.connection?.stats; + } + + async init(context: LearnerInitContext): Promise { + if (this.disposed) { + throw new IsolationError('disposed', { method: 'init' }); + } + if (this.connection !== undefined) { + throw new IsolationError('already-initialized', { method: 'init' }); + } + this.config = context.config; + this.ledger = context.ledger; + + const channel = await this.options.transport.open(); + this.connection = new FrameConnection({ + channel, + originator: 'r', + frameSize: this.options.frameSize ?? DEFAULT_FRAME_SIZE, + ...(this.options.maxPayloadBytes === undefined + ? {} + : { maxPayloadBytes: this.options.maxPayloadBytes }), + timer: this.timer, + handler: (method, params) => this.serveHostRequest(method, params), + errorCodeFor: (error) => runtimeWireCodeFor(error), + }); + const channelProcessId = channel.processId; + if (channelProcessId !== undefined) { + this.descriptor = { ...this.descriptor, processId: channelProcessId }; + } + + const result = await this.call( + 'init', + { + track: this.track, + ...(this.options.learnerOptions === undefined + ? {} + : { learnerOptions: this.options.learnerOptions }), + runId: context.runId, + role: context.role, + babyId: context.babyId, + config: learnerVisibleWireConfig(context.config), + learnerContract: { + version: context.learnerContract.version, + text: context.learnerContract.text, + ...(context.learnerContract.track === undefined + ? {} + : { track: context.learnerContract.track }), + }, + seed: context.seed, + symbolInventory: [...context.symbolInventory], + ...(context.initialPolicy === undefined + ? {} + : { initialPolicy: context.initialPolicy }), + }, + (raw) => InitResultSchema.parse(raw), + ); + + this.reconcileCapabilities(result.capabilities); + this.descriptor = mergeDescriptor(this.descriptor, result.isolation); + this.provenance = result.provenance; + await this.syncPolicy(result.policyDigest, 'init'); + } + + async observe(observation: Observation): Promise { + const result = await this.call( + 'observe', + { + runId: observation.runId, + turn: observation.turn, + recipient: observation.recipient, + encoding: observation.encoding, + payload: observation.payload, + scenarioRef: observation.scenarioRef, + }, + (raw) => PolicyStateResultSchema.parse(raw), + ); + await this.syncPolicy(result.policyDigest, 'observe'); + } + + async act(budget: TurnBudget): Promise { + const result = await this.call( + 'act', + { + turn: budget.turn, + role: budget.role, + responseBudgetMs: budget.responseBudgetMs, + availableActions: [...budget.availableActions], + ...(budget.candidateRefs === undefined + ? {} + : { candidateRefs: [...budget.candidateRefs] }), + ...(budget.window === undefined ? {} : { window: budget.window }), + }, + (raw) => EnvelopeResultSchema.parse(raw), + ); + await this.syncPolicy(result.policyDigest, 'act'); + // Verbatim: judging the envelope is the Gateway's job (SPEC §9.4). + return result.envelope as TurnProposalEnvelope; + } + + async receive(delivery: DeliveredChannelArtifact): Promise { + const result = await this.call( + 'receive', + { + runId: delivery.runId, + turn: delivery.turn, + logicalSender: delivery.logicalSender, + carrier: delivery.carrier, + publicArtifact: delivery.publicArtifact, + channelEventHash: delivery.channelEventHash, + }, + (raw) => EnvelopeResultSchema.parse(raw), + ); + await this.syncPolicy(result.policyDigest, 'receive'); + return result.envelope as LedgerDraftEnvelope; + } + + async onOutcome(outcome: OutcomeEvent): Promise { + const result = await this.call( + 'on_outcome', + outcomeToWire(outcome), + (raw) => PolicyStateResultSchema.parse(raw), + ); + await this.syncPolicy(result.policyDigest, 'on_outcome'); + } + + /** + * The policy as of the last completed call (see this file's header). Throws + * rather than guessing when the handshake has not happened: a policy + * checkpoint hash over a placeholder would be evidence of nothing. + */ + exportPolicy(): unknown { + if (this.policy === undefined) { + throw new IsolationError('not-initialized', { method: 'exportPolicy' }); + } + return this.policy.value; + } + + /** Digest of {@link exportPolicy}, recomputed locally after each refresh. */ + get policyDigest(): Sha256Hash | undefined { + return this.policy?.digest; + } + + /** + * Ask the host to attempt each access SPEC §10.3 says it does not have. + * Returns outcome codes only (see `protocol.ts`). + */ + async probeIsolation( + request: { + readPath?: string; + connect?: { host: string; port: number; timeoutMs?: number }; + } = {}, + ): Promise { + return this.call( + 'isolation_probe', + { + ...(request.readPath === undefined ? {} : { readPath: request.readPath }), + ...(request.connect === undefined ? {} : { connect: request.connect }), + }, + (raw) => IsolationProbeResultSchema.parse(raw), + ); + } + + /** + * Re-read the hosted adapter's self-declared provenance (SPEC §6.5). + * + * `describeProvenance()` is synchronous, so the value it returns is the one + * captured at `init`; this is how a caller that wants a fresh declaration — + * the ALD-057 semantic-leakage battery, for instance — asks for one. + */ + async refreshProvenance(): Promise { + const result = await this.call('describe_provenance', {}, (raw) => + ProvenanceResultSchema.parse(raw), + ); + this.provenance = result.provenance; + return result.provenance; + } + + /** Re-read the host's descriptor (container id appears only inside one). */ + async refreshIsolation(): Promise { + const result = await this.call('describe_isolation', {}, (raw) => + IsolationDescriptorSchema.parse(raw), + ); + this.descriptor = mergeDescriptor(this.descriptor, result); + return this.isolation; + } + + /** Graceful teardown: ask, then close, then make sure. */ + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + const connection = this.connection; + if (connection !== undefined && !connection.isClosed) { + try { + await connection.request('shutdown', {}, SHUTDOWN_DEADLINE_MS); + } catch { + // A host that will not answer `shutdown` is killed below. + } + connection.close(); + } + await this.options.transport.terminate(); + } + + /** Kill the host with no handshake (ALD-055 criterion 3). */ + async terminate(): Promise { + await this.options.transport.terminate(); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private installCapability(capability: HostCapability): void { + switch (capability) { + case 'updatePolicy': + this.updatePolicy = async (batch: UpdateBatch): Promise => { + const result = await this.call( + 'update_policy', + { + runId: batch.runId, + turns: [...batch.turns], + learningSignal: batch.learningSignal, + }, + (raw) => CheckpointResultSchema.parse(raw), + ); + await this.syncPolicy(result.policyDigest, 'update_policy'); + return result.checkpoint; + }; + return; + case 'measureAffect': + this.measureAffect = async (): Promise => { + const result = await this.call('measure_affect', {}, (raw) => + MeasureAffectResultSchema.parse(raw), + ); + await this.syncPolicy(result.policyDigest, 'measure_affect'); + return result.measurement; + }; + return; + case 'applyCurriculumStage': + this.applyCurriculumStage = async (stage: CurriculumStage): Promise => { + const result = await this.call( + 'apply_curriculum_stage', + { stage }, + (raw) => PolicyStateResultSchema.parse(raw), + ); + await this.syncPolicy(result.policyDigest, 'apply_curriculum_stage'); + }; + return; + case 'describeProvenance': + this.describeProvenance = (): LearnerProvenance => { + if (this.provenance === undefined) { + throw new IsolationError('not-initialized', { + method: 'describeProvenance', + }); + } + return this.provenance; + }; + return; + default: { + const exhaustive: never = capability; + throw new IsolationError('configuration', { method: String(exhaustive) }); + } + } + } + + /** + * Make the proxy's optional surface equal the host's. + * + * A member the host does not have is deleted rather than left in place to + * throw later: `if (adapter.updatePolicy)` is how the runtime and the + * conformance harness read SPEC §6.2, so the property's *presence* is part + * of the contract, not just its behaviour. + */ + private reconcileCapabilities(reported: readonly HostCapability[]): void { + this.capabilities = [...reported]; + for (const capability of HOST_CAPABILITIES) { + const wanted = reported.includes(capability); + const present = this[capability] !== undefined; + if (wanted && !present) { + this.installCapability(capability); + } else if (!wanted && present) { + delete this[capability]; + } + } + } + + /** Capabilities the host reported at `init`. */ + get hostCapabilities(): readonly HostCapability[] { + return this.capabilities; + } + + private deadline(): number { + return ( + this.options.deadlineMs ?? + this.config?.turnResponseBudgetMs ?? + DEFAULT_CALL_DEADLINE_MS + ); + } + + private requireConnection(method: HostMethod): FrameConnection { + if (this.disposed) { + throw new IsolationError('disposed', { method }); + } + const connection = this.connection; + if (connection === undefined) { + throw new IsolationError('not-initialized', { method }); + } + return connection; + } + + /** + * One host call: deadline, schema, policy digest, and — on the turn path in + * Mode R — the fixed-schedule pad, applied whether the call succeeded, + * failed, or timed out. + */ + private async call( + method: HostMethod, + params: unknown, + parse: (raw: unknown) => T, + ): Promise { + const connection = this.requireConnection(method); + const deadlineMs = this.deadline(); + const normalize = this.timing === 'normalized' && TURN_PATH.has(method); + const startedAt = this.timer.now(); + this.diagnostics.calls += 1; + try { + const raw = await connection.request(method, params, deadlineMs); + try { + return parse(raw); + } catch (cause) { + throw new IsolationError('protocol-violation', { method, cause }); + } + } catch (error) { + if (error instanceof IsolationError) { + if (error.code === 'deadline-exceeded') { + this.diagnostics.deadlineExceeded += 1; + } else if (error.code === 'host-error') { + this.diagnostics.hostErrors += 1; + } + } + throw error; + } finally { + if (normalize) { + await this.padToDeadline(startedAt, deadlineMs); + } + } + } + + /** + * Hold the result until the deadline tick. + * + * This is the timing half of SPEC §10.3: an accepted turn, a turn the host + * refused, and a turn the host never answered all return to the caller at + * the same point on the schedule. + */ + private async padToDeadline(startedAt: number, deadlineMs: number): Promise { + const remaining = deadlineMs - (this.timer.now() - startedAt); + if (remaining <= 0) { + return; + } + this.diagnostics.paddedCalls += 1; + await this.timer.delay(remaining).promise; + } + + /** + * Pull and cache the policy when the host says it moved. + * + * The digest is recomputed from the received value: if the transport had + * altered the policy in any way, the recomputed digest would differ from + * the host's and this raises `protocol-violation` instead of silently + * checkpointing a different object than the host holds. + */ + private async syncPolicy(digest: Sha256Hash, method: HostMethod): Promise { + if (this.policy?.digest === digest) { + return; + } + const connection = this.requireConnection('export_policy'); + const raw = await connection.request('export_policy', {}, this.deadline()); + let result: { policy: unknown; policyDigest: Sha256Hash }; + try { + result = ExportPolicyResultSchema.parse(raw); + } catch (cause) { + throw new IsolationError('protocol-violation', { + method: 'export_policy', + cause, + }); + } + const recomputed = hashCanonical( + HASH_DOMAINS.policyCheckpoint, + result.policy, + ); + if (recomputed !== result.policyDigest) { + throw new IsolationError('protocol-violation', { method: 'export_policy' }); + } + if (recomputed !== digest) { + // Only one call is ever in flight per host, so the state cannot have + // moved between the two: a mismatch means the peer (or an adapter whose + // `exportPolicy()` is not deterministic) is broken, and caching a policy + // that disagrees with the method result would corrupt the checkpoint. + throw new IsolationError('protocol-violation', { method }); + } + this.diagnostics.policyRefreshes += 1; + this.policy = { digest: recomputed, value: result.policy }; + } + + /** The one method the host may call: `ledger.append` (SPEC §6.3, §4.2). */ + private async serveHostRequest( + method: string, + params: unknown, + ): Promise { + if (method !== 'ledger_append') { + throw new HostProtocolError('unknown-method'); + } + const ledger = this.ledger; + if (ledger === undefined) { + throw new HostProtocolError('not-initialized'); + } + const parsed = LedgerAppendParamsSchema.safeParse(params); + if (!parsed.success) { + throw new HostProtocolError('invalid-params'); + } + try { + const event = await ledger.append( + parsed.data.draft, + parsed.data.channelEventHash === undefined + ? undefined + : { channelEventHash: parsed.data.channelEventHash }, + ); + this.diagnostics.ledgerAppends += 1; + return { event }; + } catch (error) { + // The host learns `internal` and nothing else: why the Nursery's + // Evidence Writer refused is not a Baby's business (SPEC §4.2, §10.3). + this.lastLedgerFailure = error; + throw error; + } + } +} + +/** + * The configuration a Baby host may see (SPEC §6.2, §9.5, §10.1). + * + * `LearnerInitContext.config` is typed as `LearnerVisibleRunConfig`, but a + * caller holding a full `RunConfig` satisfies that type structurally and + * `@ald/learners`' conformance harness passes exactly that. `randomSeed` is + * therefore stripped here rather than assumed absent: with the run seed and + * the public Scenario Engine, a host could regenerate researcher-only ground + * truth and the other Baby's private seed. The host's own schema also rejects + * the key, so the withholding is enforced on both sides of the boundary. + */ +function learnerVisibleWireConfig( + config: LearnerVisibleRunConfig, +): Record { + return Object.fromEntries( + Object.entries(config as Record).filter( + ([key]) => key !== 'randomSeed', + ), + ); +} + +/** Merge the host's self-report into the descriptor without losing local facts. */ +function mergeDescriptor( + local: IsolationDescriptor, + reported: { + boundary: IsolationBoundary; + processId?: number; + containerId?: string; + hostLabel?: string; + }, +): IsolationDescriptor { + const processId = reported.processId ?? local.processId; + const hostLabel = reported.hostLabel ?? local.hostLabel; + return { + boundary: reported.boundary, + ...(local.timingNormalization === undefined + ? {} + : { timingNormalization: local.timingNormalization }), + ...(processId === undefined ? {} : { processId }), + ...(reported.containerId === undefined + ? {} + : { containerId: reported.containerId }), + ...(hostLabel === undefined ? {} : { hostLabel }), + }; +} + +/** + * `OutcomeEvent` → wire form, without reading a reward the learning signal + * forbids reading (see `OutcomeEventSchema`'s doc comment). + */ +export function outcomeToWire(outcome: OutcomeEvent): Record { + const descriptor = Object.getOwnPropertyDescriptor(outcome, 'reward'); + const withheld = descriptor !== undefined && descriptor.get !== undefined; + return { + runId: outcome.runId, + turn: outcome.turn, + role: outcome.role, + success: outcome.success, + payload: [...outcome.payload], + ...(withheld ? { rewardWithheld: true } : { reward: outcome.reward }), + }; +} + +function runtimeWireCodeFor(error: unknown): HostErrorCode { + return isHostProtocolError(error) ? error.code : 'internal'; +} diff --git a/packages/isolation/src/tcp-transport.ts b/packages/isolation/src/tcp-transport.ts new file mode 100644 index 0000000..0cae662 --- /dev/null +++ b/packages/isolation/src/tcp-transport.ts @@ -0,0 +1,203 @@ +/** + * Separate-container transport: the same frames over a TCP socket on a + * container-internal network (SPEC §5.2, §5.3; ALD-055). + * + * The socket carries no authority of its own. It is reachable only from the + * network the Nursery and that one Baby share (`deploy/mode-r/docker-compose.yml` + * puts `baby-a` on `net-a`, `baby-b` on `net-b`, and only the `nursery` + * service on both, with both networks `internal: true`), so the topology — + * not this file — is what denies the Baby-to-Baby route and the internet. + * + * A host serves **one** connection at a time by default: one container is one + * Baby (SPEC §4.1 item 5, "one `LearnerAdapter` process per Baby per track"), + * and a second concurrent connection would mean two Babies sharing one + * address space, which is exactly what Mode R exists to prevent. + */ +import { createServer, Socket, type Server } from 'node:net'; + +import { IsolationError } from './errors.js'; +import type { FrameChannel } from './channel.js'; + +/** One TCP socket as a {@link FrameChannel}. */ +export class TcpFrameChannel implements FrameChannel { + readonly kind = 'tcp' as const; + + private lineHandler: ((line: string) => void) | undefined; + private closeHandler: (() => void) | undefined; + private closed = false; + + constructor(private readonly socket: Socket) { + this.socket.setEncoding('utf8'); + this.socket.setNoDelay(true); + this.socket.on('data', (chunk: string) => { + this.lineHandler?.(chunk); + }); + this.socket.on('error', () => { + this.settle(); + }); + this.socket.on('close', () => { + this.settle(); + }); + this.socket.on('end', () => { + this.settle(); + }); + } + + /** A remote container's process id is reported by `describe_isolation`. */ + get processId(): number | undefined { + return undefined; + } + + get remoteLabel(): string { + return `${this.socket.remoteAddress ?? 'unknown'}:${String(this.socket.remotePort ?? 0)}`; + } + + write(lines: readonly string[]): void { + if (this.closed) { + throw new IsolationError('host-unavailable'); + } + for (const line of lines) { + this.socket.write(line); + } + } + + onLine(handler: (line: string) => void): void { + this.lineHandler = handler; + } + + onClose(handler: () => void): void { + this.closeHandler = handler; + if (this.closed) { + handler(); + } + } + + close(): void { + if (!this.closed) { + this.socket.end(); + this.socket.destroy(); + } + this.settle(); + } + + private settle(): void { + if (this.closed) { + return; + } + this.closed = true; + this.closeHandler?.(); + } +} + +export interface TcpConnectOptions { + host: string; + port: number; + /** Connection deadline. Default 10 s, which is a container start, not a turn. */ + timeoutMs?: number; + /** Attempts before giving up, for a container that is still booting. */ + attempts?: number; + /** Delay between attempts. */ + retryDelayMs?: number; +} + +/** Connect to a container-hosted learner, retrying while it boots. */ +export async function connectTcpFrameChannel( + options: TcpConnectOptions, +): Promise { + const attempts = Math.max(1, options.attempts ?? 1); + const timeoutMs = options.timeoutMs ?? 10_000; + let lastCause: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await connectOnce(options.host, options.port, timeoutMs); + } catch (cause) { + lastCause = cause; + if (attempt + 1 < attempts) { + await new Promise((resolve) => { + setTimeout(resolve, options.retryDelayMs ?? 250); + }); + } + } + } + throw new IsolationError('connect-failed', { cause: lastCause }); +} + +async function connectOnce( + host: string, + port: number, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + const fail = (cause: unknown): void => { + socket.destroy(); + reject(cause instanceof Error ? cause : new Error('connect failed')); + }; + socket.setTimeout(timeoutMs, () => { + fail(new Error('connect timed out')); + }); + socket.once('error', fail); + socket.connect(port, host, () => { + socket.setTimeout(0); + socket.removeListener('error', fail); + resolve(new TcpFrameChannel(socket)); + }); + }); +} + +export interface TcpFrameServerOptions { + port: number; + /** Bind address. Default `0.0.0.0` so a container network can reach it. */ + host?: string; + /** Concurrent connections allowed. Default 1: one container, one Baby. */ + maxConnections?: number; + onChannel: (channel: TcpFrameChannel) => void; +} + +export interface TcpFrameServer { + readonly port: number; + readonly server: Server; + close(): Promise; +} + +/** Listen for exactly one Baby's runtime connection. */ +export async function createTcpFrameServer( + options: TcpFrameServerOptions, +): Promise { + const limit = options.maxConnections ?? 1; + let open = 0; + const server = createServer((socket) => { + if (open >= limit) { + // Refusing rather than queueing: a second concurrent connection would + // mean two Babies inside one host process. + socket.destroy(); + return; + } + open += 1; + socket.on('close', () => { + open -= 1; + }); + options.onChannel(new TcpFrameChannel(socket)); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(options.port, options.host ?? '0.0.0.0', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + + const address = server.address(); + return { + port: typeof address === 'object' && address !== null ? address.port : options.port, + server, + async close(): Promise { + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + }, + }; +} diff --git a/packages/isolation/src/testing.ts b/packages/isolation/src/testing.ts new file mode 100644 index 0000000..b2e8815 --- /dev/null +++ b/packages/isolation/src/testing.ts @@ -0,0 +1,128 @@ +/** + * Test doubles for the isolation boundary, shipped in `src` for the same + * reason `@ald/gateway` ships its own: the doubles are part of the contract + * this package asks other packages to hold up, and the red-team harness + * (ALD-067) reuses them. + * + * A loopback channel pair is *not* an isolation claim. It exercises the + * protocol — framing, padding, error shape, capability reconciliation, + * deadlines — inside one process, which is exactly what makes it useful for + * measuring the transport (ALD-040) without the noise of process start-up. + * Anything that claims process or container separation must use the real + * transports; the README states that boundary. + */ +import type { FrameChannel } from './channel.js'; +import type { HostTransport } from './remote-adapter.js'; + +/** In-memory channel: one half of a loopback pair. */ +export class LoopbackChannel implements FrameChannel { + readonly kind = 'loopback' as const; + + /** Every frame this side wrote, in order — the wire, verbatim. */ + readonly written: string[] = []; + + peer: LoopbackChannel | undefined; + + private lineHandler: ((line: string) => void) | undefined; + private closeHandler: (() => void) | undefined; + private closed = false; + + write(lines: readonly string[]): void { + for (const line of lines) { + this.written.push(line); + } + const peer = this.peer; + if (peer === undefined || peer.closed) { + return; + } + // Asynchronous delivery, so a loopback pair has the same interleaving + // rules as a real pipe: a handler never runs inside `write`. + setImmediate(() => { + for (const line of lines) { + peer.deliver(line); + } + }); + } + + onLine(handler: (line: string) => void): void { + this.lineHandler = handler; + } + + onClose(handler: () => void): void { + this.closeHandler = handler; + if (this.closed) { + handler(); + } + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.closeHandler?.(); + const peer = this.peer; + if (peer !== undefined && !peer.closed) { + setImmediate(() => { + peer.close(); + }); + } + } + + /** Bytes this side put on the wire. */ + get bytesWritten(): number { + return this.written.reduce( + (total, line) => total + Buffer.byteLength(line, 'utf8'), + 0, + ); + } + + private deliver(line: string): void { + if (this.closed) { + return; + } + this.lineHandler?.(line); + } +} + +export interface LoopbackPair { + runtime: LoopbackChannel; + host: LoopbackChannel; +} + +/** Two connected {@link LoopbackChannel}s: a runtime side and a host side. */ +export function createLoopbackChannelPair(): LoopbackPair { + const runtime = new LoopbackChannel(); + const host = new LoopbackChannel(); + runtime.peer = host; + host.peer = runtime; + return { runtime, host }; +} + +/** + * A {@link HostTransport} over a channel that already exists. + * + * `boundary` is the caller's to declare, and the caller must declare it + * honestly: a loopback pair is `in-process` unless a test is deliberately + * simulating a descriptor. + */ +export class DirectHostTransport implements HostTransport { + constructor( + readonly boundary: HostTransport['boundary'], + private readonly channel: FrameChannel, + readonly hostLabel?: string, + ) {} + + current(): FrameChannel { + return this.channel; + } + + open(): Promise { + return Promise.resolve(this.channel); + } + + terminate(): Promise { + this.channel.close(); + return Promise.resolve(); + } +} diff --git a/packages/isolation/src/timer.ts b/packages/isolation/src/timer.ts new file mode 100644 index 0000000..0a04ecb --- /dev/null +++ b/packages/isolation/src/timer.ts @@ -0,0 +1,91 @@ +/** + * The one clock this package uses (SPEC §8.3 turn deadlines, §10.3 timing + * normalization). + * + * Every wait — the per-call deadline and the Mode R fixed-schedule pad — goes + * through {@link IsolationTimer}, so a test can drive both without a + * wall-clock sleep and without patching global timers (which would also patch + * the timers Node's own stream and child-process machinery uses). + */ + +export interface IsolationDelay { + readonly promise: Promise; + /** Cancel the pending resolution. The promise then never settles. */ + cancel(): void; +} + +export interface IsolationTimer { + /** Monotonic-ish milliseconds. Only differences are ever used. */ + now(): number; + delay(ms: number): IsolationDelay; +} + +/** Real timers and `performance.now()`. */ +export const systemTimer: IsolationTimer = { + now(): number { + return performance.now(); + }, + delay(ms: number): IsolationDelay { + let resolve: (() => void) | undefined; + const promise = new Promise((settle) => { + resolve = settle; + }); + const handle = setTimeout(() => { + resolve?.(); + }, Math.max(0, ms)); + return { + promise, + cancel(): void { + clearTimeout(handle); + }, + }; + }, +}; + +/** + * Test timer: records every requested wait and resolves it immediately, so a + * test can assert *that* a call was padded to the deadline and *by how much* + * without spending the time (SPEC §10.3: what matters is that the schedule is + * fixed, and the schedule is exactly this sequence of requested waits). + * + * `now()` advances by the recorded amount as each delay resolves, so elapsed + * time inside the adapter behaves as if the wait really happened. + */ +export class RecordingTimer implements IsolationTimer { + /** Every `delay(ms)` this timer was asked for, in call order. */ + readonly waits: number[] = []; + + private clock: number; + + constructor(start = 0) { + this.clock = start; + } + + now(): number { + return this.clock; + } + + /** Advance the virtual clock without a delay (simulates work taking time). */ + advance(ms: number): void { + this.clock += ms; + } + + delay(ms: number): IsolationDelay { + this.waits.push(ms); + let cancelled = false; + const promise = new Promise((resolve) => { + setImmediate(() => { + if (!cancelled) { + this.clock += Math.max(0, ms); + resolve(); + } + }); + }); + return { + promise, + cancel(): void { + cancelled = true; + }, + }; + } +} diff --git a/packages/isolation/tsconfig.json b/packages/isolation/tsconfig.json new file mode 100644 index 0000000..5eb0873 --- /dev/null +++ b/packages/isolation/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../types" + }, + { + "path": "../hashing" + }, + { + "path": "../learners" + }, + { + "path": "../scenario" + } + ] +} diff --git a/packages/leakage/__tests__/.gitkeep b/packages/leakage/__tests__/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/leakage/__tests__/semantic-leakage.test.ts b/packages/leakage/__tests__/semantic-leakage.test.ts new file mode 100644 index 0000000..3df9302 --- /dev/null +++ b/packages/leakage/__tests__/semantic-leakage.test.ts @@ -0,0 +1,152 @@ +import type { LearnerProvenance, LearnerTrackId, Sha256Hash } from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { + evaluateSemanticLeakage, + type SemanticFeatureRow, +} from '../src/index.js'; + +const HASH = `sha256:${'1'.repeat(64)}` as Sha256Hash; +const PRE_REGISTRATION = { + seed: 'semantic-leakage-test-v1', + confidence: 0.95, + permutations: 20, + maximumAccuracyAdvantage: 0.1, + minimumTestRows: 200, + positiveControlMinimumAdvantage: 0.2, +} as const; + +function provenance( + track: LearnerTrackId, + options: { tokenizer?: boolean; textAligned?: boolean } = {}, +): LearnerProvenance { + const frozen = track === 'frozen-llm'; + return { + track, + modelRef: `reference:${track}`, + textTokenizerPresent: options.tokenizer ?? frozen, + textAlignedEncoderPresent: options.textAligned ?? frozen, + weightUpdatePath: + track === 'no-learning' || frozen ? 'none' : 'private-buffers-only', + components: [ + { + name: frozen ? 'frozen-language-model' : 'sensory-policy', + kind: frozen ? 'language-model' : 'sensory-encoder', + provenance: frozen ? 'frozen-open-weight' : 'random-init', + hash: HASH, + textAligned: options.textAligned ?? frozen, + }, + ], + }; +} + +function rows(leaked: boolean, count = 800): SemanticFeatureRow[] { + return Array.from({ length: count }, (_, index) => { + const label = index % 4; + return { + label: `english-class-${String(label)}`, + features: leaked + ? Array.from({ length: 4 }, (_, feature) => + feature === label ? 1 : 0, + ) + : [0, 0, 0, 0], + }; + }); +} + +describe('semantic-leakage battery (ALD-057)', () => { + it.each(['scratch-rl', 'self-supervised', 'hybrid'] as const)( + 'runs all strict qualification checks for %s', + (track) => { + const result = evaluateSemanticLeakage({ + provenance: provenance(track), + frozenFeatures: rows(false), + preRegistration: PRE_REGISTRATION, + }); + expect(result.tokenizerVocabularyAudit.passed).toBe(true); + expect(result.linearProbe).toMatchObject({ + observedAccuracy: 0.25, + shuffledControl: { confidence: 0.95, lower: 0.25, upper: 0.25 }, + withinShuffledInterval: true, + majorityBaselineAccuracy: 0.25, + negativeBoundDecision: 'below-bound', + positiveControl: { observedAccuracy: 1, detected: true }, + }); + expect(result.visionLanguageEncoderAudit.passed).toBe(true); + expect(result.classification).toBe('strict-ungrounded-eligible'); + expect(result.claimEligible).toBe(true); + }, + ); + + it('blocks an above-control English-label linear probe', () => { + const result = evaluateSemanticLeakage({ + provenance: provenance('scratch-rl'), + frozenFeatures: rows(true), + preRegistration: PRE_REGISTRATION, + }); + expect(result.linearProbe?.observedAccuracy).toBe(1); + expect(result.linearProbe?.withinShuffledInterval).toBe(false); + expect(result.classification).toBe('strict-ungrounded-blocked'); + expect(result.claimEligible).toBe(false); + }); + + it('blocks an underpowered negative bound even when the point estimate is at chance', () => { + const result = evaluateSemanticLeakage({ + provenance: provenance('scratch-rl'), + frozenFeatures: rows(false, 64), + preRegistration: PRE_REGISTRATION, + }); + expect(result.linearProbe?.withinShuffledInterval).toBe(true); + expect(result.linearProbe?.negativeBoundDecision).toBe( + 'insufficient-test-rows', + ); + expect(result.classification).toBe('strict-ungrounded-blocked'); + }); + + it('automatically weakens a hybrid with text-aligned frozen features', () => { + const result = evaluateSemanticLeakage({ + provenance: provenance('hybrid', { textAligned: true }), + frozenFeatures: rows(false), + preRegistration: PRE_REGISTRATION, + }); + expect(result.visionLanguageEncoderAudit.passed).toBe(false); + expect(result.classification).toBe('weakened-text-aligned-features'); + expect(result.claimEligible).toBe(false); + }); + + it('classifies exempt and control tracks without ungrounded claims', () => { + const frozen = evaluateSemanticLeakage({ + provenance: provenance('frozen-llm'), + frozenFeatures: rows(false), + preRegistration: PRE_REGISTRATION, + }); + const control = evaluateSemanticLeakage({ + provenance: provenance('no-learning'), + frozenFeatures: rows(false), + preRegistration: PRE_REGISTRATION, + }); + expect(frozen).toMatchObject({ + linearProbe: null, + classification: 'pretrained-exempt', + claimEligible: false, + }); + expect(control).toMatchObject({ + linearProbe: null, + classification: 'no-learning-control', + claimEligible: false, + }); + }); + + it('rejects any confidence rule other than the pre-registered 95%', () => { + expect(() => + evaluateSemanticLeakage({ + provenance: provenance('scratch-rl'), + frozenFeatures: rows(false), + preRegistration: { + ...PRE_REGISTRATION, + confidence: 0.9, + } as unknown as typeof PRE_REGISTRATION, + }), + ).toThrow(/0\.95/u); + }); +}); diff --git a/packages/leakage/package.json b/packages/leakage/package.json new file mode 100644 index 0000000..5a869bc --- /dev/null +++ b/packages/leakage/package.json @@ -0,0 +1,25 @@ +{ + "name": "@ald/leakage", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/analysis": "*", + "@ald/learners": "*", + "@ald/scenario": "*", + "zod": "4.4.3" + } +} diff --git a/packages/leakage/src/index.ts b/packages/leakage/src/index.ts new file mode 100644 index 0000000..3ae1376 --- /dev/null +++ b/packages/leakage/src/index.ts @@ -0,0 +1,414 @@ +/** Deterministic semantic-leakage qualification (SPEC §6.5, ALD-057). */ +import { quantileSorted, wilsonInterval, type WilsonInterval } from '@ald/analysis'; +import { SeededPrng } from '@ald/hashing'; +import type { LearnerProvenance, LearnerTrackId } from '@ald/types'; + +export const SEMANTIC_LEAKAGE_ANALYSIS_VERSION = 'semantic-leakage-v2'; +export const SEMANTIC_LEAKAGE_CONFIDENCE = 0.95; +export const SEMANTIC_LEAKAGE_MAXIMUM_ADVANTAGE = 0.1; +export const SEMANTIC_LEAKAGE_MINIMUM_TEST_ROWS = 200; +export const SEMANTIC_LEAKAGE_POSITIVE_CONTROL_ADVANTAGE = 0.2; + +export interface SemanticFeatureRow { + readonly features: readonly number[]; + /** Human-language label, available only to the offline analysis. */ + readonly label: string; +} + +export interface SemanticLeakagePreRegistration { + readonly seed: string; + readonly confidence: 0.95; + readonly permutations: number; + readonly maximumAccuracyAdvantage: number; + readonly minimumTestRows: number; + readonly positiveControlMinimumAdvantage: number; +} + +export interface SemanticLeakageInput { + readonly provenance: LearnerProvenance; + readonly frozenFeatures: readonly SemanticFeatureRow[]; + readonly preRegistration: SemanticLeakagePreRegistration; +} + +export interface LinearProbeResult { + readonly observedAccuracy: number; + readonly shuffledControl: { + readonly confidence: 0.95; + readonly lower: number; + readonly upper: number; + readonly permutations: number; + }; + readonly withinShuffledInterval: boolean; + readonly majorityBaselineAccuracy: number; + readonly accuracyAdvantage: number; + /** A two-sided 90% Wilson interval is a one-sided 95% upper/lower bound. */ + readonly observedOneSided95: WilsonInterval; + readonly advantageUpperBound: number; + readonly maximumAccuracyAdvantage: number; + readonly negativeBoundDecision: + | 'below-bound' + | 'not-below-bound' + | 'insufficient-test-rows'; + readonly positiveControl: { + readonly observedAccuracy: number; + readonly advantageLowerBound: number; + readonly minimumAdvantage: number; + readonly detected: boolean; + }; + readonly trainRows: number; + readonly testRows: number; + readonly classes: number; +} + +export type SemanticClaimClassification = + | 'strict-ungrounded-eligible' + | 'strict-ungrounded-blocked' + | 'weakened-text-aligned-features' + | 'pretrained-exempt' + | 'no-learning-control'; + +export interface SemanticLeakageResult { + readonly analysisVersion: typeof SEMANTIC_LEAKAGE_ANALYSIS_VERSION; + readonly track: LearnerTrackId; + readonly modelRef: string; + readonly componentHashes: string[]; + readonly tokenizerVocabularyAudit: { + readonly passed: boolean; + readonly textTokenizerPresent: boolean; + readonly languageComponents: string[]; + }; + readonly linearProbe: LinearProbeResult | null; + readonly visionLanguageEncoderAudit: { + readonly passed: boolean; + readonly textAlignedComponents: string[]; + }; + readonly classification: SemanticClaimClassification; + readonly claimEligible: boolean; +} + +interface Split { + train: number[]; + test: number[]; +} + +function assertInput(input: SemanticLeakageInput): void { + if (input.preRegistration.seed.length === 0) { + throw new Error('semantic-leakage seed must not be empty'); + } + if (input.preRegistration.confidence !== SEMANTIC_LEAKAGE_CONFIDENCE) { + throw new Error('semantic-leakage confidence must be pre-registered at 0.95'); + } + if ( + !Number.isInteger(input.preRegistration.permutations) || + input.preRegistration.permutations < 20 + ) { + throw new Error('semantic-leakage permutations must be an integer of at least 20'); + } + if ( + !Number.isFinite(input.preRegistration.maximumAccuracyAdvantage) || + input.preRegistration.maximumAccuracyAdvantage <= 0 || + input.preRegistration.maximumAccuracyAdvantage >= 1 + ) { + throw new Error('maximumAccuracyAdvantage must be within (0, 1)'); + } + if ( + !Number.isInteger(input.preRegistration.minimumTestRows) || + input.preRegistration.minimumTestRows < 1 + ) { + throw new Error('minimumTestRows must be a positive integer'); + } + if ( + !Number.isFinite(input.preRegistration.positiveControlMinimumAdvantage) || + input.preRegistration.positiveControlMinimumAdvantage <= 0 || + input.preRegistration.positiveControlMinimumAdvantage >= 1 + ) { + throw new Error('positiveControlMinimumAdvantage must be within (0, 1)'); + } + if (input.frozenFeatures.length < 8) { + throw new Error('semantic-leakage probe requires at least 8 feature rows'); + } + const dimension = input.frozenFeatures[0]?.features.length ?? 0; + if (dimension === 0) { + throw new Error('semantic-leakage feature vectors must not be empty'); + } + for (const [index, row] of input.frozenFeatures.entries()) { + if (row.label.length === 0 || row.features.length !== dimension) { + throw new Error(`semantic-leakage row ${String(index)} has an invalid shape`); + } + if (row.features.some((value) => !Number.isFinite(value))) { + throw new Error(`semantic-leakage row ${String(index)} contains a non-finite feature`); + } + } +} + +/** Stratification prevents a chance result from being a split imbalance. */ +function stratifiedSplit(labels: readonly number[], seed: string): Split { + const byClass = new Map(); + labels.forEach((label, index) => { + const rows = byClass.get(label) ?? []; + rows.push(index); + byClass.set(label, rows); + }); + const train: number[] = []; + const test: number[] = []; + for (const [label, rows] of [...byClass].sort(([a], [b]) => a - b)) { + if (rows.length < 4) { + throw new Error(`semantic-leakage label ${String(label)} requires at least 4 rows`); + } + const shuffled = new SeededPrng(`${seed}/split/${String(label)}`).shuffle(rows); + const testCount = Math.max(1, Math.floor(shuffled.length / 4)); + test.push(...shuffled.slice(0, testCount)); + train.push(...shuffled.slice(testCount)); + } + return { train, test }; +} + +function trainLinearProbe( + rows: readonly SemanticFeatureRow[], + labels: readonly number[], + train: readonly number[], + classes: number, +): number[][] { + const dimensions = rows[0]?.features.length ?? 0; + const weights = Array.from({ length: classes }, () => + Array.from({ length: dimensions + 1 }, () => 0), + ); + for (let epoch = 0; epoch < 400; epoch += 1) { + const gradients = weights.map((row) => row.map(() => 0)); + for (const rowIndex of train) { + const vector = [...(rows[rowIndex]?.features ?? []), 1]; + const logits = weights.map((classWeights) => + classWeights.reduce( + (sum, weight, dimension) => sum + weight * (vector[dimension] ?? 0), + 0, + ), + ); + const maximum = Math.max(...logits); + const exponentials = logits.map((value) => Math.exp(value - maximum)); + const denominator = exponentials.reduce((sum, value) => sum + value, 0); + for (let classIndex = 0; classIndex < classes; classIndex += 1) { + const error = + (exponentials[classIndex] ?? 0) / denominator - + (labels[rowIndex] === classIndex ? 1 : 0); + for (let dimension = 0; dimension < vector.length; dimension += 1) { + const gradient = gradients[classIndex]; + if (gradient !== undefined) { + gradient[dimension] = + (gradient[dimension] ?? 0) + error * (vector[dimension] ?? 0); + } + } + } + } + const rate = 0.2 / Math.sqrt(epoch + 1); + for (let classIndex = 0; classIndex < classes; classIndex += 1) { + for (let dimension = 0; dimension <= dimensions; dimension += 1) { + const classWeights = weights[classIndex]; + const gradient = gradients[classIndex]; + if (classWeights !== undefined && gradient !== undefined) { + classWeights[dimension] = + (classWeights[dimension] ?? 0) - + (rate * (gradient[dimension] ?? 0)) / train.length; + } + } + } + } + return weights; +} + +function accuracy( + weights: readonly (readonly number[])[], + rows: readonly SemanticFeatureRow[], + labels: readonly number[], + indices: readonly number[], +): number { + let correct = 0; + for (const index of indices) { + const vector = [...(rows[index]?.features ?? []), 1]; + const logits = weights.map((classWeights) => + classWeights.reduce( + (sum, weight, dimension) => sum + weight * (vector[dimension] ?? 0), + 0, + ), + ); + let predicted = 0; + for (let classIndex = 1; classIndex < logits.length; classIndex += 1) { + if ((logits[classIndex] ?? -Infinity) > (logits[predicted] ?? -Infinity)) { + predicted = classIndex; + } + } + if (predicted === labels[index]) { + correct += 1; + } + } + return correct / indices.length; +} + +function evaluateLinearProbe(input: SemanticLeakageInput): LinearProbeResult { + const names = [...new Set(input.frozenFeatures.map((row) => row.label))].sort(); + if (names.length < 2) { + throw new Error('semantic-leakage probe requires at least two labels'); + } + const labelIndex = new Map(names.map((name, index) => [name, index])); + const labels = input.frozenFeatures.map((row) => labelIndex.get(row.label) ?? -1); + const split = stratifiedSplit(labels, input.preRegistration.seed); + const observed = accuracy( + trainLinearProbe(input.frozenFeatures, labels, split.train, names.length), + input.frozenFeatures, + labels, + split.test, + ); + const testClassCounts = new Array(names.length).fill(0); + for (const index of split.test) { + const label = labels[index] as number; + testClassCounts[label] = (testClassCounts[label] as number) + 1; + } + const majorityBaselineAccuracy = Math.max(...testClassCounts) / split.test.length; + const observedSuccesses = Math.round(observed * split.test.length); + const observedOneSided95 = wilsonInterval( + observedSuccesses, + split.test.length, + 0.9, + ); + const prng = new SeededPrng(`${input.preRegistration.seed}/label-shuffled`); + const shuffledAccuracies: number[] = []; + const trainLabels = split.train.map((index) => labels[index] ?? -1); + for (let permutation = 0; permutation < input.preRegistration.permutations; permutation += 1) { + const shuffled = prng.shuffle(trainLabels); + const controlLabels = [...labels]; + split.train.forEach((rowIndex, position) => { + controlLabels[rowIndex] = shuffled[position] ?? -1; + }); + shuffledAccuracies.push( + accuracy( + trainLinearProbe(input.frozenFeatures, controlLabels, split.train, names.length), + input.frozenFeatures, + labels, + split.test, + ), + ); + } + shuffledAccuracies.sort((a, b) => a - b); + const alpha = (1 - SEMANTIC_LEAKAGE_CONFIDENCE) / 2; + const lower = quantileSorted(shuffledAccuracies, alpha); + const upper = quantileSorted(shuffledAccuracies, 1 - alpha); + const positiveRows = input.frozenFeatures.map((row) => ({ + ...row, + features: [ + ...row.features, + ...names.map((name) => (name === row.label ? 1 : 0)), + ], + })); + const positiveAccuracy = accuracy( + trainLinearProbe(positiveRows, labels, split.train, names.length), + positiveRows, + labels, + split.test, + ); + const positiveInterval = wilsonInterval( + Math.round(positiveAccuracy * split.test.length), + split.test.length, + 0.9, + ); + const advantageUpperBound = + observedOneSided95.upper - majorityBaselineAccuracy; + const negativeBoundDecision = + split.test.length < input.preRegistration.minimumTestRows + ? 'insufficient-test-rows' + : advantageUpperBound <= + input.preRegistration.maximumAccuracyAdvantage + ? 'below-bound' + : 'not-below-bound'; + const positiveAdvantageLowerBound = + positiveInterval.lower - majorityBaselineAccuracy; + return { + observedAccuracy: observed, + shuffledControl: { + confidence: SEMANTIC_LEAKAGE_CONFIDENCE, + lower, + upper, + permutations: input.preRegistration.permutations, + }, + withinShuffledInterval: observed >= lower && observed <= upper, + majorityBaselineAccuracy, + accuracyAdvantage: observed - majorityBaselineAccuracy, + observedOneSided95, + advantageUpperBound, + maximumAccuracyAdvantage: + input.preRegistration.maximumAccuracyAdvantage, + negativeBoundDecision, + positiveControl: { + observedAccuracy: positiveAccuracy, + advantageLowerBound: positiveAdvantageLowerBound, + minimumAdvantage: + input.preRegistration.positiveControlMinimumAdvantage, + detected: + positiveAdvantageLowerBound >= + input.preRegistration.positiveControlMinimumAdvantage, + }, + trainRows: split.train.length, + testRows: split.test.length, + classes: names.length, + }; +} + +export function evaluateSemanticLeakage( + input: SemanticLeakageInput, +): SemanticLeakageResult { + assertInput(input); + const provenance = input.provenance; + const languageComponents = provenance.components + .filter( + (component) => + component.kind === 'language-model' || + component.provenance === 'frozen-open-weight', + ) + .map((component) => component.name); + const textAlignedComponents = provenance.components + .filter((component) => component.textAligned) + .map((component) => component.name); + const tokenizerPassed = + !provenance.textTokenizerPresent && languageComponents.length === 0; + const encoderPassed = + !provenance.textAlignedEncoderPresent && textAlignedComponents.length === 0; + + let linearProbe: LinearProbeResult | null = null; + let classification: SemanticClaimClassification; + if (provenance.track === 'frozen-llm') { + classification = 'pretrained-exempt'; + } else if (provenance.track === 'no-learning') { + classification = 'no-learning-control'; + } else { + linearProbe = evaluateLinearProbe(input); + if (!encoderPassed && provenance.track === 'hybrid') { + classification = 'weakened-text-aligned-features'; + } else if ( + tokenizerPassed && + encoderPassed && + linearProbe.negativeBoundDecision === 'below-bound' && + linearProbe.positiveControl.detected + ) { + classification = 'strict-ungrounded-eligible'; + } else { + classification = 'strict-ungrounded-blocked'; + } + } + + return { + analysisVersion: SEMANTIC_LEAKAGE_ANALYSIS_VERSION, + track: provenance.track, + modelRef: provenance.modelRef, + componentHashes: provenance.components.map((component) => component.hash), + tokenizerVocabularyAudit: { + passed: tokenizerPassed, + textTokenizerPresent: provenance.textTokenizerPresent, + languageComponents, + }, + linearProbe, + visionLanguageEncoderAudit: { + passed: encoderPassed, + textAlignedComponents, + }, + classification, + claimEligible: classification === 'strict-ungrounded-eligible', + }; +} diff --git a/packages/leakage/tsconfig.json b/packages/leakage/tsconfig.json new file mode 100644 index 0000000..1db47da --- /dev/null +++ b/packages/leakage/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../types" + }, + { + "path": "../hashing" + }, + { + "path": "../analysis" + }, + { + "path": "../learners" + }, + { + "path": "../scenario" + } + ] +} diff --git a/packages/learners/__tests__/adapter-recovery.test.ts b/packages/learners/__tests__/adapter-recovery.test.ts new file mode 100644 index 0000000..8ced932 --- /dev/null +++ b/packages/learners/__tests__/adapter-recovery.test.ts @@ -0,0 +1,644 @@ +/** + * Mid-run re-initialization of a learner adapter (SPEC §7.3 crash recovery), + * and the SPEC §14.5 retry of a failed adapter call. + * + * `NurseryRuntime.recover()` re-initializes the *same* run's adapter — same + * private seed, same `runId`, same Baby ledger chain — from the last exported + * policy checkpoint, and then keeps appending to that chain. Two properties + * have to survive that: + * + * - blinding nonces stay unique within the chain (LEDGER §12), which rules out + * any nonce drawn from a stream position that `init()` resets; + * - the episodic registries survive, so no term gets a second `term.first_*` + * event (CONCEPT-IDEA.md §11.2 rule 1) and no `hyp::` reference + * is issued twice for two different hypotheses (rule 3, LEDGER §5). + * + * A derived run (SPEC §7.4) loads the same checkpoint against a *fresh* chain + * under a new `runId` and must do the opposite: record its own first uses. + */ +import { + HASH_DOMAINS, + fixedTokenInventory, + type LedgerEvent, + type LedgerEventDraft, + type PrivateLedgerClient, + type RunConfig, + type Sha256Hash, +} from '@ald/types'; +import { domainHash, hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; + +import { + RecordingLedgerClient, + buildConformanceRunConfig, +} from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { NoLearningAdapter } from '../src/no-learning.js'; +import { TabularReinforceAdapter } from '../src/tabular-reinforce.js'; +import { + parseExportedTabularPolicy, + type ExportedEpisodicRegistries, +} from '../src/policy.js'; + +const OPTIONS = { learningRate: 1, temperature: 0.5 } as const; +const INVENTORY_SIZE = 6; +const CANDIDATE_REFS = ['object:a', 'object:b', 'object:c', 'object:d']; + +const inventory = fixedTokenInventory(INVENTORY_SIZE); + +function channelHashFor(turn: number): Sha256Hash { + return domainHash(HASH_DOMAINS.channelEvent, `turn-${String(turn)}`); +} + +/** The symbol the partner is taken to have sent on `turn`. */ +function deliveredSymbol(turn: number): string { + return inventory[turn % INVENTORY_SIZE] as string; +} + +async function initAdapter( + ledger: PrivateLedgerClient, + config: RunConfig, + initialPolicy?: unknown, +): Promise { + const adapter = new TabularReinforceAdapter(OPTIONS); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('scratch-rl'), + seed: 'private-seed-baby-a', + symbolInventory: inventory, + ledger, + ...(initialPolicy === undefined ? {} : { initialPolicy }), + }); + return adapter; +} + +/** + * One turn of the referential game against a single adapter: odd turns are + * sender turns, even turns receiver turns, so both tables move and hypotheses + * are revised rather than only created. + */ +async function playTurn( + adapter: TabularReinforceAdapter, + ledger: RecordingLedgerClient, + config: RunConfig, + turn: number, + options: { update?: boolean } = {}, +): Promise { + const sender = turn % 2 === 1; + ledger.turn = turn; + await adapter.observe({ + runId: config.runId, + turn, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: sender + ? [ + [turn % 4, (turn + 1) % 4, 1], + [(turn + 2) % 4, (turn + 3) % 4, 0], + ] + : [ + [turn % 4, 0], + [(turn + 1) % 4, 1], + [(turn + 2) % 4, 2], + [(turn + 3) % 4, 3], + ], + scenarioRef: `scenario:${String(turn)}`, + }); + + if (sender) { + const envelope = await adapter.act({ + turn, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + await ledger.append(envelope.privateLedgerDraft); + } + + const channelEventHash = channelHashFor(turn); + const interpretation = await adapter.receive({ + runId: config.runId, + turn, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols: [deliveredSymbol(turn)] }, + channelEventHash, + }); + await ledger.append(interpretation.privateLedgerDraft, { channelEventHash }); + + if (!sender) { + const envelope = await adapter.act({ + turn, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + await ledger.append(envelope.privateLedgerDraft); + } + + await adapter.onOutcome({ + runId: config.runId, + turn, + role: sender ? 'sender' : 'receiver', + success: turn % 3 !== 0, + reward: turn % 3 !== 0 ? 1 : 0, + payload: [turn % 3 !== 0 ? 1 : 0], + }); + + if (options.update ?? true) { + await adapter.updatePolicy({ + runId: config.runId, + turns: [turn], + learningSignal: 'extrinsic-task', + }); + } +} + +async function playTurns( + adapter: TabularReinforceAdapter, + ledger: RecordingLedgerClient, + config: RunConfig, + turns: readonly number[], + options: { update?: boolean } = {}, +): Promise { + for (const turn of turns) { + await playTurn(adapter, ledger, config, turn, options); + } +} + +function contentString(draft: LedgerEventDraft, field: string): string { + const value = draft.content[field]; + return typeof value === 'string' ? value : ''; +} + +/** + * Run turns 1..8, export the checkpoint, re-initialize a fresh adapter from it + * against the same chain, and run turns 9..16 — the shape of a restart + * recovery, with the drafts of both halves collected in one ledger. + */ +async function runWithRestart(): Promise<{ + ledger: RecordingLedgerClient; + policy: unknown; + beforeRestart: number; +}> { + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 16, + symbolInventorySize: INVENTORY_SIZE, + }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + + const first = await initAdapter(ledger, config); + await playTurns(first, ledger, config, [1, 2, 3, 4, 5, 6, 7, 8]); + const policy = first.exportPolicy(); + const beforeRestart = ledger.drafts.length; + + const second = await initAdapter(ledger, config, policy); + await playTurns(second, ledger, config, [9, 10, 11, 12, 13, 14, 15, 16]); + + return { ledger, policy, beforeRestart }; +} + +describe('re-initialization inside a run (SPEC §7.3)', () => { + it('never reuses a blinding nonce across the restart (LEDGER §12)', async () => { + const { ledger, beforeRestart } = await runWithRestart(); + const nonces = ledger.drafts.map((draft) => draft.blindingNonce); + + expect(beforeRestart).toBeGreaterThan(0); + expect(nonces.length).toBeGreaterThan(beforeRestart); + for (const nonce of nonces) { + expect(nonce).toMatch(/^[0-9a-f]{24}$/u); + } + expect(new Set(nonces).size).toBe(nonces.length); + }); + + it('re-emits no term.first_* event for a term already recorded', async () => { + const { ledger } = await runWithRestart(); + + for (const eventType of ['term.first_emitted', 'term.first_received'] as const) { + const terms = ledger + .draftsOf(eventType) + .map((draft) => contentString(draft, 'termRef')); + expect(terms.length).toBeGreaterThan(0); + expect(new Set(terms).size).toBe(terms.length); + } + // The restart really did cross terms it had already seen: the delivered + // symbols cycle through the whole inventory twice. + expect(ledger.countOf('term.first_received')).toBe(INVENTORY_SIZE); + }); + + it('never issues one hypothesis reference for two hypotheses', async () => { + const { ledger } = await runWithRestart(); + + const issued = new Set(); + for (const eventType of ['hypothesis.created', 'hypothesis.revised'] as const) { + for (const draft of ledger.draftsOf(eventType)) { + const ref = contentString(draft, 'hypothesisRef'); + expect(issued.has(ref)).toBe(false); + issued.add(ref); + } + } + expect(issued.size).toBeGreaterThan(0); + + // LEDGER §5: every revision names a prior hypothesis that exists. + const revisions = ledger.draftsOf('hypothesis.revised'); + expect(revisions.length).toBeGreaterThan(0); + for (const draft of revisions) { + expect(issued.has(contentString(draft, 'priorHypothesisRef'))).toBe(true); + } + }); + + it('carries the episodic registries in the exported checkpoint', async () => { + const { policy } = await runWithRestart(); + const parsed = parseExportedTabularPolicy(policy); + const registries = parsed.registries as ExportedEpisodicRegistries; + + expect(parsed.version).toBe(3); + expect(registries.babyId).toBe('A'); + expect(registries.emitted.length).toBeGreaterThan(0); + expect(registries.received.length).toBeGreaterThan(0); + expect(registries.hypotheses.length).toBeGreaterThan(0); + // Canonical order, so the checkpoint hash does not depend on discovery + // order (SPEC §14.3). + expect(registries.received).toEqual([...registries.received].sort()); + }); + + it('starts a derived run (SPEC §7.4) with empty registries', async () => { + const { policy } = await runWithRestart(); + const derivedConfig = buildConformanceRunConfig('scratch-rl', { + episodes: 4, + symbolInventorySize: INVENTORY_SIZE, + runId: 'run-derived', + }); + const ledger = new RecordingLedgerClient(derivedConfig.runId, 'baby-a'); + const derived = await initAdapter(ledger, derivedConfig, policy); + await playTurns(derived, ledger, derivedConfig, [1, 2]); + + // A fresh chain has no first-use event for any term yet, so the derived + // run records its own. + expect(ledger.countOf('term.first_emitted')).toBeGreaterThan(0); + expect(ledger.countOf('term.first_received')).toBeGreaterThan(0); + const registries = parseExportedTabularPolicy(derived.exportPolicy()) + .registries as ExportedEpisodicRegistries; + expect(registries.runId).toBe('run-derived'); + }); + + it('holds the checkpoint hash constant while no policy update runs', async () => { + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 8, + symbolInventorySize: INVENTORY_SIZE, + }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + const adapter = await initAdapter(ledger, config); + const before = hashCanonical( + HASH_DOMAINS.policyCheckpoint, + adapter.exportPolicy(), + ); + + // SPEC §7.2: `updatePolicy` is disabled once evaluation starts. New terms + // are still first used in that phase, and that must not move the policy + // hash the E11 harness checks for constancy. + await playTurns(adapter, ledger, config, [1, 2, 3, 4], { update: false }); + + expect(ledger.countOf('term.first_received')).toBeGreaterThan(0); + expect( + hashCanonical(HASH_DOMAINS.policyCheckpoint, adapter.exportPolicy()), + ).toBe(before); + }); +}); + +/** + * A ledger whose first append of one event type fails, standing in for the + * transient Ledger-Writer fault SPEC §14.5 bullet 4 retries an adapter call + * for. Everything else passes through to a recording client. + */ +class FailOnceLedgerClient implements PrivateLedgerClient { + private failed = false; + + constructor( + readonly inner: RecordingLedgerClient, + private readonly eventType: string, + ) {} + + async append( + draft: LedgerEventDraft, + options?: { channelEventHash?: Sha256Hash }, + ): Promise { + if (!this.failed && draft.eventType === this.eventType) { + this.failed = true; + throw new Error(`ledger unavailable for ${draft.eventType}`); + } + return this.inner.append(draft, options); + } +} + +describe('retried adapter calls (SPEC §14.5)', () => { + it('re-emits hypothesis.created when the first append failed', async () => { + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 4, + symbolInventorySize: INVENTORY_SIZE, + }); + const recording = new RecordingLedgerClient(config.runId, 'baby-a'); + const ledger = new FailOnceLedgerClient(recording, 'hypothesis.created'); + const adapter = await initAdapter(ledger, config); + + recording.turn = 1; + await adapter.observe({ + runId: config.runId, + turn: 1, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: [ + [0, 0, 1], + [1, 1, 0], + ], + scenarioRef: 'scenario:1', + }); + const envelope = await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + await recording.append(envelope.privateLedgerDraft); + + const outcome = { + runId: config.runId, + turn: 1, + role: 'sender' as const, + success: true, + reward: 1, + payload: [1], + }; + await expect(adapter.onOutcome(outcome)).rejects.toThrow( + 'ledger unavailable', + ); + expect(recording.countOf('hypothesis.created')).toBe(0); + + // The runtime's retry of the same call must produce the event the failed + // attempt lost, exactly once. + await adapter.onOutcome(outcome); + expect(recording.countOf('hypothesis.created')).toBe(1); + const created = recording.draftsOf('hypothesis.created')[0] as LedgerEventDraft; + const symbol = created.subjectId.replace('symbol:', ''); + expect(contentString(created, 'hypothesisRef')).toBe(`hyp:${symbol}:1`); + + // And the reference the map now holds is the one that was written, so a + // later revision resolves against it (LEDGER §5). + await adapter.updatePolicy({ + runId: config.runId, + turns: [1], + learningSignal: 'extrinsic-task', + }); + const registries = parseExportedTabularPolicy(adapter.exportPolicy()) + .registries as ExportedEpisodicRegistries; + expect(registries.hypotheses).toContainEqual({ + symbol, + version: 1, + hypothesisRef: `hyp:${symbol}:1`, + argmaxTypeCode: expect.any(Number) as number, + }); + }); + + it('re-emits term.first_received when the first append failed', async () => { + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 4, + symbolInventorySize: INVENTORY_SIZE, + }); + const recording = new RecordingLedgerClient(config.runId, 'baby-a'); + const ledger = new FailOnceLedgerClient(recording, 'term.first_received'); + const adapter = await initAdapter(ledger, config); + + recording.turn = 1; + const delivery = { + runId: config.runId, + turn: 1, + logicalSender: 'baby-b' as const, + carrier: 'fixed-token' as const, + publicArtifact: { symbols: [deliveredSymbol(1)] }, + channelEventHash: channelHashFor(1), + }; + await expect(adapter.receive(delivery)).rejects.toThrow('ledger unavailable'); + expect(recording.countOf('term.first_received')).toBe(0); + + // The retry delivers the same artifact, so the first use of that term is + // recorded exactly once and is not swallowed by the failed attempt. + const envelope = await adapter.receive(delivery); + expect(recording.countOf('term.first_received')).toBe(1); + expect( + contentString( + recording.draftsOf('term.first_received')[0] as LedgerEventDraft, + 'termRef', + ), + ).toBe(`symbol:${deliveredSymbol(1)}`); + expect(envelope.privateLedgerDraft.eventType).toBe('interpretation.recorded'); + }); +}); + +/** + * SPEC §14.5 retries the *whole* adapter call, so every private draw and every + * state change inside one has to be idempotent per turn, or the retried run + * stops being the run its seed replays (§14.3): a second `sampleIndex` shifts + * the action stream, and a second `predictionProgressReward` steps the + * intrinsic predictor twice and changes the reward for that turn. + */ +describe('idempotent adapter calls per turn (SPEC §14.5, §14.3)', () => { + /** Turn 1 as sender, turn 2 as sender, driven by hand. */ + async function senderTurn( + adapter: TabularReinforceAdapter, + config: RunConfig, + turn: number, + ): Promise { + await adapter.observe({ + runId: config.runId, + turn, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: [ + [turn % 4, (turn + 1) % 4, 1], + [(turn + 2) % 4, (turn + 3) % 4, 0], + ], + scenarioRef: `scenario:${String(turn)}`, + }); + } + + it('replays the same proposal when act() is called twice for one turn', async () => { + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 4, + symbolInventorySize: INVENTORY_SIZE, + }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + const adapter = await initAdapter(ledger, config); + + ledger.turn = 1; + await senderTurn(adapter, config, 1); + const budget = { + turn: 1, + role: 'sender' as const, + responseBudgetMs: 1_000, + availableActions: ['emit_symbols' as const], + }; + const first = await adapter.act(budget); + const second = await adapter.act(budget); + + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + // The first use of that term was recorded once, not once per attempt. + expect(ledger.countOf('term.first_emitted')).toBe(1); + + // And the action stream did not advance: the next turn draws what it + // would have drawn without the retry. + await ledger.append(first.privateLedgerDraft); + ledger.turn = 2; + await senderTurn(adapter, config, 2); + const next = await adapter.act({ ...budget, turn: 2 }); + + const control = new RecordingLedgerClient(config.runId, 'baby-a'); + const controlAdapter = await initAdapter(control, config); + control.turn = 1; + await senderTurn(controlAdapter, config, 1); + const controlFirst = await controlAdapter.act(budget); + await control.append(controlFirst.privateLedgerDraft); + control.turn = 2; + await senderTurn(controlAdapter, config, 2); + const controlNext = await controlAdapter.act({ ...budget, turn: 2 }); + + expect(JSON.stringify(next)).toBe(JSON.stringify(controlNext)); + }); + + it('leaves the predictor and the baseline as if onOutcome ran once', async () => { + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 4, + symbolInventorySize: INVENTORY_SIZE, + learningSignal: 'intrinsic-prediction-progress', + }); + const intrinsic = { ...OPTIONS, intrinsicMode: 'prediction-progress' } as const; + + const play = async (attempts: number): Promise => { + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + const adapter = new TabularReinforceAdapter(intrinsic); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('scratch-rl'), + seed: 'private-seed-baby-a', + symbolInventory: inventory, + ledger, + }); + ledger.turn = 1; + await senderTurn(adapter, config, 1); + const envelope = await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + await ledger.append(envelope.privateLedgerDraft); + for (let attempt = 0; attempt < attempts; attempt += 1) { + await adapter.onOutcome({ + runId: config.runId, + turn: 1, + role: 'sender', + success: false, + payload: [0], + }); + } + await adapter.updatePolicy({ + runId: config.runId, + turns: [1], + learningSignal: 'intrinsic-prediction-progress', + }); + return adapter.exportPolicy(); + }; + + const once = await play(1); + const retried = await play(2); + + // The exported checkpoint carries the predictor, the baseline and the + // learned tables, so equality here is the whole learned state. + expect(retried).toEqual(once); + const predictor = parseExportedTabularPolicy(retried) + .registries as ExportedEpisodicRegistries; + expect(predictor.predictor).toHaveLength(1); + }); +}); + +/** + * The `no-learning` control shares the nonce derivation, so its chain keeps + * unique nonces across a restart too. It exports no learned state, so it has + * no checkpoint to restore registries from — see `NoLearningAdapter.emitted`. + */ +describe('no-learning re-initialization (SPEC §7.3)', () => { + it('never reuses a blinding nonce across the restart', async () => { + const config = buildConformanceRunConfig('no-learning', { + episodes: 8, + symbolInventorySize: INVENTORY_SIZE, + }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + + const play = async ( + adapter: NoLearningAdapter, + turns: readonly number[], + ): Promise => { + for (const turn of turns) { + ledger.turn = turn; + await adapter.observe({ + runId: config.runId, + turn, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: [ + [turn % 4, (turn + 1) % 4, 1], + [(turn + 2) % 4, (turn + 3) % 4, 0], + ], + scenarioRef: `scenario:${String(turn)}`, + }); + const envelope = await adapter.act({ + turn, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + await ledger.append(envelope.privateLedgerDraft); + const channelEventHash = channelHashFor(turn); + const interpretation = await adapter.receive({ + runId: config.runId, + turn, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols: [deliveredSymbol(turn)] }, + channelEventHash, + }); + await ledger.append(interpretation.privateLedgerDraft, { + channelEventHash, + }); + } + }; + + const initNoLearning = async (): Promise => { + const adapter = new NoLearningAdapter(); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('no-learning'), + seed: 'private-seed-baby-a', + symbolInventory: inventory, + ledger, + }); + return adapter; + }; + + await play(await initNoLearning(), [1, 2, 3, 4]); + await play(await initNoLearning(), [5, 6, 7, 8]); + + const nonces = ledger.drafts.map((draft) => draft.blindingNonce); + expect(nonces.length).toBeGreaterThan(8); + expect(new Set(nonces).size).toBe(nonces.length); + }); +}); diff --git a/packages/learners/__tests__/affect-policies.test.ts b/packages/learners/__tests__/affect-policies.test.ts new file mode 100644 index 0000000..7c432d0 --- /dev/null +++ b/packages/learners/__tests__/affect-policies.test.ts @@ -0,0 +1,203 @@ +/** + * ALD-033 — the reference affect behaviours adapters use inside an open + * window (SPECIFICATION.md §9.3, §6.2; EXPERIMENT-NOTEBOOK.md E20). + * + * The Gateway's rules are tested in `@ald/gateway`; here we pin the purity, + * determinism, and declared domain of the helpers, plus the end-to-end + * property that matters for `affectMode: "derived"`: a measurement produced by + * `measurementFromState` maps, under the Gateway's fixed pre-registered + * `argmax-v1` mapping, to the display the projection table predicts. + */ +import { describe, expect, it } from 'vitest'; + +import { AFFECT_DISPLAY_IDS, AffectStateMeasurementSchema } from '@ald/types'; +import { SeededPrng, canonicalJson } from '@ald/hashing'; +import { resolveAffectDerivedMapping } from '@ald/gateway'; + +import { + AFFECT_MEASUREMENT_VERSION, + AFFECT_POLICY_VERSION, + AFFECT_SCORE_SLOTS, + AffectPolicyError, + OUTCOME_LINKED_AFFECT_MAPPING, + affectDisplayIndex, + affectProposal, + measurementFromState, + noLearningAffect, + outcomeLinkedAffect, + type AffectMeasurementState, +} from '../src/affect-policies.js'; + +function state( + overrides: Partial = {}, +): AffectMeasurementState { + return { + recentSuccessRate: 0.5, + recentRejectionRate: 0, + recentPredictionError: 0.5, + noveltyRate: 0, + ...overrides, + }; +} + +describe('ALD-033: noLearningAffect', () => { + it('draws only allowlisted displays and is reproducible from the seed', () => { + const draws = (seed: string): string[] => { + const prng = new SeededPrng(seed).derive('affect'); + return Array.from({ length: 60 }, () => noLearningAffect(prng)); + }; + const first = draws('seed-affect-1'); + expect(draws('seed-affect-1')).toEqual(first); + expect(draws('seed-affect-2')).not.toEqual(first); + for (const displayId of first) { + expect(AFFECT_DISPLAY_IDS).toContain(displayId); + } + }); + + it('covers all six displays over a long seeded stream (chance baseline)', () => { + const prng = new SeededPrng('seed-affect-coverage').derive('affect'); + const counts = new Map(); + for (let index = 0; index < 6000; index += 1) { + const displayId = noLearningAffect(prng); + counts.set(displayId, (counts.get(displayId) ?? 0) + 1); + } + expect([...counts.keys()].sort()).toEqual([...AFFECT_DISPLAY_IDS]); + // Observed frequencies, reported as software behaviour, not a finding. + for (const count of counts.values()) { + expect(count).toBeGreaterThan(800); + expect(count).toBeLessThan(1200); + } + }); +}); + +describe('ALD-033: outcomeLinkedAffect', () => { + it('maps the unit interval onto the six bands of the pre-registered mapping', () => { + expect(OUTCOME_LINKED_AFFECT_MAPPING).toBe('outcome-linked-bands-v1'); + expect(outcomeLinkedAffect(0)).toBe('A1'); + expect(outcomeLinkedAffect(0.16)).toBe('A1'); + expect(outcomeLinkedAffect(1 / 6)).toBe('A2'); + expect(outcomeLinkedAffect(0.34)).toBe('A3'); + expect(outcomeLinkedAffect(0.5)).toBe('A4'); + expect(outcomeLinkedAffect(0.7)).toBe('A5'); + expect(outcomeLinkedAffect(0.9)).toBe('A6'); + expect(outcomeLinkedAffect(1)).toBe('A6'); + }); + + it('is a pure function of the rate, with no state between calls', () => { + for (const rate of [0, 0.25, 0.5, 0.75, 1]) { + expect(outcomeLinkedAffect(rate)).toBe(outcomeLinkedAffect(rate)); + } + }); + + it('refuses a rate outside its declared domain rather than clamping', () => { + for (const bad of [-0.01, 1.01, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => outcomeLinkedAffect(bad)).toThrow(AffectPolicyError); + } + try { + outcomeLinkedAffect(2); + } catch (error) { + expect((error as AffectPolicyError).code).toBe('out-of-range'); + } + }); +}); + +describe('ALD-033: measurementFromState', () => { + it('produces a schema-valid six-score measurement at the pinned version', () => { + const value = measurementFromState(state({ recentSuccessRate: 0.75 })); + expect(AffectStateMeasurementSchema.parse(value)).toEqual(value); + expect(value.measurementVersion).toBe(AFFECT_MEASUREMENT_VERSION); + expect(value.scores).toHaveLength(AFFECT_SCORE_SLOTS.length); + expect(Object.keys(value).sort()).toEqual(['measurementVersion', 'scores']); + }); + + it('follows the pre-registered projection table exactly', () => { + const value = measurementFromState({ + recentSuccessRate: 0.8, + recentRejectionRate: 0.1, + recentPredictionError: 0.3, + noveltyRate: 0.2, + }); + expect(value.scores).toEqual([0.8, 0.2, 0.3, 0.7, 0.1, 0.2]); + }); + + it('is deterministic and canonicalizable', () => { + const a = measurementFromState(state({ recentPredictionError: 1 / 3 })); + const b = measurementFromState(state({ recentPredictionError: 1 / 3 })); + expect(canonicalJson(a)).toBe(canonicalJson(b)); + }); + + it('refuses every out-of-domain statistic', () => { + const fields: (keyof AffectMeasurementState)[] = [ + 'recentSuccessRate', + 'recentRejectionRate', + 'recentPredictionError', + 'noveltyRate', + ]; + for (const field of fields) { + expect(() => measurementFromState(state({ [field]: -1 }))).toThrow( + AffectPolicyError, + ); + expect(() => measurementFromState(state({ [field]: 1.5 }))).toThrow( + AffectPolicyError, + ); + expect(() => + measurementFromState(state({ [field]: Number.NaN })), + ).toThrow(AffectPolicyError); + } + }); + + it('feeds the Gateway argmax-v1 mapping to the display the table predicts', () => { + const mapping = resolveAffectDerivedMapping('argmax-v1'); + const cases: [AffectMeasurementState, string][] = [ + [state({ recentSuccessRate: 1, recentPredictionError: 0 }), 'A1'], + [state({ recentSuccessRate: 0, recentPredictionError: 0 }), 'A2'], + [state({ recentSuccessRate: 0.4, recentPredictionError: 1 }), 'A3'], + [state({ recentSuccessRate: 0.4, recentPredictionError: 0 }), 'A4'], + [ + state({ + recentSuccessRate: 0.2, + recentPredictionError: 0.2, + recentRejectionRate: 1, + }), + 'A5', + ], + [ + state({ + recentSuccessRate: 0.2, + recentPredictionError: 0.2, + noveltyRate: 1, + }), + 'A6', + ], + ]; + for (const [input, expected] of cases) { + expect(mapping.map(measurementFromState(input))).toBe(expected); + } + }); +}); + +describe('ALD-033: window proposal helpers', () => { + it('builds exactly the tool-only submit_affect proposal', () => { + expect(affectProposal('A3')).toEqual({ + kind: 'submit_affect', + publicArtifact: { displayId: 'A3' }, + }); + expect(Object.keys(affectProposal('A3')).sort()).toEqual([ + 'kind', + 'publicArtifact', + ]); + }); + + it('reports the allowlist index and refuses anything else', () => { + AFFECT_DISPLAY_IDS.forEach((displayId, index) => { + expect(affectDisplayIndex(displayId)).toBe(index); + }); + expect(() => + affectDisplayIndex('A7' as (typeof AFFECT_DISPLAY_IDS)[number]), + ).toThrow(AffectPolicyError); + }); + + it('pins the module version recorded in exported policies', () => { + expect(AFFECT_POLICY_VERSION).toBe('affect-policies-v1'); + }); +}); diff --git a/packages/learners/__tests__/carrier-support.test.ts b/packages/learners/__tests__/carrier-support.test.ts new file mode 100644 index 0000000..b994a7b --- /dev/null +++ b/packages/learners/__tests__/carrier-support.test.ts @@ -0,0 +1,215 @@ +import { fixedTokenInventory, type LearnerInitContext } from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { generativeBitmapModule } from '../../gateway/src/carriers/bitmap.js'; +import { generativeCanvasModule } from '../../gateway/src/carriers/canvas.js'; +import { generativeToneModule } from '../../gateway/src/carriers/tone.js'; +import type { CarrierModule } from '../../gateway/src/carrier-modules.js'; +import { + RecordingLedgerClient, + buildConformanceRunConfig, + runLearnerAdapterConformance, +} from '../src/conformance.js'; +import { + CARRIER_EMIT_KIND, + createCarrierSupport, + type CarrierAdapterSupport, + type CarrierMode, +} from '../src/carrier-support.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { resolveGameShape } from '../src/game.js'; +import { ExportedRecurrentScratchPolicySchema } from '../src/recurrent-scratch-policy.js'; +import { createRecurrentActorCriticAdapterFactory } from '../src/tabular-reinforce.js'; + +const GENERATIVE_MODULES: Readonly> = { + 'fixed-token': undefined, + 'fixed-glyph': undefined, + 'generative-bitmap': generativeBitmapModule, + 'generative-canvas': generativeCanvasModule, + 'generative-tone': generativeToneModule, +}; + +function supportFor( + carrier: CarrierMode, + seed: string, + options: { acquirePartnerForms?: boolean; modifyAcquiredForms?: boolean } = {}, +): { support: CarrierAdapterSupport; context: LearnerInitContext } { + const config = buildConformanceRunConfig('scratch-rl', { + carrierMode: carrier, + messageLength: 1, + symbolInventorySize: 8, + maxStrokes: 8, + }); + const context: LearnerInitContext = { + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('scratch-rl'), + seed, + symbolInventory: fixedTokenInventory(8), + ledger: new RecordingLedgerClient(config.runId, 'baby-a'), + }; + return { + support: createCarrierSupport( + context, + resolveGameShape({ messageLength: 1 }, 1), + { inventedFormCount: 8, ...options }, + ), + context, + }; +} + +function assertAcceptedByGateway( + module: CarrierModule, + context: LearnerInitContext, + artifact: unknown, +): void { + const result = module.validate( + { + kind: CARRIER_EMIT_KIND[context.config.carrierMode], + publicArtifact: artifact, + }, + { + runContext: { + runId: context.runId, + config: context.config, + symbolInventory: [...context.symbolInventory], + seed: context.seed, + }, + maxSymbolRepeats: 4, + }, + ); + expect(result).toMatchObject({ ok: true }); +} + +describe.each([ + 'generative-bitmap', + 'generative-canvas', + 'generative-tone', +] as const)('%s learnable form bank (E13 qualification)', (carrier) => { + it('acquires, exactly imitates, modifies, validates, and restores forms', () => { + const source = supportFor(carrier, `${carrier}-source`); + const learner = supportFor(carrier, `${carrier}-learner`, { + acquirePartnerForms: true, + modifyAcquiredForms: true, + }); + const artifact = source.support.artifactForForms([0]); + const before = learner.support.exportLearningState(); + + const firstObservation = learner.support.observeDelivery(artifact, 3); + expect(firstObservation.marks[0]?.formIndex).toBeNull(); + expect(learner.support.exportLearningState()).toEqual(before); + + const changes = learner.support.commitObservedForms([3]); + expect(changes.map((change) => change.kind)).toEqual([ + 'acquired', + 'modified', + ]); + const acquired = changes[0]; + const modified = changes[1]; + if (acquired === undefined || modified === undefined) { + throw new Error('expected one acquired and one modified form'); + } + expect(acquired.markHash).toBe(firstObservation.artifactMarkHash); + expect(modified.parentMarkHash).toBe(acquired.markHash); + expect(modified.markHash).not.toBe(acquired.markHash); + + const recognized = learner.support.parseDelivery(artifact).marks[0]; + expect(recognized).toMatchObject({ + formIndex: acquired.slot, + origin: 'acquired', + }); + expect(learner.support.artifactForForms([acquired.slot])).toEqual(artifact); + + const module = GENERATIVE_MODULES[carrier]; + if (module === undefined) throw new Error(`missing module for ${carrier}`); + assertAcceptedByGateway(module, learner.context, artifact); + assertAcceptedByGateway( + module, + learner.context, + learner.support.artifactForForms([modified.slot]), + ); + + const checkpoint = learner.support.exportLearningState(); + const restored = supportFor(carrier, `${carrier}-learner`, { + acquirePartnerForms: true, + modifyAcquiredForms: true, + }); + restored.support.restoreLearningState(JSON.parse(JSON.stringify(checkpoint))); + expect(restored.support.exportLearningState()).toEqual(checkpoint); + expect(restored.support.formInventoryHash).toBe( + learner.support.formInventoryHash, + ); + + const corrupted = JSON.parse(JSON.stringify(checkpoint)); + corrupted.slots[0].artifact.unexpected = 'not-a-carrier-field'; + expect(() => restored.support.restoreLearningState(corrupted)).toThrow( + /Unrecognized key|inconsistent or duplicate forms/u, + ); + + const evaluationSource = supportFor(carrier, `${carrier}-evaluation-source`); + restored.support.observeDelivery( + evaluationSource.support.artifactForForms([0]), + 9, + ); + expect(restored.support.exportLearningState()).toEqual(checkpoint); + }); +}); + +describe('carrier learning controls', () => { + it('keeps symbolic inventories immutable and rejects modification without acquisition', () => { + const symbolic = supportFor('fixed-token', 'symbolic', { + acquirePartnerForms: true, + modifyAcquiredForms: true, + }); + const before = symbolic.support.exportLearningState(); + symbolic.support.observeDelivery({ symbols: ['S01'] }, 1); + expect(symbolic.support.commitObservedForms([1])).toEqual([]); + expect(symbolic.support.exportLearningState()).toEqual(before); + + expect(() => + supportFor('generative-bitmap', 'invalid-options', { + modifyAcquiredForms: true, + }), + ).toThrow(/requires acquirePartnerForms/u); + }); + + it.each([ + 'generative-bitmap', + 'generative-canvas', + 'generative-tone', + ] as const)('runs the recurrent adapter contract over %s', async (carrier) => { + const result = await runLearnerAdapterConformance( + createRecurrentActorCriticAdapterFactory({ + learningRate: 0.01, + recurrent: { hiddenSize: 8, ppoEpochs: 2 }, + inventedFormCount: 8, + acquirePartnerForms: true, + modifyAcquiredForms: true, + }), + { + carrierMode: carrier, + messageLength: 1, + symbolInventorySize: 8, + episodes: 12, + seed: `${carrier}-adapter`, + }, + ); + + for (const role of ['baby-a', 'baby-b'] as const) { + const policy = ExportedRecurrentScratchPolicySchema.parse( + result.adapters[role].exportPolicy(), + ); + expect(policy.version).toBe(2); + if (policy.version !== 2) throw new Error('expected carrier-state policy'); + expect(policy.carrierState.carrier).toBe(carrier); + expect( + policy.carrierState.slots.some((slot) => slot.origin === 'acquired'), + ).toBe(true); + expect( + policy.carrierState.slots.some((slot) => slot.origin === 'modified'), + ).toBe(true); + } + }); +}); diff --git a/packages/learners/__tests__/conformance.test.ts b/packages/learners/__tests__/conformance.test.ts new file mode 100644 index 0000000..3162e7f --- /dev/null +++ b/packages/learners/__tests__/conformance.test.ts @@ -0,0 +1,556 @@ +import type { + DeliveredChannelArtifact, + LearnerAdapter, + LearnerAdapterFactory, + LearnerInitContext, + LedgerDraftEnvelope, + Observation, + OutcomeEvent, + PrivateLedgerClient, + TurnBudget, + TurnProposalEnvelope, +} from '@ald/types'; +import { LedgerEventSchema } from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { + RecordingLedgerClient, + assertAgentNativeContent, + assertToolOnlyProposal, + buildConformanceRunConfig, + runLearnerAdapterConformance, + tailSuccessRate, +} from '../src/conformance.js'; +import { LearnerConformanceError } from '../src/errors.js'; +import { createNoLearningAdapterFactory } from '../src/no-learning.js'; +import { createTabularReinforceAdapterFactory } from '../src/tabular-reinforce.js'; + +type Defect = + | 'none' + | 'free-text' + | 'english-artifact-field' + | 'english-ledger-gloss' + | 'trusted-field' + | 'nested-trusted-field' + | 'wrong-draft-type' + | 'human-audit-content' + | 'rewritten-channel-hash' + | 'wrong-interpretation-type' + | 'off-menu-selection' + | 'unexpected-update-policy'; + +/** A deliberately non-conforming adapter, one defect at a time. */ +class DefectiveAdapter implements LearnerAdapter { + readonly track = 'no-learning' as const; + private ledger: PrivateLedgerClient | undefined; + private candidateRefs: string[] = []; + + constructor(private readonly defect: Defect) { + if (defect === 'unexpected-update-policy') { + Object.defineProperty(this, 'updatePolicy', { + value: async () => + Promise.resolve({ + policyCheckpointRef: 'policy:none', + policyHash: `sha256:${'0'.repeat(64)}`, + turn: 0, + }), + }); + } + } + + async init(context: LearnerInitContext): Promise { + this.ledger = context.ledger; + return Promise.resolve(); + } + + async observe(_observation: Observation): Promise { + return Promise.resolve(); + } + + async act(turnBudget: TurnBudget): Promise { + this.candidateRefs = turnBudget.candidateRefs ?? []; + const proposal = + turnBudget.role === 'sender' + ? { + kind: 'emit_symbols', + publicArtifact: + this.defect === 'english-artifact-field' + ? { symbols: ['S01'], note: 'the red one' } + : { symbols: ['S01'] }, + } + : { + kind: 'select_object', + publicArtifact: { + objectRef: + this.defect === 'off-menu-selection' + ? 'object:not-offered' + : (this.candidateRefs[0] as string), + }, + }; + const draft = { + eventType: + this.defect === 'wrong-draft-type' ? 'term.first_emitted' : 'intention.recorded', + contentSchema: + this.defect === 'human-audit-content' + ? 'human-audit-ledger' + : 'agent-native-ledger', + subjectId: 'symbol:S01', + content: + this.defect === 'english-ledger-gloss' + ? { + artifactRef: 'proposal:x', + termRef: 'symbol:S01', + hypothesis: 'S13 means red circle', + } + : { artifactRef: 'proposal:x', termRef: 'symbol:S01' }, + blindingNonce: '0123456789abcdef01234567', + evidenceRefs: [], + }; + + if (this.defect === 'trusted-field') { + return Promise.resolve({ + proposal: { ...proposal, turn: turnBudget.turn }, + privateLedgerDraft: draft, + } as unknown as TurnProposalEnvelope); + } + if (this.defect === 'nested-trusted-field') { + return Promise.resolve({ + proposal: { + ...proposal, + publicArtifact: { ...proposal.publicArtifact, timestamp: 'now' }, + }, + privateLedgerDraft: draft, + } as unknown as TurnProposalEnvelope); + } + if (this.defect === 'free-text') { + return Promise.resolve({ + proposal, + privateLedgerDraft: draft, + message: 'the first mark is for the target', + } as unknown as TurnProposalEnvelope); + } + return Promise.resolve({ + proposal, + privateLedgerDraft: draft, + } as unknown as TurnProposalEnvelope); + } + + async receive( + delivery: DeliveredChannelArtifact, + ): Promise { + return Promise.resolve({ + channelEventHash: + this.defect === 'rewritten-channel-hash' + ? `sha256:${'a'.repeat(64)}` + : delivery.channelEventHash, + privateLedgerDraft: { + eventType: + this.defect === 'wrong-interpretation-type' + ? 'intention.recorded' + : 'interpretation.recorded', + contentSchema: 'agent-native-ledger', + subjectId: 'symbol:S01', + content: { artifactRef: delivery.channelEventHash }, + blindingNonce: '0123456789abcdef01234567', + evidenceRefs: [], + }, + } as unknown as LedgerDraftEnvelope); + } + + async onOutcome(_outcome: OutcomeEvent): Promise { + void this.ledger; + return Promise.resolve(); + } + + exportPolicy(): unknown { + return { kind: 'defective' }; + } +} + +function defectiveFactory(defect: Defect): LearnerAdapterFactory { + return { track: 'no-learning', create: () => new DefectiveAdapter(defect) }; +} + +describe('assertToolOnlyProposal (SPEC §6.3, §11.3)', () => { + const valid = { + proposal: { kind: 'emit_symbols', publicArtifact: { symbols: ['S01'] } }, + privateLedgerDraft: { + eventType: 'intention.recorded', + contentSchema: 'agent-native-ledger', + subjectId: 'symbol:S01', + content: { artifactRef: 'proposal:x' }, + blindingNonce: '0123456789abcdef01234567', + evidenceRefs: [], + }, + }; + + it('accepts a bare tool call with its intention draft', () => { + expect(() => assertToolOnlyProposal(valid, 'unit')).not.toThrow(); + }); + + it('rejects free text alongside a valid tool call', () => { + expect(() => + assertToolOnlyProposal({ ...valid, message: 'hello' }, 'unit'), + ).toThrow(LearnerConformanceError); + }); + + it('rejects a missing intention draft', () => { + expect(() => + assertToolOnlyProposal({ proposal: valid.proposal }, 'unit'), + ).toThrow(/missing privateLedgerDraft/u); + }); + + it('rejects every runtime-assigned trusted field, at any depth', () => { + for (const key of ['runId', 'turn', 'sender', 'hash', 'timestamp']) { + expect(() => + assertToolOnlyProposal( + { + ...valid, + proposal: { + ...valid.proposal, + publicArtifact: { symbols: ['S01'], [key]: 'x' }, + }, + }, + 'unit', + ), + key, + ).toThrow(/trusted field/u); + expect(() => + assertToolOnlyProposal( + { + ...valid, + proposal: { + ...valid.proposal, + publicArtifact: { nested: [{ [key]: 'x' }] }, + }, + }, + 'unit', + ), + `nested ${key}`, + ).toThrow(/trusted field/u); + } + }); + + it('rejects a trusted field regardless of letter case', () => { + expect(() => + assertToolOnlyProposal( + { + ...valid, + proposal: { + ...valid.proposal, + publicArtifact: { symbols: ['S01'], RunId: 'x' }, + }, + }, + 'unit', + ), + ).toThrow(/trusted field/u); + }); + + it('rejects extra keys on the proposal itself', () => { + expect(() => + assertToolOnlyProposal( + { ...valid, proposal: { ...valid.proposal, note: 'x' } }, + 'unit', + ), + ).toThrow(/exactly kind and publicArtifact/u); + }); + + it('rejects a non-object envelope', () => { + expect(() => assertToolOnlyProposal('emit S01', 'unit')).toThrow( + LearnerConformanceError, + ); + }); + + it('rejects a publicArtifact key that is not part of the kind\'s schema, even when it names no trusted field', () => { + // Regression: assertToolOnlyProposal used to constrain only the + // envelope's and the proposal's own key sets, never publicArtifact's, so + // a non-trusted free-text field riding alongside the tool call (e.g. a + // side channel between Babies) passed unnoticed (SPEC §6.3, §11.3). + expect(() => + assertToolOnlyProposal( + { + ...valid, + proposal: { + ...valid.proposal, + publicArtifact: { symbols: ['S01'], sideChannel: 'candidate 2' }, + }, + }, + 'unit', + ), + ).toThrow(/publicArtifact for kind "emit_symbols" must hold exactly symbols/u); + }); + + it('rejects a trusted field hidden as a non-enumerable own property', () => { + // Regression: assertNoTrustedFields used to walk with Object.entries, + // which only sees own *enumerable* properties, so a trusted field + // defined non-enumerably was invisible to it even though Object.keys + // still matched the schema's field set. + const publicArtifact: Record = { symbols: ['S01'] }; + Object.defineProperty(publicArtifact, 'runId', { + value: 'leaked-run', + enumerable: false, + }); + expect(Object.keys(publicArtifact)).toEqual(['symbols']); + expect(() => + assertToolOnlyProposal( + { ...valid, proposal: { ...valid.proposal, publicArtifact } }, + 'unit', + ), + ).toThrow(/trusted field/u); + }); + + it('rejects a trusted field planted on the publicArtifact prototype', () => { + // Regression: assertNoTrustedFields never walked the prototype chain, + // so a trusted field inherited rather than owned was invisible to it — + // and also invisible to Object.keys, so it would not even trip the + // artifact key-set check. + const publicArtifact = Object.assign( + Object.create({ runId: 'leaked-run', timestamp: 'leaked-ts' }), + { symbols: ['S01'] }, + ); + expect(Object.keys(publicArtifact)).toEqual(['symbols']); + expect(() => + assertToolOnlyProposal( + { ...valid, proposal: { ...valid.proposal, publicArtifact } }, + 'unit', + ), + ).toThrow(/trusted field/u); + }); +}); + +describe('assertAgentNativeContent (SPEC §11.4, CONCEPT-IDEA.md §20.6)', () => { + it('accepts the opaque references the reference adapters write', () => { + expect(() => + assertAgentNativeContent( + { + artifactRef: `proposal:sha256:${'a'.repeat(64)}`, + termRef: 'symbol:S01', + symbols: ['S01', 'S12'], + hypothesisRef: 'hyp:S01:2', + priorHypothesisRef: 'hyp:S01:1', + evidenceRef: 'outcome:37', + selection: 'o:0011aabbccdd', + targetTypeCode: 5, + associationOverTypeCodes: [0.5, 0.5], + policy: 'uniform-random', + }, + 'unit', + ), + ).not.toThrow(); + }); + + it('rejects an English gloss hiding among them', () => { + for (const gloss of [ + 'the red one', + 'S13 means red circle', + 'pick the object that is on the left of the target', + ]) { + expect(() => + assertAgentNativeContent({ termRef: 'symbol:S13', note: gloss }, 'unit'), + gloss, + ).toThrow(/human-language/u); + } + }); + + it('never repeats the offending text in the error it throws (§10.2)', () => { + expect(() => + assertAgentNativeContent({ note: 'the red one' }, 'unit'), + ).toThrow(/^(?!.*the red one).*$/su); + }); +}); + +describe('runLearnerAdapterConformance', () => { + it('rejects each defect it exists to catch', async () => { + const defects: Defect[] = [ + 'free-text', + 'english-artifact-field', + 'english-ledger-gloss', + 'trusted-field', + 'nested-trusted-field', + 'wrong-draft-type', + 'human-audit-content', + 'rewritten-channel-hash', + 'wrong-interpretation-type', + 'off-menu-selection', + 'unexpected-update-policy', + ]; + for (const defect of defects) { + await expect( + runLearnerAdapterConformance(defectiveFactory(defect), { + episodes: 2, + seed: `defect-${defect}`, + }), + defect, + ).rejects.toThrow(LearnerConformanceError); + } + }); + + it('names the smuggled English rather than a generic schema failure', async () => { + // Regression: the harness parsed each envelope with the SPEC §11.3 schema + // and then discarded the parse result, so a natural-language field the + // schema *stripped* still travelled on the raw envelope into the other + // Baby's receive(); and a private draft was checked only for + // `contentSchema: 'agent-native-ledger'`, never for what its content + // actually said. Both were a working covert channel through a + // "conforming" adapter (SPEC §6.3, §11.3, §11.4). + await expect( + runLearnerAdapterConformance(defectiveFactory('english-artifact-field'), { + episodes: 1, + seed: 'defect-artifact-english', + }), + ).rejects.toThrow(/publicArtifact for kind "emit_symbols"|note/u); + await expect( + runLearnerAdapterConformance(defectiveFactory('english-ledger-gloss'), { + episodes: 1, + seed: 'defect-gloss', + }), + ).rejects.toThrow(/human-language/u); + }); + + it('still accepts both shipped adapters under the content scan', async () => { + for (const factory of [ + createNoLearningAdapterFactory(), + createTabularReinforceAdapterFactory({ learningRate: 1, temperature: 0.5 }), + ]) { + const result = await runLearnerAdapterConformance(factory, { + episodes: 8, + seed: `shipped-${factory.track}`, + }); + expect(result.successFlags).toHaveLength(8); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(result.ledgers[role].drafts.length).toBeGreaterThan(0); + for (const draft of result.ledgers[role].drafts) { + expect(() => + assertAgentNativeContent(draft.content, draft.eventType), + ).not.toThrow(); + } + } + } + }); + + it('accepts the reference adapter under the same assertions', async () => { + const result = await runLearnerAdapterConformance( + createNoLearningAdapterFactory(), + { episodes: 6, seed: 'harness-happy-path' }, + ); + expect(result.successFlags).toHaveLength(6); + expect(tailSuccessRate(result, 3)).toBeGreaterThanOrEqual(0); + }); + + it('alternates roles every episode by default (SPEC §8.1 step 9)', async () => { + const result = await runLearnerAdapterConformance( + createNoLearningAdapterFactory(), + { episodes: 8, seed: 'role-reversal' }, + ); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(result.ledgers[role].countOf('interpretation.recorded')).toBe(4); + expect(result.ledgers[role].countOf('intention.recorded')).toBe(8); + } + }); + + it('honors roleReversalPeriod', async () => { + const result = await runLearnerAdapterConformance( + createNoLearningAdapterFactory(), + { episodes: 8, seed: 'role-reversal-4', roleReversalPeriod: 4 }, + ); + expect(result.config.roleReversalPeriod).toBe(4); + expect(result.ledgers['baby-a'].countOf('interpretation.recorded')).toBe(4); + }); + + it('rejects more candidates than there are object types', async () => { + await expect( + runLearnerAdapterConformance(createNoLearningAdapterFactory(), { + episodes: 1, + candidateCount: 32, + }), + ).rejects.toThrow(LearnerConformanceError); + }); + + it('gives each Baby a different derived seed', async () => { + const result = await runLearnerAdapterConformance( + createNoLearningAdapterFactory(), + { episodes: 4, seed: 'per-baby-seed' }, + ); + expect(result.adapters['baby-a'].exportPolicy()).not.toEqual( + result.adapters['baby-b'].exportPolicy(), + ); + }); +}); + +describe('buildConformanceRunConfig', () => { + it('produces a configuration that satisfies the track cross-checks', () => { + expect(buildConformanceRunConfig('no-learning').learningSignal).toBe('none'); + expect(buildConformanceRunConfig('scratch-rl').learningSignal).toBe( + 'extrinsic-task', + ); + expect( + buildConformanceRunConfig('scratch-rl', { + learningSignal: 'intrinsic-prediction-progress', + }).learningSignal, + ).toBe('intrinsic-prediction-progress'); + }); + + it('rejects a track/learning-signal combination the spec forbids', () => { + expect(() => + buildConformanceRunConfig('no-learning', { learningSignal: 'extrinsic-task' }), + ).toThrow(); + }); + + it('binds maxSymbolsPerMessage to the harness message length', () => { + expect(buildConformanceRunConfig('scratch-rl', { messageLength: 2 }) + .maxSymbolsPerMessage).toBe(2); + }); + + it('allows software qualification to extend the turn response budget', () => { + expect( + buildConformanceRunConfig('frozen-llm', { + turnResponseBudgetMs: 120_000, + }).turnResponseBudgetMs, + ).toBe(120_000); + }); +}); + +describe('RecordingLedgerClient', () => { + it('chains, sequences and validates the events it synthesizes', async () => { + const ledger = new RecordingLedgerClient('run-x', 'baby-b'); + const draft = { + eventType: 'term.first_emitted', + contentSchema: 'agent-native-ledger' as const, + subjectId: 'symbol:S01', + content: { termRef: 'symbol:S01' }, + blindingNonce: '0123456789abcdef01234567', + evidenceRefs: [], + }; + ledger.turn = 3; + const first = await ledger.append(draft); + const second = await ledger.append( + { ...draft, subjectId: 'symbol:S02', content: { termRef: 'symbol:S02' } }, + { channelEventHash: `sha256:${'b'.repeat(64)}` }, + ); + + expect(first.sequence).toBe(1); + expect(second.sequence).toBe(2); + expect(first.previousEntryHash).toBe(`sha256:${'0'.repeat(64)}`); + expect(second.previousEntryHash).toBe(first.entryHash); + expect(second.channelEventHash).toBe(`sha256:${'b'.repeat(64)}`); + expect(first.babyId).toBe('B'); + expect(first.turn).toBe(3); + expect(() => LedgerEventSchema.parse(second)).not.toThrow(); + expect(ledger.countOf('term.first_emitted')).toBe(2); + expect(ledger.draftsOf('term.first_emitted')).toHaveLength(2); + }); + + it('rejects a draft missing its required content fields', async () => { + const ledger = new RecordingLedgerClient('run-x', 'baby-a'); + await expect( + ledger.append({ + eventType: 'hypothesis.revised', + contentSchema: 'agent-native-ledger', + subjectId: 'symbol:S01', + content: { hypothesisRef: 'hyp:S01:2' }, + blindingNonce: '0123456789abcdef01234567', + evidenceRefs: [], + }), + ).rejects.toThrow(/priorHypothesisRef/u); + }); +}); diff --git a/packages/learners/__tests__/contract-lint.test.ts b/packages/learners/__tests__/contract-lint.test.ts new file mode 100644 index 0000000..f95e8c9 --- /dev/null +++ b/packages/learners/__tests__/contract-lint.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + CONTRACT_LINT_RULES, + lintLearnerContractText, +} from '../src/contract-lint.js'; +import { learnerContractsDirectory } from '../src/contracts.js'; + +const SCRIPT = fileURLToPath( + new URL('../../../scripts/lint-learner-contracts.mjs', import.meta.url), +); + +/** One text per banned pattern of SPEC §6.4, keyed by the rule it must trip. */ +const OFFENDING_TEXTS: Record = { + 'symbol-identifier': ['The mark S13 is available to both learners.'], + 'glyph-identifier': ['The carrier offers glyph G04 first.'], + 'affect-identifier': ['Display A3 reports low arousal.'], + 'assigned-meaning': [ + 'A mark means an object attribute.', + 'The first mark stands for a target object.', + ], + 'mapping-arrow': ['mark -> object attribute', 'mark => object attribute'], + 'illustrative-example': [ + 'For example, a mark may be reused.', + 'Choose a mark, e.g. the first one.', + 'An Example exchange follows.', + ], + 'color-or-shape-word': [ + 'The target is the red object.', + 'A triangle is a valid target.', + 'Prefer the BLUE candidate.', + ], + emoji: ['Use \u{1F642} as an affect display.'], + 'fenced-code-block': ['```text', ' ~~~'], +}; + +function runScript(directory: string): { status: number; output: string } { + try { + const output = execFileSync(process.execPath, [SCRIPT, directory], { + encoding: 'utf8', + }); + return { status: 0, output }; + } catch (error) { + const failure = error as { status?: number; stdout?: string }; + return { status: failure.status ?? 1, output: failure.stdout ?? '' }; + } +} + +describe('lintLearnerContractText', () => { + it('catches every banned pattern and names the rule and line', () => { + for (const [ruleId, texts] of Object.entries(OFFENDING_TEXTS)) { + for (const text of texts) { + const violations = lintLearnerContractText(`clean line\n${text}`); + expect( + violations.some( + (violation) => violation.startsWith('2: ') && violation.includes(ruleId), + ), + `${ruleId} should reject: ${text}`, + ).toBe(true); + } + } + }); + + it('covers every declared rule with at least one fixture', () => { + expect(Object.keys(OFFENDING_TEXTS).sort()).toEqual( + CONTRACT_LINT_RULES.map((rule) => rule.id).sort(), + ); + }); + + it('accepts contract prose that names no marks or categories', () => { + expect( + lintLearnerContractText( + 'Treat every unfamiliar mark as semantically unknown.\n' + + 'Preserve contradictory evidence rather than overwriting it.', + ), + ).toEqual([]); + }); + + it('exempts the contract header line but not a header elsewhere', () => { + expect(lintLearnerContractText('')).toEqual( + [], + ); + const violations = lintLearnerContractText( + 'body\n', + ); + expect(violations.some((violation) => violation.includes('mapping-arrow'))).toBe( + true, + ); + }); + + it('reports one violation per rule per line, in line order', () => { + const violations = lintLearnerContractText('a mark means red\nclean\nS13'); + expect(violations[0]).toContain('1: assigned-meaning'); + expect(violations[1]).toContain('1: color-or-shape-word'); + expect(violations[2]).toContain('3: symbol-identifier'); + }); +}); + +describe('the shipped contracts', () => { + it('holds one contract per implemented track and nothing else', () => { + expect(readdirSync(learnerContractsDirectory()).sort()).toEqual([ + 'learner-contract.frozen-llm.v1.md', + 'learner-contract.hybrid.v1.md', + 'learner-contract.no-learning.v1.md', + 'learner-contract.scratch-rl.v1.md', + 'learner-contract.self-supervised.v1.md', + ]); + }); + + it('passes the lint, header included', () => { + for (const file of readdirSync(learnerContractsDirectory())) { + const text = readFileSync(join(learnerContractsDirectory(), file), 'utf8'); + expect(lintLearnerContractText(text), file).toEqual([]); + } + }); +}); + +describe('scripts/lint-learner-contracts.mjs', () => { + it('exits 0 and reports the count for the shipped contracts', () => { + const result = runScript(learnerContractsDirectory()); + expect(result.status).toBe(0); + expect(result.output).toContain('5 contract file(s) clean'); + }); + + it('exits 1 and prints file:line: rule for a seeded contract', () => { + const directory = mkdtempSync(join(tmpdir(), 'ald-contract-lint-')); + writeFileSync( + join(directory, 'learner-contract.bad.v1.md'), + '\n\nclean line\nthe mark S13 means red\n', + 'utf8', + ); + const result = runScript(directory); + expect(result.status).toBe(1); + expect(result.output).toContain('learner-contract.bad.v1.md:4: symbol-identifier'); + expect(result.output).toContain('assigned-meaning'); + expect(result.output).toContain('color-or-shape-word'); + }); + + it('exits 1 when the directory holds no contracts', () => { + const directory = mkdtempSync(join(tmpdir(), 'ald-contract-lint-empty-')); + const result = runScript(directory); + expect(result.status).toBe(1); + expect(result.output).toContain('no contract files found'); + }); + + it('agrees with the TypeScript twin on every fixture', () => { + const directory = mkdtempSync(join(tmpdir(), 'ald-contract-lint-twin-')); + const texts = Object.values(OFFENDING_TEXTS).flat(); + texts.forEach((text, index) => { + writeFileSync( + join(directory, `learner-contract.case${index}.v1.md`), + `\n${text}\n`, + 'utf8', + ); + }); + const result = runScript(directory); + const expected = texts.reduce( + (total, text) => + total + + lintLearnerContractText(`\n${text}\n`) + .length, + 0, + ); + expect(result.status).toBe(1); + expect(result.output).toContain(`${expected} violation(s)`); + }); +}); diff --git a/packages/learners/__tests__/contracts.test.ts b/packages/learners/__tests__/contracts.test.ts new file mode 100644 index 0000000..5b7cf5f --- /dev/null +++ b/packages/learners/__tests__/contracts.test.ts @@ -0,0 +1,125 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { HASH_DOMAINS } from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; + +import { + learnerContractPath, + loadLearnerContract, + promptBundleHash, +} from '../src/contracts.js'; +import { LearnerConfigurationError } from '../src/errors.js'; + +const CONCEPT_IDEA = fileURLToPath( + new URL('../../../CONCEPT-IDEA.md', import.meta.url), +); + +/** The CONCEPT-IDEA.md §20.3 template, extracted from the source document. */ +function conceptTemplate(): string { + const source = readFileSync(CONCEPT_IDEA, 'utf8'); + const section = source.slice( + source.indexOf('### 20.3'), + source.indexOf('### 20.4'), + ); + const match = /```text\n([\s\S]*?)\n```/u.exec(section); + if (match?.[1] === undefined) { + throw new Error('CONCEPT-IDEA.md §20.3 template block not found'); + } + return match[1].trim(); +} + +describe('loadLearnerContract', () => { + it('uses the CONCEPT-IDEA.md §20.3 template verbatim as the frozen-llm v1 body', () => { + expect(loadLearnerContract('frozen-llm').text).toBe(conceptTemplate()); + }); + + it('resolves contracts relative to the module, not the working directory', () => { + expect(learnerContractPath('scratch-rl')).toMatch( + /contracts\/learner-contract\.scratch-rl\.v1\.md$/u, + ); + expect(loadLearnerContract('scratch-rl').version).toBe('1'); + expect(loadLearnerContract('scratch-rl').track).toBe('scratch-rl'); + }); + + it('states the tool-only constraint, the alternate-channel ban, and evidence preservation', () => { + for (const track of ['scratch-rl', 'no-learning'] as const) { + // Contract bodies are hard-wrapped, so phrases are matched against the + // whitespace-normalized text. + const text = loadLearnerContract(track) + .text.toLowerCase() + .replace(/\s+/gu, ' '); + expect(text).toContain('approved tool proposal'); + expect(text).toContain('construct another communication route'); + expect(text).toContain('alternate mark channel'); + expect(text).toContain('no assigned meaning'); + } + expect(loadLearnerContract('scratch-rl').text).toContain( + 'Preserve contradictory evidence', + ); + expect(loadLearnerContract('no-learning').text).toContain( + 'preserve contradictory evidence', + ); + }); + + it('rejects a missing version', () => { + expect(() => loadLearnerContract('scratch-rl', 2)).toThrow(); + }); + + it('loads contracts for the self-supervised and hybrid tracks', () => { + expect(loadLearnerContract('self-supervised')).toMatchObject({ + track: 'self-supervised', + version: '1', + }); + expect(loadLearnerContract('hybrid')).toMatchObject({ + track: 'hybrid', + version: '1', + }); + }); +}); + +describe('promptBundleHash', () => { + it('hashes the canonical track-to-body map under the prompt-bundle domain', () => { + const contracts = [ + loadLearnerContract('scratch-rl'), + loadLearnerContract('no-learning'), + ]; + expect(promptBundleHash(contracts)).toBe( + hashCanonical(HASH_DOMAINS.promptBundle, { + 'scratch-rl': contracts[0]?.text, + 'no-learning': contracts[1]?.text, + }), + ); + }); + + it('does not depend on the order the contracts were loaded', () => { + const a = loadLearnerContract('scratch-rl'); + const b = loadLearnerContract('no-learning'); + expect(promptBundleHash([a, b])).toBe(promptBundleHash([b, a])); + }); + + it('changes when a track joins the bundle', () => { + const pair = [ + loadLearnerContract('scratch-rl'), + loadLearnerContract('no-learning'), + ]; + expect(promptBundleHash(pair)).not.toBe( + promptBundleHash([...pair, loadLearnerContract('frozen-llm')]), + ); + }); + + it('rejects an empty bundle and conflicting contracts for one track', () => { + expect(() => promptBundleHash([])).toThrow(LearnerConfigurationError); + const contract = loadLearnerContract('scratch-rl'); + expect(() => + promptBundleHash([contract, { ...contract, text: 'different body' }]), + ).toThrow(LearnerConfigurationError); + }); + + it('is stable across loads, so a referenced version is immutable in evidence', () => { + expect(promptBundleHash([loadLearnerContract('frozen-llm')])).toBe( + promptBundleHash([loadLearnerContract('frozen-llm')]), + ); + }); +}); diff --git a/packages/learners/__tests__/empty-message.test.ts b/packages/learners/__tests__/empty-message.test.ts new file mode 100644 index 0000000..2022a28 --- /dev/null +++ b/packages/learners/__tests__/empty-message.test.ts @@ -0,0 +1,356 @@ +/** + * SPEC §9.6 `disabled`: a receiver turn with no delivered message. + * + * The control delivers no artifact at all, and §8.2 forbids an interpretation + * event when there is no channel event to reference, so the receiver acts + * without having received anything. Both reference tracks must therefore + * accept an empty message: the choice falls back to uniform over the offered + * candidates (no symbol, no information), the intention event records + * `symbols: []`, and — for the learning track — the turn contributes no + * REINFORCE update, because there is no symbol row to credit. + */ +import { HASH_DOMAINS, fixedTokenInventory } from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; +import type { + DeliveredChannelArtifact, + LearnerAdapter, + Observation, + RunConfig, +} from '@ald/types'; + +import { + RecordingLedgerClient, + buildConformanceRunConfig, +} from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { NoLearningAdapter } from '../src/no-learning.js'; +import { TabularReinforceAdapter } from '../src/tabular-reinforce.js'; + +const SYMBOL_INVENTORY_SIZE = 8; +const CANDIDATES = 4; +const TURNS = 2_000; + +/** Four candidate rows, receiver view (`attributeCount` columns, no target flag). */ +const RECEIVER_PAYLOAD = [ + [0, 0], + [1, 1], + [2, 2], + [3, 3], +]; + +/** One sender row set whose target is the first row. */ +const SENDER_PAYLOAD = [ + [0, 0, 1], + [1, 1, 0], + [2, 2, 0], + [3, 3, 0], +]; + +const CANDIDATE_REFS = Array.from( + { length: CANDIDATES }, + (_, index) => `object:candidate-${String(index)}`, +); + +function observation( + config: RunConfig, + turn: number, + payload: number[][], +): Observation { + return { + runId: config.runId, + turn, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload, + scenarioRef: 'scenario:empty-message', + }; +} + +async function initAdapter( + adapter: T, + track: 'no-learning' | 'scratch-rl', + seed = 'seed-empty-message', +): Promise<{ adapter: T; config: RunConfig; ledger: RecordingLedgerClient }> { + const config = buildConformanceRunConfig(track, { + episodes: TURNS, + symbolInventorySize: SYMBOL_INVENTORY_SIZE, + }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract(track), + seed, + symbolInventory: fixedTokenInventory(SYMBOL_INVENTORY_SIZE), + ledger, + }); + return { adapter, config, ledger }; +} + +function policyHash(adapter: LearnerAdapter): string { + return hashCanonical(HASH_DOMAINS.policyCheckpoint, adapter.exportPolicy()); +} + +/** The frequency of each candidate index over `turns` empty-message turns. */ +async function receiverFrequencies( + adapter: LearnerAdapter, + config: RunConfig, + turns: number, +): Promise { + const counts = new Array(CANDIDATES).fill(0); + for (let turn = 1; turn <= turns; turn += 1) { + await adapter.observe(observation(config, turn, RECEIVER_PAYLOAD)); + const envelope = await adapter.act({ + turn, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + const objectRef = ( + envelope.proposal.publicArtifact as { objectRef: string } + ).objectRef; + const index = CANDIDATE_REFS.indexOf(objectRef); + expect(index).toBeGreaterThanOrEqual(0); + counts[index] = (counts[index] ?? 0) + 1; + } + return counts.map((count) => count / turns); +} + +describe('scratch-rl receiver with no delivered message (SPEC §9.6 disabled)', () => { + it('chooses uniformly over the candidates with no receive()', async () => { + const { adapter, config } = await initAdapter( + new TabularReinforceAdapter({ learningRate: 1, temperature: 0.5 }), + 'scratch-rl', + ); + const frequencies = await receiverFrequencies(adapter, config, TURNS); + for (const frequency of frequencies) { + expect(frequency).toBeGreaterThanOrEqual(0.2); + expect(frequency).toBeLessThanOrEqual(0.3); + } + expect(frequencies.reduce((sum, value) => sum + value, 0)).toBeCloseTo(1); + }, 30_000); + + it('records an empty symbol list and no channel evidence ref', async () => { + const { adapter, config } = await initAdapter( + new TabularReinforceAdapter(), + 'scratch-rl', + ); + await adapter.observe(observation(config, 1, RECEIVER_PAYLOAD)); + const envelope = await adapter.act({ + turn: 1, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + const draft = envelope.privateLedgerDraft; + expect(draft.eventType).toBe('intention.recorded'); + expect(draft.content.symbols).toEqual([]); + // §8.2: with nothing delivered there is no channel event to reference. + expect( + draft.evidenceRefs.some((ref) => ref.startsWith('channel:')), + ).toBe(false); + // The distribution over the four candidates is exactly uniform. + expect(draft.content.associationWeights).toEqual([0.25, 0.25, 0.25, 0.25]); + expect(draft.content.probability).toBe(0.25); + }); + + it('leaves the policy hash untouched when every receiver turn is empty', async () => { + const { adapter, config } = await initAdapter( + new TabularReinforceAdapter({ learningRate: 1, temperature: 0.5 }), + 'scratch-rl', + ); + const before = policyHash(adapter); + const receiverBefore = adapter.exportPolicy().thetaReceiver; + for (let turn = 1; turn <= 200; turn += 1) { + await adapter.observe(observation(config, turn, RECEIVER_PAYLOAD)); + await adapter.act({ + turn, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + const success = turn % 3 === 0; + await adapter.onOutcome({ + runId: config.runId, + turn, + role: 'receiver', + success, + reward: success ? 1 : 0, + payload: [success ? 1 : 0], + }); + await adapter.updatePolicy?.({ + runId: config.runId, + turns: [turn], + learningSignal: 'extrinsic-task', + }); + } + // No symbol row to credit, and no baseline movement either: an + // empty-message receiver turn is evidence about nothing. + expect(policyHash(adapter)).toBe(before); + const policy = adapter.exportPolicy() as { + baseline: number; + thetaReceiver: number[][][]; + }; + expect(policy.baseline).toBe(0); + expect(policy.thetaReceiver).toEqual(receiverBefore); + }, 30_000); + + it('still updates the sender side of a run whose receiver turns are empty', async () => { + const { adapter, config } = await initAdapter( + new TabularReinforceAdapter({ learningRate: 1, temperature: 0.5 }), + 'scratch-rl', + ); + const receiverBefore = adapter.exportPolicy().thetaReceiver; + for (let turn = 1; turn <= 40; turn += 1) { + const isSender = turn % 2 === 1; + await adapter.observe( + observation(config, turn, isSender ? SENDER_PAYLOAD : RECEIVER_PAYLOAD), + ); + await adapter.act( + isSender + ? { + turn, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + } + : { + turn, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }, + ); + await adapter.onOutcome({ + runId: config.runId, + turn, + role: isSender ? 'sender' : 'receiver', + success: true, + reward: 1, + payload: [1], + }); + await adapter.updatePolicy?.({ + runId: config.runId, + turns: [turn], + learningSignal: 'extrinsic-task', + }); + } + const policy = adapter.exportPolicy() as { + baseline: number; + thetaSender: number[][]; + thetaReceiver: number[][][]; + }; + // The sender half learned; the receiver half saw no symbol at all. + expect(policy.thetaSender.flat().some((value) => value !== 0)).toBe(true); + expect(policy.baseline).toBeGreaterThan(0); + expect(policy.thetaReceiver).toEqual(receiverBefore); + }); + + it('does not reuse a message delivered on an earlier turn', async () => { + const { adapter, config } = await initAdapter( + new TabularReinforceAdapter(), + 'scratch-rl', + ); + const delivery: DeliveredChannelArtifact = { + runId: config.runId, + turn: 1, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols: [fixedTokenInventory(SYMBOL_INVENTORY_SIZE)[0] as string] }, + channelEventHash: `sha256:${'a'.repeat(64)}`, + }; + await adapter.observe(observation(config, 1, RECEIVER_PAYLOAD)); + await adapter.receive(delivery); + const bound = await adapter.act({ + turn: 1, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + expect(bound.privateLedgerDraft.content.symbols).toHaveLength(1); + + // §8.2 `ledgerLagTurns: 0`: turn 2 delivered nothing, so turn 1's message + // is not in hand any more. + await adapter.observe(observation(config, 2, RECEIVER_PAYLOAD)); + const empty = await adapter.act({ + turn: 2, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + expect(empty.privateLedgerDraft.content.symbols).toEqual([]); + }); +}); + +describe('no-learning receiver with no delivered message (SPEC §9.6 disabled)', () => { + it('chooses uniformly over the candidates with no receive()', async () => { + const { adapter, config } = await initAdapter( + new NoLearningAdapter(), + 'no-learning', + ); + const frequencies = await receiverFrequencies(adapter, config, TURNS); + for (const frequency of frequencies) { + expect(frequency).toBeGreaterThanOrEqual(0.2); + expect(frequency).toBeLessThanOrEqual(0.3); + } + }, 30_000); + + it('records an empty symbol list', async () => { + const { adapter, config } = await initAdapter( + new NoLearningAdapter(), + 'no-learning', + ); + await adapter.observe(observation(config, 1, RECEIVER_PAYLOAD)); + const envelope = await adapter.act({ + turn: 1, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + expect(envelope.privateLedgerDraft.content.symbols).toEqual([]); + }); + + it('does not reuse a message delivered on an earlier turn', async () => { + const { adapter, config } = await initAdapter( + new NoLearningAdapter(), + 'no-learning', + ); + await adapter.observe(observation(config, 1, RECEIVER_PAYLOAD)); + await adapter.receive({ + runId: config.runId, + turn: 1, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols: [fixedTokenInventory(SYMBOL_INVENTORY_SIZE)[0] as string] }, + channelEventHash: `sha256:${'b'.repeat(64)}`, + }); + const bound = await adapter.act({ + turn: 1, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + expect(bound.privateLedgerDraft.content.symbols).toHaveLength(1); + + await adapter.observe(observation(config, 2, RECEIVER_PAYLOAD)); + const empty = await adapter.act({ + turn: 2, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + expect(empty.privateLedgerDraft.content.symbols).toEqual([]); + }); +}); diff --git a/packages/learners/__tests__/frozen-llm-conformance.test.ts b/packages/learners/__tests__/frozen-llm-conformance.test.ts new file mode 100644 index 0000000..8aa417d --- /dev/null +++ b/packages/learners/__tests__/frozen-llm-conformance.test.ts @@ -0,0 +1,184 @@ +/** + * ALD-044 criterion 2 and criterion 3 (conformance half): the `frozen-llm` + * adapter completes full turns in both roles, over several seeds, through the + * shared `runLearnerAdapterConformance` harness — the same harness the + * `no-learning` and `scratch-rl` reference tracks pass — and satisfies the + * SPEC §6.2 rule that `updatePolicy` is absent for this track. + * + * Prototype Mode. The model is a deterministic `ScriptedModelClient`: this + * machine has no 3B-8B open-weight model and no GPU. Everything asserted here + * is software readiness of the adapter and its seams. The success rates the + * scripted convention produces are a property of the double and are not a + * finding about any model's behavior. + */ +import { HASH_DOMAINS, fixedTokenInventory } from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; + +import { runLearnerAdapterConformance } from '../src/conformance.js'; +import { createFrozenLlmAdapterFactory } from '../src/frozen-llm.js'; +import { ScriptedModelClient } from '../src/llm-scripted-client.js'; + +const INVENTORY_SIZE = 32; +const INVENTORY = fixedTokenInventory(INVENTORY_SIZE); +const SEEDS = ['ald-e10-seed-1', 'ald-e10-seed-2', 'ald-e10-seed-3']; + +function factory( + overrides: { behaviors?: Parameters[0] } = {}, +): ReturnType { + void overrides; + return createFrozenLlmAdapterFactory({ + client: new ScriptedModelClient({ symbolInventory: INVENTORY }), + valuesPerAttribute: 4, + attributeCount: 2, + messageLength: 1, + }); +} + +describe('ALD-044 criterion 2: full turns through the conformance harness', () => { + it.each(SEEDS)( + 'completes 16 episodes in both roles under seed %s', + async (seed) => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 16, + seed, + symbolInventorySize: INVENTORY_SIZE, + }); + + expect(result.episodes).toBe(16); + // One sender proposal and one receiver proposal per episode. + expect(result.proposals).toBe(32); + for (const role of ['baby-a', 'baby-b'] as const) { + const ledger = result.ledgers[role]; + expect(ledger.countOf('intention.recorded')).toBeGreaterThan(0); + expect(ledger.countOf('interpretation.recorded')).toBeGreaterThan(0); + expect(ledger.countOf('term.first_emitted')).toBeGreaterThan(0); + expect(ledger.countOf('term.first_received')).toBeGreaterThan(0); + expect(ledger.countOf('hypothesis.created')).toBeGreaterThan(0); + expect(ledger.countOf('policy.checkpointed')).toBe(0); + } + }, + ); + + it('never reads OutcomeEvent.reward: this track consumes no learning signal', async () => { + // `rewardVisibility: 'forbidden'` makes reading `outcome.reward` throw. + const result = await runLearnerAdapterConformance(factory(), { + episodes: 8, + seed: SEEDS[0], + symbolInventorySize: INVENTORY_SIZE, + rewardVisibility: 'forbidden', + }); + expect(result.episodes).toBe(8); + }); + + it('exposes no updatePolicy, so the harness records no checkpoints', async () => { + const built = factory(); + const adapter = built.create(); + expect(adapter.updatePolicy).toBeUndefined(); + + const result = await runLearnerAdapterConformance(factory(), { + episodes: 4, + seed: SEEDS[1], + symbolInventorySize: INVENTORY_SIZE, + }); + expect(result.checkpoints['baby-a']).toHaveLength(0); + expect(result.checkpoints['baby-b']).toHaveLength(0); + }); + + it('reports provenance and an exported policy for every adapter it built', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 4, + seed: SEEDS[2], + symbolInventorySize: INVENTORY_SIZE, + }); + for (const role of ['baby-a', 'baby-b'] as const) { + const adapter = result.adapters[role]; + expect(adapter.describeProvenance?.()).toMatchObject({ + track: 'frozen-llm', + weightUpdatePath: 'none', + }); + expect(adapter.exportPolicy()).toMatchObject({ + track: 'frozen-llm', + contractVersion: '1', + }); + } + }); +}); + +describe('ALD-044 determinism (SPEC §14.3)', () => { + it.each(SEEDS)( + 'reproduces identical policy hashes and ledger drafts under seed %s', + async (seed) => { + const options = { + episodes: 10, + seed, + symbolInventorySize: INVENTORY_SIZE, + } as const; + const first = await runLearnerAdapterConformance(factory(), options); + const second = await runLearnerAdapterConformance(factory(), options); + + expect(second.policyHashes).toEqual(first.policyHashes); + expect(second.successFlags).toEqual(first.successFlags); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(second.ledgers[role].drafts).toEqual( + first.ledgers[role].drafts, + ); + expect( + hashCanonical( + HASH_DOMAINS.policyCheckpoint, + second.adapters[role].exportPolicy(), + ), + ).toBe( + hashCanonical( + HASH_DOMAINS.policyCheckpoint, + first.adapters[role].exportPolicy(), + ), + ); + } + }, + ); + + it('gives different seeds different private conventions', async () => { + const first = await runLearnerAdapterConformance(factory(), { + episodes: 8, + seed: SEEDS[0], + symbolInventorySize: INVENTORY_SIZE, + }); + const second = await runLearnerAdapterConformance(factory(), { + episodes: 8, + seed: SEEDS[1], + symbolInventorySize: INVENTORY_SIZE, + }); + // The episodes themselves are seeded, so the two runs differ somewhere in + // the private ledgers even though the model is the same double. + expect(second.ledgers['baby-a'].drafts).not.toEqual( + first.ledgers['baby-a'].drafts, + ); + }); + + it('is a scripted convention, not a result: the success rate is recorded, not tuned', async () => { + const rates = await Promise.all( + SEEDS.map(async (seed) => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 16, + seed, + symbolInventorySize: INVENTORY_SIZE, + }); + return result.successRate; + }), + ); + // The only assertions are that the rate is well defined and reproducible. + // No threshold is claimed: the double's shared naming function, not any + // model or any learning, is what determines it. + for (const rate of rates) { + expect(rate).toBeGreaterThanOrEqual(0); + expect(rate).toBeLessThanOrEqual(1); + } + const repeat = await runLearnerAdapterConformance(factory(), { + episodes: 16, + seed: SEEDS[0], + symbolInventorySize: INVENTORY_SIZE, + }); + expect(repeat.successRate).toBe(rates[0]); + }); +}); diff --git a/packages/learners/__tests__/frozen-llm-gateway.test.ts b/packages/learners/__tests__/frozen-llm-gateway.test.ts new file mode 100644 index 0000000..fac2467 --- /dev/null +++ b/packages/learners/__tests__/frozen-llm-gateway.test.ts @@ -0,0 +1,303 @@ +/** + * ALD-044 criterion 2 (end-to-end half) and criterion 3 (Gateway-conformance + * half): every completion shape the frozen model can produce is carried + * through a real `SymbolGatewayImpl` over a real `InMemoryEvidenceWriter`, and + * the reason code the §9.4 rejection framework assigns is asserted. + * + * This is the executable form of the mapping table documented in + * `src/llm-prompt.ts`. It is also the EXPERIMENT-NOTEBOOK.md E10 "Prohibited + * attempts" plumbing: the adapter forwards a violating completion unsanitized + * on purpose (SPEC §10.2 forbids "sanitized and passed through"), so the + * violation becomes an append-only `channel.rejected` event carrying a reason + * code and a payload hash — and never the attempted content (§9.4). + * + * `@ald/gateway` is imported by these tests only; `@ald/learners`'s `src/` + * does not depend on it, because a learner adapter runs inside the Baby's + * isolation boundary and reaches the Gateway through the runtime (SPEC §6.3). + */ +import { + GATEWAY_REASON_CODES, + InMemoryEvidenceWriter, + StepClock, + SymbolGatewayImpl, +} from '@ald/gateway'; +import { deriveSeedHex } from '@ald/hashing'; +import { + TurnProposalEnvelopeSchema, + fixedTokenInventory, + type GatewayRunContext, + type GatewayTurnContext, + type Observation, + type RunConfig, + type TurnBudget, +} from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { buildConformanceRunConfig, RecordingLedgerClient } from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { FrozenLlmAdapter } from '../src/frozen-llm.js'; +import { + ScriptedModelClient, + type ScriptedBehavior, +} from '../src/llm-scripted-client.js'; + +const INVENTORY_SIZE = 32; +const MAX_SYMBOLS = 4; +const INVENTORY = fixedTokenInventory(INVENTORY_SIZE); + +const SENDER_PAYLOAD = [ + [0, 1, 1], + [2, 3, 0], + [1, 0, 0], + [3, 2, 0], +]; +const RECEIVER_PAYLOAD = [ + [2, 3], + [0, 1], + [3, 2], + [1, 0], +]; +const CANDIDATE_REFS = ['o:aaaa1111', 'o:bbbb2222', 'o:cccc3333', 'o:dddd4444']; + +/** + * SPEC §9.1 free text alongside a valid tool call, prose, an off-inventory + * mark, an over-long message, an extra artifact field, another carrier's + * kind, and unparseable output — one row per prohibited-attempt category. + */ +const SENDER_MAPPING: readonly [ScriptedBehavior, string | 'accepted'][] = [ + ['valid-tool-call', 'accepted'], + ['tool-call-with-free-text', 'free-text-present'], + ['prose-only', 'free-text-present'], + ['off-inventory-symbol', 'symbol-not-in-inventory'], + ['oversized-payload', 'message-too-long'], + ['extra-artifact-field', 'unexpected-artifact-field'], + ['wrong-carrier-kind', 'carrier-mismatch'], + ['malformed-json', 'invalid-envelope'], + ['empty-output', 'invalid-envelope'], +]; + +function runConfig(): RunConfig { + return buildConformanceRunConfig('frozen-llm', { + symbolInventorySize: INVENTORY_SIZE, + messageLength: MAX_SYMBOLS, + }); +} + +interface Harness { + adapter: FrozenLlmAdapter; + gateway: SymbolGatewayImpl; + evidence: InMemoryEvidenceWriter; + config: RunConfig; + ledger: RecordingLedgerClient; +} + +async function harness(behavior: ScriptedBehavior): Promise { + const config = runConfig(); + const context: GatewayRunContext = { + runId: config.runId, + config, + symbolInventory: INVENTORY, + seed: config.randomSeed, + }; + const evidence = InMemoryEvidenceWriter.forRun(config.runId, new StepClock()); + evidence.registerRun(config); + const gateway = new SymbolGatewayImpl(context, evidence); + + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + const adapter = new FrozenLlmAdapter({ + client: new ScriptedModelClient({ behavior, symbolInventory: INVENTORY }), + valuesPerAttribute: 4, + attributeCount: 2, + messageLength: 1, + }); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('frozen-llm'), + seed: deriveSeedHex(config.randomSeed, 'baby-a'), + symbolInventory: INVENTORY, + ledger, + }); + return { adapter, gateway, evidence, config, ledger }; +} + +function observation( + payload: number[][], + runId: string, + turn = 1, +): Observation { + return { + runId, + turn, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload, + scenarioRef: 'scn:0123456789abcdef', + }; +} + +function senderBudget(turn = 1): TurnBudget { + return { + turn, + role: 'sender', + responseBudgetMs: 5_000, + availableActions: ['emit_symbols'], + }; +} + +function turnContext(turn = 1): GatewayTurnContext { + return { turn, sender: 'baby-a', recipient: 'baby-b' }; +} + +describe('ALD-044 × ALD-034: completion shape → Gateway reason code', () => { + it.each(SENDER_MAPPING)( + 'a %s completion is %s at the Gateway', + async (behavior, expected) => { + const { adapter, gateway, config } = await harness(behavior); + await adapter.observe(observation(SENDER_PAYLOAD, config.runId)); + const envelope = await adapter.act(senderBudget()); + const result = await gateway.submitProposal(turnContext(), envelope); + + if (expected === 'accepted') { + expect(result.kind).toBe('accepted'); + return; + } + expect(result.kind).toBe('rejected'); + if (result.kind !== 'rejected') { + return; + } + expect(result.reasonCode).toBe(expected); + expect(GATEWAY_REASON_CODES).toContain(result.reasonCode); + expect(result.rejectedPayloadHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + }, + ); + + it('commits an accepted proposal with the model marks and the intention event', async () => { + const { adapter, gateway, evidence, config } = await harness('valid-tool-call'); + await adapter.observe(observation(SENDER_PAYLOAD, config.runId)); + const envelope = await adapter.act(senderBudget()); + const result = await gateway.submitProposal(turnContext(), envelope); + + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + const marks = (envelope.proposal.publicArtifact as { symbols: string[] }) + .symbols; + expect(result.delivery?.publicArtifact).toEqual({ symbols: marks }); + expect(result.senderLedgerEvent.eventType).toBe('intention.recorded'); + expect(result.senderLedgerEvent.contentSchema).toBe('agent-native-ledger'); + expect(evidence.channelEvents(config.runId)).toHaveLength(1); + expect(evidence.channelEvents(config.runId)[0]?.gatewayValidationResult).toBe( + 'accepted', + ); + expect(evidence.ledgerEvents(config.runId, 'A')).toHaveLength(1); + }); + + it('stores no attempted content for any rejected completion (SPEC §9.4)', async () => { + const forbidden = [ + 'ZZ9', + 'annotationCode', + 'I think this mark', + 'Here is my choice', + '{"symbols": ["', + ]; + for (const [behavior, expected] of SENDER_MAPPING) { + if (expected === 'accepted') { + continue; + } + const { adapter, gateway, evidence, config } = await harness(behavior); + await adapter.observe(observation(SENDER_PAYLOAD, config.runId)); + await gateway.submitProposal( + turnContext(), + await adapter.act(senderBudget()), + ); + const events = evidence.channelEvents(config.runId); + expect(events).toHaveLength(1); + expect(events[0]?.gatewayValidationResult).toBe('rejected'); + const serialized = JSON.stringify(events[0]); + for (const fragment of forbidden) { + expect(serialized, `${behavior} leaked ${fragment}`).not.toContain( + fragment, + ); + } + expect(events[0]).not.toHaveProperty('publicArtifact'); + } + }); + + it('counts consecutive prohibited attempts toward the §9.4 pause', async () => { + const { adapter, gateway, config } = await harness('prose-only'); + expect(config.maxConsecutiveRejections).toBe(3); + const results = []; + for (const turn of [1, 2, 3]) { + await adapter.observe(observation(SENDER_PAYLOAD, config.runId, turn)); + results.push( + await gateway.submitProposal( + turnContext(turn), + await adapter.act(senderBudget(turn)), + ), + ); + } + const last = results[2]; + expect(last?.kind).toBe('rejected'); + if (last?.kind === 'rejected') { + expect(last.consecutiveRejections).toBe(3); + expect(last.pauseRequested).toBe(true); + } + }); +}); + +describe('ALD-044 receiver-side violations (SPEC §11.3 task action)', () => { + /** + * A receiver's `select_object` is a task action: the Nursery Controller + * parses it against `TurnProposalEnvelopeSchema` and forfeits the turn with + * `invalid-task-action` when it does not match (see + * `packages/orchestrator/src/nursery-runtime.ts`). The orchestrator is not + * imported here; what is asserted is the property it keys on. + */ + const RECEIVER_ROWS: readonly [ScriptedBehavior, boolean][] = [ + ['valid-tool-call', true], + ['tool-call-with-free-text', false], + ['prose-only', false], + ['malformed-json', false], + ['off-inventory-symbol', true], + ['wrong-carrier-kind', false], + ]; + + it.each(RECEIVER_ROWS)( + 'a %s completion parses as a task action: %s', + async (behavior, parses) => { + const { adapter, config } = await harness(behavior); + await adapter.observe(observation(RECEIVER_PAYLOAD, config.runId)); + const envelope = await adapter.act({ + turn: 1, + role: 'receiver', + responseBudgetMs: 5_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + const parsed = TurnProposalEnvelopeSchema.safeParse(envelope); + expect(parsed.success && parsed.data.proposal.kind === 'select_object').toBe( + parses, + ); + }, + ); + + it('records an off-candidate selection as a count, never as ledger text', async () => { + const { adapter, config } = await harness('off-inventory-symbol'); + await adapter.observe(observation(RECEIVER_PAYLOAD, config.runId)); + const envelope = await adapter.act({ + turn: 1, + role: 'receiver', + responseBudgetMs: 5_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + const content = envelope.privateLedgerDraft.content; + expect(content.selection).toBeUndefined(); + expect(content.offInventoryCount).toBe(1); + expect(JSON.stringify(content)).not.toContain('ZZ9'); + }); +}); diff --git a/packages/learners/__tests__/frozen-llm.test.ts b/packages/learners/__tests__/frozen-llm.test.ts new file mode 100644 index 0000000..13b5a84 --- /dev/null +++ b/packages/learners/__tests__/frozen-llm.test.ts @@ -0,0 +1,698 @@ +/** + * ALD-044 — the `frozen-llm` adapter itself. + * + * Covers criterion 1 (the track is selectable through the ALD-023 run + * configuration and records the exact model and weight hashes), the adapter + * half of criterion 2 (a full observation → proposal → private-ledger turn), + * and criterion 3's structural half (no weight-update path anywhere on the + * adapter). The Gateway half of criteria 2 and 3 lives in + * `frozen-llm-gateway.test.ts` and `frozen-llm-conformance.test.ts`. + * + * Every test drives a `ScriptedModelClient`: this machine has no 3B-8B weights + * and no GPU, so what is demonstrated here is software readiness of the + * mechanism, never a result about model behavior (Prototype Mode). + */ +import { deriveSeedHex, hashCanonical } from '@ald/hashing'; +import { + HASH_DOMAINS, + TurnProposalEnvelopeSchema, + fixedTokenInventory, + type LearnerInitContext, + type Observation, + type RunConfig, + type TurnBudget, +} from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { buildConformanceRunConfig, RecordingLedgerClient } from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { validateLearnerDraft } from '../src/drafts.js'; +import { LearnerConfigurationError } from '../src/errors.js'; +import { + FrozenLlmAdapter, + createFrozenLlmAdapterFactory, + type FrozenLlmAdapterOptions, +} from '../src/frozen-llm.js'; +import { formatModelRef, type LocalModelClient } from '../src/llm-client.js'; +import { LocalModelTimeoutError, ToolUnavailableError } from '../src/llm-errors.js'; +import { ScriptedModelClient } from '../src/llm-scripted-client.js'; + +const INVENTORY_SIZE = 32; +const MESSAGE_LENGTH = 4; +const INVENTORY = fixedTokenInventory(INVENTORY_SIZE); + +/** attributeCount 2 × valuesPerAttribute 4: 16 type codes, 4 candidates. */ +const SENDER_PAYLOAD = [ + [0, 1, 1], + [2, 3, 0], + [1, 0, 0], + [3, 2, 0], +]; +const RECEIVER_PAYLOAD = [ + [2, 3], + [0, 1], + [3, 2], + [1, 0], +]; +const CANDIDATE_REFS = ['o:aaaa1111', 'o:bbbb2222', 'o:cccc3333', 'o:dddd4444']; + +function config(overrides: Partial = {}): RunConfig { + return { + ...buildConformanceRunConfig('frozen-llm', { + symbolInventorySize: INVENTORY_SIZE, + messageLength: MESSAGE_LENGTH, + }), + ...overrides, + }; +} + +function observation( + payload: number[][], + recipient: 'baby-a' | 'baby-b', + turn = 1, +): Observation { + return { + runId: 'run-conformance', + turn, + recipient, + encoding: 'opaque-numeric', + payload, + scenarioRef: 'scn:0123456789abcdef', + }; +} + +function senderBudget(turn = 1, responseBudgetMs = 5_000): TurnBudget { + return { turn, role: 'sender', responseBudgetMs, availableActions: ['emit_symbols'] }; +} + +function receiverBudget(turn = 1, responseBudgetMs = 5_000): TurnBudget { + return { + turn, + role: 'receiver', + responseBudgetMs, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }; +} + +interface Harness { + adapter: FrozenLlmAdapter; + ledger: RecordingLedgerClient; + context: LearnerInitContext; +} + +async function harness( + options: FrozenLlmAdapterOptions = {}, + overrides: Partial = {}, + role: 'baby-a' | 'baby-b' = 'baby-a', +): Promise { + const runConfig = config(overrides); + const ledger = new RecordingLedgerClient(runConfig.runId, role); + const client = options.client ?? new ScriptedModelClient({ symbolInventory: INVENTORY }); + const adapter = new FrozenLlmAdapter({ + valuesPerAttribute: 4, + attributeCount: 2, + messageLength: 1, + ...options, + client, + }); + const context: LearnerInitContext = { + runId: runConfig.runId, + role, + babyId: role === 'baby-a' ? 'A' : 'B', + config: runConfig, + learnerContract: loadLearnerContract('frozen-llm'), + seed: deriveSeedHex('ald-frozen-llm-test', role), + symbolInventory: INVENTORY, + ledger, + }; + await adapter.init(context); + return { adapter, ledger, context }; +} + +describe('ALD-044 criterion 1: selectable and bound to exact model/weight hashes', () => { + it('accepts the conformance reference modelRef only for the scripted double in prototype mode', async () => { + const { adapter } = await harness(); + expect(adapter.modelRef).toBe('reference:frozen-llm'); + expect(adapter.describeProvenance().modelRef).toBe('reference:frozen-llm'); + }); + + it('accepts the exact @ form', async () => { + const client = new ScriptedModelClient({ symbolInventory: INVENTORY }); + const description = client.describe(); + const modelRef = formatModelRef(description.modelId, description.weightsHash); + const { adapter } = await harness( + { client }, + { + babyA: { track: 'frozen-llm', modelRef, trainingIsolation: 'independent' }, + }, + ); + expect(adapter.modelRef).toBe(modelRef); + expect(adapter.exportPolicy().weightsHash).toBe(description.weightsHash); + expect(adapter.exportPolicy().modelId).toBe(description.modelId); + }); + + it('refuses a modelRef that does not identify the model in use', async () => { + await expect( + harness( + {}, + { + babyA: { + track: 'frozen-llm', + modelRef: `some-other-model@sha256:${'11'.repeat(32)}`, + trainingIsolation: 'independent', + }, + }, + ), + ).rejects.toBeInstanceOf(LearnerConfigurationError); + }); + + it('refuses the reference modelRef for a research-grade deployment', async () => { + await expect( + harness({}, { deploymentMode: 'research-grade' }), + ).rejects.toBeInstanceOf(LearnerConfigurationError); + }); + + it('refuses the reference modelRef for a client that is not the scripted double', async () => { + const weightsHash = `sha256:${'99'.repeat(32)}`; + const realish: LocalModelClient = { + describe: () => ({ + modelId: 'local-3b', + weightsHash, + weightsHashSource: 'weights-file', + contextLength: 8_192, + toolCallingMode: 'json-schema-grammar', + }), + complete: () => + Promise.resolve({ raw: '', finishReason: 'stop' as const }), + }; + await expect(harness({ client: realish })).rejects.toBeInstanceOf( + LearnerConfigurationError, + ); + }); + + it('requires learningSignal "none" for a frozen model', async () => { + // The schema already forbids the combination, so the run configuration is + // bypassed here to prove the adapter re-checks it rather than trusting it. + const runConfig = { ...config(), learningSignal: 'extrinsic-task' as const }; + await expect(harness({}, runConfig)).rejects.toBeInstanceOf( + LearnerConfigurationError, + ); + }); + + it('refuses a run whose role is configured for another track', async () => { + await expect( + harness( + {}, + { + babyA: { + track: 'scratch-rl', + modelRef: 'reference:frozen-llm', + trainingIsolation: 'independent', + }, + }, + ), + ).rejects.toBeInstanceOf(LearnerConfigurationError); + }); + + it('refuses a contract that governs another track, and an empty contract', async () => { + const runConfig = config(); + const build = async ( + contract: { version: string; text: string; track?: 'scratch-rl' }, + ): Promise => { + const adapter = new FrozenLlmAdapter({ + client: new ScriptedModelClient(), + }); + await adapter.init({ + runId: runConfig.runId, + role: 'baby-a', + babyId: 'A', + config: runConfig, + learnerContract: contract, + seed: 'seed', + symbolInventory: INVENTORY, + ledger: new RecordingLedgerClient(runConfig.runId, 'baby-a'), + }); + }; + await expect( + build({ version: '1', text: 'x', track: 'scratch-rl' }), + ).rejects.toBeInstanceOf(LearnerConfigurationError); + await expect(build({ version: '1', text: ' ' })).rejects.toBeInstanceOf( + LearnerConfigurationError, + ); + }); + + it('has no default model: the factory refuses to build without a client', () => { + let thrown: unknown; + try { + createFrozenLlmAdapterFactory(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(LearnerConfigurationError); + expect((thrown as Error).message).toContain('ALD-044'); + expect( + createFrozenLlmAdapterFactory({ client: new ScriptedModelClient() }).track, + ).toBe('frozen-llm'); + }); + + it('records the model provenance for the SPEC §6.5 battery', async () => { + const { adapter } = await harness(); + const provenance = adapter.describeProvenance(); + expect(provenance.track).toBe('frozen-llm'); + expect(provenance.weightUpdatePath).toBe('none'); + // §6.5's closing paragraph exempts this track: it is never claimed to be + // language-naive, so the text tokenizer is declared rather than denied. + expect(provenance.textTokenizerPresent).toBe(true); + expect(provenance.components).toHaveLength(1); + expect(provenance.components[0]).toMatchObject({ + kind: 'language-model', + provenance: 'frozen-open-weight', + textAligned: true, + hash: new ScriptedModelClient().describe().weightsHash, + }); + }); +}); + +describe('ALD-044 criterion 2: a full turn through the adapter', () => { + it('emits a tool-only proposal with its intention draft as sender', async () => { + const { adapter, ledger } = await harness(); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + const envelope = await adapter.act(senderBudget()); + + const parsed = TurnProposalEnvelopeSchema.parse(envelope); + expect(parsed.proposal.kind).toBe('emit_symbols'); + const symbols = (parsed.proposal.publicArtifact as { symbols: string[] }) + .symbols; + expect(symbols).toHaveLength(1); + expect(INVENTORY).toContain(symbols[0]); + expect(Object.keys(envelope.proposal).sort()).toEqual([ + 'kind', + 'publicArtifact', + ]); + + const draft = validateLearnerDraft(envelope.privateLedgerDraft); + expect(draft.eventType).toBe('intention.recorded'); + expect(draft.contentSchema).toBe('agent-native-ledger'); + expect(draft.content.outputClass).toBe('tool-call'); + expect(draft.content.targetTypeCode).toBe(1); + expect(draft.content.offInventoryCount).toBe(0); + expect(draft.content.contractVersion).toBe('1'); + + // The first-use event is on the chain before the proposal is returned. + expect(ledger.countOf('term.first_emitted')).toBe(1); + }); + + it('interprets a delivery and selects a candidate as receiver', async () => { + const { adapter, ledger } = await harness(); + await adapter.observe(observation(RECEIVER_PAYLOAD, 'baby-a')); + const channelEventHash = `sha256:${'aa'.repeat(32)}`; + const interpretation = await adapter.receive({ + runId: 'run-conformance', + turn: 1, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols: ['S05'] }, + channelEventHash, + }); + expect(interpretation.channelEventHash).toBe(channelEventHash); + const interpretationDraft = validateLearnerDraft( + interpretation.privateLedgerDraft, + ); + expect(interpretationDraft.eventType).toBe('interpretation.recorded'); + expect(interpretationDraft.content.symbols).toEqual(['S05']); + expect(interpretationDraft.content.candidateTypeCodes).toEqual([ + 11, 1, 14, 4, + ]); + expect(ledger.countOf('term.first_received')).toBe(1); + + const envelope = await adapter.act(receiverBudget()); + const parsed = TurnProposalEnvelopeSchema.parse(envelope); + expect(parsed.proposal.kind).toBe('select_object'); + const objectRef = (parsed.proposal.publicArtifact as { objectRef: string }) + .objectRef; + expect(CANDIDATE_REFS).toContain(objectRef); + const draft = validateLearnerDraft(envelope.privateLedgerDraft); + expect(draft.content.selection).toBe(objectRef); + expect(draft.evidenceRefs).toContain(`channel:${channelEventHash}`); + }); + + it('folds the outcome into private memory and appends hypothesis events', async () => { + const { adapter, ledger } = await harness(); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + const envelope = await adapter.act(senderBudget()); + const symbol = (envelope.proposal.publicArtifact as { symbols: string[] }) + .symbols[0] as string; + + await adapter.onOutcome({ + runId: 'run-conformance', + turn: 1, + role: 'sender', + success: true, + reward: 1, + payload: [1], + }); + expect(ledger.countOf('hypothesis.created')).toBe(1); + const created = ledger.draftsOf('hypothesis.created')[0]; + expect(created?.content.hypothesisRef).toBe(`hyp:${symbol}:1`); + expect(created?.content.argmaxTypeCode).toBe(1); + + const policy = adapter.exportPolicy(); + expect(policy.memory.turns).toBe(1); + expect(policy.memory.symbols[0]).toMatchObject({ + symbol, + emitted: 1, + hypothesisRef: `hyp:${symbol}:1`, + argmaxTypeCode: 1, + }); + }); + + it('records a contradiction rather than overwriting a confident hypothesis', async () => { + const { adapter, ledger } = await harness(); + for (const turn of [1, 2]) { + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a', turn)); + await adapter.act(senderBudget(turn)); + await adapter.onOutcome({ + runId: 'run-conformance', + turn, + role: 'sender', + success: turn === 1, + reward: null, + payload: [turn === 1 ? 1 : 0], + }); + } + expect(ledger.countOf('hypothesis.created')).toBe(1); + expect(ledger.countOf('hypothesis.contradicted')).toBe(1); + expect(ledger.countOf('hypothesis.revised')).toBe(0); + }); + + it('revises a hypothesis when the memory argmax moves', async () => { + const { adapter, ledger } = await harness(); + // Two turns naming the same mark for different target rows: the second + // moves the argmax, which must append a revision that references the + // prior hypothesis rather than rewriting it. + const client = new ScriptedModelClient({ + behaviorFor: () => 'valid-tool-call', + }); + void client; + const payloads = [ + [ + [0, 1, 1], + [2, 3, 0], + ], + [ + [0, 1, 0], + [2, 3, 1], + ], + ]; + for (const [index, payload] of payloads.entries()) { + const turn = index + 1; + await adapter.observe(observation(payload, 'baby-a', turn)); + await adapter.act(senderBudget(turn)); + await adapter.onOutcome({ + runId: 'run-conformance', + turn, + role: 'sender', + success: true, + reward: null, + payload: [1], + }); + } + const revised = ledger.draftsOf('hypothesis.revised'); + if (revised.length > 0) { + expect(revised[0]?.content.priorHypothesisRef).toMatch(/^hyp:S\d{2}:1$/u); + } else { + // The naming function may map both rows to the same mark and type code; + // in that case a created event and no revision is the correct record. + expect(ledger.countOf('hypothesis.created')).toBeGreaterThan(0); + } + }); + + it('is idempotent across a §14.5 retry: one completion, one identical envelope', async () => { + const client = new ScriptedModelClient({ symbolInventory: INVENTORY }); + const { adapter, ledger } = await harness({ client }); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + const first = await adapter.act(senderBudget()); + const second = await adapter.act(senderBudget()); + expect(second).toEqual(first); + expect(client.callCount).toBe(1); + expect(adapter.modelCallCount).toBe(1); + expect(ledger.countOf('term.first_emitted')).toBe(1); + expect(adapter.exportPolicy().memory.symbols[0]?.emitted).toBe(1); + }); + + it('is idempotent across a repeated outcome', async () => { + const { adapter, ledger } = await harness(); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + await adapter.act(senderBudget()); + const outcome = { + runId: 'run-conformance', + turn: 1, + role: 'sender' as const, + success: true, + reward: null, + payload: [1], + }; + await adapter.onOutcome(outcome); + await adapter.onOutcome(outcome); + expect(ledger.countOf('hypothesis.created')).toBe(1); + expect(adapter.exportPolicy().memory.turns).toBe(1); + }); + + it('tolerates a turn with no delivery (SPEC §9.6 disabled)', async () => { + const { adapter } = await harness(); + await adapter.observe(observation(RECEIVER_PAYLOAD, 'baby-a')); + const envelope = await adapter.act(receiverBudget()); + expect(TurnProposalEnvelopeSchema.parse(envelope).proposal.kind).toBe( + 'select_object', + ); + const draft = validateLearnerDraft(envelope.privateLedgerDraft); + expect(draft.content.symbols).toEqual([]); + }); +}); + +describe('ALD-044 criterion 3: no weight-update path', () => { + it('exposes no updatePolicy member, on the instance or its prototype', async () => { + const { adapter } = await harness(); + const withUpdate = adapter as unknown as Record; + expect(withUpdate.updatePolicy).toBeUndefined(); + expect('updatePolicy' in withUpdate).toBe(false); + const names = new Set(); + let current: object | null = adapter; + while (current !== null && current !== Object.prototype) { + for (const key of Reflect.ownKeys(current)) { + if (typeof key === 'string') { + names.add(key); + } + } + current = Object.getPrototypeOf(current); + } + for (const forbidden of [ + 'updatePolicy', + 'train', + 'fineTune', + 'applyGradients', + 'setWeights', + ]) { + expect([...names]).not.toContain(forbidden); + } + }); + + it('exports a policy carrying no prompt text and no raw model output', async () => { + const client = new ScriptedModelClient({ + behavior: 'tool-call-with-free-text', + symbolInventory: INVENTORY, + }); + const { adapter } = await harness({ client }); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + await adapter.act(senderBudget()); + await adapter.onOutcome({ + runId: 'run-conformance', + turn: 1, + role: 'sender', + success: false, + reward: null, + payload: [0], + }); + + const policy = adapter.exportPolicy(); + const serialized = JSON.stringify(policy); + const contract = loadLearnerContract('frozen-llm').text; + expect(serialized).not.toContain(contract.slice(0, 40)); + for (const fragment of ['Here is my choice', 'I will keep using it']) { + expect(serialized).not.toContain(fragment); + } + expect(policy.contractVersion).toBe('1'); + expect(policy.toolCallingMode).toBe('scripted'); + expect(policy.weightsHashSource).toBe('scripted-double'); + // Canonicalizable: hashing the export is what the runtime records. + expect(hashCanonical(HASH_DOMAINS.policyCheckpoint, policy)).toMatch( + /^sha256:[0-9a-f]{64}$/u, + ); + }); + + it('keeps model output out of the private ledger entirely', async () => { + const client = new ScriptedModelClient({ + behaviors: ['prose-only', 'off-inventory-symbol', 'tool-call-with-free-text'], + symbolInventory: INVENTORY, + }); + const { adapter, ledger } = await harness({ client }); + const intentions: Record[] = []; + for (const turn of [1, 2, 3]) { + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a', turn)); + const envelope = await adapter.act(senderBudget(turn)); + // The Evidence Writer commits the intention draft with the channel + // event (SPEC §8.2); the harness stands in for it here. + await ledger.append(envelope.privateLedgerDraft); + intentions.push(envelope.privateLedgerDraft.content); + await adapter.onOutcome({ + runId: 'run-conformance', + turn, + role: 'sender', + success: false, + reward: null, + payload: [0], + }); + } + const serialized = JSON.stringify(ledger.drafts); + for (const fragment of ['ZZ9', 'I think this mark', 'Here is my choice']) { + expect(serialized).not.toContain(fragment); + } + expect(intentions.map((content) => content.outputClass)).toEqual([ + 'text-only', + 'tool-call', + 'tool-call-text', + ]); + // The off-inventory mark is counted, never stored. + expect(intentions.map((content) => content.offInventoryCount)).toEqual([ + 0, 1, 0, + ]); + }); +}); + +describe('ALD-044 adapter faults versus model content', () => { + it('throws a typed timeout when no completion arrives inside the model budget', async () => { + const client = new ScriptedModelClient({ + behavior: 'timeout', + timeoutOvershootMs: 30, + symbolInventory: INVENTORY, + }); + const { adapter } = await harness({ client }); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + await expect(adapter.act(senderBudget(1, 40))).rejects.toBeInstanceOf( + LocalModelTimeoutError, + ); + }); + + it('throws when no tool this track can produce is offered', async () => { + const { adapter } = await harness(); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + await expect( + adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols', 'emit_bitmap'], + }), + ).resolves.toBeDefined(); + await expect( + adapter.act({ + turn: 2, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['emit_bitmap'], + }), + ).rejects.toThrow(); + expect(ToolUnavailableError.name).toBe('ToolUnavailableError'); + }); + + it('refuses every method before init()', async () => { + const adapter = new FrozenLlmAdapter({ client: new ScriptedModelClient() }); + await expect(adapter.act(senderBudget())).rejects.toThrow( + /init\(\) must be called/u, + ); + expect(() => adapter.exportPolicy()).toThrow(/init\(\) must be called/u); + }); +}); + +describe('ALD-044 bounded private memory', () => { + it('keeps at most maxTrackedSymbols records and maxDigestEntries in the prompt', async () => { + const client = new ScriptedModelClient({ symbolInventory: INVENTORY }); + const { adapter } = await harness({ + client, + maxTrackedSymbols: 3, + maxDigestEntries: 2, + }); + for (let turn = 1; turn <= 12; turn += 1) { + const target = turn % 4; + const payload = SENDER_PAYLOAD.map((row, index) => [ + (row[0] as number + turn) % 4, + (row[1] as number + turn) % 4, + index === target ? 1 : 0, + ]); + await adapter.observe(observation(payload, 'baby-a', turn)); + await adapter.act(senderBudget(turn)); + await adapter.onOutcome({ + runId: 'run-conformance', + turn, + role: 'sender', + success: true, + reward: null, + payload: [1], + }); + } + expect(adapter.exportPolicy().memory.symbols.length).toBeLessThanOrEqual(3); + const digest = client.requests.at(-1)?.memoryDigest; + expect(digest?.entries.length).toBeLessThanOrEqual(2); + expect(digest?.symbolsTracked).toBeLessThanOrEqual(3); + }); + + it('puts only opaque numeric state and inventory marks in the prompt', async () => { + const client = new ScriptedModelClient({ symbolInventory: INVENTORY }); + const { adapter } = await harness({ client }); + await adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + await adapter.act(senderBudget()); + + const sent = client.requests[0]; + expect(sent?.systemPrompt).toBe(loadLearnerContract('frozen-llm').text); + expect(sent?.observation.candidates).toEqual( + SENDER_PAYLOAD.map((row) => row.slice(0, 2)), + ); + expect(sent?.observation.targetIndex).toBe(0); + expect(sent?.tools).toHaveLength(1); + expect(sent?.tools[0]?.name).toBe('emit_symbols'); + expect(sent?.tools[0]?.parameters.properties.symbols).toMatchObject({ + maxItems: MESSAGE_LENGTH, + }); + expect(sent?.temperature).toBe(0); + expect(sent?.timeBudgetMs).toBe(4_000); + }); + + it('derives the sampling seed from the private seed, turn and role', async () => { + const clientA = new ScriptedModelClient({ symbolInventory: INVENTORY }); + const clientB = new ScriptedModelClient({ symbolInventory: INVENTORY }); + const a = await harness({ client: clientA }); + const b = await harness({ client: clientB }); + for (const target of [a, b]) { + await target.adapter.observe(observation(SENDER_PAYLOAD, 'baby-a')); + await target.adapter.act(senderBudget()); + } + expect(clientA.requests[0]?.samplingSeed).toBe( + clientB.requests[0]?.samplingSeed, + ); + + const other = await harness( + { client: new ScriptedModelClient({ symbolInventory: INVENTORY }) }, + {}, + 'baby-b', + ); + await other.adapter.observe(observation(SENDER_PAYLOAD, 'baby-b')); + await other.adapter.act(senderBudget()); + const otherClient = (other.adapter as unknown as { + options: { client: ScriptedModelClient }; + }).options.client; + expect(otherClient.requests[0]?.samplingSeed).not.toBe( + clientA.requests[0]?.samplingSeed, + ); + }); +}); diff --git a/packages/learners/__tests__/frozen-qualification.test.ts b/packages/learners/__tests__/frozen-qualification.test.ts new file mode 100644 index 0000000..fa2223c --- /dev/null +++ b/packages/learners/__tests__/frozen-qualification.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import { ScriptedModelClient } from '../src/llm-scripted-client.js'; +import type { LocalModelClient } from '../src/llm-client.js'; +import type { LlamaServerAttestation } from '../src/llama-server-probe.js'; +import { + FROZEN_MODEL_QUALIFICATION_LABEL, + runFrozenModelQualification, + type FrozenModelQualificationOptions, +} from '../src/frozen-qualification.js'; + +const HASH = `sha256:${'2'.repeat(64)}` as const; +const RESPONSE_HASH = `sha256:${'4'.repeat(64)}` as const; + +function client(): LocalModelClient { + const scripted = new ScriptedModelClient(); + return { + describe: () => ({ + modelId: 'real-provenance-double', + weightsHash: HASH, + weightsHashSource: 'weights-file', + quantization: 'Q4_K_M', + contextLength: 4_096, + toolCallingMode: 'json-schema-grammar', + }), + complete: (request) => scripted.complete(request), + }; +} + +function server(endpoint: string): LlamaServerAttestation { + return { + endpoint, + health: 'ok', + buildInfo: 'b10809-5266f24da', + modelAlias: 'real-provenance-double', + modelFileType: 'Q4_K - Medium', + modelParameterCount: 4_022_468_096, + modelSizeBytes: 2_491_323_904, + configuredContextLength: 4_096, + trainingContextLength: 40_960, + totalSlots: 1, + slotContextLengths: [4_096], + chatTemplateSha256: `sha256:${'5'.repeat(64)}`, + supportsTools: true, + modalities: { vision: false, video: false, audio: false }, + }; +} + +function options(): FrozenModelQualificationOptions { + return { + clients: { 'baby-a': client(), 'baby-b': client() }, + softwareCommit: '1'.repeat(40), + executedAt: '2026-09-09T00:00:00.000Z', + runtime: { + id: 'llama.cpp-0.4.0-b10809-5266f24da', + artifactHash: `sha256:${'3'.repeat(64)}`, + installationManager: 'homebrew', + formula: 'llama.cpp', + formulaVersion: '0.4.0', + bottleSha256: `sha256:${'6'.repeat(64)}`, + }, + launch: { + host: '127.0.0.1', + contextLength: 4_096, + parallelSlots: 1, + promptCacheEnabled: false, + temperature: 0, + maxOutputTokens: 192, + turnResponseBudgetMs: 120_000, + thinkingDisabled: true, + toolChoice: 'required', + }, + servers: { + 'baby-a': server('http://127.0.0.1:18091'), + 'baby-b': server('http://127.0.0.1:18092'), + }, + reset: { + strategy: 'clean-process-restart', + roles: { + 'baby-a': { + beforeResponseHash: RESPONSE_HASH, + afterResponseHash: RESPONSE_HASH, + matched: true, + }, + 'baby-b': { + beforeResponseHash: RESPONSE_HASH, + afterResponseHash: RESPONSE_HASH, + matched: true, + }, + }, + }, + episodes: 2, + }; +} + +describe('frozen-model qualification report', () => { + it('measures both roles, isolation, reset, tool use, and freeze semantics', async () => { + const report = await runFrozenModelQualification(options()); + expect(report.claimBoundary).toBe(FROZEN_MODEL_QUALIFICATION_LABEL); + expect(report.proposals).toBe(4); + expect(report.roles['baby-a'].intentions).toBeGreaterThan(0); + expect(report.roles['baby-a'].interpretations).toBeGreaterThan(0); + expect(report.roles['baby-b'].intentions).toBeGreaterThan(0); + expect(report.roles['baby-b'].interpretations).toBeGreaterThan(0); + expect(report.roles['baby-a'].privateMemoryChanged).toBe(true); + expect(report.roles['baby-b'].privateMemoryChanged).toBe(true); + expect(report.toolBoundary).toEqual({ + requiredToolChoice: true, + liveCalls: 4, + conformingCalls: 4, + violations: 0, + }); + expect(report.freezeSemantics).toMatchObject({ + weightUpdatePath: 'none', + updatePolicyExposed: false, + privateMemoryExpectedToChange: true, + }); + expect(report.isolation.topology).toBe( + 'dedicated-process-per-role-serialized', + ); + expect(report.model).toMatchObject({ + modelId: 'real-provenance-double', + weightsHashSource: 'weights-file', + }); + + const json = JSON.stringify(report); + for (const prohibited of [ + 'systemPrompt', + 'observation', + 'privateLedgerDraft', + 'rawOutput', + 'candidateRefs', + 'deliveredSymbols', + ]) { + expect(json).not.toContain(prohibited); + } + }); + + it('requires enough episodes for both roles to send and receive', async () => { + await expect( + runFrozenModelQualification({ ...options(), episodes: 1 }), + ).rejects.toThrow(/at least two episodes/u); + }); + + it('rejects incomplete inference-runtime provenance', async () => { + const valid = options(); + await expect( + runFrozenModelQualification({ + ...valid, + runtime: { ...valid.runtime, artifactHash: 'sha256:not-a-digest' }, + }), + ).rejects.toThrow(/exact SHA-256/u); + }); + + it('rejects a shared client or endpoint', async () => { + const valid = options(); + await expect( + runFrozenModelQualification({ + ...valid, + clients: { + 'baby-a': valid.clients['baby-a'], + 'baby-b': valid.clients['baby-a'], + }, + }), + ).rejects.toThrow(/isolated/u); + }); + + it('rejects a mismatched clean-process replay', async () => { + const valid = options(); + await expect( + runFrozenModelQualification({ + ...valid, + reset: { + ...valid.reset, + roles: { + ...valid.reset.roles, + 'baby-b': { + beforeResponseHash: RESPONSE_HASH, + afterResponseHash: `sha256:${'7'.repeat(64)}`, + matched: true, + }, + }, + }, + }), + ).rejects.toThrow(/reset replay did not match/u); + }); +}); diff --git a/packages/learners/__tests__/game.test.ts b/packages/learners/__tests__/game.test.ts new file mode 100644 index 0000000..3e8887f --- /dev/null +++ b/packages/learners/__tests__/game.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; + +import { + attributesFromTypeCode, + typeCodeCount, + typeCodeFromAttributes, +} from '../src/game.js'; +import { + argmaxIndex, + parseObservationPayload, + resolveGameShape, + roundTo, + softmax, +} from '../src/game.js'; +import { LearnerConfigurationError, LearnerStateError } from '../src/errors.js'; + +describe('type-code arithmetic', () => { + it('encodes attribute vectors positionally', () => { + expect(typeCodeFromAttributes([0, 0], 4)).toBe(0); + expect(typeCodeFromAttributes([0, 3], 4)).toBe(3); + expect(typeCodeFromAttributes([2, 1], 4)).toBe(9); + expect(typeCodeFromAttributes([3, 3], 4)).toBe(15); + }); + + it('round-trips every type code for several attribute spaces', () => { + for (const attributeCount of [1, 2, 3]) { + for (const valuesPerAttribute of [2, 3, 4, 5]) { + const total = typeCodeCount(attributeCount, valuesPerAttribute); + for (let code = 0; code < total; code += 1) { + const attributes = attributesFromTypeCode( + code, + attributeCount, + valuesPerAttribute, + ); + expect(attributes).toHaveLength(attributeCount); + expect(typeCodeFromAttributes(attributes, valuesPerAttribute)).toBe(code); + } + } + } + }); + + it('rejects attribute codes outside the value range', () => { + expect(() => typeCodeFromAttributes([4, 0], 4)).toThrow(LearnerStateError); + expect(() => attributesFromTypeCode(16, 2, 4)).toThrow( + LearnerConfigurationError, + ); + }); +}); + +describe('observation parsing', () => { + it('reads a sender view from the trailing target column', () => { + const parsed = parseObservationPayload( + [ + [0, 1, 0], + [2, 3, 1], + [1, 1, 0], + ], + 2, + 4, + ); + expect(parsed.view).toBe('sender'); + expect(parsed.targetIndex).toBe(1); + expect(parsed.typeCodes).toEqual([1, 11, 5]); + }); + + it('reads a receiver view with no target column', () => { + const parsed = parseObservationPayload( + [ + [0, 1], + [2, 3], + ], + 2, + 4, + ); + expect(parsed.view).toBe('receiver'); + expect(parsed.targetIndex).toBeNull(); + expect(parsed.typeCodes).toEqual([1, 11]); + }); + + it('rejects an unexpected row width, ragged rows, and two targets', () => { + expect(() => parseObservationPayload([[0, 1, 2, 3]], 2, 4)).toThrow( + LearnerStateError, + ); + expect(() => + parseObservationPayload( + [ + [0, 1], + [0], + ], + 2, + 4, + ), + ).toThrow(LearnerStateError); + expect(() => + parseObservationPayload( + [ + [0, 1, 1], + [2, 3, 1], + ], + 2, + 4, + ), + ).toThrow(LearnerStateError); + expect(() => parseObservationPayload([], 2, 4)).toThrow(LearnerStateError); + }); +}); + +describe('numeric helpers', () => { + it('softmax sums to one and is monotone in the logit', () => { + const probs = softmax([0, 1, 2], 1); + expect(probs.reduce((sum, value) => sum + value, 0)).toBeCloseTo(1, 12); + expect(probs[0]).toBeLessThan(probs[1] as number); + expect(probs[1]).toBeLessThan(probs[2] as number); + }); + + it('softmax of equal logits is uniform at any temperature', () => { + for (const temperature of [0.25, 0.5, 1, 4]) { + expect(softmax([0, 0, 0, 0], temperature)).toEqual([0.25, 0.25, 0.25, 0.25]); + } + }); + + it('softmax is numerically stable for extreme logits', () => { + const probs = softmax([1000, 0, -1000], 1); + expect(probs[0]).toBeCloseTo(1, 12); + expect(Number.isNaN(probs[2] as number)).toBe(false); + }); + + it('softmax rejects a non-positive temperature', () => { + expect(() => softmax([0, 1], 0)).toThrow(LearnerConfigurationError); + }); + + it('argmax returns the lowest index on a tie', () => { + expect(argmaxIndex([1, 3, 3, 2])).toBe(1); + expect(argmaxIndex([])).toBe(-1); + }); + + it('rounds negative zero to zero so canonical JSON is stable', () => { + expect(Object.is(roundTo(-1e-15, 12), 0)).toBe(true); + }); +}); + +describe('game shape resolution', () => { + it('defaults to the E11 naming stage', () => { + expect(resolveGameShape({}, undefined)).toEqual({ + valuesPerAttribute: 4, + attributeCount: 2, + messageLength: 1, + typeCount: 16, + }); + }); + + it('rejects a message longer than the protocol limit', () => { + expect(() => resolveGameShape({ messageLength: 3 }, 2)).toThrow( + LearnerConfigurationError, + ); + }); + + it('rejects non-integer shapes', () => { + expect(() => resolveGameShape({ attributeCount: 0 }, undefined)).toThrow( + LearnerConfigurationError, + ); + }); +}); diff --git a/packages/learners/__tests__/hybrid.test.ts b/packages/learners/__tests__/hybrid.test.ts new file mode 100644 index 0000000..a5caa0f --- /dev/null +++ b/packages/learners/__tests__/hybrid.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { HASH_DOMAINS, fixedTokenInventory } from '@ald/types'; +import { domainHash } from '@ald/hashing'; + +import { + RecordingLedgerClient, + buildConformanceRunConfig, + runLearnerAdapterConformance, +} from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { + ExportedHybridPolicySchema, + HybridAdapter, + createHybridAdapterFactory, +} from '../src/hybrid.js'; + +function standInContract() { + return { ...loadLearnerContract('scratch-rl'), track: 'hybrid' as const }; +} + +describe('HybridAdapter', () => { + it('completes full turns and updates every private component', async () => { + const result = await runLearnerAdapterConformance( + createHybridAdapterFactory({ codeBits: 3 }), + { + episodes: 10, + seed: 'hybrid-conformance', + learningSignal: 'extrinsic-task', + learnerContract: standInContract(), + }, + ); + + expect(result.proposals).toBe(20); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(result.checkpoints[role]).toHaveLength(10); + expect(new Set(result.policyHashes[role]).size).toBeGreaterThan(1); + + const adapter = result.adapters[role] as HybridAdapter; + const policy = ExportedHybridPolicySchema.parse(adapter.exportPolicy()); + expect(policy.components.map((component) => component.kind)).toEqual([ + 'sensory-encoder', + 'world-model', + 'world-model', + 'communication-policy', + ]); + expect(policy.components.every((component) => component.hash.startsWith('sha256:'))).toBe( + true, + ); + expect(policy.world.message.pairs).toBeGreaterThan(0); + expect(adapter.describeProvenance()).toMatchObject({ + track: 'hybrid', + textTokenizerPresent: false, + textAlignedEncoderPresent: false, + weightUpdatePath: 'private-buffers-only', + }); + } + }); + + it('reproduces the same initialized component hashes for the same private seed', async () => { + const initialize = async (): Promise => { + const adapter = new HybridAdapter({ codeBits: 3 }); + const config = buildConformanceRunConfig('hybrid', { + episodes: 2, + learningSignal: 'extrinsic-task', + }); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: standInContract(), + seed: 'hybrid-initialization', + symbolInventory: fixedTokenInventory(8), + ledger: new RecordingLedgerClient(config.runId, 'baby-a'), + }); + return adapter; + }; + + const first = await initialize(); + const second = await initialize(); + + expect(second.initialPolicyHash()).toBe(first.initialPolicyHash()); + expect(second.componentRecords()).toEqual(first.componentRecords()); + }); + + it('automatically weakens classification for a text-aligned frozen feature path', async () => { + const adapter = new HybridAdapter({ + codeBits: 3, + frozenVisualFeatures: { + name: 'fixture-text-aligned-encoder', + hash: domainHash(HASH_DOMAINS.policyCheckpoint, 'fixture-weights'), + textAligned: true, + dimension: 2, + outputScale: 4, + project: (row) => [row[0] ?? 0, row[1] ?? 0], + }, + }); + const config = buildConformanceRunConfig('hybrid', { + episodes: 2, + learningSignal: 'extrinsic-task', + }); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: standInContract(), + seed: 'hybrid-text-aligned', + symbolInventory: fixedTokenInventory(8), + ledger: new RecordingLedgerClient(config.runId, 'baby-a'), + }); + + expect(adapter.claimClassification()).toBe('weakened-text-aligned-features'); + expect(adapter.describeProvenance()).toMatchObject({ + textAlignedEncoderPresent: true, + }); + expect(adapter.componentRecords().at(-1)).toMatchObject({ + name: 'fixture-text-aligned-encoder', + provenance: 'frozen-visual-features', + textAligned: true, + }); + }); +}); diff --git a/packages/learners/__tests__/ledger-prediction.test.ts b/packages/learners/__tests__/ledger-prediction.test.ts new file mode 100644 index 0000000..cde2ff9 --- /dev/null +++ b/packages/learners/__tests__/ledger-prediction.test.ts @@ -0,0 +1,182 @@ +import { fixedTokenInventory } from '@ald/types'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { + runLearnerAdapterConformance, + type ConformanceResult, +} from '../src/conformance.js'; +import { LearnerStateError } from '../src/errors.js'; +import { + predictReceiverChoice, + predictSenderSymbol, +} from '../src/ledger-prediction.js'; +import { parseExportedTabularPolicy } from '../src/policy.js'; +import { createTabularReinforceAdapterFactory } from '../src/tabular-reinforce.js'; + +const OPTIONS = { learningRate: 1, temperature: 0.5 } as const; + +describe('ledger-to-prediction functions (RESEARCH §6.8)', () => { + let evaluation: ConformanceResult; + let policies: Record<'baby-a' | 'baby-b', unknown>; + + beforeAll(async () => { + const trained = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(OPTIONS), + { + episodes: 400, + seed: 'prediction-train', + validation: 'none', + collectDrafts: false, + recordPolicyHashes: false, + }, + ); + policies = { + 'baby-a': trained.adapters['baby-a'].exportPolicy(), + 'baby-b': trained.adapters['baby-b'].exportPolicy(), + }; + evaluation = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(OPTIONS), + { + episodes: 60, + seed: 'prediction-eval', + updatePolicy: false, + initialPolicies: policies, + }, + ); + }, 30_000); + + it('matches the receiver argmax recorded in every interpretation event', () => { + let checked = 0; + for (const role of ['baby-a', 'baby-b'] as const) { + for (const draft of evaluation.ledgers[role].draftsOf( + 'interpretation.recorded', + )) { + const prediction = predictReceiverChoice( + policies[role], + draft.content.symbols as string[], + evaluation.symbolInventory, + draft.content.candidateTypeCodes as number[], + ); + expect(prediction.index).toBe(draft.content.argmaxCandidateIndex); + const recorded = draft.content.inferredDistribution as number[]; + prediction.distribution.forEach((value, index) => { + expect(value).toBeCloseTo(recorded[index] as number, 6); + }); + checked += 1; + } + } + expect(checked).toBeGreaterThan(50); + }); + + it('is computed from exported policy state alone, so a JSON round trip is enough', () => { + const draft = evaluation.ledgers['baby-a'].draftsOf( + 'interpretation.recorded', + )[0]; + const symbols = draft?.content.symbols as string[]; + const typeCodes = draft?.content.candidateTypeCodes as number[]; + const roundTripped = JSON.parse( + JSON.stringify(policies['baby-a']), + ) as unknown; + expect( + predictReceiverChoice( + roundTripped, + symbols, + evaluation.symbolInventory, + typeCodes, + ), + ).toEqual( + predictReceiverChoice( + policies['baby-a'], + symbols, + evaluation.symbolInventory, + typeCodes, + ), + ); + }); + + it('predicts the sender symbol as the argmax of its own table', () => { + const policy = parseExportedTabularPolicy(policies['baby-a']); + const inventory = evaluation.symbolInventory; + for (let typeCode = 0; typeCode < policy.thetaSender.length; typeCode += 1) { + const prediction = predictSenderSymbol(policy, typeCode, inventory); + const logits = policy.thetaSender[typeCode] as number[]; + const best = logits.indexOf(Math.max(...logits)); + expect(prediction.index).toBe(best); + expect(prediction.symbol).toBe(inventory[best]); + expect(prediction.distribution).toHaveLength(logits.length); + } + }); + + it('returns the lowest index for an untrained, uniform policy', () => { + const uniform = { + version: 1, + thetaSender: [ + [0, 0], + [0, 0], + ], + thetaReceiver: [ + [ + [0, 0], + [0, 0], + ], + ], + baseline: 0, + options: { + valuesPerAttribute: 2, + attributeCount: 1, + messageLength: 1, + learningRate: 0.3, + baselineDecay: 0.9, + temperature: 1, + }, + }; + const inventory = fixedTokenInventory(2); + expect(predictSenderSymbol(uniform, 1, inventory).index).toBe(0); + const receiver = predictReceiverChoice(uniform, ['S02'], inventory, [1, 0]); + expect(receiver.index).toBe(0); + expect(receiver.distribution).toEqual([0.5, 0.5]); + }); + + it('rejects inputs it cannot ground in the policy', () => { + const policy = policies['baby-a']; + const inventory = evaluation.symbolInventory; + expect(() => + predictReceiverChoice(policy, ['S99'], inventory, [0, 1]), + ).toThrow(LearnerStateError); + expect(() => + predictReceiverChoice(policy, ['S01', 'S02'], inventory, [0, 1]), + ).toThrow(LearnerStateError); + expect(() => predictReceiverChoice(policy, ['S01'], inventory, [99])).toThrow( + LearnerStateError, + ); + expect(() => predictReceiverChoice(policy, ['S01'], inventory, [])).toThrow( + LearnerStateError, + ); + expect(() => predictSenderSymbol(policy, -1, inventory)).toThrow( + LearnerStateError, + ); + expect(() => predictSenderSymbol(policy, 0, ['S01'])).toThrow( + LearnerStateError, + ); + }); + + it('rejects a malformed or ragged policy', () => { + expect(() => parseExportedTabularPolicy({ version: 2 })).toThrow(); + expect(() => + parseExportedTabularPolicy({ + version: 1, + thetaSender: [[0, 0], [0]], + thetaReceiver: [[[0, 0], [0, 0]]], + baseline: 0, + options: { + valuesPerAttribute: 2, + attributeCount: 1, + messageLength: 1, + learningRate: 0.3, + baselineDecay: 0.9, + temperature: 1, + }, + }), + ).toThrow(/rectangular/u); + }); +}); diff --git a/packages/learners/__tests__/llama-server-probe.test.ts b/packages/learners/__tests__/llama-server-probe.test.ts new file mode 100644 index 0000000..fd9ae97 --- /dev/null +++ b/packages/learners/__tests__/llama-server-probe.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { LocalModelResponseError } from '../src/llm-errors.js'; +import { probeLlamaServer } from '../src/llama-server-probe.js'; +import type { + FetchLike, + FetchLikeResponse, +} from '../src/llm-server-client.js'; + +function response(body: unknown): FetchLikeResponse { + return { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +function probeFetch(slotContextLength = 8_192): FetchLike { + return (url) => { + const path = new URL(url).pathname; + if (path === '/health') return Promise.resolve(response({ status: 'ok' })); + if (path === '/props') { + return Promise.resolve( + response({ + build_info: 'b10809-5266f24da', + model_alias: 'qwen3-4b-q4-k-m', + model_ftype: 'Q4_K - Medium', + total_slots: 1, + chat_template: 'template bytes', + chat_template_caps: { supports_tools: true }, + modalities: { vision: false, video: false, audio: false }, + }), + ); + } + if (path === '/v1/models') { + return Promise.resolve( + response({ + data: [ + { + id: 'qwen3-4b-q4-k-m', + meta: { + n_ctx: 8_192, + n_ctx_train: 40_960, + n_params: 4_022_468_096, + size: 2_491_323_904, + }, + }, + ], + }), + ); + } + return Promise.resolve(response([{ id: 0, n_ctx: slotContextLength }])); + }; +} + +describe('live llama-server attestation', () => { + it('cross-checks health, model metadata, tools, and slots', async () => { + const result = await probeLlamaServer({ + endpoint: 'http://127.0.0.1:18091', + modelId: 'qwen3-4b-q4-k-m', + fetchImpl: probeFetch(), + }); + expect(result).toMatchObject({ + health: 'ok', + buildInfo: 'b10809-5266f24da', + modelAlias: 'qwen3-4b-q4-k-m', + modelFileType: 'Q4_K - Medium', + configuredContextLength: 8_192, + totalSlots: 1, + slotContextLengths: [8_192], + supportsTools: true, + }); + expect(result.chatTemplateSha256).toMatch(/^sha256:[0-9a-f]{64}$/u); + }); + + it('rejects a slot whose context does not match the loaded model', async () => { + await expect( + probeLlamaServer({ + endpoint: 'http://127.0.0.1:18091', + modelId: 'qwen3-4b-q4-k-m', + fetchImpl: probeFetch(4_096), + }), + ).rejects.toBeInstanceOf(LocalModelResponseError); + }); +}); diff --git a/packages/learners/__tests__/llm-client.test.ts b/packages/learners/__tests__/llm-client.test.ts new file mode 100644 index 0000000..985463a --- /dev/null +++ b/packages/learners/__tests__/llm-client.test.ts @@ -0,0 +1,547 @@ +/** + * ALD-044: the local-model client seam — the `modelRef` grammar, the + * loopback-only network policy (SPEC §10.3), weight hashing + * (`sha256sum`-reproducible), the OpenAI-compatible request/response path + * driven through an injected `fetch`, and the deterministic scripted double. + * + * No test here opens a socket: every transport call goes through a fake + * `fetch` that records what the client sent. + */ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + ConstrainedCompletionResponseSchema, + formatModelRef, + parseCompletionResponse, + parseModelRef, + type ConstrainedCompletionRequest, +} from '../src/llm-client.js'; +import { + LocalModelResponseError, + LocalModelTransportError, + NonLoopbackEndpointError, + WeightsFileUnreadableError, + WeightsHashUnavailableError, + isLocalModelErrorCode, + LOCAL_MODEL_ERROR_CODES, +} from '../src/llm-errors.js'; +import { hashWeightsFile } from '../src/llm-weights.js'; +import { + OpenAiCompatibleLocalClient, + assertLoopbackEndpoint, + isLoopbackEndpoint, + type FetchLike, + type FetchLikeResponse, +} from '../src/llm-server-client.js'; +import { + SCRIPTED_BEHAVIORS, + ScriptedModelClient, +} from '../src/llm-scripted-client.js'; +import { buildToolDefinitions } from '../src/llm-prompt.js'; +import { classifyModelOutput } from '../src/llm-prompt.js'; + +const HASH = `sha256:${'ab'.repeat(32)}`; + +interface RecordedCall { + url: string; + method: string; + body: unknown; +} + +function fakeFetch( + handler: (call: RecordedCall) => { status?: number; body: unknown }, +): { fetchImpl: FetchLike; calls: RecordedCall[] } { + const calls: RecordedCall[] = []; + const fetchImpl: FetchLike = (url, init) => { + const call: RecordedCall = { + url, + method: init.method, + body: init.body === undefined ? undefined : (JSON.parse(init.body) as unknown), + }; + calls.push(call); + const { status = 200, body } = handler(call); + const response: FetchLikeResponse = { + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(JSON.stringify(body)), + }; + return Promise.resolve(response); + }; + return { fetchImpl, calls }; +} + +function completionBody( + toolName: string, + args: unknown, + content = '', +): unknown { + return { + choices: [ + { + message: { + content, + tool_calls: [ + { + function: { + name: toolName, + arguments: typeof args === 'string' ? args : JSON.stringify(args), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }; +} + +function turnBudget( + role: 'sender' | 'receiver', +): ConstrainedCompletionRequest['tools'] { + return buildToolDefinitions( + { + turn: 1, + role, + responseBudgetMs: 1_000, + availableActions: [role === 'sender' ? 'emit_symbols' : 'select_object'], + ...(role === 'receiver' ? { candidateRefs: ['o:aa', 'o:bb'] } : {}), + }, + { symbolInventory: ['S01', 'S02'], maxSymbolsPerMessage: 2, candidateRefs: ['o:aa', 'o:bb'] }, + ); +} + +function request( + overrides: Partial = {}, +): ConstrainedCompletionRequest { + return { + systemPrompt: 'CONTRACT BODY', + memoryDigest: { + version: 'frozen-llm-memory-v1', + turns: 0, + symbolsTracked: 0, + entries: [], + }, + observation: { candidates: [[0, 1], [2, 3]], targetIndex: 0 }, + tools: turnBudget('sender'), + maxOutputTokens: 64, + timeBudgetMs: 500, + samplingSeed: 7, + temperature: 0, + ...overrides, + }; +} + +describe('ALD-044 modelRef grammar', () => { + it('formats and parses @', () => { + const ref = formatModelRef('qwen2.5-3b-instruct-q4_k_m', HASH); + expect(ref).toBe(`qwen2.5-3b-instruct-q4_k_m@${HASH}`); + expect(parseModelRef(ref)).toEqual({ + modelId: 'qwen2.5-3b-instruct-q4_k_m', + weightsHash: HASH, + }); + }); + + it('rejects strings that are not the two-part form', () => { + expect(parseModelRef('reference:frozen-llm')).toBeUndefined(); + expect(parseModelRef('@only-hash')).toBeUndefined(); + expect(parseModelRef('only-model@')).toBeUndefined(); + }); +}); + +describe('ALD-044 loopback-only network policy (SPEC §10.3)', () => { + const allowed = [ + 'http://127.0.0.1:8080', + 'http://localhost:11434', + 'https://localhost:8443/v1', + 'http://[::1]:8080/v1', + 'unix:/run/llama/llama.sock', + 'http+unix://%2Frun%2Fllama.sock', + ]; + const refused = [ + 'http://127.0.0.1.example.com:8080', + 'http://192.168.1.10:8080', + 'http://10.0.0.5:8080', + 'https://api.example.com/v1', + 'http://model.internal:8080', + 'ftp://localhost/model', + 'not-a-url', + 'http://127.0.0.2:8080', + ]; + + it.each(allowed)('accepts the loopback endpoint %s', (endpoint) => { + expect(isLoopbackEndpoint(endpoint)).toBe(true); + expect(() => { + assertLoopbackEndpoint(endpoint); + }).not.toThrow(); + }); + + it.each(refused)('refuses the non-loopback endpoint %s', (endpoint) => { + expect(isLoopbackEndpoint(endpoint)).toBe(false); + expect(() => { + assertLoopbackEndpoint(endpoint); + }).toThrow(NonLoopbackEndpointError); + }); + + it('refuses a non-loopback endpoint before any request is made', async () => { + const { fetchImpl, calls } = fakeFetch(() => ({ body: {} })); + await expect( + OpenAiCompatibleLocalClient.create({ + endpoint: 'https://api.example.com', + modelId: 'm', + weightsHash: HASH, + fetchImpl, + }), + ).rejects.toBeInstanceOf(NonLoopbackEndpointError); + expect(calls).toHaveLength(0); + }); + + it('names a closed error-code union', () => { + expect(LOCAL_MODEL_ERROR_CODES).toContain('non-loopback-endpoint'); + expect(isLocalModelErrorCode('model-timeout')).toBe(true); + expect(isLocalModelErrorCode('unknown-code')).toBe(false); + }); +}); + +describe('ALD-044 weight hashing records the exact weights', () => { + it('stream-hashes a weights file to the same digest as sha256sum', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ald-weights-')); + const path = join(dir, 'model.gguf'); + const bytes = Buffer.from(`GGUF${randomUUID()}`.repeat(4096)); + await writeFile(path, bytes); + + const digest = await hashWeightsFile(path); + expect(digest.sizeBytes).toBe(bytes.byteLength); + expect(digest.weightsHash).toBe( + `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + ); + }); + + it('refuses an empty or missing weights file rather than hashing nothing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ald-weights-')); + const empty = join(dir, 'empty.gguf'); + await writeFile(empty, ''); + await expect(hashWeightsFile(empty)).rejects.toBeInstanceOf( + WeightsFileUnreadableError, + ); + await expect( + hashWeightsFile(join(dir, 'absent.gguf')), + ).rejects.toBeInstanceOf(WeightsFileUnreadableError); + }); + + it('hashes the weights file at construction and records the source', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ald-weights-')); + const path = join(dir, 'model.gguf'); + await writeFile(path, 'weights-bytes'); + const { fetchImpl, calls } = fakeFetch(() => ({ body: {} })); + + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080', + modelId: 'local-3b', + weightsPath: path, + quantization: 'Q4_K_M', + fetchImpl, + }); + const description = client.describe(); + expect(description.weightsHashSource).toBe('weights-file'); + expect(description.weightsHash).toBe( + (await hashWeightsFile(path)).weightsHash, + ); + expect(description.quantization).toBe('Q4_K_M'); + expect(description.toolCallingMode).toBe('json-schema-grammar'); + // No model listing was needed because the file supplied the digest. + expect(calls).toHaveLength(0); + }); + + it('falls back to the server-reported digest and records that source', async () => { + const digest = `sha256:${'cd'.repeat(32)}`; + const { fetchImpl, calls } = fakeFetch(() => ({ + body: { data: [{ id: 'local-3b', digest }] }, + })); + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080', + modelId: 'local-3b', + fetchImpl, + }); + expect(client.describe()).toMatchObject({ + weightsHash: digest, + weightsHashSource: 'server-reported', + }); + expect(calls[0]?.url).toBe('http://127.0.0.1:8080/v1/models'); + }); + + it('reads the Ollama listing shape as well', async () => { + const bare = 'ef'.repeat(32); + const { fetchImpl } = fakeFetch(() => ({ + body: { models: [{ name: 'local-3b', digest: bare }] }, + })); + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://localhost:11434', + modelId: 'local-3b', + fetchImpl, + }); + expect(client.describe().weightsHash).toBe(`sha256:${bare}`); + }); + + it('refuses to construct when no weight hash is available at all', async () => { + const { fetchImpl } = fakeFetch(() => ({ body: { data: [] } })); + await expect( + OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080', + modelId: 'local-3b', + fetchImpl, + }), + ).rejects.toBeInstanceOf(WeightsHashUnavailableError); + }); +}); + +describe('ALD-044 constrained completion over the loopback endpoint', () => { + it('sends the frozen contract verbatim, the digest, and the tool schema', async () => { + const { fetchImpl, calls } = fakeFetch(() => + ({ body: completionBody('emit_symbols', { symbols: ['S02'] }) }), + ); + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080/v1', + modelId: 'local-3b', + weightsHash: HASH, + fetchImpl, + }); + + const response = await client.complete(request()); + expect(response.toolCall).toEqual({ + name: 'emit_symbols', + arguments: { symbols: ['S02'] }, + }); + expect(response.finishReason).toBe('tool-call'); + + const call = calls[0]; + expect(call?.url).toBe('http://127.0.0.1:8080/v1/chat/completions'); + const body = call?.body as { + messages: { role: string; content: string }[]; + tools: { function: { name: string; parameters: unknown } }[]; + seed: number; + max_tokens: number; + temperature: number; + tool_choice: string; + chat_template_kwargs: { enable_thinking: boolean }; + }; + expect(body.messages[0]).toEqual({ + role: 'system', + content: 'CONTRACT BODY', + }); + expect(body.tools[0]?.function.name).toBe('emit_symbols'); + expect(body.tools[0]?.function.parameters).toMatchObject({ + additionalProperties: false, + properties: { + symbols: { + items: { type: 'string', enum: ['S01', 'S02'] }, + maxItems: 2, + }, + }, + }); + expect(body.seed).toBe(7); + expect(body.max_tokens).toBe(64); + expect(body.temperature).toBe(0); + expect(body.tool_choice).toBe('required'); + expect(body.chat_template_kwargs).toEqual({ enable_thinking: false }); + + // The user message carries only opaque numeric observation state and the + // memory digest (SPEC §10.1: no attribute names anywhere). + const user = body.messages[1]?.content ?? ''; + expect(user).toContain('"candidates"'); + expect(user).toContain('"targetIndex"'); + expect(JSON.parse(user)).toEqual({ + memory: request().memoryDigest, + observation: request().observation, + }); + }); + + it('passes an unparseable arguments string through unrepaired', async () => { + const { fetchImpl } = fakeFetch(() => ({ + body: completionBody('emit_symbols', '{"symbols": ["S0'), + })); + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080', + modelId: 'local-3b', + weightsHash: HASH, + fetchImpl, + }); + const response = await client.complete(request()); + expect(response.toolCall?.arguments).toBe('{"symbols": ["S0'); + }); + + it('reports assistant text with no tool call as prose', async () => { + const { fetchImpl } = fakeFetch(() => ({ + body: { + choices: [ + { message: { content: 'Let us agree that S01 means the round one.' }, finish_reason: 'stop' }, + ], + }, + })); + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080', + modelId: 'local-3b', + weightsHash: HASH, + fetchImpl, + }); + const response = await client.complete(request()); + expect(response.toolCall).toBeUndefined(); + expect(response.raw.length).toBeGreaterThan(0); + }); + + it('raises a transport error on a non-2xx status and never echoes the body', async () => { + const { fetchImpl } = fakeFetch(() => ({ status: 503, body: { error: 'busy' } })); + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080', + modelId: 'local-3b', + weightsHash: HASH, + fetchImpl, + }); + await expect(client.complete(request())).rejects.toMatchObject({ + code: 'model-transport-failure', + }); + await client.complete(request()).catch((error: unknown) => { + expect((error as LocalModelTransportError).message).not.toContain('busy'); + }); + }); + + it('raises a response error for a body that is not a completion', async () => { + const { fetchImpl } = fakeFetch(() => ({ body: { choices: [] } })); + const client = await OpenAiCompatibleLocalClient.create({ + endpoint: 'http://127.0.0.1:8080', + modelId: 'local-3b', + weightsHash: HASH, + fetchImpl, + }); + await expect(client.complete(request())).rejects.toBeInstanceOf( + LocalModelResponseError, + ); + }); +}); + +describe('ALD-044 completion-envelope validation', () => { + it('accepts a well-formed completion and rejects anything else', () => { + expect( + parseCompletionResponse({ raw: '', finishReason: 'stop' }), + ).toEqual({ raw: '', finishReason: 'stop' }); + expect(() => parseCompletionResponse({ raw: 1, finishReason: 'stop' })).toThrow( + LocalModelResponseError, + ); + expect(() => parseCompletionResponse({ finishReason: 'stop' })).toThrow( + LocalModelResponseError, + ); + expect( + ConstrainedCompletionResponseSchema.safeParse({ + raw: '', + finishReason: 'stop', + extra: 1, + }).success, + ).toBe(false); + }); + + it('never puts the malformed value into the error message', () => { + try { + parseCompletionResponse({ raw: 'SECRET-PROMPT-ECHO', finishReason: 'nope' }); + expect.unreachable('a bad finishReason must be rejected'); + } catch (error) { + expect((error as Error).message).not.toContain('SECRET-PROMPT-ECHO'); + } + }); +}); + +describe('ALD-044 ScriptedModelClient (test double)', () => { + it('declares itself a double so no run can mistake it for real weights', () => { + const description = new ScriptedModelClient().describe(); + expect(description.weightsHashSource).toBe('scripted-double'); + expect(description.toolCallingMode).toBe('scripted'); + expect(description.weightsHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + }); + + it('produces one classified output per prohibited-attempt category', async () => { + const expected: Record = { + 'valid-tool-call': 'tool-call', + 'tool-call-with-free-text': 'tool-call-text', + 'prose-only': 'text-only', + 'malformed-json': 'unparsed', + 'oversized-payload': 'tool-call', + 'off-inventory-symbol': 'tool-call', + 'extra-artifact-field': 'tool-call', + 'wrong-carrier-kind': 'kind-mismatch', + 'empty-output': 'unparsed', + }; + for (const behavior of SCRIPTED_BEHAVIORS) { + if (behavior === 'timeout') { + continue; + } + const client = new ScriptedModelClient({ behavior }); + const tools = turnBudget('sender'); + const response = await client.complete(request({ tools })); + const classified = classifyModelOutput(response, tools); + expect(classified.outputClass, behavior).toBe(expected[behavior]); + } + }); + + it('marks an extra artifact field as a non-clean tool call', async () => { + const client = new ScriptedModelClient({ behavior: 'extra-artifact-field' }); + const tools = turnBudget('sender'); + const classified = classifyModelOutput( + await client.complete(request({ tools })), + tools, + ); + expect(classified.outputClass).toBe('tool-call'); + if (classified.outputClass === 'tool-call') { + expect(classified.argsClean).toBe(false); + expect(Object.keys(classified.args).sort()).toEqual([ + 'annotationCode', + 'symbols', + ]); + } + }); + + it('is deterministic for the same request and cycles behaviors by call index', async () => { + const first = new ScriptedModelClient({ + behaviors: ['valid-tool-call', 'prose-only'], + }); + const second = new ScriptedModelClient({ + behaviors: ['valid-tool-call', 'prose-only'], + }); + const a1 = await first.complete(request()); + const a2 = await first.complete(request()); + const b1 = await second.complete(request()); + const b2 = await second.complete(request()); + expect(a1).toEqual(b1); + expect(a2).toEqual(b2); + expect(a1.toolCall).toBeDefined(); + expect(a2.toolCall).toBeUndefined(); + expect(first.callCount).toBe(2); + expect(first.requests).toHaveLength(2); + }); + + it('names the same mark for the same attribute row in both roles', () => { + const client = new ScriptedModelClient(); + const inventory = ['S01', 'S02', 'S03', 'S04']; + expect(client.namingSymbolFor([1, 2], inventory)).toBe( + client.namingSymbolFor([1, 2], inventory), + ); + const distinct = new Set( + [[0, 0], [0, 1], [1, 0], [3, 3]].map((row) => + client.namingSymbolFor(row, inventory), + ), + ); + expect(distinct.size).toBeGreaterThan(1); + }); + + it('waits past the request budget for the timeout behavior', async () => { + const client = new ScriptedModelClient({ + behavior: 'timeout', + timeoutOvershootMs: 5, + }); + const started = Date.now(); + await client.complete(request({ timeBudgetMs: 20 })); + expect(Date.now() - started).toBeGreaterThanOrEqual(20); + }); +}); diff --git a/packages/learners/__tests__/no-learning.test.ts b/packages/learners/__tests__/no-learning.test.ts new file mode 100644 index 0000000..7804e11 --- /dev/null +++ b/packages/learners/__tests__/no-learning.test.ts @@ -0,0 +1,284 @@ +import { HASH_DOMAINS, fixedTokenInventory } from '@ald/types'; +import { domainHash, hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; + +import { + NoLearningAdapter, + createNoLearningAdapterFactory, +} from '../src/no-learning.js'; +import { + RecordingLedgerClient, + buildConformanceRunConfig, + runLearnerAdapterConformance, +} from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { LearnerStateError } from '../src/errors.js'; + +const factory = () => createNoLearningAdapterFactory(); + +async function initAdapter(seed = 'seed-a') { + const adapter = new NoLearningAdapter(); + const config = buildConformanceRunConfig('no-learning', { episodes: 8 }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('no-learning'), + seed, + symbolInventory: fixedTokenInventory(8), + ledger, + }); + return { adapter, ledger }; +} + +describe('NoLearningAdapter conformance (ALD-042)', () => { + it('passes the adapter conformance harness', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 40, + seed: 'no-learning-conformance', + }); + expect(result.episodes).toBe(40); + expect(result.proposals).toBe(80); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(result.ledgers[role].countOf('intention.recorded')).toBe(40); + expect(result.ledgers[role].countOf('interpretation.recorded')).toBe(20); + expect(result.ledgers[role].countOf('term.first_emitted')).toBeGreaterThan(0); + expect(result.ledgers[role].countOf('term.first_received')).toBeGreaterThan(0); + expect(result.ledgers[role].countOf('hypothesis.created')).toBe(0); + } + }); + + it('exposes no updatePolicy method (SPEC §6.2)', async () => { + const { adapter } = await initAdapter(); + expect(adapter.updatePolicy).toBeUndefined(); + expect('updatePolicy' in adapter).toBe(false); + }); + + it('scores at chance over 2000 episodes with four candidates', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 2_000, + seed: 'chance-control', + validation: 'none', + collectDrafts: false, + }); + expect(result.successRate).toBeGreaterThanOrEqual(0.22); + expect(result.successRate).toBeLessThanOrEqual(0.28); + }); + + it('never changes its policy hash across a whole run', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 200, + seed: 'chance-control', + validation: 'none', + collectDrafts: false, + }); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(new Set(result.policyHashes[role]).size).toBe(1); + } + }); + + it('emits symbols uniformly over the inventory', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 1_200, + seed: 'uniform-emission', + symbolInventorySize: 4, + validation: 'none', + }); + const counts = new Map(); + for (const role of ['baby-a', 'baby-b'] as const) { + for (const draft of result.ledgers[role].draftsOf('intention.recorded')) { + if (!draft.subjectId.startsWith('symbol:')) { + continue; + } + for (const symbol of draft.content.symbols as string[]) { + counts.set(symbol, (counts.get(symbol) ?? 0) + 1); + } + } + } + expect(counts.size).toBe(4); + for (const count of counts.values()) { + // 1200 draws over 4 symbols: expectation 300, sd ~15. + expect(count).toBeGreaterThan(240); + expect(count).toBeLessThan(360); + } + }); + + it('exports a seed hash and never the raw seed', async () => { + const { adapter } = await initAdapter('private-seed-material'); + const policy = adapter.exportPolicy(); + expect(policy).toEqual({ + kind: 'uniform-random', + seedHash: domainHash(HASH_DOMAINS.seed, 'private-seed-material'), + symbolInventorySize: 8, + messageLength: 1, + attributeCount: 2, + valuesPerAttribute: 4, + }); + expect(JSON.stringify(policy)).not.toContain('private-seed-material'); + }); + + it('replays identically from the same seed and differs across seeds', async () => { + const first = await runLearnerAdapterConformance(factory(), { + episodes: 30, + seed: 'replay', + }); + const second = await runLearnerAdapterConformance(factory(), { + episodes: 30, + seed: 'replay', + }); + const other = await runLearnerAdapterConformance(factory(), { + episodes: 30, + seed: 'replay-other', + }); + const drafts = (result: typeof first) => + JSON.stringify(result.ledgers['baby-a'].drafts); + expect(drafts(first)).toBe(drafts(second)); + expect(drafts(first)).not.toBe(drafts(other)); + }); + + it('records the first emission and first receipt of each symbol exactly once', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 400, + seed: 'first-use', + symbolInventorySize: 4, + }); + for (const role of ['baby-a', 'baby-b'] as const) { + const ledger = result.ledgers[role]; + expect(ledger.countOf('term.first_emitted')).toBe(4); + expect(ledger.countOf('term.first_received')).toBe(4); + const subjects = ledger + .draftsOf('term.first_emitted') + .map((draft) => draft.subjectId); + expect(new Set(subjects).size).toBe(4); + for (const draft of ledger.draftsOf('term.first_emitted')) { + expect(draft.content.termRef).toBe(draft.subjectId); + expect(draft.contentSchema).toBe('agent-native-ledger'); + } + } + }); + + it('writes an intention draft whose artifactRef addresses its own proposal', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 4, + seed: 'artifact-ref', + }); + const senderDraft = result.ledgers['baby-a'] + .draftsOf('intention.recorded') + .find((draft) => draft.subjectId.startsWith('symbol:')); + expect(senderDraft).toBeDefined(); + const symbols = senderDraft?.content.symbols as string[]; + expect(senderDraft?.content.artifactRef).toBe( + `proposal:${hashCanonical(HASH_DOMAINS.babyProposal, { + kind: 'emit_symbols', + publicArtifact: { symbols }, + })}`, + ); + expect(senderDraft?.content.policy).toBe('uniform-random'); + expect(typeof senderDraft?.content.targetTypeCode).toBe('number'); + }); + + it('reports a uniform inferred type distribution when receiving', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 4, + seed: 'uniform-interpretation', + }); + const draft = result.ledgers['baby-b'].draftsOf('interpretation.recorded')[0]; + const distribution = draft?.content.inferredTypeDistribution as number[]; + expect(distribution).toHaveLength(16); + expect(new Set(distribution)).toEqual(new Set([0.0625])); + expect(draft?.evidenceRefs?.[0]).toMatch(/^channel:sha256:[a-f0-9]{64}$/u); + }); + + it('emits 24 hex characters of blinding nonce per event, all distinct', async () => { + const result = await runLearnerAdapterConformance(factory(), { + episodes: 60, + seed: 'nonces', + }); + const nonces = result.ledgers['baby-a'].drafts.map( + (draft) => draft.blindingNonce, + ); + expect(nonces.length).toBeGreaterThan(60); + for (const nonce of nonces) { + expect(nonce).toMatch(/^[0-9a-f]{24}$/u); + } + expect(new Set(nonces).size).toBe(nonces.length); + }); + + it('refuses to act before init or observe, and without candidate refs', async () => { + const bare = new NoLearningAdapter(); + await expect( + bare.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }), + ).rejects.toThrow(LearnerStateError); + + const { adapter } = await initAdapter(); + await expect( + adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }), + ).rejects.toThrow(LearnerStateError); + await expect( + adapter.act({ + turn: 1, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + }), + ).rejects.toThrow(LearnerStateError); + }); + + it('refuses an action the turn budget does not offer (tool-only, SPEC §6.3)', async () => { + const { adapter } = await initAdapter(); + await adapter.observe({ + runId: 'run-conformance', + turn: 1, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: [ + [0, 0, 1], + [1, 1, 0], + ], + scenarioRef: 'scenario:x', + }); + await expect( + adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + }), + ).rejects.toThrow(LearnerStateError); + }); + + it('counts outcomes privately without writing them to the ledger', async () => { + const { adapter, ledger } = await initAdapter(); + const before = ledger.drafts.length; + await adapter.onOutcome({ + runId: 'run-conformance', + turn: 1, + role: 'receiver', + success: true, + reward: 1, + payload: [1], + }); + await adapter.onOutcome({ + runId: 'run-conformance', + turn: 2, + role: 'sender', + success: false, + reward: 0, + payload: [0], + }); + expect(adapter.outcomeCounters).toEqual({ outcomes: 2, successes: 1 }); + expect(ledger.drafts.length).toBe(before); + }); +}); diff --git a/packages/learners/__tests__/predictive-model.test.ts b/packages/learners/__tests__/predictive-model.test.ts new file mode 100644 index 0000000..5318a71 --- /dev/null +++ b/packages/learners/__tests__/predictive-model.test.ts @@ -0,0 +1,492 @@ +import { HASH_DOMAINS } from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; + +import { + MAX_CODE_BITS, + SeededSensoryEncoder, + type FrozenFeatureExtractor, +} from '../src/encoder.js'; +import { LearnerConfigurationError, LearnerStateError } from '../src/errors.js'; +import { attributesFromTypeCode, typeCodeCount } from '../src/game.js'; +import { + PREDICTION_PROGRESS_REWARD_DEFINITION, + PREDICTIVE_LOSS_DEFINITION, + PREDICTIVE_PAIRING_RULE, + PayloadMeanModel, + PredictiveCountModel, + lowestArgmax, + mixWithUniform, + normalizeLogs, + parseExportedPredictiveModel, +} from '../src/predictive-model.js'; + +/** Attribute rows of the whole 2-attribute / 4-value object space. */ +function attributeSpace(): number[][] { + return Array.from({ length: typeCodeCount(2, 4) }, (_, typeCode) => + attributesFromTypeCode(typeCode, 2, 4), + ); +} + +function uniformModel( + overrides: Partial<{ + messageLength: number; + featureCount: number; + symbolCount: number; + smoothing: number; + }> = {}, +): PredictiveCountModel { + return new PredictiveCountModel({ + messageLength: overrides.messageLength ?? 1, + featureCount: overrides.featureCount ?? 4, + symbolCount: overrides.symbolCount ?? 3, + smoothing: overrides.smoothing ?? 1, + priorNoise: 0, + countIncrement: 1, + }); +} + +describe('PredictiveCountModel (ALD-046 predictive loss)', () => { + it('names the pre-registered loss and pairing rule in every export', () => { + const exported = uniformModel().export(); + expect(exported.lossDefinition).toBe( + 'predictive-cross-entropy:message|candidate-features:v1', + ); + expect(exported.lossDefinition).toBe(PREDICTIVE_LOSS_DEFINITION); + expect(exported.pairingRule).toBe(PREDICTIVE_PAIRING_RULE); + expect(PREDICTION_PROGRESS_REWARD_DEFINITION).toBe( + 'prediction-progress:partner-message-log-likelihood:v1', + ); + }); + + it('starts exactly uniform when priorNoise is zero', () => { + const model = uniformModel(); + expect(model.symbolProbabilities(0, 2)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(model.candidatePosterior([0, 1, 2, 3], [1])).toEqual([ + 0.25, 0.25, 0.25, 0.25, + ]); + expect(model.pairs).toBe(0); + }); + + it('starts from a seeded random initialization when priorNoise is positive', () => { + const left = new PredictiveCountModel({ + messageLength: 1, + featureCount: 4, + symbolCount: 3, + smoothing: 1, + priorNoise: 0.25, + countIncrement: 1, + seed: 'seed-a', + }); + const right = new PredictiveCountModel({ + messageLength: 1, + featureCount: 4, + symbolCount: 3, + smoothing: 1, + priorNoise: 0.25, + countIncrement: 1, + seed: 'seed-b', + }); + const again = new PredictiveCountModel({ + messageLength: 1, + featureCount: 4, + symbolCount: 3, + smoothing: 1, + priorNoise: 0.25, + countIncrement: 1, + seed: 'seed-a', + }); + expect(left.hash()).not.toBe(right.hash()); + expect(left.hash()).toBe(again.hash()); + expect(left.symbolProbabilities(0, 0)).not.toEqual([1 / 3, 1 / 3, 1 / 3]); + }); + + it('refuses a positive priorNoise with no seed to draw it from', () => { + expect( + () => + new PredictiveCountModel({ + messageLength: 1, + featureCount: 2, + symbolCount: 2, + smoothing: 1, + priorNoise: 0.5, + countIncrement: 1, + }), + ).toThrow(LearnerConfigurationError); + }); + + it('raises the probability of the pair it folded and nothing else', () => { + const model = uniformModel(); + const before = model.symbolProbabilities(0, 1); + model.observe(1, [2]); + const after = model.symbolProbabilities(0, 1); + expect(after[2] as number).toBeGreaterThan(before[2] as number); + expect(after[0] as number).toBeLessThan(before[0] as number); + expect(model.symbolProbabilities(0, 0)).toEqual(before); + expect(model.pairs).toBe(1); + }); + + it('scores the candidate whose features predict the message highest', () => { + const model = uniformModel(); + for (let repeat = 0; repeat < 20; repeat += 1) { + model.observe(2, [0]); + model.observe(3, [1]); + } + const posterior = model.candidatePosterior([0, 1, 2, 3], [0]); + expect(lowestArgmax(posterior)).toBe(2); + expect(lowestArgmax(model.candidatePosterior([0, 1, 2, 3], [1]))).toBe(3); + }); + + it('treats an empty message as no evidence (SPEC §9.6 disabled)', () => { + const model = uniformModel(); + for (let repeat = 0; repeat < 20; repeat += 1) { + model.observe(2, [0]); + } + expect(model.messageLogProbability(2, [])).toBe(0); + expect(model.candidatePosterior([0, 1, 2, 3], [])).toEqual([ + 0.25, 0.25, 0.25, 0.25, + ]); + model.observe(1, []); + expect(model.pairs).toBe(20); + }); + + it('marginalizes a symbol over the positions it occupied', () => { + const model = uniformModel({ messageLength: 2, symbolCount: 3 }); + for (let repeat = 0; repeat < 20; repeat += 1) { + model.observe(1, [0, 2]); + } + expect(lowestArgmax(model.featurePosteriorForSymbol(0, [0]))).toBe(1); + expect(lowestArgmax(model.featurePosteriorForSymbol(2, [1]))).toBe(1); + // Position 1 saw symbol 2 for feature 1, never symbol 0, so symbol 0 is + // *less* probable under feature 1 there than under any unvisited feature. + const atPosition1 = model.featurePosteriorForSymbol(0, [1]); + expect(lowestArgmax(atPosition1)).not.toBe(1); + expect(atPosition1[1] as number).toBeLessThan(atPosition1[0] as number); + }); + + it('applies the curriculum learningRate as the count increment', () => { + const model = uniformModel(); + model.setCountIncrement(4); + expect(model.countIncrement).toBe(4); + model.observe(0, [0]); + expect(model.export().weights[0]?.[0]?.[0]).toBe(4); + expect(() => model.setCountIncrement(0)).toThrow(LearnerConfigurationError); + }); + + it('rejects a feature code or symbol index outside its tables', () => { + const model = uniformModel(); + expect(() => model.observe(4, [0])).toThrow(LearnerStateError); + expect(() => model.observe(0, [3])).toThrow(LearnerStateError); + expect(() => model.candidatePosterior([], [0])).toThrow(LearnerStateError); + }); + + it('round-trips through export and restore bit-for-bit', () => { + const model = new PredictiveCountModel({ + messageLength: 2, + featureCount: 4, + symbolCount: 3, + smoothing: 1, + priorNoise: 0.25, + countIncrement: 1, + seed: 'round-trip', + }); + model.observe(1, [0, 2]); + model.observe(3, [2, 1]); + const exported = model.export(); + + const restored = new PredictiveCountModel({ + messageLength: 2, + featureCount: 4, + symbolCount: 3, + smoothing: 1, + priorNoise: 0, + countIncrement: 1, + }); + restored.restore(exported); + expect(restored.hash()).not.toBe(model.hash()); // options differ + expect(restored.export().weights).toEqual(exported.weights); + expect(restored.pairs).toBe(2); + expect(restored.symbolProbabilities(0, 1)).toEqual( + model.symbolProbabilities(0, 1), + ); + }); + + it('refuses a checkpoint whose shape does not match the run', () => { + const wide = uniformModel({ symbolCount: 5 }).export(); + expect(() => uniformModel().restore(wide)).toThrow( + LearnerConfigurationError, + ); + }); + + it('rejects a ragged or negative exported model', () => { + const exported = uniformModel().export(); + const ragged = { + ...exported, + weights: [[[0, 0, 0], [0, 0]]], + }; + expect(() => parseExportedPredictiveModel(ragged)).toThrow( + LearnerConfigurationError, + ); + const negative = structuredClone(exported); + (negative.weights[0] as number[][])[0] = [-1, 0, 0]; + expect(() => parseExportedPredictiveModel(negative)).toThrow( + LearnerConfigurationError, + ); + }); + + it('hashes identically across two independently built models', () => { + expect(uniformModel().hash()).toBe(uniformModel().hash()); + expect(uniformModel().hash()).toBe( + hashCanonical(HASH_DOMAINS.policyCheckpoint, uniformModel().export()), + ); + }); + + it('never exports a success, reward, or outcome key (ALD-046 cb 3)', () => { + const model = uniformModel(); + model.observe(1, [0]); + expect(JSON.stringify(model.export())).not.toMatch( + /success|reward|outcome/iu, + ); + }); +}); + +describe('PayloadMeanModel (hybrid world-model payload head)', () => { + it('predicts the global mean for an unseen key and moves toward observations', () => { + const model = new PayloadMeanModel(1, 0.5); + expect(model.predict('k')).toEqual([0]); + const error = model.update('k', [1]); + expect(error).toBe(1); + expect(model.predict('k')).toEqual([0.5]); + expect(model.predict('unseen')).toEqual([0.5]); + }); + + it('pads and truncates a payload to its declared dimension', () => { + const model = new PayloadMeanModel(2, 1); + model.update('k', [1]); + expect(model.predict('k')).toEqual([1, 0]); + model.update('j', [1, 1, 9]); + expect(model.predict('j')).toEqual([1, 1]); + }); + + it('round-trips through export and restore', () => { + const model = new PayloadMeanModel(1, 0.25); + model.update('a', [1]); + model.update('b', [0]); + const exported = model.export(); + expect(exported.entries.map((entry) => entry.key)).toEqual(['a', 'b']); + + const restored = new PayloadMeanModel(1, 0.25); + restored.restore(exported); + expect(restored.export()).toEqual(exported); + expect(restored.hash()).toBe(model.hash()); + }); + + it('refuses a dimension mismatch and an invalid rate', () => { + const model = new PayloadMeanModel(1, 0.25); + expect(() => model.restore(new PayloadMeanModel(2, 0.25).export())).toThrow( + LearnerConfigurationError, + ); + expect(() => new PayloadMeanModel(1, 2)).toThrow(LearnerConfigurationError); + expect(() => new PayloadMeanModel(0, 0.5)).toThrow( + LearnerConfigurationError, + ); + }); +}); + +describe('distribution helpers', () => { + it('normalizes log-weights without underflowing on a long message', () => { + expect(normalizeLogs([])).toEqual([]); + expect(normalizeLogs([0, 0, 0, 0])).toEqual([0.25, 0.25, 0.25, 0.25]); + const shifted = normalizeLogs([-5_000, -5_000]); + expect(shifted).toEqual([0.5, 0.5]); + }); + + it('mixes a distribution with the uniform at the exploration rate', () => { + expect(mixWithUniform([1, 0], 0)).toEqual([1, 0]); + expect(mixWithUniform([1, 0], 1)).toEqual([0.5, 0.5]); + expect(mixWithUniform([1, 0], 0.5)).toEqual([0.75, 0.25]); + expect(mixWithUniform([], 0.5)).toEqual([]); + expect(() => mixWithUniform([1, 0], 1.5)).toThrow( + LearnerConfigurationError, + ); + }); + + it('breaks argmax ties on the lowest index', () => { + expect(lowestArgmax([])).toBe(-1); + expect(lowestArgmax([1, 1, 1])).toBe(0); + expect(lowestArgmax([0, 3, 3])).toBe(1); + }); +}); + +describe('SeededSensoryEncoder (ALD-047 from-scratch sensory encoder)', () => { + const options = { seed: 'encoder-seed', rowDimension: 2, inputScale: 4 }; + + it('is deterministic for one seed and differs across seeds', () => { + const left = new SeededSensoryEncoder(options); + const again = new SeededSensoryEncoder(options); + const other = new SeededSensoryEncoder({ ...options, seed: 'other-seed' }); + + const rows = attributeSpace(); + const leftCodes = rows.map((row) => left.encode(row)); + expect(rows.map((row) => again.encode(row))).toEqual(leftCodes); + expect(left.hash()).toBe(again.hash()); + expect(other.hash()).not.toBe(left.hash()); + expect(rows.map((row) => other.encode(row))).not.toEqual(leftCodes); + }); + + it('keeps every code inside [0, codeCount) in both modes', () => { + for (const mode of ['random-projection', 'seeded-hash'] as const) { + const encoder = new SeededSensoryEncoder({ ...options, mode, codeBits: 4 }); + expect(encoder.codeCount).toBe(16); + for (const row of attributeSpace()) { + const code = encoder.encode(row); + expect(Number.isInteger(code)).toBe(true); + expect(code).toBeGreaterThanOrEqual(0); + expect(code).toBeLessThan(encoder.codeCount); + } + } + }); + + it('separates most of the object space at the default 5 bits (measurement, not a claim)', () => { + const rows = attributeSpace(); + const distinct = [1, 2, 3, 4, 5].map((seed) => { + const encoder = new SeededSensoryEncoder({ + ...options, + seed: `separation-${String(seed)}`, + }); + return new Set(rows.map((row) => encoder.encode(row))).size; + }); + // Recorded as a measurement of the encoder's collision behaviour on the + // 16-type space: no threshold is pre-registered, so this only pins that the + // encoder is not degenerate (it does not collapse the space to one code). + for (const count of distinct) { + expect(count).toBeGreaterThan(1); + expect(count).toBeLessThanOrEqual(rows.length); + } + }); + + it('rejects a row of the wrong width or with a non-finite value', () => { + const encoder = new SeededSensoryEncoder(options); + expect(() => encoder.encode([1])).toThrow(LearnerStateError); + expect(() => encoder.encode([1, Number.NaN])).toThrow(LearnerStateError); + }); + + it('validates codeBits and rowDimension', () => { + expect(() => new SeededSensoryEncoder({ ...options, codeBits: 0 })).toThrow( + LearnerConfigurationError, + ); + expect( + () => + new SeededSensoryEncoder({ ...options, codeBits: MAX_CODE_BITS + 1 }), + ).toThrow(LearnerConfigurationError); + expect( + () => new SeededSensoryEncoder({ ...options, rowDimension: 0 }), + ).toThrow(LearnerConfigurationError); + }); + + it('puts an injected frozen feature bank in the sensory path and records it', () => { + const calls: number[][] = []; + const frozen: FrozenFeatureExtractor = { + name: 'test-double-visual-features', + hash: `sha256:${'ab'.repeat(32)}`, + textAligned: true, + dimension: 3, + outputScale: 2, + project(row) { + calls.push([...row]); + return [row[0] ?? 0, row[1] ?? 0, ((row[0] ?? 0) + (row[1] ?? 0)) % 2]; + }, + }; + const encoder = new SeededSensoryEncoder({ + ...options, + frozenFeatures: frozen, + }); + expect(encoder.inputDimension).toBe(3); + expect(encoder.inputScale).toBe(2); + encoder.encode([1, 2]); + expect(calls).toEqual([[1, 2]]); + expect(encoder.frozenFeatures).toEqual({ + name: 'test-double-visual-features', + hash: `sha256:${'ab'.repeat(32)}`, + textAligned: true, + dimension: 3, + outputScale: 2, + }); + expect(encoder.export().frozenFeatures?.textAligned).toBe(true); + }); + + it('refuses a frozen bank whose projection has the wrong width', () => { + const encoder = new SeededSensoryEncoder({ + ...options, + frozenFeatures: { + name: 'short-double', + hash: `sha256:${'cd'.repeat(32)}`, + textAligned: false, + dimension: 3, + outputScale: 2, + project: () => [1, 2], + }, + }); + expect(() => encoder.encode([1, 2])).toThrow(LearnerStateError); + }); + + it('refuses a frozen descriptor that is not a sha256 hash', () => { + expect( + () => + new SeededSensoryEncoder({ + ...options, + frozenFeatures: { + name: 'bad-hash', + hash: 'not-a-hash', + textAligned: false, + dimension: 2, + outputScale: 4, + project: (row) => [...row], + }, + }), + ).toThrow(); + }); + + it('round-trips through export and load, in both modes', () => { + for (const mode of ['random-projection', 'seeded-hash'] as const) { + const encoder = new SeededSensoryEncoder({ ...options, mode }); + const restored = SeededSensoryEncoder.load(encoder.export()); + expect(restored.hash()).toBe(encoder.hash()); + for (const row of attributeSpace()) { + expect(restored.encode(row)).toBe(encoder.encode(row)); + } + } + }); + + it('refuses to load across a frozen-feature mismatch', () => { + const frozen: FrozenFeatureExtractor = { + name: 'test-double-visual-features', + hash: `sha256:${'ab'.repeat(32)}`, + textAligned: false, + dimension: 2, + outputScale: 4, + project: (row) => [...row], + }; + const plain = new SeededSensoryEncoder(options).export(); + const withFrozen = new SeededSensoryEncoder({ + ...options, + frozenFeatures: frozen, + }).export(); + + expect(() => SeededSensoryEncoder.load(plain, frozen)).toThrow( + LearnerConfigurationError, + ); + expect(() => SeededSensoryEncoder.load(withFrozen)).toThrow( + LearnerConfigurationError, + ); + expect(() => + SeededSensoryEncoder.load(withFrozen, { ...frozen, textAligned: true }), + ).toThrow(LearnerConfigurationError); + }); + + it('never exports the private seed', () => { + const encoder = new SeededSensoryEncoder(options); + expect(JSON.stringify(encoder.export())).not.toContain(options.seed); + expect(encoder.export().seedHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + }); +}); diff --git a/packages/learners/__tests__/random-control.test.ts b/packages/learners/__tests__/random-control.test.ts new file mode 100644 index 0000000..68b6adc --- /dev/null +++ b/packages/learners/__tests__/random-control.test.ts @@ -0,0 +1,196 @@ +/** + * SPEC §9.6 `random`: the control delivers a uniformly drawn message whose + * length is anywhere in `[1, maxSymbolsPerMessage]`, and §9.6 requires all six + * conditions to run over "the same scenarios and learner interfaces". A + * delivered length that differs from the adapter's configured `messageLength` + * is therefore a legal artifact, and the reference `scratch-rl` adapter must + * act on it rather than crash: a deterministic adapter failure would be + * retried once by `NurseryRuntime` (§14.5) and then pause the control run. + * + * The contract is: score the delivered prefix the receiver holds tables for, + * treat missing positions as absent, and record the delivered symbols verbatim + * in the interpretation event (§8.2). + */ +import { fixedTokenInventory, type RunConfig } from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { + RecordingLedgerClient, + buildConformanceRunConfig, +} from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { TabularReinforceAdapter } from '../src/tabular-reinforce.js'; + +const SYMBOL_INVENTORY_SIZE = 8; +const inventory = fixedTokenInventory(SYMBOL_INVENTORY_SIZE); + +const RECEIVER_PAYLOAD = [ + [0, 0], + [1, 1], + [2, 2], + [3, 3], +]; + +const CANDIDATE_REFS = ['o:aabbccdd0011', 'o:aabbccdd0022', 'o:aabbccdd0033']; + +async function initAdapter( + messageLength: number, + maxSymbolsPerMessage: number, +): Promise<{ + adapter: TabularReinforceAdapter; + ledger: RecordingLedgerClient; + config: RunConfig; +}> { + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 4, + symbolInventorySize: SYMBOL_INVENTORY_SIZE, + messageLength: maxSymbolsPerMessage, + }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + const adapter = new TabularReinforceAdapter({ + learningRate: 1, + temperature: 0.5, + messageLength, + }); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('scratch-rl'), + seed: 'seed-random-control', + symbolInventory: inventory, + ledger, + }); + return { adapter, ledger, config }; +} + +/** One receiver turn: observe, receive `symbols`, then select a candidate. */ +async function receiverTurn( + adapter: TabularReinforceAdapter, + ledger: RecordingLedgerClient, + config: RunConfig, + turn: number, + symbols: string[], +): Promise<{ interpretation: Record; intention: Record }> { + ledger.turn = turn; + await adapter.observe({ + runId: config.runId, + turn, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: RECEIVER_PAYLOAD.slice(0, CANDIDATE_REFS.length), + scenarioRef: 'scn:00112233445566aa', + }); + const channelEventHash = `sha256:${'c'.repeat(64)}` as const; + const envelope = await adapter.receive({ + runId: config.runId, + turn, + logicalSender: 'baby-b', + carrier: 'fixed-token', + publicArtifact: { symbols }, + channelEventHash, + }); + await ledger.append(envelope.privateLedgerDraft, { channelEventHash }); + + const proposal = await adapter.act({ + turn, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + await ledger.append(proposal.privateLedgerDraft); + + await adapter.onOutcome({ + runId: config.runId, + turn, + role: 'receiver', + success: true, + reward: 1, + payload: [1], + }); + await adapter.updatePolicy({ + runId: config.runId, + turns: [turn], + learningSignal: 'extrinsic-task', + }); + + return { + interpretation: envelope.privateLedgerDraft.content, + intention: proposal.privateLedgerDraft.content, + }; +} + +describe('scratch-rl receiver under the SPEC §9.6 random control', () => { + it('acts on a message longer than its configured messageLength', async () => { + const { adapter, ledger, config } = await initAdapter(1, 4); + const before = adapter.exportPolicy(); + const delivered = [ + inventory[2] as string, + inventory[5] as string, + inventory[7] as string, + ]; + + const { interpretation, intention } = await receiverTurn( + adapter, + ledger, + config, + 1, + delivered, + ); + + // The interpretation event records what was actually delivered (§8.2), + // not the truncated prefix the policy could score. + expect(interpretation.symbols).toEqual(delivered); + expect(intention.symbols).toEqual(delivered); + expect(intention.associationWeights as number[]).toHaveLength( + CANDIDATE_REFS.length, + ); + expect(CANDIDATE_REFS).toContain(proposalSelection(intention)); + // Only the prefix has a receiver table, so only the prefix is credited. + const policy = adapter.exportPolicy(); + expect(policy.thetaReceiver).toHaveLength(1); + expect(policy.thetaReceiver[0]?.[2]).not.toEqual( + before.thetaReceiver[0]?.[2], + ); + expect(policy.thetaReceiver[0]?.[5]).toEqual(before.thetaReceiver[0]?.[5]); + }); + + it('acts on a message shorter than its configured messageLength', async () => { + const { adapter, ledger, config } = await initAdapter(2, 4); + const before = adapter.exportPolicy(); + const delivered = [inventory[3] as string]; + + const { interpretation, intention } = await receiverTurn( + adapter, + ledger, + config, + 1, + delivered, + ); + + expect(interpretation.symbols).toEqual(delivered); + expect(intention.symbols).toEqual(delivered); + expect(CANDIDATE_REFS).toContain(proposalSelection(intention)); + // Position 0 was delivered and credited; position 1 was absent, so its + // table is untouched. + const policy = adapter.exportPolicy(); + expect(policy.thetaReceiver).toHaveLength(2); + expect(policy.thetaReceiver[0]?.[3]).not.toEqual( + before.thetaReceiver[0]?.[3], + ); + expect(policy.thetaReceiver[1]).toEqual(before.thetaReceiver[1]); + }); + + it('still refuses a delivered symbol outside the inventory', async () => { + const { adapter, ledger, config } = await initAdapter(1, 4); + await expect( + receiverTurn(adapter, ledger, config, 1, ['S99']), + ).rejects.toThrow(/not in the inventory/u); + }); +}); + +function proposalSelection(content: Record): string { + return content.selection as string; +} diff --git a/packages/learners/__tests__/recurrent-adapters.test.ts b/packages/learners/__tests__/recurrent-adapters.test.ts new file mode 100644 index 0000000..c3b392a --- /dev/null +++ b/packages/learners/__tests__/recurrent-adapters.test.ts @@ -0,0 +1,157 @@ +import { HASH_DOMAINS } from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { describe, expect, it } from 'vitest'; + +import { runLearnerAdapterConformance } from '../src/conformance.js'; +import { RECURRENT_ARCHITECTURE } from '../src/recurrent-model.js'; +import { + ExportedRecurrentScratchPolicySchema, +} from '../src/recurrent-scratch-policy.js'; +import { + ExportedSelfSupervisedPolicySchema, + createRecurrentSelfSupervisedAdapterFactory, +} from '../src/self-supervised.js'; +import { createRecurrentActorCriticAdapterFactory } from '../src/tabular-reinforce.js'; + +const recurrentOptions = { + learningRate: 0.01, + recurrent: { hiddenSize: 8, ppoEpochs: 4 }, +} as const; + +describe('recurrent scientific adapters (ALD-045, ALD-046)', () => { + it('runs the scratch GRU actor-critic through the complete adapter contract', async () => { + const result = await runLearnerAdapterConformance( + createRecurrentActorCriticAdapterFactory(recurrentOptions), + { episodes: 40, seed: 'recurrent-adapter-contract' }, + ); + + expect(result.proposals).toBe(80); + for (const role of ['baby-a', 'baby-b'] as const) { + const policy = ExportedRecurrentScratchPolicySchema.parse( + result.adapters[role].exportPolicy(), + ); + expect(policy.architecture).toBe(RECURRENT_ARCHITECTURE); + expect(policy.model.updateCount).toBe(40); + expect(policy.model.parameterCount).toBeGreaterThan(0); + expect(result.checkpoints[role]).toHaveLength(40); + expect(result.ledgers[role].countOf('intention.recorded')).toBe(40); + expect(result.ledgers[role].countOf('interpretation.recorded')).toBe(20); + } + }); + + it('restores a recurrent policy exactly and keeps its exported weights frozen in evaluation', async () => { + const trained = await runLearnerAdapterConformance( + createRecurrentActorCriticAdapterFactory(recurrentOptions), + { episodes: 24, seed: 'recurrent-restore-source' }, + ); + const initialPolicies = { + 'baby-a': trained.adapters['baby-a'].exportPolicy(), + 'baby-b': trained.adapters['baby-b'].exportPolicy(), + }; + const evaluation = await runLearnerAdapterConformance( + createRecurrentActorCriticAdapterFactory(recurrentOptions), + { + episodes: 12, + seed: 'recurrent-restore-evaluation', + updatePolicy: false, + initialPolicies: JSON.parse(JSON.stringify(initialPolicies)), + }, + ); + + for (const role of ['baby-a', 'baby-b'] as const) { + expect(evaluation.adapters[role].exportPolicy()).toEqual( + initialPolicies[role], + ); + expect(new Set(evaluation.policyHashes[role])).toEqual( + new Set([ + hashCanonical(HASH_DOMAINS.policyCheckpoint, initialPolicies[role]), + ]), + ); + } + }); + + it('keeps independently seeded Baby policies distinct', async () => { + const result = await runLearnerAdapterConformance( + createRecurrentActorCriticAdapterFactory(recurrentOptions), + { episodes: 1, seed: 'recurrent-independent-init', updatePolicy: false }, + ); + + const babyA = ExportedRecurrentScratchPolicySchema.parse( + result.adapters['baby-a'].exportPolicy(), + ); + const babyB = ExportedRecurrentScratchPolicySchema.parse( + result.adapters['baby-b'].exportPolicy(), + ); + expect(babyA.model.parameters).not.toEqual(babyB.model.parameters); + expect(babyA.model.optimizer.step).toBe(0); + expect(babyB.model.optimizer.step).toBe(0); + }); + + it('runs the reward-free GRU through the same adapter contract and capacity', async () => { + const scratch = await runLearnerAdapterConformance( + createRecurrentActorCriticAdapterFactory(recurrentOptions), + { episodes: 8, seed: 'matched-scratch-capacity' }, + ); + const rewardFree = await runLearnerAdapterConformance( + createRecurrentSelfSupervisedAdapterFactory(recurrentOptions), + { + episodes: 8, + seed: 'matched-reward-free-capacity', + learningSignal: 'self-supervised', + rewardVisibility: 'forbidden', + }, + ); + + for (const role of ['baby-a', 'baby-b'] as const) { + const scratchPolicy = ExportedRecurrentScratchPolicySchema.parse( + scratch.adapters[role].exportPolicy(), + ); + const rewardFreePolicy = ExportedSelfSupervisedPolicySchema.parse( + rewardFree.adapters[role].exportPolicy(), + ); + expect(rewardFreePolicy.version).toBe(2); + if (rewardFreePolicy.version !== 2) throw new Error('expected recurrent policy'); + expect(rewardFreePolicy.model.kind).toBe(RECURRENT_ARCHITECTURE); + expect(rewardFreePolicy.model.model.parameterCount).toBe( + scratchPolicy.model.parameterCount, + ); + expect(rewardFree.checkpoints[role]).toHaveLength(8); + expect(rewardFree.ledgers[role].countOf('intention.recorded')).toBe(8); + expect(rewardFree.ledgers[role].countOf('interpretation.recorded')).toBe(4); + } + }); + + it('restores and freezes the recurrent reward-free policy', async () => { + const source = await runLearnerAdapterConformance( + createRecurrentSelfSupervisedAdapterFactory(recurrentOptions), + { + episodes: 12, + seed: 'reward-free-restore-source', + learningSignal: 'self-supervised', + rewardVisibility: 'forbidden', + }, + ); + const initialPolicies = { + 'baby-a': source.adapters['baby-a'].exportPolicy(), + 'baby-b': source.adapters['baby-b'].exportPolicy(), + }; + const evaluation = await runLearnerAdapterConformance( + createRecurrentSelfSupervisedAdapterFactory(recurrentOptions), + { + episodes: 8, + seed: 'reward-free-restore-source', + learningSignal: 'self-supervised', + rewardVisibility: 'forbidden', + updatePolicy: false, + initialPolicies: JSON.parse(JSON.stringify(initialPolicies)), + }, + ); + + for (const role of ['baby-a', 'baby-b'] as const) { + expect(evaluation.adapters[role].exportPolicy()).toEqual( + initialPolicies[role], + ); + expect(new Set(evaluation.policyHashes[role]).size).toBe(1); + } + }); +}); diff --git a/packages/learners/__tests__/recurrent-model.test.ts b/packages/learners/__tests__/recurrent-model.test.ts new file mode 100644 index 0000000..af7da67 --- /dev/null +++ b/packages/learners/__tests__/recurrent-model.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + RECURRENT_ARCHITECTURE, + RECURRENT_RL_OBJECTIVE, + RECURRENT_SELF_SUPERVISED_OBJECTIVE, + RecurrentCommunicationModel, +} from '../src/recurrent-model.js'; + +const options = { + typeCount: 4, + symbolCount: 3, + messageLength: 1, + hiddenSize: 5, + learningRate: 0.01, + ppoEpochs: 4, +}; + +function probability( + model: RecurrentCommunicationModel, + feature: number, + symbol: number, +): number { + return model.predictSymbols(feature)[0]?.[symbol] ?? 0; +} + +describe('RecurrentCommunicationModel scientific backbone (ALD-045, ALD-046)', () => { + it('locks the architecture, parameter count, memory budget, and independent initialization', () => { + const first = new RecurrentCommunicationModel('baby-a-seed', options); + const replay = new RecurrentCommunicationModel('baby-a-seed', options); + const independent = new RecurrentCommunicationModel('baby-b-seed', options); + + expect(first.parameterCount).toBe(273); + expect(first.memoryBytes()).toBe((273 * 3 + 5 * 4) * 8); + expect(first.export()).toEqual(replay.export()); + expect(first.export().parameters).not.toEqual(independent.export().parameters); + expect(first.export()).toMatchObject({ + architecture: RECURRENT_ARCHITECTURE, + rlObjective: RECURRENT_RL_OBJECTIVE, + selfSupervisedObjective: RECURRENT_SELF_SUPERVISED_OBJECTIVE, + optimizer: { name: 'adam-v1', step: 0 }, + }); + + const independentBefore = independent.export(); + first.updatePredictive([{ featureCode: 0, messageSymbolIndices: [0] }]); + expect(independent.export()).toEqual(independentBefore); + expect(first.export().optimizer.step).toBeGreaterThan(0); + expect(independent.export().optimizer.step).toBe(0); + }); + + it('matches a central-difference gradient on the predictive objective', () => { + const model = new RecurrentCommunicationModel('gradient-seed', options); + const check = model.checkPredictiveGradient(2, [1]); + + expect(Math.abs(check.analytic)).toBeGreaterThan(1e-6); + expect(check.relativeError).toBeLessThan(1e-6); + }); + + it('performs a clipped PPO-style reward-to-parameter update', () => { + const model = new RecurrentCommunicationModel('rl-seed', options); + const before = probability(model, 0, 0); + let observedDelta = 0; + let clippedEpochs = 0; + + for (let turn = 1; turn <= 40; turn += 1) { + model.sender(turn, 0); + model.recordActions(turn, [0]); + model.recordReward(turn, 1); + const result = model.updateReinforcement([turn]); + expect(result.objective).toBe(RECURRENT_RL_OBJECTIVE); + expect(result.gradientNorm).toBeLessThanOrEqual(1 + 1e-12); + observedDelta += result.parameterDeltaL2; + clippedEpochs += result.clippedEpochs; + } + + expect(observedDelta).toBeGreaterThan(0); + expect(clippedEpochs).toBeGreaterThanOrEqual(0); + expect(probability(model, 0, 0)).toBeGreaterThan(before); + expect(model.export().updateCount).toBe(40); + }); + + it('learns a toy reward-free feature-to-message mapping', () => { + const model = new RecurrentCommunicationModel('predictive-seed', options); + const before = (probability(model, 0, 0) + probability(model, 1, 1)) / 2; + let changed = 0; + + for (let step = 0; step < 120; step += 1) { + const result = model.updatePredictive([ + { featureCode: 0, messageSymbolIndices: [0] }, + { featureCode: 1, messageSymbolIndices: [1] }, + ]); + expect(result.objective).toBe(RECURRENT_SELF_SUPERVISED_OBJECTIVE); + changed += result.parameterDeltaL2; + } + + model.resetLiveHiddenToCheckpoint(); + const after = (probability(model, 0, 0) + probability(model, 1, 1)) / 2; + expect(changed).toBeGreaterThan(0); + expect(after).toBeGreaterThan(before + 0.15); + expect(after).toBeGreaterThan(0.48); + }); + + it('round-trips optimizer/checkpoint state and freezes exports during evaluation', () => { + const original = new RecurrentCommunicationModel('restore-seed', options); + original.updatePredictive([{ featureCode: 3, messageSymbolIndices: [2] }]); + const checkpoint = original.export(); + const restored = new RecurrentCommunicationModel('different-seed', options); + restored.restore(checkpoint); + + expect(restored.export()).toEqual(checkpoint); + expect(restored.predictSymbols(3)).toEqual(original.predictSymbols(3)); + + const frozen = JSON.stringify(restored.export()); + restored.sender(10, 2); + restored.receiver(11, [1], [0, 1, 2, 3]); + expect(JSON.stringify(restored.export())).toBe(frozen); + }); + + it('does not update without a recorded local action and reward', () => { + const model = new RecurrentCommunicationModel('negative-control', options); + const before = model.export(); + model.sender(1, 0); + const result = model.updateReinforcement([1]); + + expect(result.trajectories).toBe(0); + expect(result.parameterDeltaL2).toBe(0); + expect(model.export()).toEqual(before); + }); +}); diff --git a/packages/learners/__tests__/registry.test.ts b/packages/learners/__tests__/registry.test.ts new file mode 100644 index 0000000..bb4c752 --- /dev/null +++ b/packages/learners/__tests__/registry.test.ts @@ -0,0 +1,131 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { LearnerTrackIdSchema } from '@ald/types'; +import { describe, expect, it } from 'vitest'; + +import { + ADAPTER_FACTORIES, + UNIMPLEMENTED_TRACK_BACKLOG_ITEMS, + createLearnerAdapterFactory, +} from '../src/index.js'; +import { + LEARNER_LEDGER_EVENT_TYPES, + REQUIRED_CONTENT_FIELDS, + isLearnerLedgerEventType, + validateLearnerDraft, +} from '../src/drafts.js'; + +const LEDGER_DESIGN = fileURLToPath( + new URL('../../../LEDGER-INTEGRITY-DESIGN.md', import.meta.url), +); + +/** The LEDGER-INTEGRITY-DESIGN.md §5 event-type list, read from the document. */ +function documentedEventTypes(): string[] { + const source = readFileSync(LEDGER_DESIGN, 'utf8'); + const section = source.slice( + source.indexOf('## 5. Event Types'), + source.indexOf('## 6. Binding Ledgers'), + ); + return [...section.matchAll(/^- `([a-z._]+)`[;.]$/gmu)].map( + (match) => match[1] as string, + ); +} + +describe('ADAPTER_FACTORIES', () => { + it('addresses all five SPEC §6.1 track IDs behind one interface', () => { + expect(Object.keys(ADAPTER_FACTORIES).sort()).toEqual( + [...LearnerTrackIdSchema.options].sort(), + ); + }); + + it('builds the two zero-configuration reference tracks', () => { + expect(ADAPTER_FACTORIES['no-learning']().track).toBe('no-learning'); + expect(ADAPTER_FACTORIES['scratch-rl']().track).toBe('scratch-rl'); + expect(createLearnerAdapterFactory('no-learning').create().track).toBe( + 'no-learning', + ); + expect( + createLearnerAdapterFactory('scratch-rl', { temperature: 0.5 }).create() + .track, + ).toBe('scratch-rl'); + }); + + it('has no remaining unimplemented SPEC §6.1 tracks', () => { + expect(UNIMPLEMENTED_TRACK_BACKLOG_ITEMS).toEqual({}); + }); +}); + +describe('ledger event-type registry (LEDGER §5)', () => { + it('matches the event types the design document lists', () => { + expect([...LEARNER_LEDGER_EVENT_TYPES]).toEqual(documentedEventTypes()); + }); + + it('declares required content fields for every event type', () => { + for (const eventType of LEARNER_LEDGER_EVENT_TYPES) { + expect(REQUIRED_CONTENT_FIELDS[eventType].length).toBeGreaterThan(0); + expect(isLearnerLedgerEventType(eventType)).toBe(true); + } + expect(isLearnerLedgerEventType('term.invented')).toBe(false); + }); +}); + +describe('validateLearnerDraft', () => { + const base = { + contentSchema: 'agent-native-ledger' as const, + subjectId: 'symbol:S01', + blindingNonce: '0123456789abcdef01234567', + evidenceRefs: [], + }; + + it('accepts a well-formed draft and defaults evidenceRefs', () => { + const draft = validateLearnerDraft({ + eventType: 'intention.recorded', + contentSchema: 'agent-native-ledger', + subjectId: 'symbol:S01', + content: { artifactRef: 'proposal:x' }, + blindingNonce: '0123456789abcdef01234567', + }); + expect(draft.evidenceRefs).toEqual([]); + }); + + it('rejects an unknown event type', () => { + expect(() => + validateLearnerDraft({ + ...base, + eventType: 'term.invented', + content: { termRef: 'symbol:S01' }, + }), + ).toThrow(/Unknown ledger event type/u); + }); + + it('rejects every missing required content field', () => { + for (const [eventType, fields] of Object.entries(REQUIRED_CONTENT_FIELDS)) { + expect(() => + validateLearnerDraft({ ...base, eventType, content: {} }), + eventType, + ).toThrow(new RegExp(fields.join(', '), 'u')); + } + }); + + it('rejects an empty string in a required field', () => { + expect(() => + validateLearnerDraft({ + ...base, + eventType: 'term.first_emitted', + content: { termRef: '' }, + }), + ).toThrow(/termRef/u); + }); + + it('rejects a missing blinding nonce', () => { + expect(() => + validateLearnerDraft({ + eventType: 'term.first_emitted', + contentSchema: 'agent-native-ledger', + subjectId: 'symbol:S01', + content: { termRef: 'symbol:S01' }, + }), + ).toThrow(); + }); +}); diff --git a/packages/learners/__tests__/self-supervised.test.ts b/packages/learners/__tests__/self-supervised.test.ts new file mode 100644 index 0000000..9d2b24f --- /dev/null +++ b/packages/learners/__tests__/self-supervised.test.ts @@ -0,0 +1,674 @@ +import { HASH_DOMAINS, fixedTokenInventory } from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { + RecordingLedgerClient, + assertAgentNativeContent, + buildConformanceRunConfig, + runLearnerAdapterConformance, + type ConformanceOptions, + type ConformanceResult, +} from '../src/conformance.js'; +import { loadLearnerContract, type TrackLearnerContract } from '../src/contracts.js'; +import { validateLearnerDraft } from '../src/drafts.js'; +import { + LearnerConfigurationError, + UnsupportedLearningSignalError, +} from '../src/errors.js'; +import { PREDICTIVE_LOSS_DEFINITION } from '../src/predictive-model.js'; +import { + SELF_SUPERVISED_UPDATE_RECORD_KEYS, + ScalarRewardRefusedError, + SelfSupervisedAdapter, + createSelfSupervisedAdapterFactory, +} from '../src/self-supervised.js'; + +/** + * `contracts/learner-contract.self-supervised.v1.md` does not exist yet + * (SPEC §6.4 requires one per track, and authoring it is outside this + * workstream's file ownership — see the integrator notes). Every test therefore + * injects the `scratch-rl` contract body retagged for this track: it is a + * stand-in for the contract-loading seam, not the track's real contract, and + * nothing here depends on its text. + */ +function standInContract(): TrackLearnerContract { + return { ...loadLearnerContract('scratch-rl'), track: 'self-supervised' }; +} + +function conformanceOptions( + overrides: ConformanceOptions = {}, +): ConformanceOptions { + return { + learningSignal: 'self-supervised', + learnerContract: standInContract(), + // The harness installs a getter that throws if `OutcomeEvent.reward` is + // ever read, which is the strongest available proof of ALD-046 cb 1. + rewardVisibility: 'forbidden', + ...overrides, + }; +} + +async function initAdapter( + options: Parameters[0] = {}, + overrides: { + seed?: string; + symbolInventorySize?: number; + runId?: string; + initialPolicy?: unknown; + } = {}, +) { + const adapter = new SelfSupervisedAdapter(options); + const symbolInventorySize = overrides.symbolInventorySize ?? 8; + const config = buildConformanceRunConfig( + 'self-supervised', + conformanceOptions({ + episodes: 8, + symbolInventorySize, + ...(overrides.runId === undefined ? {} : { runId: overrides.runId }), + }), + ); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: standInContract(), + seed: overrides.seed ?? 'seed-a', + symbolInventory: fixedTokenInventory(symbolInventorySize), + ledger, + ...(overrides.initialPolicy === undefined + ? {} + : { initialPolicy: overrides.initialPolicy }), + }); + return { adapter, ledger, config }; +} + +const SENDER_PAYLOAD = [ + [0, 0, 1], + [1, 1, 0], + [2, 2, 0], + [3, 3, 0], +]; + +const RECEIVER_PAYLOAD = [ + [1, 1], + [0, 0], + [3, 3], + [2, 2], +]; + +const CANDIDATE_REFS = ['1', '2', '3', '4'].map( + (digit) => `object:sha256:${digit.repeat(64)}`, +); + +describe('SelfSupervisedAdapter conformance (ALD-046 cb 2)', () => { + it('completes full turns and a predictive update on three seeds, in both roles', async () => { + for (const seed of ['ss-seed-1', 'ss-seed-2', 'ss-seed-3']) { + const result = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ episodes: 24, seed }), + ); + expect(result.proposals).toBe(48); + for (const role of ['baby-a', 'baby-b'] as const) { + // roleReversalPeriod 1: every Baby is sender on half the episodes and + // receiver on the other half, so both halves of the adapter ran. + expect(result.ledgers[role].countOf('intention.recorded')).toBe(24); + expect(result.ledgers[role].countOf('interpretation.recorded')).toBe(12); + expect( + result.ledgers[role].countOf('hypothesis.created'), + ).toBeGreaterThan(0); + expect(result.checkpoints[role]).toHaveLength(24); + for (const checkpoint of result.checkpoints[role]) { + expect(checkpoint.policyCheckpointRef).toBe( + `policy:${checkpoint.policyHash}`, + ); + expect(checkpoint.policyHash).toMatch(/^sha256:[a-f0-9]{64}$/u); + } + } + } + }); + + it('exposes updatePolicy, as SPEC §6.2 requires for a trainable track', async () => { + const { adapter } = await initAdapter(); + expect(typeof adapter.updatePolicy).toBe('function'); + expect(adapter.track).toBe('self-supervised'); + expect(createSelfSupervisedAdapterFactory().track).toBe('self-supervised'); + }); + + it('folds pairs into the predictive model as the run proceeds', async () => { + const result = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ episodes: 12, seed: 'ss-folding' }), + ); + for (const role of ['baby-a', 'baby-b'] as const) { + const policy = result.adapters[role].exportPolicy() as { + model: { pairs: number }; + }; + expect(policy.model.pairs).toBeGreaterThan(0); + } + // Every checkpoint hash differs from the one before while pairs accumulate. + expect(new Set(result.policyHashes['baby-a']).size).toBeGreaterThan(1); + }); + + it('writes agent-native content only, with no natural-language gloss', async () => { + const result = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ episodes: 8, seed: 'ss-agent-native' }), + ); + for (const role of ['baby-a', 'baby-b'] as const) { + for (const draft of result.ledgers[role].drafts) { + const validated = validateLearnerDraft(draft); + expect(validated.contentSchema).toBe('agent-native-ledger'); + assertAgentNativeContent(validated.content, `${role} ${draft.eventType}`); + } + } + }); +}); + +describe('SelfSupervisedAdapter reward refusal (ALD-046 cb 1)', () => { + it('requires learningSignal "self-supervised" at init', async () => { + const adapter = new SelfSupervisedAdapter(); + const config = buildConformanceRunConfig('scratch-rl', { episodes: 4 }); + expect(config.learningSignal).toBe('extrinsic-task'); + await expect( + adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: standInContract(), + seed: 'seed-a', + symbolInventory: fixedTokenInventory(8), + ledger: new RecordingLedgerClient(config.runId, 'baby-a'), + }), + ).rejects.toThrow(UnsupportedLearningSignalError); + }); + + it('refuses an update batch that names any other learning signal', async () => { + const { adapter, config } = await initAdapter(); + for (const learningSignal of [ + 'extrinsic-task', + 'intrinsic-prediction-progress', + 'none', + ] as const) { + await expect( + adapter.updatePolicy({ + runId: config.runId, + turns: [1], + learningSignal, + }), + ).rejects.toThrow(UnsupportedLearningSignalError); + } + await expect( + adapter.updatePolicy({ + runId: config.runId, + turns: [], + learningSignal: 'self-supervised', + }), + ).resolves.toMatchObject({ turn: 0 }); + }); + + it('refuses a non-null scalar reward on onOutcome', async () => { + const { adapter, config } = await initAdapter(); + for (const reward of [1, 0, -3, 0.5]) { + await expect( + adapter.onOutcome({ + runId: config.runId, + turn: 1, + role: 'sender', + success: true, + reward, + payload: [1], + }), + ).rejects.toThrow(ScalarRewardRefusedError); + } + }); + + it('accepts an outcome whose reward is null, as a reward-free run supplies', async () => { + const { adapter, config } = await initAdapter(); + await expect( + adapter.onOutcome({ + runId: config.runId, + turn: 1, + role: 'sender', + success: true, + reward: null, + payload: [1], + }), + ).resolves.toBeUndefined(); + }); + + it('never reads the reward field, which the throwing-getter probe proves', async () => { + const { adapter, config } = await initAdapter(); + const outcome: Record = { + runId: config.runId, + turn: 1, + role: 'sender', + success: true, + payload: [1], + }; + let reads = 0; + Object.defineProperty(outcome, 'reward', { + enumerable: true, + configurable: true, + get(): never { + reads += 1; + throw new Error('reward was read'); + }, + }); + await adapter.onOutcome( + outcome as unknown as Parameters[0], + ); + expect(reads).toBe(0); + }); +}); + +describe('SelfSupervisedAdapter update buffer (ALD-046 cb 3)', () => { + it('buffers exactly the pre-registered record fields and nothing else', async () => { + const { adapter, config } = await initAdapter(); + await adapter.observe({ + runId: config.runId, + turn: 1, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: SENDER_PAYLOAD, + scenarioRef: 'scenario:sender', + }); + await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + await adapter.observe({ + runId: config.runId, + turn: 2, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: RECEIVER_PAYLOAD, + scenarioRef: 'scenario:receiver', + }); + await adapter.act({ + turn: 2, + role: 'receiver', + responseBudgetMs: 1_000, + availableActions: ['select_object'], + candidateRefs: CANDIDATE_REFS, + }); + + const buffer = adapter.updateBuffer; + expect(buffer).toHaveLength(2); + const [sender, receiver] = buffer; + expect(Object.keys(sender ?? {}).sort()).toEqual([ + ...SELF_SUPERVISED_UPDATE_RECORD_KEYS, + ]); + expect(Object.keys(receiver ?? {}).sort()).toEqual( + SELF_SUPERVISED_UPDATE_RECORD_KEYS.filter( + (key) => key !== 'targetIndexIfSender', + ), + ); + expect(sender?.role).toBe('sender'); + expect(receiver?.role).toBe('receiver'); + expect(receiver?.targetIndexIfSender).toBeUndefined(); + }); + + it('carries no success, reward or outcome key in the buffer or the policy', async () => { + const { adapter, config } = await initAdapter(); + await adapter.observe({ + runId: config.runId, + turn: 1, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: SENDER_PAYLOAD, + scenarioRef: 'scenario:sender', + }); + await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + await adapter.onOutcome({ + runId: config.runId, + turn: 1, + role: 'sender', + success: true, + reward: null, + payload: [1], + }); + + const forbidden = /success|reward|outcome/iu; + expect(JSON.stringify([...adapter.updateBuffer])).not.toMatch(forbidden); + expect(JSON.stringify(adapter.exportPolicy())).not.toMatch(forbidden); + + const result = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ episodes: 10, seed: 'ss-structure' }), + ); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(JSON.stringify(result.adapters[role].exportPolicy())).not.toMatch( + forbidden, + ); + } + }); + + it('records the pre-registered loss definition in the exported policy', async () => { + const { adapter } = await initAdapter(); + const policy = adapter.exportPolicy(); + expect(policy.lossDefinition).toBe(PREDICTIVE_LOSS_DEFINITION); + expect(policy.pairingRule).toBe('receiver-argmax-self-training:v1'); + expect(policy.track).toBe('self-supervised'); + expect(policy.model.lossDefinition).toBe(PREDICTIVE_LOSS_DEFINITION); + }); + + it('clears a folded turn and cannot import a turn it never acted on', async () => { + const { adapter, config } = await initAdapter(); + await adapter.observe({ + runId: config.runId, + turn: 1, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: SENDER_PAYLOAD, + scenarioRef: 'scenario:sender', + }); + await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + // A batch naming a turn this Baby never acted on cannot import a foreign + // trajectory: there is nothing buffered under that turn (SPEC §10.4). + const checkpoint = await adapter.updatePolicy({ + runId: config.runId, + turns: [1, 99], + learningSignal: 'self-supervised', + }); + expect(checkpoint.turn).toBe(99); + expect(adapter.updateBuffer).toHaveLength(0); + expect(adapter.exportPolicy().model.pairs).toBe(1); + }); +}); + +describe('SelfSupervisedAdapter initialization and determinism (ALD-046 cb 1, ALD-045 cb 1)', () => { + it('records the random-initialization hash at init, distinct per private seed', async () => { + const left = await initAdapter({}, { seed: 'seed-left' }); + const right = await initAdapter({}, { seed: 'seed-right' }); + const again = await initAdapter({}, { seed: 'seed-left' }); + + expect(left.adapter.initialPolicyHash()).toMatch(/^sha256:[a-f0-9]{64}$/u); + expect(left.adapter.initialPolicyHash()).toBe( + hashCanonical( + HASH_DOMAINS.policyCheckpoint, + left.adapter.exportPolicy(), + ), + ); + expect(left.adapter.initialPolicyHash()).not.toBe( + right.adapter.initialPolicyHash(), + ); + expect(left.adapter.initialPolicyHash()).toBe( + again.adapter.initialPolicyHash(), + ); + }); + + it('starts from an exactly uniform model when priorNoise is disabled', async () => { + const { adapter } = await initAdapter({ priorNoise: 0 }); + const weights = adapter.exportPolicy().model.weights; + expect(weights.flat(2).every((weight) => weight === 0)).toBe(true); + }); + + it('reproduces identical policy hashes and choices from one seed', async () => { + const options = conformanceOptions({ episodes: 16, seed: 'ss-determinism' }); + const first = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + options, + ); + const second = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + options, + ); + expect(second.policyHashes).toEqual(first.policyHashes); + expect(second.successFlags).toEqual(first.successFlags); + expect( + second.ledgers['baby-a'].drafts.map((draft) => draft.blindingNonce), + ).toEqual(first.ledgers['baby-a'].drafts.map((draft) => draft.blindingNonce)); + }); + + it('diverges between two different run seeds', async () => { + const left = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ episodes: 16, seed: 'ss-left' }), + ); + const right = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ episodes: 16, seed: 'ss-right' }), + ); + expect(right.policyHashes['baby-a']).not.toEqual( + left.policyHashes['baby-a'], + ); + }); + + it('reproduces identical behaviour from an exported policy in a derived run (SPEC §7.4)', async () => { + const warm = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ episodes: 12, seed: 'ss-warm' }), + ); + const initialPolicies = { + 'baby-a': warm.adapters['baby-a'].exportPolicy(), + 'baby-b': warm.adapters['baby-b'].exportPolicy(), + }; + const derived = conformanceOptions({ + episodes: 8, + seed: 'ss-derived', + runId: 'run-ss-derived', + initialPolicies, + }); + const first = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + derived, + ); + const second = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + derived, + ); + const fresh = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory(), + conformanceOptions({ + episodes: 8, + seed: 'ss-derived', + runId: 'run-ss-derived', + }), + ); + + expect(second.policyHashes).toEqual(first.policyHashes); + expect(first.policyHashes['baby-a']).not.toEqual(fresh.policyHashes['baby-a']); + // A derived run starts a fresh ledger chain, so it re-records first uses. + expect(first.ledgers['baby-a'].countOf('term.first_emitted')).toBeGreaterThan( + 0, + ); + }); + + it('refuses a checkpoint written for a different attribute space', async () => { + const { adapter } = await initAdapter({ + attributeCount: 2, + valuesPerAttribute: 4, + }); + const policy = adapter.exportPolicy(); + await expect( + initAdapter( + { attributeCount: 4, valuesPerAttribute: 2 }, + { initialPolicy: policy }, + ), + ).rejects.toThrow(LearnerConfigurationError); + }); + + it('reports a re-tuned checkpoint difference rather than refusing it', async () => { + const { adapter } = await initAdapter({ temperature: 1 }); + const policy = adapter.exportPolicy(); + const loaded = await initAdapter( + { temperature: 0.5 }, + { initialPolicy: policy }, + ); + expect(loaded.adapter.policyLoadDiagnostics).toEqual([ + 'temperature 1 != 0.5', + ]); + }); +}); + +describe('SelfSupervisedAdapter provenance (SPEC §6.5)', () => { + it('declares a fully random-initialized, tokenizer-free path', async () => { + const { adapter } = await initAdapter(); + const provenance = adapter.describeProvenance(); + expect(provenance.track).toBe('self-supervised'); + expect(provenance.modelRef).toBe('reference:self-supervised'); + expect(provenance.textTokenizerPresent).toBe(false); + expect(provenance.textAlignedEncoderPresent).toBe(false); + expect(provenance.weightUpdatePath).toBe('private-buffers-only'); + expect(provenance.components).toHaveLength(2); + for (const component of provenance.components) { + expect(component.provenance).toBe('random-init'); + expect(component.textAligned).toBe(false); + expect(component.hash).toMatch(/^sha256:[a-f0-9]{64}$/u); + } + expect(provenance.components.map((component) => component.kind)).toEqual([ + 'world-model', + 'communication-policy', + ]); + expect( + new Set(provenance.components.map((component) => component.hash)).size, + ).toBe(2); + }); +}); + +describe('SelfSupervisedAdapter curriculum (E22, SPEC §18)', () => { + it('honours learningRate, temperature and explorationRate', async () => { + const { adapter } = await initAdapter(); + await adapter.applyCurriculumStage({ + stageIndex: 0, + startTurn: 0, + learnerOptions: { learningRate: 3, temperature: 0.25, explorationRate: 0 }, + }); + const options = adapter.exportPolicy().options; + expect(options.learningRate).toBe(3); + expect(options.temperature).toBe(0.25); + expect(options.explorationRate).toBe(0); + expect(adapter.exportPolicy().model.options.countIncrement).toBe(3); + }); + + it('rejects memoryCapacity rather than ignoring it', async () => { + const { adapter } = await initAdapter(); + await expect( + adapter.applyCurriculumStage({ + stageIndex: 1, + startTurn: 10, + learnerOptions: { memoryCapacity: 32 }, + }), + ).rejects.toThrow(/memoryCapacity/u); + }); + + it('rejects a staged bandwidth its tables were not built for', async () => { + const { adapter } = await initAdapter(); + await expect( + adapter.applyCurriculumStage({ + stageIndex: 1, + startTurn: 10, + maxSymbolsPerMessage: 2, + }), + ).rejects.toThrow(/maxSymbolsPerMessage/u); + await expect( + adapter.applyCurriculumStage({ + stageIndex: 1, + startTurn: 10, + maxSymbolsPerMessage: 1, + }), + ).resolves.toBeUndefined(); + }); + + it('freezes the model for a consolidation stage and discards its turns', async () => { + const { adapter, config } = await initAdapter(); + await adapter.applyCurriculumStage({ + stageIndex: 2, + startTurn: 0, + consolidation: true, + }); + await adapter.observe({ + runId: config.runId, + turn: 1, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: SENDER_PAYLOAD, + scenarioRef: 'scenario:sender', + }); + await adapter.act({ + turn: 1, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + const before = adapter.exportPolicy().model.weights; + await adapter.updatePolicy({ + runId: config.runId, + turns: [1], + learningSignal: 'self-supervised', + }); + expect(adapter.exportPolicy().model.weights).toEqual(before); + expect(adapter.exportPolicy().model.pairs).toBe(0); + expect(adapter.updateBuffer).toHaveLength(0); + }); +}); + +/** + * E12 measurement, not a claim. The notebook's own hypothesis is that + * reward-free mutual prediction "may produce weaker task-directed coordination + * than RL"; nothing here is tuned, and the assertions below are only that the + * two-adapter self-play game is deterministic and stays inside its own + * definition of success. The observed rate is recorded in the task notes as a + * data point. + */ +describe('SelfSupervisedAdapter self-play measurement (E12)', () => { + const seeds = ['e12-1', 'e12-2', 'e12-3']; + const results: ConformanceResult[] = []; + + beforeAll(async () => { + for (const seed of seeds) { + results.push( + await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory({ explorationRate: 0.05 }), + conformanceOptions({ + episodes: 600, + seed, + validation: 'none', + collectDrafts: false, + }), + ), + ); + } + }); + + it('reports a success rate in [0, 1] on every seed without tuning to a target', () => { + expect(results).toHaveLength(seeds.length); + for (const result of results) { + expect(result.successRate).toBeGreaterThanOrEqual(0); + expect(result.successRate).toBeLessThanOrEqual(1); + expect(result.successFlags).toHaveLength(600); + } + }); + + it('reproduces the same self-play trajectory from the same seed', async () => { + const seed = seeds[0] as string; + const repeat = await runLearnerAdapterConformance( + createSelfSupervisedAdapterFactory({ explorationRate: 0.05 }), + conformanceOptions({ + episodes: 600, + seed, + validation: 'none', + collectDrafts: false, + }), + ); + expect(repeat.successFlags).toEqual( + (results[0] as ConformanceResult).successFlags, + ); + }); +}); diff --git a/packages/learners/__tests__/tabular-reinforce.test.ts b/packages/learners/__tests__/tabular-reinforce.test.ts new file mode 100644 index 0000000..25e9677 --- /dev/null +++ b/packages/learners/__tests__/tabular-reinforce.test.ts @@ -0,0 +1,586 @@ +import { HASH_DOMAINS, fixedTokenInventory } from '@ald/types'; +import { hashCanonical } from '@ald/hashing'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { + RecordingLedgerClient, + buildConformanceRunConfig, + runLearnerAdapterConformance, + tailSuccessRate, + type ConformanceResult, +} from '../src/conformance.js'; +import { loadLearnerContract } from '../src/contracts.js'; +import { validateLearnerDraft } from '../src/drafts.js'; +import { + LearnerConfigurationError, + UnsupportedLearningSignalError, +} from '../src/errors.js'; +import { predictReceiverChoice } from '../src/ledger-prediction.js'; +import { + parseExportedTabularPolicy, + tabularPolicyShape, +} from '../src/policy.js'; +import { + TabularReinforceAdapter, + createTabularReinforceAdapterFactory, +} from '../src/tabular-reinforce.js'; + +/** + * E11 training hyperparameters. Only the ratio `learningRate / temperature` + * matters for a tabular softmax policy, so this is one point on a family of + * equivalent settings; it reaches the pre-registered above-chance threshold on + * every seed tried. The adapter defaults (0.3 / 1.0) are the documented + * conservative starting point, not this tuned pair. + */ +const TRAINING_OPTIONS = { learningRate: 1, temperature: 0.5 } as const; +const TRAINING_EPISODES = 3_000; +const TRAINING_SEEDS = [1, 2, 3]; + +async function initAdapter( + options: Parameters[0] = {}, + overrides: { + seed?: string; + symbolInventorySize?: number; + learningSignal?: 'extrinsic-task' | 'intrinsic-prediction-progress'; + initialPolicy?: unknown; + } = {}, +) { + const adapter = new TabularReinforceAdapter(options); + const config = buildConformanceRunConfig('scratch-rl', { + episodes: 8, + symbolInventorySize: overrides.symbolInventorySize ?? 8, + ...(overrides.learningSignal === undefined + ? {} + : { learningSignal: overrides.learningSignal }), + }); + const ledger = new RecordingLedgerClient(config.runId, 'baby-a'); + await adapter.init({ + runId: config.runId, + role: 'baby-a', + babyId: 'A', + config, + learnerContract: loadLearnerContract('scratch-rl'), + seed: overrides.seed ?? 'seed-a', + symbolInventory: fixedTokenInventory(overrides.symbolInventorySize ?? 8), + ledger, + ...(overrides.initialPolicy === undefined + ? {} + : { initialPolicy: overrides.initialPolicy }), + }); + return { adapter, ledger, config }; +} + +describe('TabularReinforceAdapter conformance (ALD-045)', () => { + it('passes the adapter conformance harness', async () => { + const result = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(TRAINING_OPTIONS), + { episodes: 40, seed: 'scratch-rl-conformance' }, + ); + expect(result.proposals).toBe(80); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(result.ledgers[role].countOf('intention.recorded')).toBe(40); + expect(result.ledgers[role].countOf('interpretation.recorded')).toBe(20); + expect(result.ledgers[role].countOf('hypothesis.created')).toBeGreaterThan(0); + expect(result.checkpoints[role]).toHaveLength(40); + for (const checkpoint of result.checkpoints[role]) { + expect(checkpoint.policyCheckpointRef).toBe( + `policy:${checkpoint.policyHash}`, + ); + expect(checkpoint.policyHash).toMatch(/^sha256:[a-f0-9]{64}$/u); + } + } + }); + + it('exposes updatePolicy, unlike the control tracks (SPEC §6.2)', async () => { + const { adapter } = await initAdapter(); + expect(typeof adapter.updatePolicy).toBe('function'); + }); + + it('starts from seeded random parameters and exposes their initial hash', async () => { + const first = await initAdapter({}, { seed: 'random-init-a' }); + const again = await initAdapter({}, { seed: 'random-init-a' }); + const other = await initAdapter({}, { seed: 'random-init-b' }); + const firstPolicy = first.adapter.exportPolicy(); + + expect(firstPolicy.thetaSender.flat().some((value) => value !== 0)).toBe(true); + expect(firstPolicy.thetaReceiver.flat(2).some((value) => value !== 0)).toBe( + true, + ); + expect(again.adapter.exportPolicy()).toEqual(firstPolicy); + expect(other.adapter.exportPolicy()).not.toEqual(firstPolicy); + expect(first.adapter.initialPolicyHash()).toBe( + hashCanonical(HASH_DOMAINS.policyCheckpoint, firstPolicy), + ); + }); +}); + +describe('TabularReinforceAdapter training (E11)', () => { + let trained: ConformanceResult[] = []; + + beforeAll(async () => { + trained = []; + for (const seed of TRAINING_SEEDS) { + trained.push( + await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(TRAINING_OPTIONS), + { + episodes: TRAINING_EPISODES, + seed: `e11-train-${seed}`, + validation: 'none', + collectDrafts: false, + recordPolicyHashes: false, + }, + ), + ); + } + }, 60_000); + + it('reaches above-chance referential success on every seed', () => { + expect(trained).toHaveLength(TRAINING_SEEDS.length); + for (const result of trained) { + expect(tailSuccessRate(result, 200)).toBeGreaterThanOrEqual(0.85); + } + }); + + it('trains both roles privately inside one adapter', () => { + for (const result of trained) { + for (const role of ['baby-a', 'baby-b'] as const) { + const policy = result.adapters[role].exportPolicy() as { + thetaSender: number[][]; + thetaReceiver: number[][][]; + }; + const senderMoved = policy.thetaSender.some((logits) => + logits.some((logit) => logit !== 0), + ); + const receiverMoved = policy.thetaReceiver.some((table) => + table.some((logits) => logits.some((logit) => logit !== 0)), + ); + expect(senderMoved).toBe(true); + expect(receiverMoved).toBe(true); + } + } + }); + + it('holds its policy constant and stays above 0.8 in held-out evaluation', async () => { + const source = trained[0] as ConformanceResult; + const evaluation = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(TRAINING_OPTIONS), + { + episodes: 400, + seed: 'e11-held-out', + validation: 'none', + collectDrafts: false, + updatePolicy: false, + initialPolicies: { + 'baby-a': source.adapters['baby-a'].exportPolicy(), + 'baby-b': source.adapters['baby-b'].exportPolicy(), + }, + }, + ); + expect(evaluation.successRate).toBeGreaterThanOrEqual(0.8); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(new Set(evaluation.policyHashes[role]).size).toBe(1); + expect(evaluation.checkpoints[role]).toHaveLength(0); + } + }); + + it('reproduces identical behavior from an exported policy in a derived run', async () => { + const source = trained[0] as ConformanceResult; + const policies = { + 'baby-a': source.adapters['baby-a'].exportPolicy(), + 'baby-b': source.adapters['baby-b'].exportPolicy(), + }; + const roundTripped = JSON.parse(JSON.stringify(policies)) as typeof policies; + + const first = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(TRAINING_OPTIONS), + { + episodes: 40, + seed: 'e11-derived', + updatePolicy: false, + initialPolicies: policies, + }, + ); + const second = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(TRAINING_OPTIONS), + { + episodes: 40, + seed: 'e11-derived', + updatePolicy: false, + initialPolicies: roundTripped, + }, + ); + + for (const role of ['baby-a', 'baby-b'] as const) { + expect(first.adapters[role].exportPolicy()).toEqual(policies[role]); + expect(JSON.stringify(first.ledgers[role].drafts)).toBe( + JSON.stringify(second.ledgers[role].drafts), + ); + } + expect(first.successFlags).toEqual(second.successFlags); + }); +}); + +describe('TabularReinforceAdapter learning signals', () => { + it('leaves the policy untouched when updatePolicy is never called', async () => { + const { adapter, config } = await initAdapter(TRAINING_OPTIONS); + const before = hashCanonical( + HASH_DOMAINS.policyCheckpoint, + adapter.exportPolicy(), + ); + for (let turn = 1; turn <= 20; turn += 1) { + await adapter.observe({ + runId: config.runId, + turn, + recipient: 'baby-a', + encoding: 'opaque-numeric', + payload: [ + [0, 0, 1], + [1, 1, 0], + ], + scenarioRef: 'scenario:x', + }); + await adapter.act({ + turn, + role: 'sender', + responseBudgetMs: 1_000, + availableActions: ['emit_symbols'], + }); + await adapter.onOutcome({ + runId: config.runId, + turn, + role: 'sender', + success: turn % 2 === 0, + reward: turn % 2 === 0 ? 1 : 0, + payload: [turn % 2 === 0 ? 1 : 0], + }); + } + expect(hashCanonical(HASH_DOMAINS.policyCheckpoint, adapter.exportPolicy())).toBe( + before, + ); + }); + + it('runs the intrinsic prediction-progress mode without reading the task reward', async () => { + const result = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory({ + ...TRAINING_OPTIONS, + intrinsicMode: 'prediction-progress', + }), + { + episodes: 200, + seed: 'e11-intrinsic', + learningSignal: 'intrinsic-prediction-progress', + rewardVisibility: 'forbidden', + }, + ); + expect(result.episodes).toBe(200); + for (const role of ['baby-a', 'baby-b'] as const) { + expect(result.checkpoints[role]).toHaveLength(200); + expect(result.ledgers[role].countOf('hypothesis.created')).toBeGreaterThan(0); + // The intrinsic policy still moves: prediction progress is a real signal. + expect(new Set(result.policyHashes[role]).size).toBeGreaterThan(1); + } + }); + + it('does read the task reward under extrinsic-task, which the guard proves', async () => { + await expect( + runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(TRAINING_OPTIONS), + { + episodes: 4, + seed: 'e11-extrinsic-guard', + rewardVisibility: 'forbidden', + }, + ), + ).rejects.toThrow(/outcome.reward was read/u); + }); + + it('rejects a learning signal it cannot consume', async () => { + const { adapter, config } = await initAdapter(TRAINING_OPTIONS); + await expect( + adapter.updatePolicy({ + runId: config.runId, + turns: [1], + learningSignal: 'intrinsic-curiosity', + }), + ).rejects.toThrow(UnsupportedLearningSignalError); + await expect( + adapter.updatePolicy({ + runId: config.runId, + turns: [1], + learningSignal: 'self-supervised', + }), + ).rejects.toThrow(UnsupportedLearningSignalError); + }); + + it('rejects a batch signal that disagrees with the configured mode', async () => { + const { adapter, config } = await initAdapter( + { ...TRAINING_OPTIONS, intrinsicMode: 'prediction-progress' }, + { learningSignal: 'intrinsic-prediction-progress' }, + ); + await expect( + adapter.updatePolicy({ + runId: config.runId, + turns: [1], + learningSignal: 'extrinsic-task', + }), + ).rejects.toThrow(UnsupportedLearningSignalError); + }); + + it('rejects intrinsicMode paired with an extrinsic-task run at init, rather than at the first updatePolicy call', async () => { + // Regression: resolveRewardMode used to whitelist this combination at + // init (returning rewardMode 'intrinsic-prediction-progress'), but + // UpdateBatch.learningSignal always mirrors RunConfig.learningSignal, so + // every updatePolicy call was guaranteed to throw + // UnsupportedLearningSignalError instead. init() must now reject the + // mismatch itself. + await expect( + initAdapter( + { ...TRAINING_OPTIONS, intrinsicMode: 'prediction-progress' }, + { learningSignal: 'extrinsic-task' }, + ), + ).rejects.toThrow(LearnerConfigurationError); + }); + + it('rejects an intrinsic-prediction-progress run configured without intrinsicMode, at init', async () => { + // The two halves of the contract (the adapter option and the run's + // declared learningSignal) must agree in both directions. + await expect( + initAdapter(TRAINING_OPTIONS, { + learningSignal: 'intrinsic-prediction-progress', + }), + ).rejects.toThrow(LearnerConfigurationError); + }); + + it('reports the highest batch turn in the policy checkpoint reference', async () => { + const { adapter, config } = await initAdapter(TRAINING_OPTIONS); + const checkpoint = await adapter.updatePolicy({ + runId: config.runId, + turns: [7, 3, 11], + learningSignal: 'extrinsic-task', + }); + expect(checkpoint.turn).toBe(11); + expect(checkpoint.policyHash).toBe( + hashCanonical(HASH_DOMAINS.policyCheckpoint, adapter.exportPolicy()), + ); + }); + + it('ignores turns it never acted on, so a batch cannot import foreign trajectories', async () => { + const { adapter, config } = await initAdapter(TRAINING_OPTIONS); + const before = hashCanonical( + HASH_DOMAINS.policyCheckpoint, + adapter.exportPolicy(), + ); + const checkpoint = await adapter.updatePolicy({ + runId: config.runId, + turns: [101, 102, 103], + learningSignal: 'extrinsic-task', + }); + expect(checkpoint.turn).toBe(103); + expect(hashCanonical(HASH_DOMAINS.policyCheckpoint, adapter.exportPolicy())).toBe( + before, + ); + }); + + it('rejects an initialPolicy whose shape does not match the run', async () => { + const { adapter } = await initAdapter(TRAINING_OPTIONS); + const policy = adapter.exportPolicy(); + await expect( + initAdapter(TRAINING_OPTIONS, { + symbolInventorySize: 16, + initialPolicy: policy, + }), + ).rejects.toThrow(LearnerConfigurationError); + }); + + it('refuses a checkpoint written for a different game shape (SPEC §7.4)', async () => { + const { adapter } = await initAdapter({ + ...TRAINING_OPTIONS, + attributeCount: 3, + valuesPerAttribute: 4, + }); + const policy = adapter.exportPolicy(); + expect(policy.options.attributeCount).toBe(3); + + await expect( + initAdapter( + { ...TRAINING_OPTIONS, attributeCount: 2, valuesPerAttribute: 4 }, + { initialPolicy: policy }, + ), + ).rejects.toThrow(/attributeCount 3 != 2/u); + }); + + it('refuses a checkpoint whose attribute space only shares its cardinality', async () => { + // Regression: the shape check compared table dimensions alone, and a + // 2-value/4-attribute space and a 4-value/2-attribute space both give 16 + // type codes — so the parent's logits loaded silently while every type + // code decoded to a different object (SPEC §7.4 lineage). + const { adapter } = await initAdapter({ + ...TRAINING_OPTIONS, + attributeCount: 4, + valuesPerAttribute: 2, + }); + const policy = adapter.exportPolicy(); + expect(policy.thetaSender).toHaveLength(16); + + await expect( + initAdapter( + { ...TRAINING_OPTIONS, attributeCount: 2, valuesPerAttribute: 4 }, + { initialPolicy: policy }, + ), + ).rejects.toThrow(/valuesPerAttribute 2 != 4/u); + }); + + it('loads a re-tuned checkpoint but reports the hyperparameter difference', async () => { + const { adapter } = await initAdapter(TRAINING_OPTIONS); + const policy = adapter.exportPolicy(); + + const { adapter: derived } = await initAdapter( + { learningRate: 0.3, temperature: 1 }, + { initialPolicy: policy }, + ); + expect(derived.policyLoadDiagnostics).toEqual([ + 'learningRate 1 != 0.3', + 'temperature 0.5 != 1', + ]); + expect(derived.exportPolicy().thetaSender).toEqual(policy.thetaSender); + }); +}); + +describe('TabularReinforceAdapter multi-symbol messages', () => { + it('learns with position-specific receiver tables when messageLength is 2', async () => { + const options = { ...TRAINING_OPTIONS, messageLength: 2 }; + const result = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(options), + { + episodes: 400, + seed: 'multi-symbol', + messageLength: 2, + symbolInventorySize: 8, + validation: 'none', + }, + ); + expect(result.config.maxSymbolsPerMessage).toBe(2); + + const policy = parseExportedTabularPolicy( + result.adapters['baby-a'].exportPolicy(), + ); + expect(policy.thetaReceiver).toHaveLength(2); + expect(tabularPolicyShape(policy)).toEqual({ + typeCount: 16, + symbolCount: 8, + messageLength: 2, + }); + + const draft = result.ledgers['baby-b'].draftsOf('interpretation.recorded')[0]; + expect(draft?.content.symbols).toHaveLength(2); + const prediction = predictReceiverChoice( + result.adapters['baby-b'].exportPolicy(), + draft?.content.symbols as string[], + result.symbolInventory, + draft?.content.candidateTypeCodes as number[], + ); + expect(prediction.distribution).toHaveLength(4); + expect(result.successRate).toBeGreaterThan(0.25); + }, 30_000); + + it('refuses a message longer than the run permits', async () => { + await expect( + initAdapter({ ...TRAINING_OPTIONS, messageLength: 4 }), + ).rejects.toThrow(LearnerConfigurationError); + }); +}); + +describe('TabularReinforceAdapter hypothesis lifecycle (CONCEPT §11.2)', () => { + let result: ConformanceResult; + + beforeAll(async () => { + result = await runLearnerAdapterConformance( + createTabularReinforceAdapterFactory(TRAINING_OPTIONS), + { + episodes: 600, + seed: 'e11-hypotheses', + validation: 'none', + }, + ); + }, 30_000); + + it('appends a creation before any revision of the same term', () => { + const drafts = result.ledgers['baby-a'].drafts; + const revised = drafts.filter( + (draft) => draft.eventType === 'hypothesis.revised', + ); + expect(revised.length).toBeGreaterThan(0); + + let checkedChain = 0; + for (const revision of revised) { + const priorRef = revision.content.priorHypothesisRef as string; + const newRef = revision.content.hypothesisRef as string; + const priorIndex = drafts.findIndex( + (draft) => + (draft.eventType === 'hypothesis.created' || + draft.eventType === 'hypothesis.revised') && + draft.content.hypothesisRef === priorRef, + ); + expect(priorIndex).toBeGreaterThanOrEqual(0); + expect(priorIndex).toBeLessThan(drafts.indexOf(revision)); + expect(newRef.startsWith(`${revision.subjectId.replace('symbol:', 'hyp:')}:`)) + .toBe(true); + const priorVersion = Number(priorRef.split(':').pop()); + expect(Number(newRef.split(':').pop())).toBe(priorVersion + 1); + const prior = drafts[priorIndex]; + expect(prior?.content.argmaxTypeCode).not.toBe( + revision.content.argmaxTypeCode, + ); + checkedChain += 1; + } + expect(checkedChain).toBeGreaterThan(0); + }); + + it('records exactly one creation per emitted or received term', () => { + for (const role of ['baby-a', 'baby-b'] as const) { + const created = result.ledgers[role].draftsOf('hypothesis.created'); + expect(new Set(created.map((draft) => draft.subjectId)).size).toBe( + created.length, + ); + for (const draft of created) { + expect(draft.content.hypothesisRef).toBe( + `${draft.subjectId.replace('symbol:', 'hyp:')}:1`, + ); + expect(draft.content.termRef).toBe(draft.subjectId); + expect(draft.evidenceRefs).toHaveLength(2); + expect(draft.evidenceRefs[1]).toMatch(/^outcome:\d+$/u); + } + } + }); + + it('preserves contradictory evidence against a confident hypothesis', () => { + const contradicted = result.ledgers['baby-a'].draftsOf( + 'hypothesis.contradicted', + ); + expect(contradicted.length).toBeGreaterThan(0); + for (const draft of contradicted) { + expect(draft.content.confidence as number).toBeGreaterThan(0.5); + expect(draft.content.evidenceRef).toMatch(/^outcome:\d+$/u); + expect(draft.content.hypothesisRef).toMatch(/^hyp:S\d{2,3}:\d+$/u); + } + }); + + it('writes agent-native content only, with no natural-language gloss', () => { + for (const role of ['baby-a', 'baby-b'] as const) { + for (const draft of result.ledgers[role].drafts) { + expect(draft.contentSchema).toBe('agent-native-ledger'); + expect(() => validateLearnerDraft(draft)).not.toThrow(); + const association = draft.content.associationOverTypeCodes; + if (Array.isArray(association)) { + expect(association).toHaveLength(16); + const total = (association as number[]).reduce( + (sum, value) => sum + value, + 0, + ); + expect(total).toBeCloseTo(1, 3); + } + } + } + }); +}); diff --git a/packages/learners/package.json b/packages/learners/package.json new file mode 100644 index 0000000..e31c444 --- /dev/null +++ b/packages/learners/package.json @@ -0,0 +1,23 @@ +{ + "name": "@ald/learners", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@ald/types": "*", + "@ald/hashing": "*", + "@ald/scenario": "*", + "zod": "4.4.3" + } +} diff --git a/packages/learners/src/affect-policies.ts b/packages/learners/src/affect-policies.ts new file mode 100644 index 0000000..620929a --- /dev/null +++ b/packages/learners/src/affect-policies.ts @@ -0,0 +1,221 @@ +/** + * Pure affect-window helpers the reference adapters call when + * `TurnBudget.window` is set (SPECIFICATION.md §9.3, §6.2 `measureAffect`; + * ALD-033; EXPERIMENT-NOTEBOOK.md E20). + * + * The Gateway owns the affect *rules*; this module owns the reference + * *behaviours* an adapter may exhibit inside a window, kept out of the + * adapters themselves so that: + * + * - the behaviours are pre-registered, named, and versioned in one place + * ({@link OUTCOME_LINKED_AFFECT_MAPPING}, {@link AFFECT_MEASUREMENT_VERSION}), + * which is what E20's "match scenarios and seeds across conditions" needs; + * - they are pure and seeded, so a replay from the registered seed reproduces + * every display choice (SPEC §14.3); + * - no adapter has to know the wire shape: it returns a display id and the + * runtime submits `{ kind: 'submit_affect', publicArtifact: { displayId } }` + * through the Gateway. + * + * Research-integrity note: none of this claims a Baby has an affective state. + * {@link outcomeLinkedAffect} is a *reference behaviour* — a fixed, declared + * function of the adapter's own recent outcomes, used as the declared-mode + * comparison condition — and {@link measurementFromState} projects four + * bounded internal statistics onto six scores under a documented, + * pre-registered formula. The display labels below (`confident`, `uncertain`, + * …) are researcher-side names for score slots and are never shown to a Baby: + * the Baby sees only `A1`-`A6`, and under `affectMode: "opaque"` it is given + * no semantics for them at all. + */ +import { + AFFECT_DISPLAY_IDS, + type AffectDisplayId, + type AffectStateMeasurement, +} from '@ald/types'; +import type { SeededPrng } from '@ald/hashing'; + +import { POLICY_DECIMALS } from './policy.js'; +import { roundAll } from './game.js'; + +/** Closed set of affect-policy input faults. */ +export type AffectPolicyErrorCode = 'out-of-range' | 'not-a-rate' | 'unknown-display'; + +/** + * An affect helper was called with an input outside its declared domain. + * + * These are adapter *faults* (a caller passed a rate outside `[0, 1]`), not + * Baby channel violations, and they are never surfaced to a Baby context: the + * runtime maps an adapter fault onto the §14.5 retry-then-pause path with an + * opaque code. + */ +export class AffectPolicyError extends Error { + override readonly name = 'AffectPolicyError'; + + constructor( + readonly code: AffectPolicyErrorCode, + message: string, + ) { + super(message); + } +} + +/** Version of the behaviours in this module, recorded in exported policies. */ +export const AFFECT_POLICY_VERSION = 'affect-policies-v1'; + +/** + * The pre-registered declared-mode reference mapping: the unit interval split + * into six equal bands, lowest recent success rate to `A1`, highest to `A6`. + * Fixed before the run and never adapted, so the mapping itself carries no + * referent information — only the adapter's own recent outcome does. + */ +export const OUTCOME_LINKED_AFFECT_MAPPING = 'outcome-linked-bands-v1'; + +/** `AffectStateMeasurement.measurementVersion` this module produces. */ +export const AFFECT_MEASUREMENT_VERSION = 'v1'; + +/** + * Researcher-side names for the six score slots of + * {@link measurementFromState}. Never Baby-visible (SPEC §10.1: a Baby + * observes no human-language labels). + */ +export const AFFECT_SCORE_SLOTS = [ + 'confident', + 'uncertain', + 'surprised', + 'settled', + 'blocked', + 'exploring', +] as const; + +function assertRate(value: number, label: string): void { + if (!Number.isFinite(value)) { + throw new AffectPolicyError('not-a-rate', `${label} must be a finite number`); + } + if (value < 0 || value > 1) { + throw new AffectPolicyError('out-of-range', `${label} must be within [0, 1]`); + } +} + +/** Index of a display in the SPEC §9.3 allowlist. */ +export function affectDisplayIndex(displayId: AffectDisplayId): number { + const index = AFFECT_DISPLAY_IDS.indexOf(displayId); + if (index < 0) { + throw new AffectPolicyError( + 'unknown-display', + 'displayId must be one of the six allowlisted displays', + ); + } + return index; +} + +/** + * The `no-learning` control behaviour: one uniformly drawn display per open + * window, from the adapter's own seeded stream. This is the affect-channel + * analogue of the track's uniform-random message policy — deliberately + * uninformative, so it establishes the chance baseline E20 compares against. + * + * Callers should pass a dedicated child stream (for example + * `prng.derive('affect')`) so affect draws never perturb the message stream. + */ +export function noLearningAffect(prng: SeededPrng): AffectDisplayId { + return AFFECT_DISPLAY_IDS[ + prng.nextInt(AFFECT_DISPLAY_IDS.length) + ] as AffectDisplayId; +} + +/** + * The declared-mode reference behaviour: a fixed function of the adapter's own + * recent success rate under {@link OUTCOME_LINKED_AFFECT_MAPPING}. A rate of + * exactly 1 maps to the last band rather than off the end. + */ +export function outcomeLinkedAffect( + recentSuccessRate: number, +): AffectDisplayId { + assertRate(recentSuccessRate, 'recentSuccessRate'); + const bands = AFFECT_DISPLAY_IDS.length; + const band = Math.min(bands - 1, Math.floor(recentSuccessRate * bands)); + return AFFECT_DISPLAY_IDS[band] as AffectDisplayId; +} + +/** + * The four bounded internal statistics `derived` mode is built from. Every + * field is a rate in `[0, 1]` computed from the adapter's own private state; + * none of them may be a function of the referent, the partner's identity, or + * any scenario ground truth (that is precisely what §9.3 rule 7 tests for). + */ +export interface AffectMeasurementState { + /** Share of this adapter's recent turns that succeeded. */ + recentSuccessRate: number; + /** Share of this adapter's recent proposals the Gateway rejected. */ + recentRejectionRate: number; + /** Normalized recent prediction error of the adapter's own model. */ + recentPredictionError: number; + /** Share of recently observed forms this adapter had not seen before. */ + noveltyRate: number; +} + +/** + * The pre-registered `derived`-mode measurement (SPEC §9.3: "the Gateway calls + * the adapter's `measureAffect()`, records the complete internal measurement + * privately, and maps it to `A1`-`A6` using a fixed pre-registered mapping"). + * + * The projection is fixed at v1: + * + * | slot | score | + * |---|---| + * | `A1` confident | `recentSuccessRate` | + * | `A2` uncertain | `1 - recentSuccessRate` | + * | `A3` surprised | `recentPredictionError` | + * | `A4` settled | `1 - recentPredictionError` | + * | `A5` blocked | `recentRejectionRate` | + * | `A6` exploring | `noveltyRate` | + * + * Scores are rounded to `POLICY_DECIMALS` so the canonical measurement — and + * therefore its digest and the Gateway's mapped display — is stable across + * platforms. + */ +export function measurementFromState( + state: AffectMeasurementState, +): AffectStateMeasurement { + assertRate(state.recentSuccessRate, 'recentSuccessRate'); + assertRate(state.recentRejectionRate, 'recentRejectionRate'); + assertRate(state.recentPredictionError, 'recentPredictionError'); + assertRate(state.noveltyRate, 'noveltyRate'); + + const scores = roundAll( + [ + state.recentSuccessRate, + 1 - state.recentSuccessRate, + state.recentPredictionError, + 1 - state.recentPredictionError, + state.recentRejectionRate, + state.noveltyRate, + ], + POLICY_DECIMALS, + ); + + return { + measurementVersion: AFFECT_MEASUREMENT_VERSION, + scores: [ + scores[0] as number, + scores[1] as number, + scores[2] as number, + scores[3] as number, + scores[4] as number, + scores[5] as number, + ], + }; +} + +/** + * The canonical `submit_affect` proposal an adapter returns for an open + * window. It carries no private ledger draft: SPEC §9.3 gives the affect + * display its own typed field and its own Gateway method, and §8.1's required + * intention draft belongs to the task turn, not to the affect window. + */ +export function affectProposal(displayId: AffectDisplayId): { + kind: 'submit_affect'; + publicArtifact: { displayId: AffectDisplayId }; +} { + affectDisplayIndex(displayId); + return { kind: 'submit_affect', publicArtifact: { displayId } }; +} diff --git a/packages/learners/src/carrier-support.ts b/packages/learners/src/carrier-support.ts new file mode 100644 index 0000000..7e5e7ea --- /dev/null +++ b/packages/learners/src/carrier-support.ts @@ -0,0 +1,981 @@ +/** + * Carrier support for the reference learner tracks (ALD-031 learner half; + * EXPERIMENT-NOTEBOOK.md E13 "Test whether agents invent both signal forms + * and meanings"). + * + * A learner adapter reasons over a discrete **form index**: "the thing I say + * when I mean type code 7" is position `i` in its own form inventory. What + * that index turns into on the wire is the carrier's business, and the five + * SPECIFICATION.md §9.1/§9.2 carriers split into two kinds: + * + * - **Symbolic carriers** (`fixed-token`, `fixed-glyph`) declare an + * inventory. A form index is an index into it, a message is + * `messageLength` marks, and both Babies share the id space — so a + * received mark maps back to an index by lookup. + * - **Generative carriers** (`generative-bitmap`, `generative-canvas`, + * `generative-tone`) declare no inventory at all (§9.2 gives them a + * grammar, not a vocabulary). Each Baby therefore **invents** its own form + * inventory at `init` from its private seed: `formCount` distinct valid + * artifacts. A message is exactly one form. When an E13-style condition + * explicitly enables learning, a novel delivered form is staged during + * `receive` and committed into the fixed-capacity bank only by + * `updatePolicy`; a deterministic parent-linked variant can occupy the next + * slot. The Gateway sees only artifacts and never learns that a bank exists. + * + * That asymmetry is the point of E13: with a generative carrier the two + * Babies start with disjoint private form inventories and no shared id space, + * so a convention can only arise if one Baby acquires, imitates, modifies, + * and reuses its partner's forms through experience. `markHash` chronology is + * what ALD-032's analysis counts; this module supplies the learnable action + * surface without assigning any meaning to a form. + * + * ### Why the bounds are restated here + * + * The §9.2 grammars are enforced by `@ald/gateway`'s carrier modules, which + * this package deliberately does not import: a learner adapter runs inside + * the Baby's isolation boundary and `@ald/gateway` pulls in the Evidence + * Store and its native SQLite driver (the same reason `drafts.ts` restates + * the LEDGER §5 event-type table). The constants below are therefore a second + * copy, and `__tests__/carrier-support.test.ts` asserts they agree with + * `@ald/gateway`'s exported bounds and that every artifact this module + * generates is accepted by the real carrier module — so a drift is a test + * failure, not a silent protocol violation. + */ +import { + HASH_DOMAINS, + AgentActionProposalSchema, + type AgentActionProposal, + type LearnerInitContext, + type LearnerVisibleRunConfig, + type RunConfig, + type TurnBudget, +} from '@ald/types'; +import { SeededPrng, hashCanonical, hashCarrierMark } from '@ald/hashing'; +import { z } from 'zod'; + +import { LearnerConfigurationError, LearnerStateError } from './errors.js'; +import type { ResolvedGameShape } from './game.js'; + +export type CarrierMode = RunConfig['carrierMode']; +export type CarrierEmitKind = Extract< + AgentActionProposal['kind'], + 'emit_symbols' | 'emit_glyphs' | 'emit_bitmap' | 'emit_canvas' | 'emit_tones' +>; +export type CarrierArtifact = AgentActionProposal['publicArtifact']; + +/** The emit tool each carrier offers (SPEC §6.3, §9.6: exactly one per run). */ +export const CARRIER_EMIT_KIND: Readonly> = { + 'fixed-token': 'emit_symbols', + 'fixed-glyph': 'emit_glyphs', + 'generative-bitmap': 'emit_bitmap', + 'generative-canvas': 'emit_canvas', + 'generative-tone': 'emit_tones', +}; + +// --- SPEC §9.1/§9.2 bounds (see the module header on why these are here) --- + +/** SPEC §9.2 `generative-bitmap`: exactly 16 * 16 = 256 bits. */ +export const BITMAP_BIT_COUNT = 256; +/** SPEC §9.2 `generative-canvas`: integer grid coordinates 0-15. */ +export const CANVAS_GRID_MAX = 15; +/** SPEC §9.2 `generative-canvas`: default `maxStrokes`, absolute ceiling 64. */ +export const DEFAULT_MAX_STROKES = 8; +export const ABSOLUTE_MAX_STROKES = 64; +/** SPEC §9.2 `generative-tone`: at most 8 tones, 8 pitch bins, 4 duration bins. */ +export const MAX_TONES = 8; +export const TONE_PITCH_BINS = 8; +export const TONE_DURATION_BINS = 4; +/** SPEC §9.1/§9.2 default inventory size, shared by both symbolic carriers. */ +export const DEFAULT_SYMBOL_INVENTORY_SIZE = 32; + +/** + * Implementation-defined hash domain for a Baby's whole form inventory. SPEC + * §14.3 requires a run to replay from its seed, and an invented inventory is + * part of what has to be reproducible; this hash is what the runtime records + * so a third party can check the inventory it replays is the one that ran. + * Defined centrally in `HASH_DOMAINS` so the evidence verifier and learner + * boundary cannot silently disagree about the preimage domain. + */ +export const CARRIER_FORM_INVENTORY_DOMAIN = HASH_DOMAINS.carrierFormInventory; + +/** Default number of forms a Baby invents for a generative carrier. */ +export const DEFAULT_INVENTED_FORM_COUNT = 32; + +/** Bounded attempts to draw a form distinct from every earlier one. */ +const DISTINCT_FORM_ATTEMPTS = 64; + +export interface CarrierSupportOptions { + /** + * How many distinct forms a Baby invents for a generative carrier. + * Defaults to `RunConfig.symbolInventorySize` (then to 32), so a + * generative condition has the same number of available forms as the + * symbolic condition it is compared against (E13 runs "the same scenarios + * across carrier conditions"). + */ + inventedFormCount?: number; + /** + * Stage novel partner forms and commit them into the fixed-capacity form + * bank during `updatePolicy`. Disabled unless an E13-style run selects it. + */ + acquirePartnerForms?: boolean; + /** + * Reserve a second slot for a deterministic variant of each acquired form. + * This makes modification executable without changing the model's action + * dimensionality. Requires `acquirePartnerForms`. + */ + modifyAcquiredForms?: boolean; +} + +export const CARRIER_LEARNING_STATE_VERSION = 1 as const; + +export const CarrierFormSlotSchema = z + .object({ + slot: z.number().int().nonnegative(), + formId: z.string().min(1), + markHash: z.string().regex(/^sha256:[0-9a-f]{64}$/u), + artifact: z.record(z.string(), z.unknown()), + origin: z.enum(['initialized', 'acquired', 'modified']), + introducedTurn: z.number().int().nonnegative(), + parentMarkHash: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(), + }) + .strict(); + +export const CarrierLearningStateSchema = z + .object({ + version: z.literal(CARRIER_LEARNING_STATE_VERSION), + carrier: z.enum([ + 'fixed-token', + 'fixed-glyph', + 'generative-bitmap', + 'generative-canvas', + 'generative-tone', + ]), + capacity: z.number().int().positive(), + nextReplacementSlot: z.number().int().nonnegative(), + slots: z.array(CarrierFormSlotSchema), + }) + .strict(); + +export type CarrierLearningState = z.infer; +export type CarrierFormSlot = z.infer; + +export interface CarrierFormChange { + turn: number; + kind: 'acquired' | 'modified'; + slot: number; + formId: string; + markHash: string; + parentMarkHash?: string; +} + +/** One mark of a delivered artifact, as the receiving Baby sees it. */ +export interface DeliveredMark { + /** + * Index into *this* Baby's own form inventory, or `null` for a form it has + * never produced. Symbolic carriers resolve every in-inventory mark; + * generative carriers resolve only an exact `markHash` match. + */ + formIndex: number | null; + /** Opaque label: the inventory id, or a content-derived label for a novel form. */ + formId: string; + /** SPEC §9.2 content address of this single mark. */ + markHash: string; + /** How the current fixed-capacity slot entered this Baby's form bank. */ + origin?: CarrierFormSlot['origin']; + /** Present for a locally modified form, linking it to the observed parent. */ + parentMarkHash?: string; +} + +export interface DeliveredForms { + /** One entry per mark, in delivery order. */ + marks: DeliveredMark[]; + /** `markHash` of the delivered artifact as a whole. */ + artifactMarkHash: string; +} + +export interface CarrierAdapterSupport { + readonly carrier: CarrierMode; + readonly emitKind: CarrierEmitKind; + /** `true` for the two carriers that declare an inventory (SPEC §9.1, §9.2). */ + readonly symbolic: boolean; + /** Number of forms this Baby can choose between. */ + readonly formCount: number; + /** Marks per message: `messageLength` for symbolic carriers, `1` otherwise. */ + readonly marksPerMessage: number; + /** Domain-separated hash of the current fixed-capacity form bank. */ + readonly formInventoryHash: string; + /** Opaque label for one of this Baby's forms. */ + formId(index: number): string; + /** SPEC §9.2 content address of one of this Baby's forms. */ + formMarkHash(index: number): string; + /** The public artifact for a message of exactly `marksPerMessage` forms. */ + artifactForForms(indices: readonly number[]): CarrierArtifact; + /** SPEC §9.2 content address of a whole artifact. */ + markHashOf(artifact: unknown): string; + /** Resolve a delivered artifact against this Baby's own inventory. */ + parseDelivery(artifact: unknown): DeliveredForms; + /** Parse and stage a novel partner form; no learned state changes yet. */ + observeDelivery(artifact: unknown, turn: number): DeliveredForms; + /** Commit staged forms only when the adapter's training update executes. */ + commitObservedForms(turns: readonly number[]): CarrierFormChange[]; + /** Resolve previously parsed marks after a staged acquisition is committed. */ + resolveMarks(marks: readonly DeliveredMark[]): DeliveredMark[]; + /** Canonical fixed-capacity state included in trainable policy checkpoints. */ + exportLearningState(): CarrierLearningState; + /** Exact restore for recovery and derived-run initialization. */ + restoreLearningState(value: unknown): void; +} + +/** SPEC §9.1 default fixed-token inventory, restated (see the module header). */ +function symbolIds(size: number, prefix: 'S' | 'G'): string[] { + const width = size >= 100 ? 3 : 2; + return Array.from( + { length: size }, + (_, index) => `${prefix}${String(index + 1).padStart(width, '0')}`, + ); +} + +function maxStrokesFor(config: Pick): number { + return Math.min(config.maxStrokes ?? DEFAULT_MAX_STROKES, ABSOLUTE_MAX_STROKES); +} + +/** A content-derived opaque label for a generative form: `m` plus 8 hex. */ +function formLabel(markHash: string): string { + return `m${markHash.slice('sha256:'.length, 'sha256:'.length + 8)}`; +} + +function at(values: readonly T[], index: number): T { + const value = values[index]; + if (value === undefined) { + throw new LearnerStateError(`Form index ${index} is outside the inventory`); + } + return value; +} + +/** + * SPEC §6.3: a Baby may act only through the tools it is offered this turn. + * The generalization of `game.ts`'s `requireAction` over all five §9.2 emit + * tools plus the task tools. + */ +export function requireCarrierAction( + turnBudget: TurnBudget, + kind: CarrierEmitKind | 'select_object', +): void { + if (!turnBudget.availableActions.includes(kind)) { + throw new LearnerStateError( + `Action ${kind} is not available on turn ${turnBudget.turn}`, + ); + } +} + +// --------------------------------------------------------------------------- +// Generative form generators (SPEC §9.2 grammars) +// --------------------------------------------------------------------------- + +function drawBitmap(prng: SeededPrng): CarrierArtifact { + const bits = Array.from({ length: BITMAP_BIT_COUNT }, () => + prng.nextInt(2) === 1 ? 1 : (0 as 0 | 1), + ); + return { bitmap: { bits } }; +} + +function drawCanvas(prng: SeededPrng, maxStrokes: number): CarrierArtifact { + const count = 1 + prng.nextInt(maxStrokes); + const strokes = Array.from({ length: count }, () => ({ + startX: prng.nextInt(CANVAS_GRID_MAX + 1), + startY: prng.nextInt(CANVAS_GRID_MAX + 1), + endX: prng.nextInt(CANVAS_GRID_MAX + 1), + endY: prng.nextInt(CANVAS_GRID_MAX + 1), + width: (prng.nextInt(3) + 1) as 1 | 2 | 3, + })); + return { strokes }; +} + +function drawTones(prng: SeededPrng): CarrierArtifact { + const count = 1 + prng.nextInt(MAX_TONES); + const tones = Array.from({ length: count }, () => ({ + pitchBin: prng.nextInt(TONE_PITCH_BINS), + durationBin: 1 + prng.nextInt(TONE_DURATION_BINS), + })); + return { tones: { tones } }; +} + +function cloneArtifact(artifact: CarrierArtifact): CarrierArtifact { + return structuredClone(artifact); +} + +/** One deterministic, bounded edit that always changes a valid generative form. */ +function modifyArtifact( + carrier: CarrierMode, + artifact: CarrierArtifact, + seed: string, + turn: number, + attempt: number, +): CarrierArtifact { + const prng = new SeededPrng(seed).derive( + `carrier/${carrier}/modify/${String(turn)}/${String(attempt)}/${hashCarrierMark(carrier, artifact)}`, + ); + if (carrier === 'generative-bitmap' && 'bitmap' in artifact) { + const bits = [...artifact.bitmap.bits]; + const index = prng.nextInt(bits.length); + bits[index] = bits[index] === 1 ? 0 : 1; + return { bitmap: { bits } }; + } + if (carrier === 'generative-canvas' && 'strokes' in artifact) { + const strokes = artifact.strokes.map((stroke) => ({ ...stroke })); + const index = prng.nextInt(strokes.length); + const stroke = at(strokes, index); + stroke.startX = (stroke.startX + 1 + prng.nextInt(CANVAS_GRID_MAX)) % + (CANVAS_GRID_MAX + 1); + return { strokes }; + } + if (carrier === 'generative-tone' && 'tones' in artifact) { + const tones = artifact.tones.tones.map((tone) => ({ ...tone })); + const index = prng.nextInt(tones.length); + const tone = at(tones, index); + tone.pitchBin = (tone.pitchBin + 1 + prng.nextInt(TONE_PITCH_BINS - 1)) % + TONE_PITCH_BINS; + return { tones: { tones } }; + } + throw new LearnerConfigurationError( + `${carrier} artifact cannot be modified by the generative learner`, + ); +} + +/** + * The Baby's invented form inventory: `formCount` distinct valid artifacts + * drawn from a labelled child of its private seed. + * + * Distinctness is by `markHash`, resampled from a further-derived stream, so + * the inventory is a set of genuinely different forms rather than a bag that + * might contain the same drawing twice — otherwise two form indices would be + * indistinguishable on the wire and the policy would be learning over a + * degenerate action space. + */ +function inventForms( + carrier: CarrierMode, + seed: string, + formCount: number, + config: Pick, +): CarrierArtifact[] { + const root = new SeededPrng(seed).derive(`carrier/${carrier}/forms`); + const maxStrokes = maxStrokesFor(config); + const draw = (prng: SeededPrng): CarrierArtifact => { + switch (carrier) { + case 'generative-bitmap': + return drawBitmap(prng); + case 'generative-canvas': + return drawCanvas(prng, maxStrokes); + case 'generative-tone': + return drawTones(prng); + case 'fixed-token': + case 'fixed-glyph': + throw new LearnerConfigurationError( + `${carrier} declares an inventory and invents no forms`, + ); + } + }; + + const forms: CarrierArtifact[] = []; + const seen = new Set(); + for (let index = 0; index < formCount; index += 1) { + let accepted: CarrierArtifact | undefined; + for ( + let attempt = 0; + attempt < DISTINCT_FORM_ATTEMPTS && accepted === undefined; + attempt += 1 + ) { + const candidate = draw( + root.derive(`${String(index)}/${String(attempt)}`), + ); + const markHash = hashCarrierMark(carrier, candidate); + if (!seen.has(markHash)) { + seen.add(markHash); + accepted = candidate; + } + } + if (accepted === undefined) { + throw new LearnerConfigurationError( + `Could not invent ${formCount} distinct ${carrier} forms from this seed`, + ); + } + forms.push(accepted); + } + return forms; +} + +// --------------------------------------------------------------------------- +// Artifact reading (the mirror of the Gateway's normalization) +// --------------------------------------------------------------------------- + +function readMarkList(artifact: unknown, field: 'symbols' | 'glyphs'): string[] { + const marks = (artifact as Record | null)?.[field]; + if ( + !Array.isArray(marks) || + marks.length === 0 || + marks.some((mark) => typeof mark !== 'string' || mark.length === 0) + ) { + throw new LearnerStateError( + `A delivered artifact must carry a non-empty ${field} array`, + ); + } + return marks as string[]; +} + +class CarrierAdapterSupportImpl implements CarrierAdapterSupport { + readonly emitKind: CarrierEmitKind; + readonly symbolic: boolean; + readonly formCount: number; + readonly marksPerMessage: number; + + private readonly ids: string[]; + private readonly artifacts: CarrierArtifact[]; + private readonly markHashes: string[]; + private readonly indexByMarkHash: Map; + private readonly origins: CarrierFormSlot['origin'][]; + private readonly introducedTurns: number[]; + private readonly parentMarkHashes: Array; + private readonly pendingObserved = new Map(); + private readonly seed: string; + private readonly maxStrokes: number; + private readonly acquirePartnerForms: boolean; + private readonly modifyAcquiredForms: boolean; + private nextReplacementSlot = 0; + + constructor( + readonly carrier: CarrierMode, + context: LearnerInitContext, + shape: ResolvedGameShape, + options: CarrierSupportOptions, + ) { + this.emitKind = CARRIER_EMIT_KIND[carrier]; + this.symbolic = carrier === 'fixed-token' || carrier === 'fixed-glyph'; + this.seed = context.seed; + this.maxStrokes = maxStrokesFor(context.config); + this.acquirePartnerForms = options.acquirePartnerForms ?? false; + this.modifyAcquiredForms = options.modifyAcquiredForms ?? false; + if (this.modifyAcquiredForms && !this.acquirePartnerForms) { + throw new LearnerConfigurationError( + 'modifyAcquiredForms requires acquirePartnerForms', + ); + } + + if (this.symbolic) { + // `fixed-token` uses the inventory the runtime declared, verbatim, so + // this track's behaviour on the default carrier is unchanged. A glyph + // run's inventory is derived, because the runtime currently fills + // `symbolInventory` with fixed-token ids for every carrier. + const ids = + carrier === 'fixed-token' + ? [...context.symbolInventory] + : symbolIds( + context.config.symbolInventorySize ?? + DEFAULT_SYMBOL_INVENTORY_SIZE, + 'G', + ); + if (ids.length === 0) { + throw new LearnerConfigurationError('symbolInventory must not be empty'); + } + this.ids = ids; + this.artifacts = ids.map((id) => this.markArtifact(id)); + this.marksPerMessage = shape.messageLength; + } else { + if (shape.messageLength !== 1) { + throw new LearnerConfigurationError( + `carrier ${carrier} carries one invented form per message, so messageLength must be 1 (got ${shape.messageLength})`, + ); + } + const formCount = + options.inventedFormCount ?? + context.config.symbolInventorySize ?? + DEFAULT_INVENTED_FORM_COUNT; + if (!Number.isInteger(formCount) || formCount < 2) { + throw new LearnerConfigurationError( + 'inventedFormCount must be an integer of at least 2', + ); + } + this.artifacts = inventForms( + carrier, + context.seed, + formCount, + context.config, + ); + this.ids = this.artifacts.map((artifact) => + formLabel(hashCarrierMark(carrier, artifact)), + ); + this.marksPerMessage = 1; + } + + this.markHashes = this.artifacts.map((artifact) => + hashCarrierMark(carrier, artifact), + ); + this.formCount = this.artifacts.length; + this.indexByMarkHash = new Map( + this.markHashes.map((markHash, index) => [markHash, index]), + ); + this.origins = this.artifacts.map(() => 'initialized'); + this.introducedTurns = this.artifacts.map(() => 0); + this.parentMarkHashes = this.artifacts.map(() => undefined); + } + + get formInventoryHash(): string { + return hashCanonical(CARRIER_FORM_INVENTORY_DOMAIN, { + carrier: this.carrier, + formCount: this.formCount, + marksPerMessage: this.marksPerMessage, + markHashes: [...this.markHashes], + }); + } + + formId(index: number): string { + return at(this.ids, index); + } + + formMarkHash(index: number): string { + return at(this.markHashes, index); + } + + artifactForForms(indices: readonly number[]): CarrierArtifact { + if (indices.length !== this.marksPerMessage) { + throw new LearnerStateError( + `A ${this.carrier} message carries exactly ${this.marksPerMessage} mark(s), got ${indices.length}`, + ); + } + if (this.symbolic) { + const marks = indices.map((index) => at(this.ids, index)); + return this.carrier === 'fixed-token' + ? { symbols: marks } + : { glyphs: marks }; + } + return at(this.artifacts, indices[0] as number); + } + + markHashOf(artifact: unknown): string { + return hashCarrierMark(this.carrier, artifact); + } + + parseDelivery(artifact: unknown): DeliveredForms { + const artifactMarkHash = this.markHashOf(artifact); + if (!this.symbolic) { + const formIndex = this.indexByMarkHash.get(artifactMarkHash) ?? null; + const parentMarkHash = + formIndex === null ? undefined : this.parentMarkHashes[formIndex]; + return { + marks: [ + { + formIndex, + formId: + formIndex === null + ? formLabel(artifactMarkHash) + : at(this.ids, formIndex), + markHash: artifactMarkHash, + ...(formIndex === null + ? {} + : { + origin: at(this.origins, formIndex), + ...(parentMarkHash === undefined + ? {} + : { parentMarkHash }), + }), + }, + ], + artifactMarkHash, + }; + } + + const field = this.carrier === 'fixed-token' ? 'symbols' : 'glyphs'; + const marks = readMarkList(artifact, field); + const idIndex = new Map(this.ids.map((id, index) => [id, index])); + return { + marks: marks.map((mark) => ({ + formIndex: idIndex.get(mark) ?? null, + formId: mark, + markHash: hashCarrierMark(this.carrier, this.markArtifact(mark)), + })), + artifactMarkHash, + }; + } + + observeDelivery(artifact: unknown, turn: number): DeliveredForms { + if (!Number.isInteger(turn) || turn < 0) { + throw new LearnerStateError('delivery turn must be a non-negative integer'); + } + const delivered = this.parseDelivery(artifact); + if ( + this.symbolic || + !this.acquirePartnerForms || + delivered.marks[0]?.formIndex !== null + ) { + return delivered; + } + const markHash = delivered.artifactMarkHash; + const alreadyStaged = [...this.pendingObserved.values()].some((forms) => + forms.some( + (candidate) => hashCarrierMark(this.carrier, candidate) === markHash, + ), + ); + if (!alreadyStaged) { + const forms = this.pendingObserved.get(turn) ?? []; + forms.push(cloneArtifact(artifact as CarrierArtifact)); + this.pendingObserved.set(turn, forms); + } + return delivered; + } + + commitObservedForms(turns: readonly number[]): CarrierFormChange[] { + if (this.symbolic || !this.acquirePartnerForms) return []; + const changes: CarrierFormChange[] = []; + const orderedTurns = [...new Set(turns)].sort((left, right) => left - right); + for (const turn of orderedTurns) { + const artifacts = this.pendingObserved.get(turn) ?? []; + for (const artifact of artifacts) { + const parentMarkHash = hashCarrierMark(this.carrier, artifact); + if (!this.indexByMarkHash.has(parentMarkHash)) { + changes.push(this.replaceNextSlot(artifact, 'acquired', turn)); + } + if (this.modifyAcquiredForms) { + let variant: CarrierArtifact | undefined; + for ( + let attempt = 0; + attempt < DISTINCT_FORM_ATTEMPTS && variant === undefined; + attempt += 1 + ) { + const candidate = modifyArtifact( + this.carrier, + artifact, + this.seed, + turn, + attempt, + ); + if ( + !this.indexByMarkHash.has( + hashCarrierMark(this.carrier, candidate), + ) + ) { + variant = candidate; + } + } + if (variant === undefined) { + throw new LearnerStateError( + `Could not derive a distinct ${this.carrier} variant`, + ); + } + changes.push( + this.replaceNextSlot(variant, 'modified', turn, parentMarkHash), + ); + } + } + this.pendingObserved.delete(turn); + } + return changes; + } + + resolveMarks(marks: readonly DeliveredMark[]): DeliveredMark[] { + return marks.map((mark) => { + const formIndex = this.indexByMarkHash.get(mark.markHash) ?? null; + if (formIndex === null) return { ...mark, formIndex }; + const parentMarkHash = this.parentMarkHashes[formIndex]; + return { + formIndex, + formId: at(this.ids, formIndex), + markHash: mark.markHash, + origin: at(this.origins, formIndex), + ...(parentMarkHash === undefined ? {} : { parentMarkHash }), + }; + }); + } + + exportLearningState(): CarrierLearningState { + return { + version: CARRIER_LEARNING_STATE_VERSION, + carrier: this.carrier, + capacity: this.formCount, + nextReplacementSlot: this.nextReplacementSlot, + slots: this.artifacts.map((artifact, slot) => { + const parentMarkHash = this.parentMarkHashes[slot]; + return { + slot, + formId: at(this.ids, slot), + markHash: at(this.markHashes, slot), + artifact: cloneArtifact(artifact) as Record, + origin: at(this.origins, slot), + introducedTurn: at(this.introducedTurns, slot), + ...(parentMarkHash === undefined ? {} : { parentMarkHash }), + }; + }), + }; + } + + restoreLearningState(value: unknown): void { + const restoredState = CarrierLearningStateSchema.parse(value); + if ( + restoredState.carrier !== this.carrier || + restoredState.capacity !== this.formCount + ) { + throw new LearnerConfigurationError( + 'carrier learning state does not match this run configuration', + ); + } + if ( + restoredState.slots.length !== this.formCount || + restoredState.nextReplacementSlot >= this.formCount + ) { + throw new LearnerConfigurationError( + 'carrier learning state does not fill its declared capacity', + ); + } + + const seen = new Set(); + const restored = restoredState.slots.map((slot, index) => { + if (slot.slot !== index) { + throw new LearnerConfigurationError( + 'carrier learning state slots must be complete and ordered', + ); + } + const proposal = AgentActionProposalSchema.parse({ + kind: this.emitKind, + publicArtifact: slot.artifact, + }); + const artifact = proposal.publicArtifact; + if ( + this.carrier === 'generative-canvas' && + 'strokes' in artifact && + artifact.strokes.length > this.maxStrokes + ) { + throw new LearnerConfigurationError( + 'carrier learning state exceeds this run maxStrokes', + ); + } + const markHash = hashCarrierMark(this.carrier, artifact); + const rawMarkHash = hashCarrierMark(this.carrier, slot.artifact); + const expectedId = this.symbolic + ? at(this.ids, index) + : formLabel(markHash); + if ( + rawMarkHash !== markHash || + slot.markHash !== markHash || + slot.formId !== expectedId || + seen.has(markHash) + ) { + throw new LearnerConfigurationError( + 'carrier learning state contains inconsistent or duplicate forms', + ); + } + if ( + (slot.origin === 'modified') !== + (slot.parentMarkHash !== undefined) + ) { + throw new LearnerConfigurationError( + 'only modified forms must carry a parentMarkHash', + ); + } + if ( + this.symbolic && + (slot.origin !== 'initialized' || + slot.introducedTurn !== 0 || + markHash !== at(this.markHashes, index) || + restoredState.nextReplacementSlot !== 0) + ) { + throw new LearnerConfigurationError( + 'symbolic carrier learning state must preserve its declared inventory', + ); + } + seen.add(markHash); + return { slot, artifact: cloneArtifact(artifact) }; + }); + + this.indexByMarkHash.clear(); + for (const { slot, artifact } of restored) { + const index = slot.slot; + this.artifacts[index] = artifact; + this.ids[index] = slot.formId; + this.markHashes[index] = slot.markHash; + this.origins[index] = slot.origin; + this.introducedTurns[index] = slot.introducedTurn; + this.parentMarkHashes[index] = slot.parentMarkHash; + this.indexByMarkHash.set(slot.markHash, index); + } + this.nextReplacementSlot = restoredState.nextReplacementSlot; + this.pendingObserved.clear(); + } + + private replaceNextSlot( + artifact: CarrierArtifact, + origin: 'acquired' | 'modified', + turn: number, + parentMarkHash?: string, + ): CarrierFormChange { + const slot = this.nextReplacementSlot; + const oldHash = at(this.markHashes, slot); + if (this.indexByMarkHash.get(oldHash) === slot) { + this.indexByMarkHash.delete(oldHash); + } + const markHash = hashCarrierMark(this.carrier, artifact); + const formId = formLabel(markHash); + this.artifacts[slot] = cloneArtifact(artifact); + this.ids[slot] = formId; + this.markHashes[slot] = markHash; + this.origins[slot] = origin; + this.introducedTurns[slot] = turn; + this.parentMarkHashes[slot] = parentMarkHash; + this.indexByMarkHash.set(markHash, slot); + this.nextReplacementSlot = (slot + 1) % this.formCount; + return { + turn, + kind: origin, + slot, + formId, + markHash, + ...(parentMarkHash === undefined ? {} : { parentMarkHash }), + }; + } + + /** The single-mark artifact a symbolic carrier's `markHash` is taken over. */ + private markArtifact(mark: string): CarrierArtifact { + return this.carrier === 'fixed-token' ? { symbols: [mark] } : { glyphs: [mark] }; + } +} + +/** + * Build the carrier support for one adapter at `init`. + * + * The generative branch draws this Baby's form inventory from + * `LearnerInitContext.seed`, which is the private per-Baby seed the runtime + * derives and never shares (SPEC §11.1: `randomSeed` is withheld from the + * learner precisely so a Baby cannot regenerate the other's private stream). + * Two Babies in one run therefore invent different inventories, which is the + * E13 starting condition. + */ +export function createCarrierSupport( + context: LearnerInitContext, + shape: ResolvedGameShape, + options: CarrierSupportOptions = {}, +): CarrierAdapterSupport { + return new CarrierAdapterSupportImpl( + context.config.carrierMode, + context, + shape, + options, + ); +} + +// --------------------------------------------------------------------------- +// Proposal and ledger-draft helpers shared by the reference tracks +// --------------------------------------------------------------------------- + +/** + * The SPEC §11.3 proposal for a message of form indices. + * + * The cast is unavoidable and is load-bearing only in the type system: + * `AgentActionProposal` is a discriminated union over `kind`, and the pairing + * "this `kind` goes with this artifact shape" is decided at run time by + * `support.carrier`, which no union member can express. The pairing itself is + * enforced structurally — `CARRIER_EMIT_KIND` and `artifactForForms` are + * keyed by the same `carrier` — and checked against the real Gateway modules + * in `__tests__/carrier-support.test.ts`, so a mismatch is a test failure + * rather than a rejected turn. + */ +export function carrierProposal( + support: CarrierAdapterSupport, + indices: readonly number[], +): AgentActionProposal { + return { + kind: support.emitKind, + publicArtifact: support.artifactForForms(indices), + } as AgentActionProposal; +} + +/** + * `subjectId` for a ledger event about one mark (SPEC §11.4). + * + * `fixed-token` keeps the `symbol:` form the reference tracks have always + * written, so an existing E00-E03 ledger is unchanged; every other carrier + * uses `form: