From e83e50d575ac496b2a340dec54dabb88bb9afb45 Mon Sep 17 00:00:00 2001 From: Pavilion-devs Date: Tue, 14 Jul 2026 13:33:22 +0100 Subject: [PATCH 1/6] Prepare Samaritan winner-ready judge release --- .github/workflows/ci.yml | 56 + .gitignore | 8 + README.md | 58 +- apps/dashboard/index.html | 17 + apps/dashboard/src/App.tsx | 11 + apps/dashboard/src/Casebook.tsx | 313 + apps/dashboard/src/Command.tsx | 236 + apps/dashboard/src/Matchroom.tsx | 369 + apps/dashboard/src/Shell.tsx | 109 + apps/dashboard/src/Study.tsx | 317 + apps/dashboard/src/api.ts | 55 + apps/dashboard/src/main.tsx | 10 + apps/dashboard/src/styles.css | 7585 +++++++++++++++++ apps/dashboard/tsconfig.json | 21 + apps/dashboard/vite.config.ts | 37 + .../england-argentina-2026-07-15.json | 34 + config/captures/france-spain-2026-07-14.json | 34 + docs/01-research.md | 18 +- docs/02-architecture.md | 30 +- docs/04-algorithms.md | 2 + docs/05-harness.md | 17 +- docs/06-gate-study.md | 107 + docs/07-paper-study-preregistration.md | 185 + docs/08-winning-submission-plan.md | 816 ++ docs/09-paper-study-v2-candidate.md | 111 + docs/PHASE-2-STATUS.md | 21 +- docs/PHASE-4-STATUS.md | 79 + docs/UI.md | 609 ++ docs/proof/decision-receipt.md | 37 + docs/proof/solana-devnet-anchor.md | 125 + docs/research/capture-readiness.md | 10 + ...istorical-gate-study-causal-economic-v3.md | 96 + ...istorical-gate-study-causal-economic-v4.md | 104 + .../historical-gate-study-v1-invalidation.md | 63 + ...al-gate-study-v3-to-v4-methodology-note.md | 17 + docs/research/historical-gate-study.md | 76 + ...ired-spain-belgium-2026-07-10-live-lane.md | 91 + docs/research/paper-fixture-universe.md | 13 + docs/research/paper-study-current.md | 43 + .../paper-study-ledger-initialization.md | 14 + docs/research/semifinal-capture-schedule.md | 12 + docs/submission/demo-script-v1.md | 78 + docs/submission/public-release-scope.md | 73 + .../sponsor-clarification-request.md | 40 + docs/submission/submission-claims-matrix.md | 109 + docs/submission/submission-form-draft.md | 88 + docs/submission/technical-overview.md | 108 + docs/submission/third-party-notices.md | 20 + docs/submission/txline-api-feedback.md | 246 + package.json | 38 +- phase0/package.json | 1 + phase0/src/capture-sse.ts | 165 +- phase0/test/capture-sse.test.ts | 47 + plan.md | 44 +- pnpm-lock.yaml | 1067 ++- pnpm-workspace.yaml | 1 - .../decision-receipt.synthetic.v1.json | 435 + public/artifacts/dashboard/casebook.json | 1 + public/artifacts/dashboard/command.json | 1 + public/artifacts/dashboard/manifest.json | 1 + .../dashboard/matchroom-spain-belgium.json | 1 + public/artifacts/dashboard/study.json | 1 + .../dashboard/synthetic-decision-receipt.json | 1 + src/agents/claude-pricing.ts | 64 + src/agents/claude-spend-ledger.ts | 272 + src/agents/claude.ts | 299 + src/agents/contracts.ts | 63 + src/agents/haiku-smoke.ts | 67 + src/agents/opus-smoke.ts | 73 + src/bus/events.ts | 13 + src/chain/decision-anchor-submit.ts | 230 + src/chain/decision-anchor.ts | 350 + src/chain/prepare-decision-anchor.ts | 40 + src/chain/submit-decision-anchor.ts | 96 + src/chain/verify-decision-anchor.ts | 60 + src/config/paper-study.ts | 57 + src/dash/api.ts | 87 + src/dash/casebook-projection.ts | 104 + src/dash/command-projection.ts | 247 + src/dash/export-public-bundle.ts | 213 + src/dash/projection.ts | 373 + src/dash/public-bundle.ts | 665 ++ src/dash/public-contract.ts | 509 ++ src/dash/server.ts | 92 + src/dash/study-projection.ts | 434 + src/demo/run-synthetic-judge-case.ts | 22 + src/demo/synthetic-judge-case.ts | 509 ++ src/detectors/bank.ts | 1 + src/detectors/economic-cases.ts | 181 + src/detectors/types.ts | 3 + src/domain/money.ts | 16 + src/exec/paper.ts | 310 + src/features/engine.ts | 142 +- src/harness/build-capture-readiness.ts | 32 + src/harness/build-paper-fixture-universe.ts | 104 + src/harness/capture-config.ts | 186 + src/harness/capture-readiness.ts | 149 + src/harness/claude-paper-study.ts | 123 + src/harness/initialize-paper-study-ledgers.ts | 88 + src/harness/paper-fixture-universe.ts | 266 + src/harness/paper-lane-runtime.ts | 196 + src/harness/paper-pipeline.ts | 408 + src/harness/paper-runtime.ts | 85 + src/harness/paper-scheduler.ts | 297 + src/harness/paper-state-rehydrator.ts | 859 ++ src/harness/paper-study-ledger.ts | 135 + src/harness/validate-capture-configs.ts | 25 + src/harness/verify-claude-paper-runtime.ts | 77 + src/harness/write-paper-study-report.ts | 81 + src/ingest/polymarket/fees.ts | 107 + src/ingest/polymarket/normalizer.ts | 45 +- src/ingest/txline/normalizer.ts | 1 + src/metrics/paper-study-observations.ts | 100 + src/metrics/paper-study-report.ts | 122 + src/metrics/paper-study.ts | 449 + src/metrics/threshold-grid.ts | 50 +- src/portfolio/paper-close-scheduler.ts | 77 + src/portfolio/paper-settlement-scheduler.ts | 45 + src/portfolio/paper.ts | 328 + src/proof/decision-receipt-schema.ts | 831 ++ src/proof/decision-receipt.ts | 624 ++ src/proof/synthetic-decision-receipt.ts | 320 + src/proof/verify-decision-receipt.ts | 29 + src/public/artifact-safety.ts | 865 ++ src/public/audit-public-artifacts.ts | 154 + src/replay/merge.ts | 62 + src/research/analyze-paired-capture.ts | 52 + src/research/build-total-line-evidence.ts | 111 +- src/research/historical-gate-study.ts | 1229 +++ src/research/main-total-selector.ts | 59 +- src/research/paired-live-study.ts | 871 ++ src/research/run-historical-gate.ts | 45 + src/research/total-line-evidence-query.ts | 60 + src/risk/paper.ts | 218 + src/store/decision-ledger.ts | 346 + test/capture-config.test.ts | 87 + test/capture-readiness.test.ts | 62 + test/claude-agents.test.ts | 300 + test/claude-paper-study.test.ts | 228 + test/dashboard-projection.test.ts | 248 + test/decision-ledger.test.ts | 284 + test/decision-receipt.test.ts | 181 + test/detectors.test.ts | 1 + test/economic-cases.test.ts | 191 + test/features.test.ts | 51 +- test/forward-labels.test.ts | 1 + test/historical-gate-study.test.ts | 237 + test/main-total-selector.test.ts | 38 + test/mapping-polymarket.test.ts | 25 + test/paired-live-study.test.ts | 44 + test/paper-close-scheduler.test.ts | 152 + test/paper-fixture-universe.test.ts | 171 + test/paper-lane-runtime.test.ts | 227 + test/paper-pipeline.test.ts | 691 ++ test/paper-portfolio.test.ts | 165 + test/paper-settlement-scheduler.test.ts | 153 + test/paper-state-rehydrator.test.ts | 405 + test/paper-study-ledger.test.ts | 75 + test/paper-study-metrics.test.ts | 152 + test/paper-study-observations.test.ts | 116 + test/paper-study-report.test.ts | 65 + test/polymarket-fees.test.ts | 93 + test/public-artifact-safety.test.ts | 307 + test/public-dashboard-bundle.test.ts | 168 + test/replay-metrics.test.ts | 37 +- test/solana-decision-anchor.test.ts | 348 + test/synthetic-judge-case.test.ts | 77 + test/threshold-grid.test.ts | 32 + test/total-line-evidence-query.test.ts | 134 + test/txline-normalizer.test.ts | 17 + 170 files changed, 35390 insertions(+), 608 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 apps/dashboard/index.html create mode 100644 apps/dashboard/src/App.tsx create mode 100644 apps/dashboard/src/Casebook.tsx create mode 100644 apps/dashboard/src/Command.tsx create mode 100644 apps/dashboard/src/Matchroom.tsx create mode 100644 apps/dashboard/src/Shell.tsx create mode 100644 apps/dashboard/src/Study.tsx create mode 100644 apps/dashboard/src/api.ts create mode 100644 apps/dashboard/src/main.tsx create mode 100644 apps/dashboard/src/styles.css create mode 100644 apps/dashboard/tsconfig.json create mode 100644 apps/dashboard/vite.config.ts create mode 100644 config/captures/england-argentina-2026-07-15.json create mode 100644 config/captures/france-spain-2026-07-14.json create mode 100644 docs/06-gate-study.md create mode 100644 docs/07-paper-study-preregistration.md create mode 100644 docs/08-winning-submission-plan.md create mode 100644 docs/09-paper-study-v2-candidate.md create mode 100644 docs/PHASE-4-STATUS.md create mode 100644 docs/UI.md create mode 100644 docs/proof/decision-receipt.md create mode 100644 docs/proof/solana-devnet-anchor.md create mode 100644 docs/research/capture-readiness.md create mode 100644 docs/research/historical-gate-study-causal-economic-v3.md create mode 100644 docs/research/historical-gate-study-causal-economic-v4.md create mode 100644 docs/research/historical-gate-study-v1-invalidation.md create mode 100644 docs/research/historical-gate-study-v3-to-v4-methodology-note.md create mode 100644 docs/research/historical-gate-study.md create mode 100644 docs/research/paired-spain-belgium-2026-07-10-live-lane.md create mode 100644 docs/research/paper-fixture-universe.md create mode 100644 docs/research/paper-study-current.md create mode 100644 docs/research/paper-study-ledger-initialization.md create mode 100644 docs/research/semifinal-capture-schedule.md create mode 100644 docs/submission/demo-script-v1.md create mode 100644 docs/submission/public-release-scope.md create mode 100644 docs/submission/sponsor-clarification-request.md create mode 100644 docs/submission/submission-claims-matrix.md create mode 100644 docs/submission/submission-form-draft.md create mode 100644 docs/submission/technical-overview.md create mode 100644 docs/submission/third-party-notices.md create mode 100644 docs/submission/txline-api-feedback.md create mode 100644 phase0/test/capture-sse.test.ts create mode 100644 proof/fixtures/decision-receipt.synthetic.v1.json create mode 100644 public/artifacts/dashboard/casebook.json create mode 100644 public/artifacts/dashboard/command.json create mode 100644 public/artifacts/dashboard/manifest.json create mode 100644 public/artifacts/dashboard/matchroom-spain-belgium.json create mode 100644 public/artifacts/dashboard/study.json create mode 100644 public/artifacts/dashboard/synthetic-decision-receipt.json create mode 100644 src/agents/claude-pricing.ts create mode 100644 src/agents/claude-spend-ledger.ts create mode 100644 src/agents/claude.ts create mode 100644 src/agents/contracts.ts create mode 100644 src/agents/haiku-smoke.ts create mode 100644 src/agents/opus-smoke.ts create mode 100644 src/chain/decision-anchor-submit.ts create mode 100644 src/chain/decision-anchor.ts create mode 100644 src/chain/prepare-decision-anchor.ts create mode 100644 src/chain/submit-decision-anchor.ts create mode 100644 src/chain/verify-decision-anchor.ts create mode 100644 src/config/paper-study.ts create mode 100644 src/dash/api.ts create mode 100644 src/dash/casebook-projection.ts create mode 100644 src/dash/command-projection.ts create mode 100644 src/dash/export-public-bundle.ts create mode 100644 src/dash/projection.ts create mode 100644 src/dash/public-bundle.ts create mode 100644 src/dash/public-contract.ts create mode 100644 src/dash/server.ts create mode 100644 src/dash/study-projection.ts create mode 100644 src/demo/run-synthetic-judge-case.ts create mode 100644 src/demo/synthetic-judge-case.ts create mode 100644 src/detectors/economic-cases.ts create mode 100644 src/domain/money.ts create mode 100644 src/exec/paper.ts create mode 100644 src/harness/build-capture-readiness.ts create mode 100644 src/harness/build-paper-fixture-universe.ts create mode 100644 src/harness/capture-config.ts create mode 100644 src/harness/capture-readiness.ts create mode 100644 src/harness/claude-paper-study.ts create mode 100644 src/harness/initialize-paper-study-ledgers.ts create mode 100644 src/harness/paper-fixture-universe.ts create mode 100644 src/harness/paper-lane-runtime.ts create mode 100644 src/harness/paper-pipeline.ts create mode 100644 src/harness/paper-runtime.ts create mode 100644 src/harness/paper-scheduler.ts create mode 100644 src/harness/paper-state-rehydrator.ts create mode 100644 src/harness/paper-study-ledger.ts create mode 100644 src/harness/validate-capture-configs.ts create mode 100644 src/harness/verify-claude-paper-runtime.ts create mode 100644 src/harness/write-paper-study-report.ts create mode 100644 src/ingest/polymarket/fees.ts create mode 100644 src/metrics/paper-study-observations.ts create mode 100644 src/metrics/paper-study-report.ts create mode 100644 src/metrics/paper-study.ts create mode 100644 src/portfolio/paper-close-scheduler.ts create mode 100644 src/portfolio/paper-settlement-scheduler.ts create mode 100644 src/portfolio/paper.ts create mode 100644 src/proof/decision-receipt-schema.ts create mode 100644 src/proof/decision-receipt.ts create mode 100644 src/proof/synthetic-decision-receipt.ts create mode 100644 src/proof/verify-decision-receipt.ts create mode 100644 src/public/artifact-safety.ts create mode 100644 src/public/audit-public-artifacts.ts create mode 100644 src/research/analyze-paired-capture.ts create mode 100644 src/research/historical-gate-study.ts create mode 100644 src/research/paired-live-study.ts create mode 100644 src/research/run-historical-gate.ts create mode 100644 src/research/total-line-evidence-query.ts create mode 100644 src/risk/paper.ts create mode 100644 src/store/decision-ledger.ts create mode 100644 test/capture-config.test.ts create mode 100644 test/capture-readiness.test.ts create mode 100644 test/claude-agents.test.ts create mode 100644 test/claude-paper-study.test.ts create mode 100644 test/dashboard-projection.test.ts create mode 100644 test/decision-ledger.test.ts create mode 100644 test/decision-receipt.test.ts create mode 100644 test/economic-cases.test.ts create mode 100644 test/historical-gate-study.test.ts create mode 100644 test/paired-live-study.test.ts create mode 100644 test/paper-close-scheduler.test.ts create mode 100644 test/paper-fixture-universe.test.ts create mode 100644 test/paper-lane-runtime.test.ts create mode 100644 test/paper-pipeline.test.ts create mode 100644 test/paper-portfolio.test.ts create mode 100644 test/paper-settlement-scheduler.test.ts create mode 100644 test/paper-state-rehydrator.test.ts create mode 100644 test/paper-study-ledger.test.ts create mode 100644 test/paper-study-metrics.test.ts create mode 100644 test/paper-study-observations.test.ts create mode 100644 test/paper-study-report.test.ts create mode 100644 test/polymarket-fees.test.ts create mode 100644 test/public-artifact-safety.test.ts create mode 100644 test/public-dashboard-bundle.test.ts create mode 100644 test/solana-decision-anchor.test.ts create mode 100644 test/synthetic-judge-case.test.ts create mode 100644 test/total-line-evidence-query.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5cd8963 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: Samaritan CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: samaritan-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + run_install: false + + - name: Install Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + + - name: Reject high or critical judge-runtime advisories + run: pnpm security:audit + + - name: Verify the complete clean-clone judge path + run: pnpm judge:check + + - name: Verify tracked public artifacts + run: pnpm public:audit + + - name: Install isolated Phase 0 capture dependencies + run: pnpm --dir phase0 install --frozen-lockfile --ignore-workspace + + - name: Typecheck Phase 0 capture tools + run: pnpm --dir phase0 exec tsc -p tsconfig.json --noEmit + + - name: Test Phase 0 capture tools + run: pnpm --dir phase0 test diff --git a/.gitignore b/.gitignore index 4c9e14a..35cd6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,14 @@ data/ *.sqlite-wal # Phase 0 local secrets and raw licensed captures. +.wallet/ phase0/.wallet/ phase0/.tokens/ samples/ + +# Local design explorations, social renders, and one-off verification helpers +# are deliberately outside the public bounty submission. The production UI and +# licence-safe judge artifacts live under apps/ and public/ respectively. +content/ +design/ +.spain-belgium-verify*.ts diff --git a/README.md b/README.md index b6e1215..58b8966 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,65 @@ # SAMARITAN -**An autonomous sports-trading system.** Deterministic signal algorithms detect market inefficiencies in real time; Claude agents act as the judgment layer; every decision is scored, ranked, and anchored on-chain. +**A governed autonomous sports-trading system.** Deterministic signal algorithms detect market inefficiencies; Claude acts only as a bounded judgment layer; deterministic code controls risk and paper execution; decisions are recorded in an append-only local ledger. Decision receipts verify offline and devnet anchoring tooling is implemented, but no anchor transaction has been submitted. - **Testing ground:** TxODDS "Trading Tools and Agents" bounty — World Cup Hackathon (Superteam Earn / Solana). 16,000 USDT track prize. **Submission deadline: July 19, 2026.** -- **Real mission:** a durable, money-making trading harness for sports and prediction markets. The hackathon is chapter one, not the book. +- **Product direction:** durable decision-control and evidence infrastructure for sports and prediction-market teams. The bounty build is paper-only. ## Status -**PHASE: PHASE 1 COMPLETE — PHASE 2 SIGNAL CORE STARTED.** The strict Node 22 workspace, canonical event contracts, evidence-bearing mapping registry, TXLine SSE clients, Gamma-refreshed Polymarket WebSocket source, streaming replay adapters, and append-only SQLite/DuckDB stores are implemented. The local research database holds 9,025,361 normalized TXLine outcomes and 85,249,582 mapped Polymarket points. Combined three-source live and two-source archive replay smokes pass. Tonight's synchronized capture supplies Phase-3 microstructure evidence while Phase 2 proceeds. +**PHASE: PHASE 4 PAPER REASONING RUNTIME IMPLEMENTED — LIVE EVIDENCE COLLECTION ACTIVE.** The strict Node 22 workspace, canonical event contracts, evidence-bearing mapping registry, TXLine SSE clients, Gamma-refreshed Polymarket WebSocket source, streaming replay adapters, append-only SQLite/DuckDB stores, feature engine, detectors, chronological research harness, bounded Claude layer, deterministic paper risk, and paper portfolio are implemented. The local research database holds 9,025,361 normalized TXLine outcomes and 85,249,582 mapped Polymarket points. The synchronized Spain-Belgium study found no supporting post-TXLine STALE_QUOTE case, so that detector remains disabled. -Phase 2 now has rolling velocity/acceleration/EWMA/CUSUM features, feed freshness and score context, research-gated `CONSENSUS_MOVE`/`XMARKET_DIVERGENCE`/`FADER_CANDIDATE`, timestamp-merged DuckDB replay, bounded forward-horizon labeling, streaming threshold-grid/classification/Brier/CLV metrics, and an evidence-weighted dynamic main-total selector. Numeric thresholds and selector weights remain deliberately unset outside explicit tests and smoke studies until the Phase-3 gate. +**Evidence correction, July 14:** Samaritan invalidated its original dynamic-total result after finding a future-informed selector and inflated emission semantics, preserved the artifact, repaired both defects, and reran the unchanged chronological boundary. The corrected Total Goals–only `CONSENSUS_MOVE` family has 38 held-out normalized buy cases across 18 fixtures, mean `+132.7` probability bps after the 100 bps historical proxy, and a fixture-clustered 95% interval of `+14.3` to `+243.9` bps. This is sampled-price historical signal evidence for a fresh forward paper review—not alpha, profitability, or executable fills. Paper v2 remains unregistered and the real-money gate remains closed. See [the corrected v4 report](docs/research/historical-gate-study-causal-economic-v4.md) and [v1 invalidation record](docs/research/historical-gate-study-v1-invalidation.md). + +The Phase-4 paper spine runs canonical events through deterministic detection, ledgers signals before model I/O, measures Haiku/Opus wall-clock decision latency from local observation time, and waits for both that latency and the current sports-order placement delay before deterministic risk and depth-aware simulation. Haiku and Opus can submit only strict structured judgments; neither can size or place an order. The July 12 paper-study registration and its empty ledgers are preserved but suspended after the July 14 selector audit. The corrected v2 protocol is an unregistered engineering candidate: its operational Claude-readiness command is disabled until Deborah signs a replacement protocol. No trading credential is connected and the real-money gate remains closed. + +## Judge quickstart + +Requirements: Node `22.23.1` and pnpm `10.32.1`. + +```bash +corepack enable +pnpm install --frozen-lockfile +pnpm judge +``` + +Then open `http://127.0.0.1:4173`. `pnpm judge` first runs the complete clean-clone verification gate, then serves the read-only dashboard. It needs no TXLine credential, Anthropic key, wallet, RPC write, paid subscription, or private archive. + +The verification gate proves: + +- all TypeScript checks and 200+ tests pass; +- a deterministic synthetic case traverses the production paper components from canonical events through settlement; +- the public Decision Receipt verifies offline; +- the frozen dashboard bundle contains only allowlisted, derived artifacts; and +- the production dashboard builds from that clean-clone bundle. + +The public experience deliberately separates two evidence classes: + +1. **Captured refusal:** the Spain–Belgium replay is real synchronized evidence and correctly ends in no trade. Only bucketed TXLine movement is public. +2. **Synthetic full lifecycle:** `pnpm demo` exercises signal → triage → thesis → deterministic risk → intent → paper fill → close → settlement → receipt with zero external calls. It is visibly synthetic and permanently excluded from performance evidence. + +Useful standalone commands: + +```bash +pnpm demo +pnpm receipt:verify -- public/artifacts/dashboard/synthetic-decision-receipt.json +pnpm public:audit +pnpm judge:check +``` + +The downloadable receipt is locally verified but **not Solana-anchored**. Offline prepare, explicitly human-gated devnet submission, and read-only network verification tooling exist; no signer has been accessed and no transaction has been submitted. Decisions locked so far: | Decision | Choice | |---|---| -| Execution modes | **Both** — paper trading (verifiable ledger) AND real-money Polymarket execution | -| Wallets | Solana mainnet wallet ready (for free real-time data tier); devnet also for testing | +| Execution modes | **Bounty: paper only.** Real-money Polymarket execution remains a gated post-bounty architecture item | +| Wallets | No judge wallet and no execution wallet connected; any devnet proof submission is explicitly human-gated | | Data source | TXLine (TxODDS) — free World Cup tier, Service Level 12 (real-time, mainnet) | | v1 markets | Match Result + dynamically selected main full-time Over/Under only | | Signal source reality | Free tier exposes `TXLineStablePriceDemargined` consensus, not named bookmakers; Momentum uses `CONSENSUS_MOVE`, not bookmaker leader-lag | | AI | Claude — Opus 4.8 (analysis), Haiku 4.5 (triage); custom harness, NOT a prompt template | | Claude budget | $200 operating target; $300 hard project ceiling | -| Real-money envelope | $50 bankroll ceiling; $15 maximum aggregate live exposure initially; per-trade/drawdown caps set at the Phase-3 gate | +| Real-money architecture | Disabled and outside the bounty proof; no production trading credential or adapter is connected | | Stack | Node 22 + strict TypeScript/pnpm/vitest; SQLite + DuckDB | ## Docs @@ -33,8 +71,14 @@ Decisions locked so far: | [docs/03-strategy-playbook.md](docs/03-strategy-playbook.md) | The edges, the math, the risk framework — and the open questions we're discussing before building | | [docs/04-algorithms.md](docs/04-algorithms.md) | The detector math: trigger conditions, free parameters, sizing, scoring — the spec the gate study fills with numbers | | [docs/05-harness.md](docs/05-harness.md) | The agent harness machine room: case lifecycle, context assembly, two-speed deliberation, memory stores, hooks, budgets | +| [docs/06-gate-study.md](docs/06-gate-study.md) | Phase-3 historical/live evidence, detector decisions, cost sensitivity, and the pending human gate | +| [docs/UI.md](docs/UI.md) | Approved Floodlight UI/UX direction: information architecture, visual system, evidence states, replay behavior, and acceptance criteria | | [docs/PHASE-1-STATUS.md](docs/PHASE-1-STATUS.md) | Implemented backbone contracts, verification evidence, and the remaining Phase-1 work | | [docs/PHASE-2-STATUS.md](docs/PHASE-2-STATUS.md) | Current feature/detector/replay implementation, real-archive smoke evidence, and open Phase-2 work | +| [docs/PHASE-4-STATUS.md](docs/PHASE-4-STATUS.md) | Strict reasoning contracts, deterministic paper-risk/execution slice, verification, and remaining integration work | +| [docs/08-winning-submission-plan.md](docs/08-winning-submission-plan.md) | Release-candidate win plan, evidence checkpoint, remaining authority gates, and demo strategy | +| [docs/submission/public-release-scope.md](docs/submission/public-release-scope.md) | Exact public inclusion/exclusion rules, clean-clone gate, and dependency isolation | +| [docs/submission/third-party-notices.md](docs/submission/third-party-notices.md) | Data-service, font, asset, and software attribution boundaries | | [plan.md](plan.md) | 7 phases to submission (July 18) with exit criteria and the mandatory Phase-3 gate | | [AGENTS.md](AGENTS.md) | Hard invariants + conventions for any AI agent writing Samaritan code | diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 0000000..fe246ca --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,17 @@ + + + + + + + + Samaritan Matchroom + + +
+ + + diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx new file mode 100644 index 0000000..645898e --- /dev/null +++ b/apps/dashboard/src/App.tsx @@ -0,0 +1,11 @@ +import { CasebookApp } from "./Casebook"; +import { CommandApp } from "./Command"; +import { MatchroomApp } from "./Matchroom"; +import { StudyApp } from "./Study"; + +export function App() { + if (window.location.pathname === "/matchroom") return ; + if (window.location.pathname === "/casebook") return ; + if (window.location.pathname === "/study") return ; + return ; +} diff --git a/apps/dashboard/src/Casebook.tsx b/apps/dashboard/src/Casebook.tsx new file mode 100644 index 0000000..1847447 --- /dev/null +++ b/apps/dashboard/src/Casebook.tsx @@ -0,0 +1,313 @@ +import { startTransition, useDeferredValue, useEffect, useState } from "react"; +import type { CasebookCaseSummary, CasebookSnapshot } from "../../../src/dash/public-contract"; +import { loadCasebook } from "./api"; +import { BrandMark, Icon, MobileNavigation, Navigation, Topbar } from "./Shell"; + +type Filters = { + fixture: string; + market: string; + detector: string; + disposition: string; + outcome: string; + lane: string; + source: string; + date: string; +}; + +const emptyFilters: Filters = { + fixture: "", + market: "", + detector: "", + disposition: "", + outcome: "", + lane: "", + source: "", + date: "" +}; + +function percentage(value: number, digits = 2) { + return `${(value * 100).toFixed(digits)}%`; +} + +function points(value: number) { + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(2)}pp`; +} + +function movementBps(value: number) { + return `${value >= 0 ? "+" : ""}${value.toLocaleString("en-US")} bps`; +} + +function compactHash(value: string) { + return `${value.slice(0, 12)}…${value.slice(-8)}`; +} + +function caseDate(value: string) { + return new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short" + }).format(new Date(value)); +} + +function SelectFilter({ label, value, options, onChange }: { + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function CasebookHero({ snapshot }: { snapshot: CasebookSnapshot }) { + return ( +
+
+ Append-only decision record +

Every decision.
Especially the pass.

+

Inspect what Samaritan saw, why it refused the opportunity, and the proof that no money moved. Unfavorable and incomplete cases stay visible by default.

+
+
+
Verified record{snapshot.statistics.totalCases.toString().padStart(2, "0")}complete case
+
No trade{snapshot.statistics.noTradeCases}capital-preserving pass
+
Executed{snapshot.statistics.executedCases}no orders constructed
+
Capital moved$0.00paper observer state
+
100% verifiedReplay identity parity
+
+
+ ); +} + +function CaseFilters({ snapshot, search, onSearch, filters, onFilter, onClear }: { + snapshot: CasebookSnapshot; + search: string; + onSearch: (value: string) => void; + filters: Filters; + onFilter: (key: keyof Filters, value: string) => void; + onClear: () => void; +}) { + const dateOption = snapshot.cases[0]?.occurredAt.slice(0, 10) ?? ""; + const hasFilters = Boolean(search || Object.values(filters).some(Boolean)); + return ( +
+ +
+ onFilter("fixture", value)} /> + onFilter("market", value)} /> + onFilter("detector", value)} /> + onFilter("disposition", value)} /> + onFilter("outcome", value)} /> + onFilter("lane", value)} /> + onFilter("source", value)} /> + onFilter("date", value)} /> +
+ +
+ ); +} + +function CaseIndex({ cases, total, nextEvidence }: { + cases: CasebookCaseSummary[]; + total: number; + nextEvidence: CasebookSnapshot["nextEvidence"]; +}) { + return ( + + ); +} + +function DecisionRail({ lifecycle }: { lifecycle: CasebookSnapshot["selectedCase"]["lifecycle"] }) { + return ( +
+
Lifecycle audit

Decision rail

No trade
+
+ {lifecycle.map((stage, index) => ( +
+ {String(index + 1).padStart(2, "0")} + + {stage.label}{stage.detail} + {stage.timingLabel} +
+ ))} +
+
+ ); +} + +function EvidenceReadout({ selected }: { selected: CasebookSnapshot["selectedCase"] }) { + const readout = selected.evidenceReadout; + return ( +
+
Executable market evidence

What Samaritan actually saw

Moved first
+
+
TXLine movement{movementBps(readout.consensusMoveFromBaselineBps)}25-bps bucket · level withheld
+
Best bid{percentage(readout.bestBid)}Executable book
+
Best ask{percentage(readout.bestAsk)}Executable entry
+
Spread{points(readout.spread)}At signal observation
+
Public-data boundaryRelative onlyNo exact TXLine level or series
+
Pre-trigger market move{movementBps(readout.preTriggerMarketMoveBps)}Before TXLine first seen
+
+
Timing conclusion{readout.movementConclusion}

The public book was already repriced. The timing sequence did not support a tradeable stale quote.

+
+ ); +} + +function AnalysisBoundary({ selected }: { selected: CasebookSnapshot["selectedCase"] }) { + return ( +
+
+ +
Analyst boundary

Thesis not requested

{selected.analysis.thesisReason}

Invalidation: {selected.analysis.invalidation}
+
+
+ +
Execution boundary

$0.00 total cost

{selected.analysis.costReason}

{selected.decision.ordersPlaced} orders · wallet untouched · gate closed
+
+
+ ); +} + +function EvidenceSequence({ selected }: { selected: CasebookSnapshot["selectedCase"] }) { + return ( +
+
Derived observation sequence

Evidence ledger

{selected.evidence.length} observations
+
MomentSourceObservationBest askAssessment
+ {selected.evidence.map((row) => ( +
+ {row.offsetLabel}{row.observedAt.slice(11, 23)} UTC + {row.source} + {row.observation} + {percentage(row.bestAsk)} + {row.assessment} +
+ ))} +
+ ); +} + +function CaseDetail({ snapshot }: { snapshot: CasebookSnapshot }) { + const selected = snapshot.selectedCase; + return ( +
+
+
Selected case{selected.summary.caseId}{caseDate(selected.summary.occurredAt)}
+
+ ESPSpain + {selected.match.clockLabel}{selected.match.scoreAtCursor.home}–{selected.match.scoreAtCursor.away}first goal seen + BelgiumBEL +
+
Authoritative disposition

Market moved before signal.

{selected.decision.explanation}

+ Open full Matchroom replay +
+ + + + +
+ Replay identity verified{selected.proof.canonicalEvents.toLocaleString("en-US")} canonical events + Identity hash{compactHash(selected.proof.identityHash)} + Replay journal head{compactHash(selected.proof.headHash)} + Derived evidence only +
+
+ ); +} + +function matchesFilters(item: CasebookCaseSummary, search: string, filters: Filters) { + const haystack = [item.caseId, item.fixtureLabel, item.marketLabel, item.detector, item.reason, item.disposition].join(" ").toLowerCase(); + return (!search || haystack.includes(search.toLowerCase())) && + (!filters.fixture || item.fixtureLabel === filters.fixture) && + (!filters.market || item.marketFamily === filters.market) && + (!filters.detector || item.detector === filters.detector) && + (!filters.disposition || item.disposition === filters.disposition) && + (!filters.outcome || item.executionOutcome === filters.outcome) && + (!filters.lane || item.evidenceLane === filters.lane) && + (!filters.source || item.source === filters.source) && + (!filters.date || item.occurredAt.startsWith(filters.date)); +} + +function CasebookView({ snapshot }: { snapshot: CasebookSnapshot }) { + const [search, setSearch] = useState(""); + const deferredSearch = useDeferredValue(search.trim()); + const [filters, setFilters] = useState(emptyFilters); + const visibleCases = snapshot.cases.filter((item) => matchesFilters(item, deferredSearch, filters)); + const updateFilter = (key: keyof Filters, value: string) => setFilters((current) => ({ ...current, [key]: value })); + const clear = () => { setSearch(""); setFilters(emptyFilters); }; + return ( +
+ +
+ +
+ + +
+ + {visibleCases.length > 0 ? :

No verified case selected

The current filters exclude every recorded case. Clear filters to reopen the complete Spain–Belgium refusal record.

} +
+
+ +
+
+ ); +} + +function CasebookLoading() { + return
Samaritan / Casebook

Verifying the decision record

Rebuilding the public case index from replay proof and append-only decision evidence.

; +} + +function CasebookError({ retry }: { retry: () => void }) { + return
Fail-closed boundary

Casebook evidence unavailable

The verified replay record changed or could not be reconstructed. Samaritan will not display a partial or fabricated case.

; +} + +export function CasebookApp() { + const [snapshot, setSnapshot] = useState(null); + const [failure, setFailure] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setFailure(false); + loadCasebook(controller.signal) + .then((nextSnapshot) => startTransition(() => setSnapshot(nextSnapshot))) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + setFailure(true); + }); + return () => controller.abort(); + }, [attempt]); + + if (failure) return { setSnapshot(null); setAttempt((value) => value + 1); }} />; + if (!snapshot) return ; + return ; +} diff --git a/apps/dashboard/src/Command.tsx b/apps/dashboard/src/Command.tsx new file mode 100644 index 0000000..ad8018d --- /dev/null +++ b/apps/dashboard/src/Command.tsx @@ -0,0 +1,236 @@ +import { startTransition, useEffect, useState } from "react"; +import type { + CommandFixture, + CommandSnapshot, + PublicBookPoint +} from "../../../src/dash/public-contract"; +import { loadCommand } from "./api"; +import { BrandMark, Icon, MobileNavigation, Navigation, Topbar } from "./Shell"; + +function percent(value: number, digits = 2) { + return `${(value * 100).toFixed(digits)}%`; +} + +function movementBps(value: number) { + return `${value >= 0 ? "+" : ""}${value.toLocaleString("en-US")} bps`; +} + +function compactHash(value: string) { + return `${value.slice(0, 10)}…${value.slice(-7)}`; +} + +function dateParts(value: string) { + const date = new Date(value); + return { + day: new Intl.DateTimeFormat("en-GB", { day: "2-digit", timeZone: "UTC" }).format(date), + month: new Intl.DateTimeFormat("en-GB", { month: "short", timeZone: "UTC" }).format(date).toUpperCase(), + time: new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" }).format(date) + }; +} + +function relativeStart(target: string, generatedAt: string) { + const difference = Date.parse(target) - Date.parse(generatedAt); + if (difference <= 0) return "Window reached"; + const hours = Math.floor(difference / 3_600_000); + const days = Math.floor(hours / 24); + return days > 0 ? `Starts in ${days}d ${hours % 24}h` : `Starts in ${hours}h`; +} + +function MiniProbabilityChart({ points }: { points: PublicBookPoint[] }) { + const frame = { left: 28, right: 624, top: 24, bottom: 194 }; + const minimumOffset = Math.min(...points.map((point) => point.offsetMs)); + const maximumOffset = Math.max(...points.map((point) => point.offsetMs)); + const x = (offset: number) => frame.left + ((offset - minimumOffset) / (maximumOffset - minimumOffset)) * (frame.right - frame.left); + const y = (probability: number) => frame.top + ((0.3 - probability) / 0.15) * (frame.bottom - frame.top); + const path = (value: (point: PublicBookPoint) => number) => points.map((point, index) => `${index === 0 ? "M" : "L"}${x(point.offsetMs).toFixed(1)} ${y(value(point)).toFixed(1)}`).join(" "); + const bid = path((point) => point.bestBid); + const ask = path((point) => point.bestAsk); + const bidReverse = [...points].reverse().map((point) => `L${x(point.offsetMs).toFixed(1)} ${y(point.bestBid).toFixed(1)}`).join(" "); + const goalX = x(0); + return ( +
+
Public executable bookBest bid Best ask
+ + Spain–Belgium public draw book around the first goal + The public executable bid and ask repriced before the goal reached Samaritan. Exact TXLine probability levels are withheld. + + + + + + point.offsetMs === 0)?.bestAsk ?? 0.18)} r="5" />GOAL FIRST SEEN + T−5s29:21T+30s + +
+ ); +} + +function SystemDeck({ snapshot }: { snapshot: CommandSnapshot }) { + return ( +
+
+ + Current posture{snapshot.system.label}{snapshot.system.detail} +
+
+ {snapshot.system.feeds.map((feed) => ( +
+ {feed.statusLabel}{feed.label}{feed.detail} +
+ ))} +
+
SnapshotDerived · read only
+
+ ); +} + +function FeaturedCase({ snapshot }: { snapshot: CommandSnapshot }) { + const featured = snapshot.featuredCase; + return ( +
+
+
Verified replayCase {featured.caseId}
+
+ ESPSpain + {featured.clockLabel}{featured.scoreLabel}first goal seen + BelgiumBEL +
+ The trade Samaritan refused + +

{featured.conclusion}

+
+
TXLine movement{movementBps(featured.consensusMoveFromBaselineBps)}25-bps bucket · level withheld
+
Executable ask{percent(featured.bestAsk)}
+
Pre-trigger market move{movementBps(featured.preTriggerMarketMoveBps)}
+
+
Authoritative outcomeNo trade · capital preserved
+ Replay verified case +
+
+ +
Market repriced first{movementBps(featured.preTriggerMarketMoveBps)}public draw-book move before TXLine first seen
+
+
+ ); +} + +function FixtureCard({ fixture, generatedAt, lead }: { fixture: CommandFixture; generatedAt: string; lead: boolean }) { + const kickoff = dateParts(fixture.kickoffUtc); + const capture = dateParts(fixture.captureStartUtc); + return ( +
+
{kickoff.day}{kickoff.month}
+
+
{fixture.statusLabel}{relativeStart(fixture.captureStartUtc, generatedAt)}
+
{fixture.home.code}{fixture.home.name}vs{fixture.away.name}{fixture.away.code}
+
Capture begins{capture.time} UTCKickoff{kickoff.time} UTC
+
Capture onlyExact identity confirmed · non-tradeable
+
+
+ ); +} + +function CaptureSchedule({ snapshot }: { snapshot: CommandSnapshot }) { + return ( + + ); +} + +function RecentCases({ snapshot }: { snapshot: CommandSnapshot }) { + return ( +
+
Decision record

Strongest recent cases

Open Casebook
+
CaseMarket readEvidenceOutcome
+ {snapshot.recentCases.map((item) => ( + + ESP{item.fixtureLabel}{item.caseId} · {item.candidateLabel} + {item.marketLabel}{movementBps(item.preTriggerMarketMoveBps)} before signal + Verified replay{snapshot.featuredCase.canonicalEvents.toLocaleString("en-US")} events + {item.dispositionLabel}{item.reason} + + ))} +
{snapshot.additionalCaseState.label}{snapshot.additionalCaseState.detail}
+
+ ); +} + +function StudyPanel({ snapshot }: { snapshot: CommandSnapshot }) { + const study = snapshot.study; + return ( +
+
Evidence governance

Paper protocol audit

{study.statusLabel}
+
V1 was invalidated before any observation.The zero-row ledgers are preserved; corrected v2 awaits Deborah's registration.
+
+
{study.filledMatches} / {study.requiredFilledMatches}filled matches
+
{study.fills} / {study.requiredFills}fills
+
+
Protocol statusSuspended audit historyConfig hash{compactHash(study.configHash)}
+
+ ); +} + +function CommandProof({ snapshot }: { snapshot: CommandSnapshot }) { + return ( + + ); +} + +function CommandView({ snapshot }: { snapshot: CommandSnapshot }) { + return ( +
+ +
+ + +
+
+
+
+ +
+
+ ); +} + +function CommandLoading() { + return
Samaritan / Command

Assembling the evidence desk

Revalidating scheduled fixtures, ledger state, and the featured replay before anything is shown.

; +} + +function CommandError({ retry }: { retry: () => void }) { + return
Fail-closed boundary

Command evidence unavailable

A fixture or evidence identity could not be verified. Samaritan will not substitute stale or fabricated operational state.

; +} + +export function CommandApp() { + const [snapshot, setSnapshot] = useState(null); + const [failure, setFailure] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setFailure(false); + loadCommand(controller.signal) + .then((nextSnapshot) => startTransition(() => setSnapshot(nextSnapshot))) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + setFailure(true); + }); + return () => controller.abort(); + }, [attempt]); + + if (failure) return { setSnapshot(null); setAttempt((value) => value + 1); }} />; + if (!snapshot) return ; + return ; +} diff --git a/apps/dashboard/src/Matchroom.tsx b/apps/dashboard/src/Matchroom.tsx new file mode 100644 index 0000000..8ec9915 --- /dev/null +++ b/apps/dashboard/src/Matchroom.tsx @@ -0,0 +1,369 @@ +import { + startTransition, + useEffect, + useEffectEvent, + useState +} from "react"; +import type { + MatchroomSnapshot, + PublicBookPoint, + ReplayState, + ReplayStepId +} from "../../../src/dash/public-contract"; +import { loadMatchroom } from "./api"; +import { BrandMark, Icon, MobileNavigation, Navigation, Topbar } from "./Shell"; + +const replayOrder: ReplayStepId[] = ["pre", "goal", "post"]; + +function percent(value: number, digits = 2) { + return `${(value * 100).toFixed(digits)}%`; +} + +function points(value: number) { + const amount = value * 100; + return `${amount >= 0 ? "+" : ""}${amount.toFixed(2)}pp`; +} + +function movementBps(value: number) { + return `${value >= 0 ? "+" : ""}${value.toLocaleString("en-US")} bps`; +} + +function utcTime(value: string) { + return `${new Date(value).toISOString().slice(11, 23)} UTC`; +} + +function originalDate(value: string) { + return new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "UTC" + }).format(new Date(`${value}T00:00:00Z`)); +} + +function compactHash(value: string) { + return `${value.slice(0, 18)}…${value.slice(-7)}`; +} + +function formatDuration(ms: number) { + return ms >= 1000 ? `${(ms / 1000).toFixed(3)}s` : `${ms}ms`; +} + +function ContextBar({ snapshot }: { snapshot: MatchroomSnapshot }) { + return ( +
+
+ + {snapshot.match.competition} · {snapshot.match.stage}{snapshot.match.home.name} vs {snapshot.match.away.name} + +
+ +
+ + Original match date{originalDate(snapshot.match.originalMatchDate)} +
+
+ ); +} + +function MatchMasthead({ snapshot }: { snapshot: MatchroomSnapshot }) { + return ( +
+
+ Captured at first goal + Fixture {snapshot.match.fixtureId} +
+
+ {snapshot.match.home.code} + {snapshot.match.home.name}{snapshot.match.home.code} +
+
+ {snapshot.match.clockLabel} +
{snapshot.match.scoreAtCursor.home}:{snapshot.match.scoreAtCursor.away}
+ First goal seen +
+
+ {snapshot.match.away.name}{snapshot.match.away.code} + {snapshot.match.away.code} +
+
+ Exact market + {snapshot.market.label} + {snapshot.market.period} · research-only mapping +
+
+ ); +} + +const chartFrame = { left: 62, right: 830, top: 27, bottom: 282 }; +const chartMinimum = 0.15; +const chartMaximum = 0.3; + +function chartX(offset: number, minimum: number, maximum: number) { + return chartFrame.left + ((offset - minimum) / (maximum - minimum)) * (chartFrame.right - chartFrame.left); +} + +function chartY(probability: number) { + return chartFrame.top + ((chartMaximum - probability) / (chartMaximum - chartMinimum)) * (chartFrame.bottom - chartFrame.top); +} + +function linePath(pointsToDraw: PublicBookPoint[], value: (point: PublicBookPoint) => number, minimum: number, maximum: number) { + return pointsToDraw.map((point, index) => `${index === 0 ? "M" : "L"}${chartX(point.offsetMs, minimum, maximum).toFixed(1)} ${chartY(value(point)).toFixed(1)}`).join(" "); +} + +function chartSegments(snapshot: MatchroomSnapshot) { + const pointsToDraw = snapshot.replay.chart; + if (snapshot.replay.availabilityGaps.length === 0) return [pointsToDraw]; + const firstSeen = Date.parse(snapshot.replay.firstSeenAt); + const gapOffsets = snapshot.replay.availabilityGaps.map((gap) => ({ + start: Date.parse(gap.startedAt) - firstSeen, + end: Date.parse(gap.endedAt) - firstSeen + })); + const segments: PublicBookPoint[][] = []; + let current: PublicBookPoint[] = []; + for (const point of pointsToDraw) { + const previous = current.at(-1); + if (previous && gapOffsets.some((gap) => previous.offsetMs <= gap.end && point.offsetMs >= gap.start)) { + if (current.length > 1) segments.push(current); + current = []; + } + current.push(point); + } + if (current.length > 1) segments.push(current); + return segments; +} + +function ProbabilityChart({ snapshot, state }: { snapshot: MatchroomSnapshot; state: ReplayState }) { + const minimum = Math.min(...snapshot.replay.chart.map((point) => point.offsetMs)); + const maximum = Math.max(...snapshot.replay.chart.map((point) => point.offsetMs)); + const segments = chartSegments(snapshot); + const cursorX = chartX(state.offsetMs, minimum, maximum); + const goalX = chartX(0, minimum, maximum); + + return ( +
+ + Public draw order book around Spain's first goal + The public executable bid and ask repriced before the goal reached Samaritan. Exact TXLine probability levels are withheld. + + + + {segments.map((segment, index) => { + const ask = linePath(segment, (point) => point.bestAsk, minimum, maximum); + const bidReverse = [...segment].reverse().map((point) => `L${chartX(point.offsetMs, minimum, maximum).toFixed(1)} ${chartY(point.bestBid).toFixed(1)}`).join(" "); + return ; + })} + {segments.map((segment, index) => point.bestBid, minimum, maximum)} />)} + {segments.map((segment, index) => point.bestAsk, minimum, maximum)} />)} + + + +
{utcTime(state.observedAt)}Derived summaries only · unavailable feed intervals render as gaps
+
+ ); +} + +function ProbabilityPanel({ snapshot, state, activeId, playing, onSelect, onTogglePlay }: { + snapshot: MatchroomSnapshot; + state: ReplayState; + activeId: ReplayStepId; + playing: boolean; + onSelect: (id: ReplayStepId) => void; + onTogglePlay: () => void; +}) { + return ( +
+
+
Licence-safe market evidence

Relative TXLine movement & public book

+
Best bidBest askBid–ask spread
+
+
+
TXLine movement{movementBps(state.consensusMoveFromBaselineBps)}25-bps bucket from case baseline · absolute level withheld
+
Executable bid / ask{(state.bestBid * 100).toFixed(2)}/{(state.bestAsk * 100).toFixed(2)}%{points(state.spread)} measured spread
+
Pre-trigger market move{movementBps(snapshot.replay.preTriggerMarketMoveBps)}Public executable market · no TXLine level disclosed
+
+
+ +

{state.conclusionTitle} {state.conclusionBody}

+ Disciplined pass +
+ +
+ +
+ {snapshot.replay.states.map((item) => )} +
+ 1× +
+
+ ); +} + +function DecisionRail({ snapshot, state }: { snapshot: MatchroomSnapshot; state: ReplayState }) { + return ( + + ); +} + +function EvidencePanel({ snapshot, activeId }: { snapshot: MatchroomSnapshot; activeId: ReplayStepId }) { + const sourceClass = { Polymarket: "poly", TXLine: "tx", Samaritan: "sam" } as const; + const sourceMark = { Polymarket: "P", TXLine: "TX", Samaritan: "S" } as const; + return ( +
+
Evidence provenance

Sequence around the goal

Case · ESP-BEL-01
+
+ + + + {snapshot.evidence.map((row) => ( + + + + + + + + ))} + +
MomentSourceVerified observationExecutable askAssessment
{row.offsetLabel}{sourceMark[row.source]}{row.source}{row.observation}{percent(row.bestAsk)}Best ask{row.assessment}
+
+
+ ); +} + +function ProofPanel({ snapshot }: { snapshot: MatchroomSnapshot }) { + return ( + + ); +} + +function LoadingScreen() { + return
Samaritan / Matchroom

Loading verified replay

Assembling derived evidence. No market data is shown until verification succeeds.

; +} + +function ErrorScreen({ retry }: { retry: () => void }) { + return
Fail-closed boundary

Evidence unavailable

The verified replay could not be loaded, so Samaritan is not presenting partial or fabricated match evidence.

; +} + +function Matchroom({ snapshot }: { snapshot: MatchroomSnapshot }) { + const [activeId, setActiveId] = useState(snapshot.replay.activeStateId); + const [playing, setPlaying] = useState(false); + const state = snapshot.replay.states.find((item) => item.id === activeId) ?? snapshot.replay.states[1]!; + + const advanceReplay = useEffectEvent(() => { + const currentIndex = replayOrder.indexOf(activeId); + if (currentIndex >= replayOrder.length - 1) { + setPlaying(false); + return; + } + startTransition(() => setActiveId(replayOrder[currentIndex + 1]!)); + }); + + useEffect(() => { + if (!playing) return; + const timer = window.setInterval(advanceReplay, 1400); + return () => window.clearInterval(timer); + }, [playing]); + + function selectState(id: ReplayStepId) { + setPlaying(false); + startTransition(() => setActiveId(id)); + } + + function togglePlay() { + if (playing) { + setPlaying(false); + return; + } + if (activeId === "post") setActiveId("pre"); + setPlaying(true); + } + + return ( +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ +
+
+ ); +} + +export function MatchroomApp() { + const [snapshot, setSnapshot] = useState(null); + const [failure, setFailure] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setFailure(false); + loadMatchroom(controller.signal) + .then((nextSnapshot) => startTransition(() => setSnapshot(nextSnapshot))) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + setFailure(true); + }); + return () => controller.abort(); + }, [attempt]); + + if (failure) return { setSnapshot(null); setAttempt((value) => value + 1); }} />; + if (!snapshot) return ; + return ; +} diff --git a/apps/dashboard/src/Shell.tsx b/apps/dashboard/src/Shell.tsx new file mode 100644 index 0000000..2e80af9 --- /dev/null +++ b/apps/dashboard/src/Shell.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from "react"; + +export type ProductRoute = "command" | "matchroom" | "casebook" | "study"; + +export type IconName = + | "arrow" + | "case" + | "chart" + | "check" + | "chevron" + | "clock" + | "command" + | "cup" + | "lock" + | "minus" + | "pause" + | "play" + | "proof" + | "pulse" + | "replay" + | "shield" + | "spark" + | "system"; + +export function Icon({ name, className }: { name: IconName; className?: string }) { + const paths: Record = { + arrow: <>, + case: <>, + chart: , + check: , + chevron: , + clock: <>, + command: <>, + cup: , + lock: <>, + minus: , + pause: , + play: , + proof: <>, + pulse: , + replay: <>, + shield: <>, + spark: <>, + system: <> + }; + return ; +} + +export function BrandMark() { + return ; +} + +export function Navigation({ active, caseCount = 1 }: { active: ProductRoute; caseCount?: number }) { + return ( + + ); +} + +export function MobileNavigation({ active }: { active: ProductRoute }) { + return ( + + ); +} + +export function Topbar({ title, modeLabel, modeClass }: { + title: string; + modeLabel: string; + modeClass: "replay" | "offline"; +}) { + return ( +
+
Samaritan
+
World Cup 2026 / Observer workspace

{title}

+
+ {modeLabel} + Paper + Real-money gate closed +
+
+ ); +} diff --git a/apps/dashboard/src/Study.tsx b/apps/dashboard/src/Study.tsx new file mode 100644 index 0000000..2bda138 --- /dev/null +++ b/apps/dashboard/src/Study.tsx @@ -0,0 +1,317 @@ +import { startTransition, useEffect, useState } from "react"; +import type { StudyGuardrails, StudySnapshot } from "../../../src/dash/public-contract"; +import { loadStudy } from "./api"; +import { BrandMark, Icon, MobileNavigation, Navigation, Topbar } from "./Shell"; + +function money(microUsd: number) { + return `$${(microUsd / 1_000_000).toFixed(2)}`; +} + +function percentage(value: number, digits = 0) { + return `${(value * 100).toFixed(digits)}%`; +} + +function bps(value: number | null) { + return value === null ? "—" : `${value >= 0 ? "+" : ""}${value.toFixed(1)} bps`; +} + +function compactHash(value: string) { + return `${value.slice(0, 12)}…${value.slice(-8)}`; +} + +function ProgressMeter({ value, target, label }: { value: number; target: number; label: string }) { + const progress = Math.min(100, target === 0 ? 0 : value / target * 100); + return ( +
+
{value} / {target}{label}
+ +
+ ); +} + +function StudyHero({ snapshot }: { snapshot: StudySnapshot }) { + const longRun = snapshot.lanes.longRun; + const evaluation = snapshot.protocol.evaluation; + return ( +
+
+ Invalidated v1 audit +

The claim
was withdrawn.

+

A causal audit found future-informed market selection before v1 admitted a single observation. Samaritan preserved the empty ledgers, suspended the protocol, and blocked model spend.

+
Former endpoint · inactive{evaluation.primaryEndpoint}
+
+
+
{longRun.statusLabel}Preserved since {new Date(snapshot.protocol.startedAt).toISOString().slice(0, 10)}
+
+ +
Preserved observations{longRun.counts.filledMatches}/0qualifying matches
+
Preserved fills{longRun.counts.fills}/0qualifying fills
+
+
+ + +
+
Status invalidatedReplacement v2 candidateNo admissions
+
+
+ ); +} + +function LaneStrip({ snapshot }: { snapshot: StudySnapshot }) { + const bounty = snapshot.lanes.bounty; + const longRun = snapshot.lanes.longRun; + return ( +
+
Decision lane{longRun.label}{longRun.reason}{longRun.statusLabel}
+
Demo lane{bounty.label}{bounty.reason}{bounty.statusLabel}
+
Neither preserved v1 lane can satisfy a gateFresh v2 ledgers require Deborah's corrected registration first.
+
+ ); +} + +function CorrectedHistoricalEvidence({ snapshot }: { snapshot: StudySnapshot }) { + const candidate = snapshot.correctedHistoricalCandidate; + return ( +
+
+
Corrected v4 · aggregate only

Historical signal candidate

+ V2 unregistered +
+
+
Held-out mean after {candidate.costProxyBps} bps proxy{bps(candidate.meanNetAfterCostProxyBps)}95% fixture-clustered CI {bps(candidate.matchClustered95Bps.low)} to {bps(candidate.matchClustered95Bps.high)}
+
Training cases{candidate.trainingNormalizedCases}Normalized before held-out review
+
Held-out cases{candidate.heldoutNormalizedCases}Across {candidate.heldoutFixtures} fixtures
+
Evidence classSampled pricesNo historical bid, ask, depth, or fill proof
+
+
Research evidence, not an active study{candidate.claimBoundary}
+
Protocol {candidate.protocolId}Configuration {compactHash(candidate.configurationHash)}
+
+ ); +} + +function SyntheticReceiptProof({ snapshot }: { snapshot: StudySnapshot }) { + const proof = snapshot.syntheticProof; + const steps = ["Signal", "Triage stub", "Thesis stub", "Hard risk", "Paper fill", "Close", "Settlement"]; + return ( +
+
+
Separate evidence class · synthetic

One case. Every boundary. Verifiable.

+ Offline verified +
+
+
+ +
Closed-world proving fixture{proof.label}

{proof.explanation} Deterministic agent stubs replace external model calls.

+
+
+ LifecycleFilled + settled + External calls{proof.externalCalls} + Performance useExcluded + Solana anchorNot submitted +
+
+
+ {steps.map((step, index) => {step}{index < steps.length - 1 ? : null})} +
+ +
+ ); +} + +function SealedEndpoint({ snapshot }: { snapshot: StudySnapshot }) { + const evaluation = snapshot.protocol.evaluation; + return ( +
+
Withdrawn endpoint

No qualifying CLV decision

Suspended
+
+
Reject zone0 bpsAccept zone
+
+
No valid endpointV1 stopped at zero observations

The selector defect invalidated the protocol before a qualifying paper result could exist.

+
Bootstrap{evaluation.bootstrapIterations.toLocaleString("en-US")} iterationsResampling blockWhole matchesControl{evaluation.randomDirectionControl}
+
+
+ {snapshot.decisionRules.accept.map((rule, index) =>
{String(index + 1).padStart(2, "0")}

{rule}

)} +
+
+ ); +} + +function OpenEndpoint({ snapshot }: { snapshot: StudySnapshot }) { + if (snapshot.results.visibility !== "open") return null; + const endpoints = snapshot.results.endpoints; + return ( +
+
Gating endpoint

Executable CLV decision

{snapshot.lanes.longRun.statusLabel}
+ {endpoints ?
+
Mean net executable CLV{bps(endpoints.meanNetClvBps)}95% CI [{bps(endpoints.netClvInterval.low)}, {bps(endpoints.netClvInterval.high)}]
+
Settlement P&L{money(endpoints.meanSettlementPnlMicroUsd)}Mean resolved result
+
Random control{bps(endpoints.randomDirectionControlClvBps)}Matched observed costs
+
Positive matches{percentage(endpoints.fractionSettledMatchesNetPositive)}Settled match fraction
+
:
Endpoint evidence incompleteThe minimum sample opened the rows, but complete close or settlement evidence is still required.
} +
+ ); +} + +function guardrailState(guardrails: StudyGuardrails | null, key: keyof StudyGuardrails): "pass" | "fail" | "waiting" { + if (!guardrails) return "waiting"; + const value = guardrails[key]; + return value === true ? "pass" : "fail"; +} + +function GuardrailsPanel({ snapshot }: { snapshot: StudySnapshot }) { + const thresholds = snapshot.protocol.guardrailThresholds; + const observed = snapshot.results.visibility === "open" ? snapshot.results.guardrails : null; + const items = [ + { label: "Fill rate", threshold: `≥ ${percentage(thresholds.minimumFillRate)}`, value: observed ? percentage(observed.fillRate, 1) : "Awaiting fills", state: guardrailState(observed, "fillRatePassed") }, + { label: "Mean slippage", threshold: `≤ ${thresholds.maximumMeanSlippageBps} bps`, value: observed ? bps(observed.meanSlippageBps) : "Awaiting fills", state: guardrailState(observed, "slippagePassed") }, + { label: "Max drawdown", threshold: `≤ ${money(thresholds.maximumDrawdownMicroUsd)}`, value: observed ? money(observed.maxDrawdownMicroUsd) : "$0.00 observed", state: guardrailState(observed, "drawdownPassed") }, + { label: "Selected depth", threshold: "Every trade", value: observed ? (observed.selectedDepthComplete ? "Complete" : "Incomplete") : "Awaiting fills", state: guardrailState(observed, "selectedDepthComplete") } + ]; + return ( + + ); +} + +function CandidatePanel({ snapshot }: { snapshot: StudySnapshot }) { + const candidate = snapshot.protocol.candidate; + return ( +
+
Preserved for audit

Invalidated v1 configuration

Withdrawn
+
Enabled detector{candidate.detector}{candidate.marketFamily}
+
+ Move Z{candidate.moveAbsZ.toFixed(1)} + CUSUM{candidate.cusumThresholdBps.toFixed(0)} bps + Minimum gap{candidate.minimumGapBps.toFixed(0)} bps + Updates{candidate.minimumUpdates} +
+
Dynamic total selector{candidate.selector}≥ {candidate.minimumCoveragePoints.toLocaleString("en-US")} points · within {percentage(candidate.maximumDistanceFromEven)} of even
+
Configuration identity{compactHash(snapshot.protocol.configHash)}
+
+ ); +} + +function RiskPanel({ snapshot }: { snapshot: StudySnapshot }) { + const risk = snapshot.protocol.risk; + return ( +
+
Paper limits

Risk envelope

Simulated USD
+
+
Bankroll{money(risk.bankrollMicroUsd)}Paper notional
+ +
+
Per trade{money(risk.perTradeStakeMicroUsd)}Max exposure{money(risk.aggregateExposureMicroUsd)}Drawdown stop{money(risk.drawdownStopMicroUsd)}
+
Deterministic limitsNo agent can increase stake, exposure, or drawdown.
+
+ ); +} + +function EvidenceQueue({ snapshot }: { snapshot: StudySnapshot }) { + const universe = snapshot.fixtureUniverse; + return ( +
+
Admission boundary

Evidence queue

{universe.longRunEligible} eligible
+
+
{universe.evidenceFixtures}Evidence fixtures
{universe.pairedBookReplays}Paired replay
{universe.longRunEligible}Long-run admitted
+
+
{universe.signalResearchOnly} sampled-history fixtures remain research-onlyThey lack synchronized executable depth and cannot count toward the long-run gate.
+ Inspect upcoming captures +
+ ); +} + +function DecisionRules({ snapshot }: { snapshot: StudySnapshot }) { + const groups = [ + { label: "Accept", className: "accept", icon: "check" as const, rules: snapshot.decisionRules.accept }, + { label: "Reject", className: "reject", icon: "minus" as const, rules: snapshot.decisionRules.reject }, + { label: "Inconclusive", className: "inconclusive", icon: "clock" as const, rules: snapshot.decisionRules.inconclusive } + ]; + return ( +
+
Historical rules · inactive

Why no verdict can be issued

V2 not registered
+
{groups.map((group) =>
{group.label}
{group.rules.map((rule) =>

{rule}

)}
)}
+
No v1 outcome can unlock anything. The real-money gate is closed, and v2 requires Deborah's registration before observations.
+
+ ); +} + +function StudyProof({ snapshot }: { snapshot: StudySnapshot }) { + return ( +
+ Both preserved v1 chains verifyLocal append-only audit history · not an external anchor + Long-run head{compactHash(snapshot.lanes.longRun.chain.headHash)} + Bounty head{compactHash(snapshot.lanes.bounty.chain.headHash)} + Protocol{snapshot.protocol.version} + Derived evidence only +
+ ); +} + +function StudyRows({ snapshot }: { snapshot: StudySnapshot }) { + if (snapshot.results.visibility !== "open") return null; + return ( +
+
Opened after stopping rule

Per-match evidence

{snapshot.results.rows.length} rows
+
FixtureTotalSignalsFillsFill rateSlippageNet CLVP&L
{snapshot.results.rows.map((row) =>
{row.fixtureId}{row.kickoffUtc.slice(0, 10)}{row.selectedLine.toFixed(1)}{row.signals}{row.fills}{percentage(row.fillRate, 1)}{bps(row.meanSlippageBps)}{bps(row.netClvBps)}{row.settlementPnlMicroUsd === null ? "—" : money(row.settlementPnlMicroUsd)}
)}
+
+ ); +} + +function StudyView({ snapshot }: { snapshot: StudySnapshot }) { + return ( +
+ +
+ +
+ + + + +
{snapshot.results.visibility === "sealed" ? : }
+
+ + + +
+ +
+
+ ); +} + +function StudyLoading() { + return
Samaritan / Study

Reconstructing the protocol audit

Verifying the preserved zero-observation v1 ledgers and their invalidation boundary.

; +} + +function StudyError({ retry }: { retry: () => void }) { + return
Fail-closed boundary

Study evidence unavailable

The protocol identity, ledger chain, or sealing state could not be reconciled. Samaritan will not expose a partial profitability result.

; +} + +export function StudyApp() { + const [snapshot, setSnapshot] = useState(null); + const [failure, setFailure] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setFailure(false); + loadStudy(controller.signal) + .then((nextSnapshot) => startTransition(() => setSnapshot(nextSnapshot))) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + setFailure(true); + }); + return () => controller.abort(); + }, [attempt]); + + if (failure) return { setSnapshot(null); setAttempt((value) => value + 1); }} />; + if (!snapshot) return ; + return ; +} diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts new file mode 100644 index 0000000..ea8cbcd --- /dev/null +++ b/apps/dashboard/src/api.ts @@ -0,0 +1,55 @@ +import type { + CasebookApiResponse, + CasebookSnapshot, + CommandApiResponse, + CommandSnapshot, + DashboardApiResponse, + MatchroomSnapshot, + StudyApiResponse, + StudySnapshot +} from "../../../src/dash/public-contract"; + +export const MATCHROOM_API_PATH = "/api/v1/matchroom/paired-spain-belgium-2026-07-10"; +export const COMMAND_API_PATH = "/api/v1/command"; +export const CASEBOOK_API_PATH = "/api/v1/casebook"; +export const STUDY_API_PATH = "/api/v1/study"; + +export async function loadStudy(signal: AbortSignal): Promise { + const response = await fetch(STUDY_API_PATH, { + headers: { accept: "application/json" }, + signal + }); + if (!response.ok) throw new Error(`Study request failed with status ${response.status}`); + const payload = (await response.json()) as StudyApiResponse; + return payload.data; +} + +export async function loadCasebook(signal: AbortSignal): Promise { + const response = await fetch(CASEBOOK_API_PATH, { + headers: { accept: "application/json" }, + signal + }); + if (!response.ok) throw new Error(`Casebook request failed with status ${response.status}`); + const payload = (await response.json()) as CasebookApiResponse; + return payload.data; +} + +export async function loadCommand(signal: AbortSignal): Promise { + const response = await fetch(COMMAND_API_PATH, { + headers: { accept: "application/json" }, + signal + }); + if (!response.ok) throw new Error(`Command request failed with status ${response.status}`); + const payload = (await response.json()) as CommandApiResponse; + return payload.data; +} + +export async function loadMatchroom(signal: AbortSignal): Promise { + const response = await fetch(MATCHROOM_API_PATH, { + headers: { accept: "application/json" }, + signal + }); + if (!response.ok) throw new Error(`Matchroom request failed with status ${response.status}`); + const payload = (await response.json()) as DashboardApiResponse; + return payload.data; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx new file mode 100644 index 0000000..33837d7 --- /dev/null +++ b/apps/dashboard/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css new file mode 100644 index 0000000..ce31979 --- /dev/null +++ b/apps/dashboard/src/styles.css @@ -0,0 +1,7585 @@ +@import "@fontsource/manrope/latin-400.css"; +@import "@fontsource/manrope/latin-500.css"; +@import "@fontsource/manrope/latin-600.css"; +@import "@fontsource/manrope/latin-700.css"; +@import "@fontsource/manrope/latin-800.css"; +@import "@fontsource/ibm-plex-mono/latin-400.css"; +@import "@fontsource/ibm-plex-mono/latin-500.css"; + +:root { + --bg: #080b19; + --sidebar: #060817; + --surface: #10142a; + --surface-raised: #141a34; + --surface-soft: #0c1023; + --border: rgba(157, 169, 218, 0.15); + --border-strong: rgba(157, 169, 218, 0.25); + --text: #f7f8fc; + --text-soft: #cbd0e2; + --muted: #929bb9; + --muted-strong: #aeb6d0; + --lime: #9ff850; + --lime-deep: #75d52c; + --blue: #6489f6; + --violet: #9183e5; + --amber: #e9b968; + --amber-soft: rgba(233, 185, 104, 0.1); + --danger: #f16e7a; + --danger-soft: rgba(241, 110, 122, 0.1); + --shadow: 0 24px 70px rgba(0, 0, 0, 0.2); + --sans: "Manrope", sans-serif; + --mono: "IBM Plex Mono", monospace; +} + +html.light { + --bg: #e8ebf3; + --sidebar: #f8f9fb; + --surface: #f9fafc; + --surface-raised: #ffffff; + --surface-soft: #f0f2f7; + --border: rgba(55, 61, 83, 0.12); + --border-strong: rgba(55, 61, 83, 0.2); + --text: #25262d; + --text-soft: #4a4d59; + --muted: #737887; + --muted-strong: #5e6371; + --lime: #54bf26; + --lime-deep: #3a9d18; + --blue: #607ee7; + --violet: #8574d7; + --amber: #a86f16; + --amber-soft: rgba(198, 135, 34, 0.1); + --danger: #c94f5b; + --danger-soft: rgba(201, 79, 91, 0.09); + --shadow: 0 24px 60px rgba(46, 49, 65, 0.12); +} + +* { + box-sizing: border-box; +} + +html { + color-scheme: dark; + scroll-behavior: smooth; +} + +html.light { + color-scheme: light; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + overflow-x: hidden; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + text-rendering: optimizeLegibility; +} + +button, +input { + color: inherit; + font: inherit; +} + +.load-screen { + min-height: 100vh; + display: grid; + align-content: center; + justify-items: center; + padding: 32px; + background: + radial-gradient(circle at 50% 42%, rgba(100, 137, 246, 0.14), transparent 22rem), + linear-gradient(145deg, #070914, var(--bg)); + text-align: center; +} + +.load-screen .brand-mark { + width: 42px; + height: 42px; + margin-bottom: 24px; +} + +.load-kicker { + color: var(--lime); + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.load-screen h1 { + margin: 10px 0 9px; + font-size: clamp(25px, 4vw, 42px); + letter-spacing: -0.05em; +} + +.load-screen p { + max-width: 480px; + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.7; +} + +.load-line { + width: min(280px, 70vw); + height: 2px; + margin: 23px 0; + overflow: hidden; + border-radius: 2px; + background: var(--border); +} + +.load-line i { + display: block; + width: 40%; + height: 100%; + border-radius: inherit; + background: var(--lime); + animation: verified-load 1.2s ease-in-out infinite alternate; +} + +@keyframes verified-load { + from { transform: translateX(-20%); } + to { transform: translateX(170%); } +} + +.error-screen .error-mark { + width: 48px; + height: 48px; + display: grid; + margin-bottom: 22px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--blue), transparent 65%); + border-radius: 14px; + background: color-mix(in srgb, var(--blue), transparent 90%); + color: var(--blue); +} + +.error-mark svg { + width: 24px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.error-screen button { + margin-top: 24px; + padding: 11px 16px; + border: 0; + border-radius: 10px; + background: var(--lime); + color: #081018; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--lime); + outline-offset: 3px; +} + +.theme-frame { + display: none; +} + +html.light .theme-frame { + height: 38px; + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid rgba(55, 61, 83, 0.1); + background: #2e2e35; + color: #f1f2f5; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 204px minmax(0, 1fr); + background: + radial-gradient(circle at 78% -10%, rgba(86, 105, 204, 0.12), transparent 35rem), + var(--bg); +} + +html.light .app-shell { + min-height: calc(100vh - 38px); + background: var(--bg); +} + +.sidebar { + position: sticky; + top: 0; + z-index: 12; + height: 100vh; + display: flex; + flex-direction: column; + padding: 24px 15px 18px; + border-right: 1px solid var(--border); + background: var(--sidebar); +} + +html.light .sidebar { + top: 38px; + height: calc(100vh - 38px); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 9px; + color: var(--text); + font-size: 16px; + font-weight: 800; + letter-spacing: -0.04em; + text-decoration: none; +} + +.brand-mark { + position: relative; + width: 29px; + height: 29px; + flex: 0 0 auto; + overflow: hidden; + border-radius: 9px; + background: var(--lime); + box-shadow: 0 8px 18px rgba(128, 224, 47, 0.16); +} + +.brand-mark i { + position: absolute; + width: 23px; + height: 5px; + border-radius: 10px; + background: var(--sidebar); + transform: rotate(-38deg); +} + +.brand-mark i:first-child { + top: 8px; + left: -1px; +} + +.brand-mark i:last-child { + right: -1px; + bottom: 8px; +} + +.side-nav { + flex: 1; + margin-top: 38px; +} + +.nav-group { + display: block; + margin: 0 11px 12px; + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.system-group { + margin-top: 32px; +} + +.side-nav a { + min-height: 46px; + display: flex; + align-items: center; + gap: 12px; + margin: 4px 0; + padding: 0 12px; + border-radius: 11px; + color: var(--muted-strong); + font-size: 12px; + font-weight: 600; + text-decoration: none; + transition: background 180ms ease, color 180ms ease, transform 180ms ease; +} + +.side-nav a:hover { + background: color-mix(in srgb, var(--surface-raised), transparent 30%); + color: var(--text); + transform: translateX(2px); +} + +.side-nav a.active { + background: var(--lime); + color: #081018; + box-shadow: 0 10px 24px rgba(116, 208, 40, 0.14); +} + +.side-nav svg, +.mobile-nav svg, +.state-chip svg, +.shield-lock svg, +.context-bar svg, +.plain-conclusion svg, +.replay-bar svg, +.outcome-icon svg, +.stage-icon svg { + width: 18px; + height: 18px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.side-nav em { + min-width: 24px; + margin-left: auto; + padding: 3px 6px; + border-radius: 7px; + background: color-mix(in srgb, var(--surface-raised), transparent 35%); + color: var(--muted); + font-size: 9px; + font-style: normal; + text-align: center; +} + +.side-nav a.active em { + background: rgba(8, 16, 24, 0.12); + color: #081018; +} + +.feed-dot { + width: 7px; + height: 7px; + margin-left: auto; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--lime), transparent 88%); +} + +.sidebar-bottom { + display: grid; + gap: 12px; +} + +.gate-card, +.owner { + display: flex; + align-items: center; + gap: 10px; + padding: 11px; + border-radius: 12px; +} + +.gate-card { + border: 1px solid color-mix(in srgb, var(--blue), transparent 80%); + background: color-mix(in srgb, var(--blue), transparent 94%); +} + +.shield-lock { + width: 31px; + height: 31px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; + background: color-mix(in srgb, var(--blue), transparent 88%); + color: var(--blue); +} + +.shield-lock svg { + width: 17px; + height: 17px; +} + +.gate-card > span:last-child, +.owner > span:last-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.gate-card b, +.owner b { + font-size: 11px; + font-weight: 700; +} + +.gate-card small, +.owner small { + overflow: hidden; + color: var(--muted); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.owner { + padding: 7px 10px; +} + +.owner-avatar { + width: 33px; + height: 33px; + display: grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--border-strong); + border-radius: 50%; + background: var(--surface-raised); + color: var(--lime-deep); + font-size: 12px; + font-weight: 800; +} + +.workspace { + min-width: 0; + padding-bottom: 36px; +} + +.topbar { + min-height: 84px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 15px 28px; + border-bottom: 1px solid var(--border); +} + +.mobile-brand { + display: none; +} + +.page-title > span { + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +.page-title h1 { + margin: 3px 0 0; + font-size: 25px; + font-weight: 700; + letter-spacing: -0.045em; +} + +.system-state { + display: flex; + align-items: center; + gap: 9px; +} + +.state-chip, +.paper-label, +.verified-label, +.discipline-chip { + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} + +.state-chip { + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-raised), transparent 15%); + color: var(--text-soft); +} + +.state-chip.replay { + border-color: color-mix(in srgb, var(--lime), transparent 78%); + color: color-mix(in srgb, var(--lime), var(--text) 30%); +} + +.state-chip.replay i, +.verified-label i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--lime), transparent 88%); +} + +.state-chip.paper { + color: var(--blue); +} + +.state-chip.gate { + border-color: color-mix(in srgb, var(--blue), transparent 80%); + background: color-mix(in srgb, var(--blue), transparent 94%); + color: color-mix(in srgb, var(--blue), var(--text) 28%); +} + +.state-chip.gate svg { + width: 15px; + height: 15px; +} + +.context-bar { + min-height: 72px; + display: grid; + grid-template-columns: minmax(250px, 1fr) auto minmax(200px, 1fr); + align-items: center; + gap: 18px; + padding: 10px 28px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-soft), transparent 34%); +} + +.fixture-select { + display: flex; + align-items: center; + gap: 11px; + justify-self: start; + padding: 0; + border: 0; + background: transparent; + cursor: default; + text-align: left; +} + +.cup-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 10px; + background: color-mix(in srgb, var(--blue), transparent 86%); + color: var(--lime-deep); +} + +.cup-mark svg { + width: 17px; + height: 17px; +} + +.fixture-select > span:nth-child(2), +.original-date > span { + display: grid; + gap: 2px; +} + +.fixture-select small, +.original-date small { + color: var(--muted); + font-size: 10px; +} + +.fixture-select b, +.original-date b { + font-size: 12px; + font-weight: 700; +} + +.fixture-select .chevron { + width: 14px; + color: var(--muted); +} + +.view-tabs { + height: 42px; + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-soft); +} + +.view-tabs a { + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 15px; + border-radius: 8px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-decoration: none; +} + +.view-tabs a.active { + background: var(--surface-raised); + color: var(--text); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.original-date { + display: flex; + align-items: center; + gap: 10px; + justify-self: end; +} + +.original-date > svg { + width: 18px; + color: var(--muted); +} + +.content { + width: min(1560px, 100%); + margin: 0 auto; + padding: 24px 28px 0; +} + +.surface { + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.match-masthead { + min-height: 156px; + display: grid; + grid-template-columns: minmax(145px, 0.75fr) minmax(165px, 1fr) auto minmax(165px, 1fr) minmax(205px, 0.95fr); + align-items: center; + gap: 22px; + padding: 22px 24px; + background: + radial-gradient(circle at 50% 135%, color-mix(in srgb, var(--blue), transparent 87%), transparent 45%), + var(--surface); +} + +.match-meta { + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.live-label { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted-strong); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.live-label i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--lime), transparent 88%); +} + +.fixture-number { + color: var(--muted); + font-family: var(--mono); + font-size: 10px; +} + +.team { + display: flex; + align-items: center; + gap: 13px; +} + +.team > span:last-child, +.away-team > span:first-child { + display: grid; + gap: 3px; +} + +.team small { + color: var(--text); + font-size: 17px; + font-weight: 700; + letter-spacing: -0.035em; +} + +.team b { + color: var(--muted); + font-size: 11px; +} + +.away-team { + justify-content: flex-end; + text-align: right; +} + +.crest { + width: 54px; + height: 54px; + display: grid; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border: 3px solid color-mix(in srgb, var(--text), transparent 20%); + border-radius: 50%; + color: #ffffff; + font-size: 10px; + font-weight: 800; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18); +} + +.crest.spain { + background: linear-gradient(#b91f2f 0 27%, #e9c43a 27% 73%, #b91f2f 73%); +} + +.crest.belgium { + background: linear-gradient(90deg, #171717 0 33%, #e9c83b 33% 66%, #c92534 66%); +} + +.score-block { + min-width: 130px; + display: grid; + justify-items: center; +} + +.clock { + padding: 4px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--lime), transparent 88%); + color: var(--lime-deep); + font-family: var(--mono); + font-size: 11px; + font-weight: 500; +} + +.score-block > div { + display: flex; + align-items: center; + gap: 12px; + margin-top: 1px; +} + +.score-block strong { + font-size: 38px; + font-weight: 700; + letter-spacing: -0.06em; +} + +.score-block i { + color: var(--muted); + font-size: 20px; + font-style: normal; +} + +.score-block small { + color: var(--muted); + font-size: 10px; +} + +.market-meta { + min-height: 86px; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 22px; + border-left: 1px solid var(--border); +} + +.market-meta small { + color: var(--muted); + font-size: 10px; +} + +.market-meta b { + margin-top: 5px; + font-size: 14px; + font-weight: 700; +} + +.market-meta span { + margin-top: 4px; + color: var(--muted); + font-size: 10px; + line-height: 1.45; +} + +.analysis-grid, +.support-grid { + display: grid; + grid-template-columns: minmax(0, 2.05fr) minmax(320px, 0.95fr); + gap: 22px; + margin-top: 22px; +} + +.probability-panel, +.decision-rail, +.evidence-panel, +.proof-panel { + min-width: 0; + padding: 24px; +} + +.probability-panel, +.decision-rail { + min-height: 680px; +} + +.panel-heading { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.panel-heading > div > span, +.panel-heading > span:first-child { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.075em; + text-transform: uppercase; +} + +.panel-heading h2 { + margin: 5px 0 0; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.03em; +} + +.legend { + display: flex; + align-items: center; + gap: 15px; + color: var(--muted-strong); + font-size: 10px; +} + +.legend span { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.legend i { + width: 16px; + display: inline-block; +} + +.legend-bid { + height: 3px; + border-radius: 2px; + background: var(--lime); +} + +.legend-ask { + height: 3px; + border-radius: 2px; + background: var(--violet); +} + +.legend-spread { + height: 8px; + border: 1px solid color-mix(in srgb, var(--violet), transparent 55%); + background: color-mix(in srgb, var(--violet), transparent 83%); +} + +.comparison-strip { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 22px; + padding: 18px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.comparison-strip > div { + min-width: 0; + display: grid; + gap: 4px; + padding: 0 20px; +} + +.comparison-strip > div:first-child { + padding-left: 0; +} + +.comparison-strip > div:not(:last-child) { + border-right: 1px solid var(--border); +} + +.comparison-strip > div > span { + color: var(--muted-strong); + font-size: 11px; + font-weight: 600; +} + +.comparison-strip strong { + overflow: hidden; + font-size: clamp(23px, 2vw, 32px); + font-weight: 700; + letter-spacing: -0.055em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comparison-strip strong i { + padding: 0 5px; + color: var(--muted); + font-style: normal; +} + +.comparison-strip small { + overflow: hidden; + color: var(--muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plain-conclusion { + min-height: 74px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + margin-top: 18px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--amber), transparent 76%); + border-radius: 14px; + background: var(--amber-soft); +} + +.conclusion-mark { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 10px; + background: color-mix(in srgb, var(--amber), transparent 84%); + color: var(--amber); +} + +.conclusion-mark svg { + width: 19px; + height: 19px; +} + +.plain-conclusion p { + margin: 0; + color: var(--muted-strong); + font-size: 12px; + line-height: 1.55; +} + +.plain-conclusion p strong { + color: var(--text); + font-weight: 700; +} + +.discipline-chip { + padding: 7px 10px; + background: color-mix(in srgb, var(--amber), transparent 84%); + color: var(--amber); +} + +.chart-wrap { + margin: 18px 0 0; +} + +.chart-wrap > svg { + width: 100%; + height: 300px; + display: block; + overflow: visible; +} + +.chart-grid line { + stroke: color-mix(in srgb, var(--muted), transparent 83%); + stroke-dasharray: 2 7; +} + +.chart-axis { + fill: var(--muted-strong); + font-family: var(--mono); + font-size: 10px; +} + +.spread-area { + fill: url(#spread-fill); +} + +.bid-path, +.ask-path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.bid-path { + stroke: color-mix(in srgb, var(--violet), transparent 60%); + stroke-width: 1.5; + stroke-dasharray: 4 4; +} + +.ask-path { + stroke: var(--violet); + stroke-width: 2.8; +} + +.goal-event line { + stroke: color-mix(in srgb, var(--text), transparent 77%); + stroke-dasharray: 3 6; +} + +.goal-event rect { + fill: var(--surface-raised); + stroke: color-mix(in srgb, var(--lime), transparent 62%); +} + +.goal-event text { + fill: var(--lime-deep); + font-family: var(--mono); + font-size: 9px; + font-weight: 500; +} + +.cursor { + transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.cursor line { + stroke: color-mix(in srgb, var(--text), transparent 74%); + stroke-dasharray: 2 5; +} + +.bid-dot, +.ask-dot { + stroke: var(--surface); + stroke-width: 3px; + transition: cy 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.bid-dot { + fill: var(--lime); +} + +.ask-dot { + fill: var(--violet); +} + +.chart-wrap figcaption { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 0; + color: var(--muted); + font-family: var(--mono); + font-size: 10px; +} + +.replay-bar { + min-height: 58px; + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.play-button { + height: 38px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 14px; + border: 0; + border-radius: 10px; + background: var(--lime); + color: #081018; + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.play-button svg { + width: 15px; + height: 15px; +} + +.play-button .play-icon { + fill: currentColor; + stroke: none; +} + +.pause-icon, +.play-button[aria-pressed="true"] .play-icon { + display: none; +} + +.play-button[aria-pressed="true"] .pause-icon { + display: block; +} + +.replay-steps { + height: 38px; + display: flex; + align-items: center; + gap: 3px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-soft); +} + +.replay-steps button { + height: 30px; + padding: 0 12px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted-strong); + cursor: pointer; + font-size: 10px; + font-weight: 700; +} + +.replay-steps button.active { + background: var(--surface-raised); + color: var(--text); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1); +} + +.speed { + margin-left: auto; + color: var(--muted); + font-family: var(--mono); + font-size: 10px; +} + +.decision-rail { + display: flex; + flex-direction: column; +} + +.paper-label { + padding: 6px 9px; + background: color-mix(in srgb, var(--blue), transparent 88%); + color: var(--blue); +} + +.decision-outcome { + display: grid; + justify-items: center; + padding: 28px 16px 23px; + border-bottom: 1px solid var(--border); + text-align: center; +} + +.outcome-icon { + width: 60px; + height: 60px; + display: grid; + place-items: center; + margin-bottom: 12px; + border: 1px solid color-mix(in srgb, var(--amber), transparent 72%); + border-radius: 18px; + background: var(--amber-soft); + color: var(--amber); +} + +.outcome-icon svg { + width: 29px; + height: 29px; +} + +.decision-outcome > span:not(.outcome-icon) { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.decision-outcome > strong { + margin-top: 4px; + color: var(--amber); + font-size: 31px; + font-weight: 700; + letter-spacing: -0.05em; +} + +.decision-outcome p { + max-width: 320px; + min-height: 38px; + margin: 8px 0 0; + color: var(--muted-strong); + font-size: 11px; + line-height: 1.55; +} + +.reason-block { + margin-top: 18px; + padding: 15px 16px; + border-left: 3px solid var(--amber); + border-radius: 0 12px 12px 0; + background: var(--amber-soft); +} + +.reason-block > span { + color: var(--muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} + +.reason-block b { + display: block; + margin-top: 5px; + font-size: 12px; + font-weight: 700; +} + +.reason-block p { + margin: 5px 0 0; + color: var(--muted-strong); + font-size: 10px; +} + +.reason-block p strong { + color: var(--text); +} + +.decision-stages { + flex: 1; + margin: 14px 0 0; + padding: 0; + list-style: none; +} + +.decision-stages li { + position: relative; + min-height: 55px; + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} + +.decision-stages li:not(:last-child)::after { + position: absolute; + top: 37px; + bottom: -11px; + left: 13px; + width: 1px; + background: var(--border-strong); + content: ""; +} + +.stage-icon { + position: relative; + z-index: 1; + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 50%; +} + +.stage-icon svg { + width: 13px; + height: 13px; + stroke-width: 2; +} + +.complete .stage-icon { + background: color-mix(in srgb, var(--lime), transparent 88%); + color: var(--lime-deep); +} + +.passed .stage-icon { + background: var(--amber-soft); + color: var(--amber); +} + +.locked .stage-icon { + background: color-mix(in srgb, var(--blue), transparent 90%); + color: var(--blue); +} + +.decision-stages li > span:nth-child(2) { + display: grid; + gap: 2px; +} + +.decision-stages b { + font-size: 11px; + font-weight: 700; +} + +.decision-stages small, +.decision-stages time { + color: var(--muted); + font-size: 9px; +} + +.decision-stages time { + font-family: var(--mono); +} + +.boundary-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 14px; + padding: 13px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.boundary-grid div { + display: grid; + gap: 4px; + padding: 0 10px; +} + +.boundary-grid div:not(:last-child) { + border-right: 1px solid var(--border); +} + +.boundary-grid span { + color: var(--muted); + font-size: 9px; +} + +.boundary-grid b { + font-size: 12px; + font-weight: 700; +} + +.protective-gate { + display: flex; + align-items: center; + gap: 10px; + margin-top: 14px; + padding: 11px; + border: 1px solid color-mix(in srgb, var(--blue), transparent 80%); + border-radius: 12px; + background: color-mix(in srgb, var(--blue), transparent 94%); +} + +.protective-gate > span:last-child { + display: grid; + gap: 2px; +} + +.protective-gate b { + color: color-mix(in srgb, var(--blue), var(--text) 28%); + font-size: 11px; +} + +.protective-gate small { + color: var(--muted); + font-size: 9px; +} + +.support-grid { + align-items: stretch; +} + +.evidence-panel, +.proof-panel { + min-height: 312px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.13); +} + +.case-id { + color: var(--muted); + font-family: var(--mono); + font-size: 10px; +} + +.evidence-table-wrap { + overflow-x: auto; + margin-top: 20px; + scrollbar-color: var(--border-strong) transparent; + scrollbar-width: thin; +} + +.evidence-table { + width: 100%; + min-width: 690px; + border-collapse: collapse; + color: var(--text-soft); + font-size: 11px; +} + +.evidence-table th { + padding: 0 12px 12px; + color: var(--muted); + font-size: 10px; + font-weight: 600; + text-align: left; +} + +.evidence-table td { + height: 64px; + padding: 10px 12px; + border-top: 1px solid var(--border); + transition: background 180ms ease; +} + +.evidence-table tr.active td { + background: color-mix(in srgb, var(--blue), transparent 95%); +} + +.evidence-table tr.active td:first-child { + box-shadow: inset 2px 0 var(--blue); +} + +.evidence-table time, +.evidence-table td:nth-child(4) b { + display: block; + color: var(--text); + font-family: var(--mono); + font-size: 10px; + font-weight: 500; +} + +.evidence-table td small { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 9px; +} + +.source { + width: 28px; + height: 28px; + display: inline-grid; + place-items: center; + margin-right: 8px; + border-radius: 8px; + font-size: 8px; + font-weight: 800; + vertical-align: middle; +} + +.source.poly { + background: color-mix(in srgb, var(--blue), transparent 87%); + color: var(--blue); +} + +.source.tx { + background: color-mix(in srgb, var(--lime), transparent 87%); + color: var(--lime-deep); +} + +.source.sam { + background: color-mix(in srgb, var(--violet), transparent 87%); + color: var(--violet); +} + +.assessment { + display: inline-flex; + padding: 6px 9px; + border-radius: 8px; + font-size: 9px; + font-weight: 700; + white-space: nowrap; +} + +.assessment.watch, +.assessment.pass { + background: var(--amber-soft); + color: var(--amber); +} + +.verified-label { + padding: 6px 9px; + background: color-mix(in srgb, var(--lime), transparent 89%); + color: var(--lime-deep); +} + +.proof-primary { + display: flex; + align-items: center; + gap: 15px; + margin-top: 20px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} + +.proof-ring { + width: 61px; + height: 61px; + display: grid; + place-items: center; + border: 4px solid var(--lime); + border-radius: 50%; + font-size: 17px; + font-weight: 700; + letter-spacing: -0.04em; +} + +.proof-ring small { + color: var(--lime-deep); + font-size: 9px; +} + +.proof-primary > span:last-child { + display: grid; + gap: 3px; +} + +.proof-primary b { + font-size: 12px; +} + +.proof-primary small { + color: var(--muted); + font-size: 10px; +} + +.proof-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin: 16px 0; +} + +.proof-stats div { + min-width: 0; +} + +.proof-stats dt { + color: var(--muted); + font-size: 9px; +} + +.proof-stats dd { + overflow: hidden; + margin: 4px 0 0; + color: var(--text); + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.proof-stats .outage dd { + color: var(--amber); +} + +.hash { + display: grid; + gap: 4px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-soft); +} + +.hash span { + color: var(--muted); + font-size: 9px; +} + +.hash code { + overflow: hidden; + color: var(--text-soft); + font-family: var(--mono); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.public-policy { + display: grid; + gap: 3px; + margin-top: 10px; + padding: 11px 12px; + border: 1px solid color-mix(in srgb, var(--blue), transparent 83%); + border-radius: 10px; + background: color-mix(in srgb, var(--blue), transparent 95%); +} + +.public-policy span, +.public-policy small { + color: var(--muted); + font-size: 9px; +} + +.public-policy b { + color: var(--text-soft); + font-size: 10px; +} + +.public-policy small { + line-height: 1.45; +} + +.mobile-nav { + display: none; +} + +.reveal { + opacity: 0; + transform: translateY(9px); + animation: reveal 480ms cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.r1 { animation-delay: 40ms; } +.r2 { animation-delay: 80ms; } +.r3 { animation-delay: 120ms; } +.r4 { animation-delay: 160ms; } +.r5 { animation-delay: 200ms; } + +@keyframes reveal { + to { opacity: 1; transform: translateY(0); } +} + +/* Command uses the same visual language as Matchroom, with one dominant case. */ +.state-chip.offline { + border-color: color-mix(in srgb, var(--violet), transparent 76%); + background: color-mix(in srgb, var(--violet), transparent 94%); + color: color-mix(in srgb, var(--violet), var(--text) 28%); +} + +.state-chip.offline i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--violet); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--violet), transparent 88%); +} + +.command-shell .workspace { + background: + radial-gradient(circle at 56% 8%, rgba(100, 137, 246, 0.07), transparent 31rem), + transparent; +} + +.command-system-deck { + min-height: 104px; + display: grid; + grid-template-columns: minmax(250px, 0.9fr) minmax(520px, 2fr) 120px; + align-items: stretch; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-soft), transparent 31%); +} + +.system-posture { + min-width: 0; + display: flex; + align-items: center; + gap: 13px; + padding: 18px 24px 18px 28px; + border-right: 1px solid var(--border); +} + +.posture-icon { + width: 38px; + height: 38px; + display: grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid color-mix(in srgb, var(--lime), transparent 75%); + border-radius: 11px; + background: color-mix(in srgb, var(--lime), transparent 91%); + color: var(--lime); +} + +.posture-icon svg, +.command-primary-action svg, +.feature-verdict svg, +.schedule-rule svg, +.fixture-boundary svg, +.command-panel-head svg, +.case-empty svg, +.case-outcome svg, +.sealed-label svg, +.study-lock svg, +.proof-policy svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.system-posture > span:last-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.system-posture small, +.deck-time small, +.deck-time span { + color: var(--muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.system-posture b { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.system-posture em { + overflow: hidden; + color: var(--muted); + font-size: 10px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.feed-deck { + min-width: 0; + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)); + align-items: stretch; +} + +.feed-state { + min-width: 0; + display: grid; + align-content: center; + gap: 4px; + padding: 15px 16px; + border-right: 1px solid var(--border); +} + +.feed-state > span { + display: flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-family: var(--mono); + font-size: 8px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.feed-state > span i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--blue); +} + +.feed-state.verified > span i { + background: var(--lime); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--lime), transparent 89%); +} + +.feed-state.initialized > span i { + background: var(--violet); +} + +.feed-state b { + font-size: 11px; +} + +.feed-state small { + overflow: hidden; + color: var(--muted); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deck-time { + display: grid; + align-content: center; + justify-items: end; + gap: 3px; + padding: 15px 25px 15px 12px; + text-align: right; +} + +.deck-time time { + font-family: var(--mono); + font-size: 11px; + font-weight: 500; +} + +.deck-time span { + font-size: 8px; + letter-spacing: 0; + text-transform: none; +} + +.command-content { + width: min(1560px, 100%); + margin: 0 auto; + padding: 24px 28px 0; +} + +.command-lead-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 350px; + gap: 18px; +} + +.command-feature { + position: relative; + min-width: 0; + min-height: 548px; + display: grid; + grid-template-columns: minmax(310px, 0.85fr) minmax(370px, 1.15fr); + overflow: hidden; + background: + linear-gradient(115deg, rgba(20, 26, 52, 0.96) 0 44%, rgba(10, 14, 31, 0.96) 44% 100%), + var(--surface); +} + +.command-feature::before { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 2px; + content: ""; + background: linear-gradient(90deg, var(--lime) 0 24%, var(--violet) 24% 72%, transparent 96%); +} + +.command-feature::after { + position: absolute; + right: -170px; + bottom: -190px; + width: 440px; + height: 440px; + border: 1px solid rgba(145, 131, 229, 0.08); + border-radius: 50%; + box-shadow: 0 0 0 58px rgba(145, 131, 229, 0.025), 0 0 0 116px rgba(145, 131, 229, 0.018); + content: ""; + pointer-events: none; +} + +.feature-copy, +.feature-visual { + position: relative; + z-index: 1; + min-width: 0; +} + +.feature-copy { + display: flex; + flex-direction: column; + padding: 30px 30px 28px; +} + +.feature-eyebrow, +.fixture-status, +.feature-eyebrow > span { + display: flex; + align-items: center; +} + +.feature-eyebrow { + justify-content: space-between; + gap: 12px; +} + +.feature-eyebrow > span { + gap: 7px; + color: var(--lime); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.feature-eyebrow > span i, +.fixture-status > span i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--lime), transparent 89%); +} + +.feature-eyebrow em { + color: var(--muted); + font-family: var(--mono); + font-size: 9px; + font-style: normal; +} + +.feature-scoreline { + display: grid; + grid-template-columns: 1fr 82px 1fr; + align-items: center; + gap: 11px; + margin: 26px 0 32px; +} + +.feature-team { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; +} + +.feature-team.away { + justify-content: flex-end; +} + +.feature-team b { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-crest { + width: 41px; + height: 41px; + display: grid; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border: 2px solid rgba(255, 255, 255, 0.4); + border-radius: 50%; + color: #fff; + font-size: 9px; + font-style: normal; + font-weight: 800; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +.command-crest.esp { + background: linear-gradient(#b82835 0 28%, #e6bd55 28% 72%, #b82835 72%); +} + +.command-crest.bel { + background: linear-gradient(90deg, #16171d 0 33%, #e0c348 33% 66%, #b82d39 66%); +} + +.feature-score { + display: grid; + justify-items: center; + gap: 1px; +} + +.feature-score small, +.feature-score em { + color: var(--muted); + font-size: 8px; + font-style: normal; + text-transform: uppercase; +} + +.feature-score strong { + font-family: var(--mono); + font-size: 24px; + letter-spacing: -0.08em; +} + +.feature-kicker { + color: var(--amber); + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.feature-copy h2 { + max-width: 420px; + margin: 8px 0 10px; + font-size: clamp(28px, 2.45vw, 42px); + line-height: 1.02; + letter-spacing: -0.06em; +} + +.feature-copy > p { + max-width: 440px; + margin: 0; + color: var(--text-soft); + font-size: 11px; + line-height: 1.65; +} + +.feature-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + margin-top: 24px; + padding: 14px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.feature-metrics > div { + min-width: 0; + display: grid; + gap: 4px; + padding: 0 12px; + border-right: 1px solid var(--border); +} + +.feature-metrics > div:first-child { + padding-left: 0; +} + +.feature-metrics > div:last-child { + padding-right: 0; + border-right: 0; +} + +.feature-metrics span { + color: var(--muted); + font-size: 8px; + white-space: nowrap; +} + +.feature-metrics b { + font-family: var(--mono); + font-size: 16px; +} + +.feature-metrics .gap b { + color: var(--amber); +} + +.feature-verdict { + display: flex; + align-items: center; + gap: 10px; + margin-top: 17px; +} + +.feature-verdict > span:first-child { + width: 32px; + height: 32px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; + background: var(--amber-soft); + color: var(--amber); +} + +.feature-verdict > span:last-child { + display: grid; + gap: 2px; +} + +.feature-verdict small { + color: var(--muted); + font-size: 8px; + text-transform: uppercase; +} + +.feature-verdict b { + color: var(--amber); + font-size: 11px; +} + +.command-primary-action { + width: fit-content; + min-height: 42px; + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: auto; + padding: 0 14px; + border-radius: 11px; + background: var(--lime); + color: #081018; + font-size: 10px; + font-weight: 800; + text-decoration: none; + box-shadow: 0 12px 30px rgba(117, 213, 44, 0.12); +} + +.command-primary-action svg:last-child { + width: 14px; + margin-left: 6px; +} + +.feature-visual { + display: grid; + align-content: center; + padding: 36px 27px 28px; + border-left: 1px solid var(--border); + background: + linear-gradient(rgba(100, 137, 246, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(100, 137, 246, 0.025) 1px, transparent 1px); + background-size: 34px 34px; +} + +.command-chart { + min-width: 0; + margin: 0; +} + +.command-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 19px; + color: var(--muted); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.command-chart-head > span:last-child { + display: flex; + align-items: center; + gap: 6px; + letter-spacing: 0; + text-transform: none; +} + +.chart-bid-dot, +.chart-ask-dot { + width: 15px; + height: 2px; + display: inline-block; + margin-left: 4px; + border-radius: 2px; + background: var(--lime); +} + +.chart-ask-dot { + background: var(--violet); +} + +.command-chart svg { + width: 100%; + height: auto; + overflow: visible; +} + +.command-chart-grid line { + stroke: var(--border); + stroke-dasharray: 2 7; +} + +.command-spread-area { + fill: url(#command-spread); +} + +.command-bid-line, +.command-ask-line { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +} + +.command-bid-line { + stroke: var(--lime); +} + +.command-ask-line { + stroke: var(--violet); +} + +.command-goal line { + stroke: color-mix(in srgb, var(--lime), transparent 45%); + stroke-dasharray: 3 6; +} + +.command-goal circle { + fill: var(--surface-soft); + stroke: var(--violet); + stroke-width: 3; +} + +.command-goal text, +.command-chart-axis { + fill: var(--muted); + font-family: var(--mono); + font-size: 9px; +} + +.command-goal text { + fill: var(--lime); + font-size: 8px; + font-weight: 700; +} + +.chart-conclusion { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 3px 18px; + margin: 20px 7px 0; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--amber), transparent 70%); + border-radius: 12px; + background: var(--amber-soft); +} + +.chart-conclusion span, +.chart-conclusion small { + color: var(--muted); + font-size: 9px; +} + +.chart-conclusion b { + grid-column: 2; + grid-row: 1 / span 2; + color: var(--amber); + font-family: var(--mono); + font-size: 18px; +} + +.capture-schedule, +.command-cases, +.command-study, +.command-proof { + min-width: 0; + padding: 22px; +} + +.command-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.command-panel-head > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.command-panel-head > div > span { + color: var(--muted); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.command-panel-head h2 { + margin: 0; + font-size: 17px; + letter-spacing: -0.04em; +} + +.command-panel-head > a { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--lime-deep); + font-size: 9px; + font-weight: 700; + text-decoration: none; +} + +.command-panel-head > a svg { + width: 13px; +} + +.schedule-count, +.sealed-label { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 8px; + background: color-mix(in srgb, var(--blue), transparent 90%); + color: var(--blue); + font-size: 8px; + font-weight: 800; + white-space: nowrap; +} + +.schedule-intro { + margin: 13px 0 17px; + color: var(--muted); + font-size: 10px; + line-height: 1.55; +} + +.fixture-stack { + display: grid; + gap: 10px; +} + +.fixture-card { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 13px; + background: var(--surface-soft); +} + +.fixture-card.lead { + border-color: color-mix(in srgb, var(--lime), transparent 75%); + box-shadow: inset 2px 0 var(--lime); +} + +.fixture-date { + display: grid; + align-content: start; + justify-items: center; + gap: 0; + padding: 14px 6px; + border-right: 1px solid var(--border); + color: var(--muted); +} + +.fixture-date b { + color: var(--text); + font-family: var(--mono); + font-size: 17px; +} + +.fixture-date span { + font-size: 8px; + font-weight: 800; +} + +.fixture-card-main { + min-width: 0; + padding: 13px 12px; +} + +.fixture-status { + justify-content: space-between; + gap: 8px; +} + +.fixture-status > span { + display: flex; + align-items: center; + gap: 6px; + color: var(--lime-deep); + font-size: 8px; + font-weight: 800; + text-transform: uppercase; +} + +.fixture-status em { + color: var(--muted); + font-family: var(--mono); + font-size: 8px; + font-style: normal; +} + +.fixture-versus { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr); + align-items: center; + gap: 5px; + margin-top: 13px; +} + +.fixture-versus > span { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.fixture-versus > span:last-child { + justify-content: flex-end; +} + +.fixture-versus b { + overflow: hidden; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fixture-versus > em { + color: var(--muted); + font-family: var(--mono); + font-size: 8px; + font-style: normal; + text-align: center; +} + +.mini-crest { + width: 27px; + height: 27px; + display: grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.23); + border-radius: 50%; + color: #fff; + font-size: 6px; + font-style: normal; + font-weight: 800; + text-shadow: 0 1px 2px #000; +} + +.mini-crest.fra { background: linear-gradient(90deg, #1f3b84 0 33%, #eceff5 33% 66%, #b22e3c 66%); } +.mini-crest.esp { background: linear-gradient(#b82835 0 28%, #e6bd55 28% 72%, #b82835 72%); } +.mini-crest.eng { background: linear-gradient(90deg, #e6e8ed 0 44%, #b82e3b 44% 56%, #e6e8ed 56%); color: #202332; text-shadow: none; } +.mini-crest.arg { background: linear-gradient(#72b7de 0 33%, #f3f2e9 33% 66%, #72b7de 66%); color: #27345e; text-shadow: none; } + +.fixture-times { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 13px; + padding: 10px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.fixture-times > span { + display: grid; + gap: 2px; +} + +.fixture-times small, +.fixture-boundary small { + color: var(--muted); + font-size: 8px; +} + +.fixture-times b { + font-family: var(--mono); + font-size: 9px; +} + +.fixture-boundary, +.schedule-rule, +.proof-policy { + display: flex; + align-items: center; + gap: 8px; +} + +.fixture-boundary { + margin-top: 10px; + color: var(--blue); +} + +.fixture-boundary svg { + width: 14px; + flex: 0 0 auto; +} + +.fixture-boundary > span, +.schedule-rule > span, +.proof-policy > span { + display: grid; + gap: 1px; +} + +.fixture-boundary b { + color: var(--text-soft); + font-size: 8px; +} + +.schedule-rule { + align-items: flex-start; + margin-top: 15px; + padding: 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--blue), transparent 94%); + color: var(--blue); +} + +.schedule-rule b, +.proof-policy b { + color: var(--text-soft); + font-size: 9px; +} + +.schedule-rule small, +.proof-policy small { + color: var(--muted); + font-size: 8px; + line-height: 1.45; +} + +.command-evidence-grid { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(330px, 0.75fr); + align-items: start; + gap: 18px; + margin-top: 18px; +} + +.command-cases { + min-height: 475px; +} + +.case-table-head, +.command-case-row { + display: grid; + grid-template-columns: 1.25fr 0.95fr 0.82fr 1fr; + align-items: center; + gap: 16px; +} + +.case-table-head { + margin-top: 22px; + padding: 0 14px 9px; + border-bottom: 1px solid var(--border); + color: var(--muted); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.command-case-row { + min-height: 84px; + margin-top: 8px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--lime), transparent 81%); + border-radius: 12px; + background: color-mix(in srgb, var(--lime), transparent 96%); + color: inherit; + text-decoration: none; + transition: border-color 180ms ease, transform 180ms ease; +} + +.command-case-row:hover { + border-color: color-mix(in srgb, var(--lime), transparent 62%); + transform: translateY(-2px); +} + +.case-identity, +.case-evidence, +.case-outcome { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; +} + +.case-identity > i { + width: 31px; + height: 31px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; + background: var(--lime); + color: #081018; + font-size: 7px; + font-style: normal; + font-weight: 900; +} + +.case-identity > span, +.case-evidence > span, +.case-market, +.case-outcome { + min-width: 0; + display: grid; + gap: 3px; +} + +.command-case-row b { + overflow: hidden; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-case-row small { + overflow: hidden; + color: var(--muted); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.case-evidence { + display: flex; +} + +.case-evidence > i { + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--lime), transparent 89%); +} + +.case-evidence b { + color: var(--lime-deep); +} + +.case-outcome { + position: relative; + padding-right: 23px; +} + +.case-outcome b { + color: var(--amber); +} + +.case-outcome svg { + position: absolute; + right: 0; + width: 14px; + color: var(--muted); +} + +.case-empty { + min-height: 104px; + display: flex; + align-items: center; + gap: 12px; + margin-top: 13px; + padding: 18px; + border: 1px dashed var(--border-strong); + border-radius: 12px; + background: var(--surface-soft); +} + +.case-empty > span:first-child { + width: 36px; + height: 36px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 10px; + background: color-mix(in srgb, var(--violet), transparent 90%); + color: var(--violet); +} + +.case-empty > span:last-child { + display: grid; + gap: 3px; +} + +.case-empty b { + font-size: 10px; +} + +.case-empty small { + color: var(--muted); + font-size: 9px; + line-height: 1.5; +} + +.command-side-stack { + display: grid; + gap: 18px; +} + +.sealed-label { + background: color-mix(in srgb, var(--violet), transparent 90%); + color: var(--violet); +} + +.sealed-label svg { + width: 12px; +} + +.study-message { + display: flex; + align-items: center; + gap: 11px; + margin-top: 18px; + padding: 13px; + border: 1px solid color-mix(in srgb, var(--violet), transparent 80%); + border-radius: 11px; + background: color-mix(in srgb, var(--violet), transparent 95%); +} + +.study-lock { + width: 32px; + height: 32px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; + background: color-mix(in srgb, var(--violet), transparent 86%); + color: var(--violet); +} + +.study-message > span:last-child { + display: grid; + gap: 3px; +} + +.study-message b { + font-size: 9px; +} + +.study-message small { + color: var(--muted); + font-size: 8px; + line-height: 1.4; +} + +.study-progress { + display: grid; + gap: 15px; + margin-top: 19px; +} + +.study-progress > div { + display: grid; + grid-template-columns: 1fr auto; + gap: 5px; +} + +.study-progress span { + font-family: var(--mono); + font-size: 11px; +} + +.study-progress span b { + font-size: 17px; +} + +.study-progress small { + align-self: end; + color: var(--muted); + font-size: 8px; +} + +.study-progress > div > i { + grid-column: 1 / -1; + height: 5px; + overflow: hidden; + border-radius: 5px; + background: var(--surface-soft); +} + +.study-progress em { + height: 100%; + display: block; + min-width: 3px; + border-radius: inherit; + background: linear-gradient(90deg, var(--lime), var(--blue)); +} + +.study-meta { + display: grid; + grid-template-columns: 0.75fr 1.25fr; + gap: 8px; + margin-top: 19px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.study-meta > span { + min-width: 0; + display: grid; + gap: 3px; +} + +.study-meta small, +.command-proof-grid small, +.command-hash span { + color: var(--muted); + font-size: 8px; +} + +.study-meta b, +.study-meta code { + overflow: hidden; + font-family: var(--mono); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.study-meta b { + color: var(--amber); +} + +.command-proof-hero { + display: flex; + align-items: center; + gap: 13px; + margin-top: 18px; +} + +.command-proof-hero > span { + width: 54px; + height: 54px; + display: grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid color-mix(in srgb, var(--lime), transparent 63%); + border-radius: 50%; + color: var(--lime); + font-family: var(--mono); + font-size: 16px; + box-shadow: inset 0 0 0 5px color-mix(in srgb, var(--lime), transparent 93%); +} + +.command-proof-hero > span small { + margin-left: -14px; + font-size: 8px; + transform: translate(16px, 4px); +} + +.command-proof-hero > div { + display: grid; + gap: 3px; +} + +.command-proof-hero b { + font-size: 10px; +} + +.command-proof-hero small { + color: var(--muted); + font-size: 8px; +} + +.command-proof-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + margin-top: 17px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--border); +} + +.command-proof-grid > span { + min-width: 0; + display: grid; + gap: 3px; + padding: 10px; + background: var(--surface-soft); +} + +.command-proof-grid b { + overflow: hidden; + font-family: var(--mono); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-hash { + display: grid; + gap: 4px; + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-soft); +} + +.command-hash code { + overflow: hidden; + color: var(--text-soft); + font-family: var(--mono); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.proof-policy { + align-items: flex-start; + margin-top: 10px; + padding: 10px; + border-radius: 9px; + background: color-mix(in srgb, var(--blue), transparent 94%); + color: var(--blue); +} + +.proof-policy svg { + width: 14px; + flex: 0 0 auto; +} + +@media (max-width: 1180px) { + .app-shell { + grid-template-columns: 80px minmax(0, 1fr); + } + + .sidebar { + align-items: center; + padding-right: 10px; + padding-left: 10px; + } + + .brand { + padding: 0; + } + + .brand > span:last-child, + .nav-group, + .side-nav a > span, + .side-nav a em, + .feed-dot, + .gate-card > span:last-child, + .owner > span:last-child { + display: none; + } + + .side-nav { + width: 100%; + } + + .side-nav a { + justify-content: center; + padding: 0; + } + + .system-group { + display: block; + height: 1px; + margin: 26px 7px 13px; + overflow: hidden; + background: var(--border); + color: transparent; + } + + .gate-card, + .owner { + justify-content: center; + padding: 7px; + } + + .match-masthead { + grid-template-columns: 120px minmax(140px, 1fr) auto minmax(140px, 1fr) 190px; + gap: 15px; + } + + .analysis-grid, + .support-grid { + grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); + } + + .comparison-strip > div { + padding: 0 14px; + } + + .comparison-strip strong { + font-size: 23px; + } + + .command-system-deck { + grid-template-columns: 240px minmax(440px, 1fr) 100px; + } + + .system-posture { + padding-left: 20px; + } + + .feed-state { + padding-right: 10px; + padding-left: 10px; + } + + .command-lead-grid { + grid-template-columns: minmax(0, 1fr) 330px; + } + + .command-feature { + grid-template-columns: minmax(275px, 0.82fr) minmax(330px, 1.18fr); + } + + .feature-copy { + padding-right: 23px; + padding-left: 23px; + } + + .feature-copy h2 { + font-size: 30px; + } + + .case-table-head, + .command-case-row { + grid-template-columns: 1.2fr 0.9fr 0.8fr 1fr; + gap: 10px; + } +} + +@media (max-width: 980px) { + .context-bar { + grid-template-columns: 1fr auto; + } + + .view-tabs { + grid-column: 1 / -1; + grid-row: 2; + justify-content: center; + } + + .match-masthead { + grid-template-columns: 1fr auto 1fr; + } + + .match-meta { + grid-column: 1 / -1; + grid-row: 1; + flex-direction: row; + } + + .market-meta { + grid-column: 1 / -1; + min-height: auto; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + padding: 14px 0 0; + border-top: 1px solid var(--border); + border-left: 0; + } + + .market-meta b, + .market-meta span { + margin: 0; + } + + .analysis-grid, + .support-grid { + grid-template-columns: 1fr; + } + + .probability-panel, + .decision-rail { + min-height: auto; + } + + .decision-stages { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0 20px; + } + + .decision-stages li:not(:last-child)::after { + display: none; + } + + .command-system-deck { + grid-template-columns: 1fr 112px; + } + + .system-posture { + border-bottom: 1px solid var(--border); + } + + .feed-deck { + grid-column: 1 / -1; + grid-row: 2; + } + + .deck-time { + grid-column: 2; + grid-row: 1; + } + + .command-lead-grid, + .command-evidence-grid { + grid-template-columns: 1fr; + } + + .command-feature { + grid-template-columns: minmax(300px, 0.8fr) minmax(380px, 1.2fr); + } + + .capture-schedule { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 18px; + } + + .capture-schedule .command-panel-head, + .capture-schedule .schedule-intro, + .capture-schedule .schedule-rule { + grid-column: 1 / -1; + } + + .fixture-stack { + grid-column: 1 / -1; + grid-template-columns: 1fr 1fr; + } + + .command-cases { + min-height: auto; + } + + .command-side-stack { + grid-template-columns: 1fr 1fr; + align-items: stretch; + } +} + +@media (max-width: 720px) { + .theme-frame { + display: none !important; + } + + .app-shell { + display: block; + } + + .sidebar { + display: none; + } + + .workspace { + padding-bottom: 92px; + } + + .topbar { + min-height: 112px; + align-content: center; + flex-wrap: wrap; + padding: 12px 15px; + } + + .mobile-brand { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + font-size: 16px; + letter-spacing: -0.04em; + } + + .mobile-brand .brand-mark { + width: 28px; + height: 28px; + } + + .page-title { + display: none; + } + + .system-state { + width: 100%; + justify-content: flex-start; + gap: 6px; + } + + .state-chip { + min-height: 32px; + padding: 0 9px; + font-size: 10px; + } + + .state-chip.gate { + width: auto; + padding: 0 9px; + font-size: 10px; + } + + .context-bar { + min-height: 118px; + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 11px 15px; + } + + .fixture-select { + flex: 1; + } + + .fixture-select small, + .original-date small { + font-size: 9px; + } + + .fixture-select b, + .original-date b { + font-size: 11px; + } + + .original-date { + margin-left: auto; + } + + .view-tabs { + width: 100%; + height: 40px; + overflow-x: auto; + justify-content: flex-start; + scrollbar-width: none; + } + + .view-tabs::-webkit-scrollbar { + display: none; + } + + .view-tabs a { + flex: 1 0 auto; + height: 30px; + padding: 0 12px; + font-size: 10px; + } + + .content { + padding: 14px 12px 0; + } + + .surface { + border-radius: 15px; + } + + .command-system-deck { + display: block; + } + + .system-posture { + min-height: 88px; + padding: 15px; + border-right: 0; + } + + .system-posture b { + font-size: 12px; + } + + .system-posture em { + white-space: normal; + } + + .deck-time { + display: none; + } + + .feed-deck { + grid-template-columns: 1fr 1fr; + } + + .feed-state { + min-height: 72px; + padding: 11px 15px; + border-top: 1px solid var(--border); + } + + .feed-state:nth-child(2n) { + border-right: 0; + } + + .feed-state small { + font-size: 8px; + } + + .command-content { + padding: 14px 12px 0; + } + + .command-lead-grid, + .command-evidence-grid { + gap: 14px; + } + + .command-evidence-grid { + margin-top: 14px; + } + + .command-feature { + min-height: 0; + grid-template-columns: 1fr; + } + + .feature-copy { + padding: 21px 18px 19px; + } + + .feature-scoreline { + margin: 21px 0 26px; + } + + .feature-copy h2 { + font-size: 31px; + } + + .feature-copy > p { + font-size: 10px; + } + + .feature-metrics b { + font-size: 14px; + } + + .command-primary-action { + width: 100%; + justify-content: center; + margin-top: 22px; + } + + .feature-visual { + padding: 24px 15px 19px; + border-top: 1px solid var(--border); + border-left: 0; + } + + .command-chart-head { + align-items: flex-start; + flex-direction: column; + } + + .chart-conclusion { + margin-right: 0; + margin-left: 0; + } + + .capture-schedule, + .command-cases, + .command-study, + .command-proof { + padding: 18px 15px; + } + + .capture-schedule { + display: block; + } + + .fixture-stack { + display: grid; + grid-template-columns: 1fr; + } + + .case-table-head { + display: none; + } + + .command-case-row { + grid-template-columns: 1fr 1fr; + gap: 13px; + margin-top: 18px; + padding: 14px; + } + + .case-identity, + .case-market { + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + } + + .case-market { + align-self: stretch; + } + + .case-evidence, + .case-outcome { + min-height: 35px; + } + + .case-empty { + min-height: 94px; + } + + .command-side-stack { + grid-template-columns: 1fr; + gap: 14px; + } + + .match-masthead { + min-height: 236px; + grid-template-columns: 1fr 92px 1fr; + gap: 8px; + padding: 17px 15px; + } + + .match-meta { + font-size: 10px; + } + + .live-label, + .fixture-number { + font-size: 9px; + } + + .team { + flex-direction: column; + gap: 7px; + text-align: center; + } + + .away-team { + flex-direction: column-reverse; + } + + .team > span:last-child, + .away-team > span:first-child { + justify-items: center; + } + + .team small { + font-size: 14px; + } + + .team b { + font-size: 10px; + } + + .crest { + width: 45px; + height: 45px; + } + + .score-block { + min-width: 80px; + } + + .score-block strong { + font-size: 33px; + } + + .score-block small, + .clock { + font-size: 9px; + } + + .market-meta { + grid-template-columns: 1fr; + gap: 4px; + padding-top: 12px; + text-align: center; + } + + .market-meta small, + .market-meta span { + font-size: 10px; + } + + .market-meta b { + font-size: 13px; + } + + .analysis-grid, + .support-grid { + gap: 14px; + margin-top: 14px; + } + + .probability-panel, + .decision-rail, + .evidence-panel, + .proof-panel { + padding: 18px 15px; + } + + .panel-heading { + align-items: flex-start; + } + + .panel-heading > div > span, + .panel-heading > span:first-child { + font-size: 10px; + } + + .panel-heading h2 { + font-size: 17px; + } + + .panel-heading { + flex-wrap: wrap; + } + + .legend { + width: 100%; + display: flex; + gap: 11px; + margin-top: 7px; + font-size: 12px; + } + + .comparison-strip { + grid-template-columns: 1fr; + gap: 0; + margin-top: 18px; + padding: 0; + border: 1px solid var(--border); + border-radius: 13px; + background: var(--surface-soft); + } + + .comparison-strip > div, + .comparison-strip > div:first-child { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 3px 14px; + padding: 14px; + border-right: 0; + } + + .comparison-strip > div:not(:last-child) { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .comparison-strip > div > span { + align-self: end; + font-size: 12px; + } + + .comparison-strip strong { + grid-column: 2; + grid-row: 1 / span 2; + font-size: 22px; + } + + .comparison-strip small { + grid-column: 1; + font-size: 10px; + } + + .plain-conclusion { + grid-template-columns: auto 1fr; + align-items: start; + padding: 14px; + } + + .plain-conclusion p { + font-size: 12px; + } + + .discipline-chip { + grid-column: 2; + justify-self: start; + padding: 6px 9px; + font-size: 10px; + } + + .chart-wrap { + margin-top: 16px; + } + + .chart-wrap > svg { + height: auto; + } + + .chart-axis { + font-size: 12px; + } + + .goal-event text { + font-size: 11px; + } + + .chart-wrap figcaption { + align-items: flex-start; + flex-direction: column; + gap: 5px; + font-size: 10px; + line-height: 1.45; + } + + .replay-bar { + flex-wrap: wrap; + } + + .play-button { + flex: 1; + justify-content: center; + font-size: 11px; + } + + .speed { + margin-left: 0; + font-size: 11px; + } + + .replay-steps { + order: 3; + width: 100%; + height: 42px; + } + + .replay-steps button { + flex: 1; + height: 34px; + padding: 0 6px; + font-size: 10px; + } + + .decision-outcome { + padding: 24px 10px 20px; + } + + .decision-outcome > strong { + font-size: 29px; + } + + .decision-outcome p { + font-size: 12px; + } + + .reason-block > span, + .reason-block p { + font-size: 10px; + } + + .reason-block b { + font-size: 13px; + } + + .decision-stages { + display: block; + } + + .decision-stages li:not(:last-child)::after { + display: block; + } + + .decision-stages b { + font-size: 12px; + } + + .decision-stages small, + .decision-stages time { + font-size: 10px; + } + + .boundary-grid span, + .protective-gate small { + font-size: 9px; + } + + .protective-gate b { + font-size: 12px; + } + + .evidence-table-wrap { + overflow: visible; + } + + .evidence-table, + .evidence-table tbody, + .evidence-table tr, + .evidence-table td { + width: 100%; + display: block; + } + + .evidence-table { + min-width: 0; + } + + .evidence-table thead { + display: none; + } + + .evidence-table tr { + display: grid; + grid-template-columns: minmax(115px, 0.9fr) minmax(0, 1.1fr); + gap: 9px 12px; + margin-top: 10px; + padding: 13px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-soft); + } + + .evidence-table tr.active { + border-color: color-mix(in srgb, var(--blue), transparent 66%); + background: color-mix(in srgb, var(--blue), transparent 95%); + box-shadow: inset 2px 0 var(--blue); + } + + .evidence-table td, + .evidence-table tr.active td, + .evidence-table tr.active td:first-child { + width: auto; + height: auto; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + } + + .evidence-table td:nth-child(3) { + grid-column: 1 / -1; + padding: 9px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + font-size: 12px; + } + + .evidence-table td:nth-child(5) { + align-self: center; + justify-self: end; + } + + .evidence-table time, + .evidence-table td:nth-child(4) b { + font-size: 11px; + } + + .evidence-table td small, + .assessment { + font-size: 10px; + } + + .proof-stats { + grid-template-columns: 1fr; + } + + .proof-stats div { + padding: 4px 0; + } + + .proof-stats dt, + .hash span { + font-size: 10px; + } + + .proof-stats dd, + .hash code { + font-size: 11px; + } + + .mobile-nav { + position: fixed; + right: 9px; + bottom: 9px; + left: 9px; + z-index: 30; + height: 68px; + display: grid; + grid-template-columns: repeat(5, 1fr); + padding: 6px; + border: 1px solid var(--border-strong); + border-radius: 17px; + background: color-mix(in srgb, var(--surface), transparent 7%); + box-shadow: 0 22px 55px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(18px); + } + + .mobile-nav a { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 4px; + border-radius: 10px; + color: var(--muted); + font-size: 9px; + font-weight: 700; + text-decoration: none; + } + + .mobile-nav a.active { + background: color-mix(in srgb, var(--lime), transparent 89%); + color: var(--lime-deep); + } + + .mobile-nav svg { + width: 18px; + height: 18px; + } +} + +@media (max-width: 380px) { + .original-date { + display: none; + } + + .match-masthead { + grid-template-columns: 1fr 82px 1fr; + } + + .fixture-number { + display: none; + } + + .comparison-strip strong { + font-size: 20px; + } + + .boundary-grid div { + padding: 0 7px; + } + + .mobile-nav a { + font-size: 8px; + } + + .feature-scoreline { + grid-template-columns: 1fr 70px 1fr; + } + + .feature-team { + flex-direction: column; + } + + .feature-team.away { + flex-direction: column-reverse; + } + + .feature-metrics > div { + padding-right: 8px; + padding-left: 8px; + } + + .feature-metrics b { + font-size: 12px; + } + + .command-case-row { + grid-template-columns: 1fr; + } + + .case-identity, + .case-market, + .case-evidence { + padding-bottom: 10px; + border-bottom: 1px solid var(--border); + } +} + +/* Casebook: verified decision record */ +.casebook-shell { + background: + radial-gradient(circle at 70% -8%, rgba(159, 248, 80, 0.055), transparent 31rem), + radial-gradient(circle at 97% 42%, rgba(100, 137, 246, 0.06), transparent 32rem), + var(--bg); +} + +.casebook-content { + width: min(1480px, 100%); + margin: 0 auto; + padding: 26px 28px 76px; +} + +.casebook-hero { + position: relative; + min-height: 252px; + display: grid; + grid-template-columns: minmax(360px, 0.82fr) minmax(580px, 1.18fr); + gap: 34px; + align-items: stretch; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 18px 18px 4px 4px; + background: + linear-gradient(90deg, rgba(159, 248, 80, 0.035) 1px, transparent 1px) 0 0 / 48px 48px, + linear-gradient(rgba(159, 248, 80, 0.035) 1px, transparent 1px) 0 0 / 48px 48px, + linear-gradient(128deg, #10162c 0%, #0c1021 56%, #12162b 100%); + box-shadow: var(--shadow); +} + +.casebook-hero::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--lime); + content: ""; +} + +.casebook-hero::after { + position: absolute; + top: -148px; + left: 34%; + width: 310px; + height: 310px; + border: 1px solid rgba(159, 248, 80, 0.1); + border-radius: 50%; + content: ""; +} + +.casebook-hero-copy { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding: 34px 0 34px 40px; +} + +.casebook-kicker { + display: flex; + align-items: center; + gap: 9px; + color: var(--lime); + font-family: var(--mono); + font-size: 9px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.casebook-kicker i { + width: 20px; + height: 1px; + background: currentColor; +} + +.casebook-hero h2 { + max-width: 580px; + margin: 14px 0 14px; + font-size: clamp(32px, 3.3vw, 54px); + font-weight: 800; + letter-spacing: -0.065em; + line-height: 0.98; +} + +.casebook-hero h2 em { + color: var(--muted-strong); + font-style: normal; + font-weight: 500; +} + +.casebook-hero-copy > p { + max-width: 570px; + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.72; +} + +.casebook-stat-deck { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1.08fr 1fr 1fr 1.15fr; + grid-template-rows: minmax(132px, 1fr) 66px; + align-self: stretch; + margin-left: 6px; + border-left: 1px solid var(--border); + background: rgba(4, 7, 17, 0.28); +} + +.casebook-stat-deck > div { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 23px 20px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.casebook-stat-deck > div > span, +.casebook-stat-deck > div > small { + color: var(--muted); + font-size: 9px; +} + +.casebook-stat-deck > div > span { + order: -2; + margin-bottom: auto; + font-family: var(--mono); + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.casebook-stat-deck > div > b { + overflow: hidden; + margin: 15px 0 4px; + font-size: clamp(22px, 2.25vw, 37px); + letter-spacing: -0.055em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.casebook-stat-deck .casebook-primary-stat b { + color: var(--lime); + font-family: var(--mono); + font-size: clamp(34px, 3.5vw, 58px); + font-weight: 500; + line-height: 0.9; +} + +.casebook-verified-stat { + grid-column: 1 / -1; + flex-direction: row !important; + align-items: center; + justify-content: flex-start !important; + gap: 13px; + padding: 15px 20px !important; + border-bottom: 0 !important; + background: linear-gradient(90deg, rgba(159, 248, 80, 0.08), transparent 75%); +} + +.casebook-verified-stat svg { + width: 22px; + fill: none; + stroke: var(--lime); + stroke-width: 1.5; +} + +.casebook-verified-stat span { + order: initial !important; + display: flex; + flex-direction: column; + gap: 2px; + margin: 0 !important; + letter-spacing: 0 !important; + text-transform: none !important; +} + +.casebook-verified-stat b { + color: var(--text); + font-size: 11px !important; + letter-spacing: -0.01em !important; +} + +.casebook-verified-stat small { + color: var(--muted); + font-size: 9px; +} + +.casebook-filters { + position: relative; + z-index: 3; + display: grid; + grid-template-columns: minmax(200px, 1.35fr) minmax(600px, 3.65fr) auto; + gap: 8px; + align-items: end; + margin-top: 12px; + padding: 10px; + border-radius: 4px 4px 12px 12px; +} + +.casebook-search { + height: 51px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + grid-template-rows: 15px 19px; + column-gap: 10px; + align-content: center; + padding: 0 13px; + border: 1px solid var(--border-strong); + border-radius: 9px; + background: var(--surface-soft); +} + +.casebook-search > svg { + grid-row: 1 / 3; + align-self: center; + width: 17px; + fill: none; + stroke: var(--muted-strong); + stroke-width: 1.5; +} + +.casebook-search > span, +.casebook-filter-select > span { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.casebook-search input { + width: 100%; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font-size: 10px; +} + +.casebook-search input::placeholder { + color: var(--muted); +} + +.casebook-search kbd { + grid-column: 3; + grid-row: 1 / 3; + align-self: center; + padding: 3px 5px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + font-family: var(--mono); + font-size: 7px; +} + +.casebook-filter-grid { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 5px; +} + +.casebook-filter-select { + position: relative; + height: 51px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 3px; + min-width: 0; + padding: 0 24px 0 9px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(7, 10, 24, 0.5); +} + +.casebook-filter-select select { + width: 100%; + overflow: hidden; + padding: 0; + border: 0; + outline: 0; + appearance: none; + background: transparent; + color: var(--text-soft); + font-family: var(--sans); + font-size: 9px; + font-weight: 700; + text-overflow: ellipsis; +} + +.casebook-filter-select > svg { + position: absolute; + right: 8px; + bottom: 12px; + width: 12px; + fill: none; + stroke: var(--muted); + stroke-width: 1.7; +} + +.casebook-clear { + height: 51px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--text-soft); + font-size: 9px; + font-weight: 800; + cursor: pointer; +} + +.casebook-clear:disabled { + opacity: 0.35; + cursor: default; +} + +.casebook-workbench { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + gap: 12px; + align-items: start; + margin-top: 12px; +} + +.casebook-index { + position: sticky; + top: 87px; + overflow: hidden; + border-radius: 14px; +} + +.casebook-panel-head, +.casebook-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.casebook-panel-head { + padding: 20px 20px 15px; + border-bottom: 1px solid var(--border); +} + +.casebook-panel-head > div > span, +.casebook-section-head > div > span { + color: var(--lime); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.casebook-panel-head h2, +.casebook-section-head h3 { + margin: 4px 0 0; + font-size: 15px; + letter-spacing: -0.035em; +} + +.casebook-panel-head > em { + color: var(--muted); + font-family: var(--mono); + font-size: 8px; + font-style: normal; +} + +.casebook-index-row { + position: relative; + padding: 18px 18px 17px 20px; + border-bottom: 1px solid var(--border); + background: linear-gradient(110deg, rgba(159, 248, 80, 0.08), rgba(159, 248, 80, 0.01) 72%); +} + +.casebook-index-row::before { + position: absolute; + inset: 0 auto 0 0; + width: 2px; + background: var(--lime); + content: ""; +} + +.casebook-index-time, +.casebook-index-match, +.casebook-index-outcome { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.casebook-index-time time { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; +} + +.casebook-index-time > span { + display: flex; + align-items: center; + gap: 5px; + color: var(--lime); + font-size: 8px; + font-weight: 700; +} + +.casebook-index-time > span i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 10px rgba(159, 248, 80, 0.45); +} + +.casebook-index-match { + justify-content: flex-start; + margin-top: 17px; +} + +.casebook-pair { + position: relative; + width: 56px; + height: 36px; + flex: 0 0 56px; +} + +.casebook-pair .command-crest { + position: absolute; + top: 0; + width: 36px; + height: 36px; + font-size: 7px; +} + +.casebook-pair .command-crest:last-child { + left: 22px; + box-shadow: 0 0 0 3px var(--surface); +} + +.casebook-index-match > span:last-child, +.casebook-index-outcome > span:nth-child(2) { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.casebook-index-match b, +.casebook-index-outcome b { + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.casebook-index-match small, +.casebook-index-outcome small, +.casebook-index-foot small { + color: var(--muted); + font-size: 8px; + line-height: 1.5; +} + +.casebook-index-tags { + display: flex; + gap: 5px; + margin: 16px 0 12px; +} + +.casebook-index-tags span { + padding: 4px 7px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted-strong); + font-family: var(--mono); + font-size: 6px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.casebook-index-outcome { + justify-content: flex-start; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.casebook-index-outcome > span:first-child { + width: 28px; + height: 28px; + display: grid; + flex: 0 0 28px; + place-items: center; + border-radius: 7px; + background: rgba(145, 131, 229, 0.12); + color: var(--violet); +} + +.casebook-index-outcome svg, +.casebook-index-foot svg, +.casebook-no-results svg { + width: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.casebook-index-outcome > svg { + width: 15px; + margin-left: auto; + color: var(--muted); +} + +.casebook-index-foot { + display: flex; + gap: 11px; + padding: 17px 18px; + color: var(--muted-strong); + background: var(--surface-soft); +} + +.casebook-index-foot > span { + display: flex; + flex-direction: column; + gap: 3px; +} + +.casebook-index-foot b { + color: var(--text-soft); + font-size: 9px; +} + +.casebook-no-results { + min-height: 260px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px; + text-align: center; +} + +.casebook-no-results > span, +.casebook-detail-empty > span { + width: 42px; + height: 42px; + display: grid; + margin-bottom: 14px; + place-items: center; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--muted); +} + +.casebook-no-results small { + max-width: 230px; + margin-top: 6px; + color: var(--muted); + font-size: 9px; + line-height: 1.55; +} + +.casebook-detail { + overflow: hidden; + border-radius: 14px; +} + +.casebook-detail-masthead { + position: relative; + min-height: 285px; + display: grid; + grid-template-columns: 130px minmax(240px, 0.85fr) minmax(260px, 1.15fr); + grid-template-rows: 1fr auto; + gap: 24px; + align-items: center; + padding: 30px 32px 26px; + overflow: hidden; + border-bottom: 1px solid var(--border); + background: + linear-gradient(90deg, transparent 49.8%, rgba(159, 248, 80, 0.045) 50%, transparent 50.2%) 0 0 / 84px 84px, + linear-gradient(transparent 49.8%, rgba(159, 248, 80, 0.045) 50%, transparent 50.2%) 0 0 / 84px 84px, + radial-gradient(circle at 56% 40%, rgba(145, 131, 229, 0.14), transparent 22rem), + #0b0f21; +} + +.casebook-detail-masthead::after { + position: absolute; + right: -86px; + bottom: -150px; + width: 330px; + height: 330px; + border: 1px solid rgba(159, 248, 80, 0.09); + border-radius: 50%; + content: ""; +} + +.casebook-detail-id { + align-self: stretch; + display: flex; + flex-direction: column; + padding-right: 22px; + border-right: 1px solid var(--border); +} + +.casebook-detail-id > span, +.casebook-detail-id > small, +.casebook-detail-verdict small, +.casebook-market-verdict small { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.casebook-detail-id > b { + margin: 9px 0 auto; + color: var(--lime); + font-family: var(--mono); + font-size: 17px; + font-weight: 500; + letter-spacing: -0.04em; +} + +.casebook-detail-id > small { + line-height: 1.55; +} + +.casebook-detail-match { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 18px; + align-items: center; +} + +.casebook-detail-match > span { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + font-size: 11px; +} + +.casebook-detail-match .command-crest { + width: 58px; + height: 58px; + font-size: 9px; +} + +.casebook-detail-match > em { + display: flex; + flex-direction: column; + align-items: center; + color: var(--text); + font-style: normal; +} + +.casebook-detail-match > em small, +.casebook-detail-match > em i { + color: var(--muted); + font-family: var(--mono); + font-size: 6px; + font-style: normal; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.casebook-detail-match > em strong { + margin: 3px 0; + font-size: 30px; + letter-spacing: -0.07em; +} + +.casebook-detail-verdict { + position: relative; + z-index: 1; + display: flex; + gap: 14px; +} + +.casebook-detail-verdict > span:first-child { + width: 40px; + height: 40px; + display: grid; + flex: 0 0 40px; + place-items: center; + border: 1px solid rgba(159, 248, 80, 0.18); + border-radius: 11px; + background: rgba(159, 248, 80, 0.07); + color: var(--lime); +} + +.casebook-detail-verdict svg { + width: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.6; +} + +.casebook-detail-verdict h2 { + margin: 7px 0 7px; + font-size: clamp(22px, 2.3vw, 35px); + letter-spacing: -0.055em; + line-height: 1.02; +} + +.casebook-detail-verdict p { + max-width: 500px; + margin: 0; + color: var(--muted); + font-size: 10px; + line-height: 1.65; +} + +.casebook-matchroom-link { + position: relative; + z-index: 1; + grid-column: 2 / 4; + width: fit-content; + display: inline-flex; + align-items: center; + gap: 9px; + padding: 10px 13px; + border: 1px solid rgba(159, 248, 80, 0.22); + border-radius: 8px; + background: rgba(159, 248, 80, 0.08); + color: var(--text); + font-size: 9px; + font-weight: 800; + text-decoration: none; +} + +.casebook-matchroom-link svg { + width: 14px; + fill: none; + stroke: var(--lime); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.casebook-decision-rail, +.casebook-readout, +.casebook-evidence-sequence { + padding: 26px 30px; + border-bottom: 1px solid var(--border); +} + +.casebook-final-state, +.casebook-move-label { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 9px; + border: 1px solid rgba(145, 131, 229, 0.23); + border-radius: 6px; + background: rgba(145, 131, 229, 0.08); + color: var(--violet); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.casebook-final-state svg { + width: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.casebook-rail-stages { + position: relative; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 20px; +} + +.casebook-rail-stages::before { + position: absolute; + top: 31px; + right: 9%; + left: 9%; + height: 1px; + background: linear-gradient(90deg, var(--lime), var(--violet)); + content: ""; +} + +.casebook-rail-stage { + position: relative; + z-index: 1; + min-height: 112px; + display: grid; + grid-template-columns: 28px 1fr auto; + grid-template-rows: 34px 1fr; + gap: 8px 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-soft); +} + +.rail-index { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; +} + +.casebook-rail-stage > i { + grid-column: 2; + grid-row: 1; + width: 25px; + height: 25px; + display: grid; + align-self: start; + place-items: center; + border-radius: 50%; + background: var(--lime); + color: #0c1420; + font-style: normal; +} + +.casebook-rail-stage.locked > i { + background: var(--violet); +} + +.casebook-rail-stage > i svg { + width: 13px; + fill: none; + stroke: currentColor; + stroke-width: 2.1; +} + +.casebook-rail-stage > span:nth-of-type(2) { + grid-column: 1 / 4; + display: flex; + flex-direction: column; + gap: 3px; +} + +.casebook-rail-stage b { + font-size: 9px; +} + +.casebook-rail-stage small { + color: var(--muted); + font-size: 8px; + line-height: 1.4; +} + +.casebook-rail-stage > em { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + font-style: normal; +} + +.casebook-move-label { + border-color: rgba(233, 185, 104, 0.25); + background: var(--amber-soft); + color: var(--amber); +} + +.casebook-move-label i { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +.casebook-readout-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + margin-top: 20px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; +} + +.casebook-readout-grid > div { + min-width: 0; + display: flex; + flex-direction: column; + padding: 18px 14px; + border-right: 1px solid var(--border); + background: rgba(7, 10, 23, 0.35); +} + +.casebook-readout-grid > div:last-child { + border-right: 0; +} + +.casebook-readout-grid span, +.casebook-readout-grid small { + color: var(--muted); + font-size: 8px; +} + +.casebook-readout-grid b { + margin: 13px 0 4px; + font-size: clamp(17px, 1.7vw, 25px); + letter-spacing: -0.05em; +} + +.casebook-readout-grid .consensus-move { + background: rgba(100, 137, 246, 0.08); +} + +.casebook-readout-grid .consensus-move b { + color: var(--blue); +} + +.casebook-readout-grid .pre-move { + background: rgba(233, 185, 104, 0.08); +} + +.casebook-readout-grid .pre-move b { + color: var(--amber); +} + +.casebook-market-verdict { + display: grid; + grid-template-columns: 38px minmax(180px, auto) 1fr; + gap: 13px; + align-items: center; + margin-top: 10px; + padding: 12px 14px; + border: 1px solid rgba(233, 185, 104, 0.15); + border-radius: 9px; + background: var(--amber-soft); +} + +.casebook-market-verdict > span:first-child { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 8px; + background: rgba(233, 185, 104, 0.12); + color: var(--amber); +} + +.casebook-market-verdict svg { + width: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.casebook-market-verdict > span:nth-child(2) { + display: flex; + flex-direction: column; + gap: 3px; +} + +.casebook-market-verdict b { + font-size: 10px; +} + +.casebook-market-verdict p { + margin: 0; + padding-left: 16px; + border-left: 1px solid rgba(233, 185, 104, 0.2); + color: var(--muted-strong); + font-size: 9px; + line-height: 1.55; +} + +.casebook-analysis-grid { + display: grid; + grid-template-columns: 1fr 1fr; + border-bottom: 1px solid var(--border); +} + +.casebook-analysis-card { + display: flex; + gap: 14px; + padding: 26px 30px; +} + +.casebook-analysis-card + .casebook-analysis-card { + border-left: 1px solid var(--border); +} + +.analysis-icon { + width: 38px; + height: 38px; + display: grid; + flex: 0 0 38px; + place-items: center; + border-radius: 9px; + background: rgba(100, 137, 246, 0.1); + color: var(--blue); +} + +.casebook-analysis-card.execution .analysis-icon { + background: rgba(159, 248, 80, 0.08); + color: var(--lime); +} + +.analysis-icon svg { + width: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.casebook-analysis-card > div > span { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.casebook-analysis-card h3 { + margin: 5px 0 7px; + font-size: 15px; +} + +.casebook-analysis-card p { + margin: 0 0 10px; + color: var(--muted); + font-size: 9px; + line-height: 1.6; +} + +.casebook-analysis-card small { + color: var(--muted-strong); + font-size: 8px; + line-height: 1.55; +} + +.casebook-evidence-head, +.casebook-evidence-row { + display: grid; + grid-template-columns: 110px 100px minmax(220px, 1fr) 80px 90px; + gap: 12px; + align-items: center; +} + +.casebook-evidence-head { + margin-top: 20px; + padding: 0 13px 9px; + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.casebook-evidence-row { + min-height: 64px; + padding: 10px 13px; + border-top: 1px solid var(--border); + font-size: 9px; +} + +.casebook-evidence-row:nth-child(even) { + background: rgba(7, 10, 23, 0.27); +} + +.casebook-evidence-row > span { + min-width: 0; +} + +.casebook-evidence-row > span:first-child { + display: flex; + flex-direction: column; + gap: 3px; +} + +.casebook-evidence-row small { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; +} + +.casebook-evidence-row > span:nth-child(2) { + display: flex; + align-items: center; + gap: 7px; +} + +.casebook-evidence-row > span:nth-child(2) i { + width: 6px; + height: 6px; + flex: 0 0 6px; + border-radius: 50%; + background: var(--blue); +} + +.casebook-evidence-row > span:nth-child(2) i.txline { + background: var(--lime); +} + +.casebook-evidence-row > span:nth-child(2) i.samaritan { + background: var(--violet); +} + +.casebook-evidence-row > span:nth-child(3) { + color: var(--text-soft); + line-height: 1.45; +} + +.casebook-evidence-row > span:last-child { + width: fit-content; + padding: 5px 7px; + border-radius: 5px; + background: rgba(233, 185, 104, 0.1); + color: var(--amber); + font-size: 8px; + font-weight: 700; +} + +.casebook-evidence-row > span:last-child.no-trade { + background: rgba(145, 131, 229, 0.1); + color: var(--violet); +} + +.casebook-proof-line { + display: grid; + grid-template-columns: minmax(190px, 1.1fr) 1fr 1fr auto; + gap: 20px; + align-items: center; + padding: 20px 30px; + background: #090d1d; +} + +.casebook-proof-line > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.casebook-proof-status { + flex-direction: row !important; + align-items: center; + gap: 10px !important; +} + +.casebook-proof-status svg, +.casebook-policy svg { + width: 20px; + flex: 0 0 20px; + fill: none; + stroke: var(--lime); + stroke-width: 1.6; +} + +.casebook-proof-status > span { + display: flex; + flex-direction: column; + gap: 3px; +} + +.casebook-proof-line b { + font-size: 9px; +} + +.casebook-proof-line small { + color: var(--muted); + font-size: 7px; +} + +.casebook-proof-line code { + overflow: hidden; + color: var(--muted-strong); + font-family: var(--mono); + font-size: 7px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.casebook-policy { + flex-direction: row !important; + align-items: center; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted-strong); + font-size: 7px; + white-space: nowrap; +} + +.casebook-policy svg { + width: 13px; + flex-basis: 13px; + stroke: var(--muted); +} + +.casebook-detail-empty { + min-height: 520px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + border-radius: 14px; + text-align: center; +} + +.casebook-detail-empty svg { + width: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; +} + +.casebook-detail-empty h2 { + margin: 0 0 8px; + font-size: 23px; + letter-spacing: -0.045em; +} + +.casebook-detail-empty p { + max-width: 430px; + margin: 0; + color: var(--muted); + font-size: 10px; + line-height: 1.65; +} + +.casebook-detail-empty button { + margin-top: 20px; + padding: 10px 14px; + border: 0; + border-radius: 7px; + background: var(--lime); + color: #081018; + font-size: 9px; + font-weight: 800; + cursor: pointer; +} + +@media (max-width: 1260px) { + .casebook-hero { + grid-template-columns: minmax(330px, 0.75fr) minmax(500px, 1.25fr); + } + + .casebook-stat-deck > div { + padding: 19px 14px; + } + + .casebook-filters { + grid-template-columns: minmax(210px, 1fr) minmax(480px, 3fr) auto; + } + + .casebook-filter-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .casebook-filter-select { + height: 43px; + } + + .casebook-detail-masthead { + grid-template-columns: 118px minmax(250px, 0.8fr) minmax(300px, 1.2fr); + padding-right: 26px; + padding-left: 26px; + } + + .casebook-readout-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .casebook-readout-grid > div:nth-child(3) { + border-right: 0; + } + + .casebook-readout-grid > div:nth-child(-n + 3) { + border-bottom: 1px solid var(--border); + } +} + +@media (max-width: 1060px) { + .casebook-content { + padding-right: 20px; + padding-left: 20px; + } + + .casebook-hero { + grid-template-columns: 1fr; + } + + .casebook-hero-copy { + min-height: 230px; + padding-right: 38px; + } + + .casebook-stat-deck { + min-height: 188px; + margin: 0; + border-top: 1px solid var(--border); + border-left: 0; + } + + .casebook-filters { + grid-template-columns: 1fr auto; + } + + .casebook-filter-grid { + grid-column: 1 / 3; + grid-row: 2; + } + + .casebook-clear { + grid-column: 2; + grid-row: 1; + } + + .casebook-workbench { + grid-template-columns: 1fr; + } + + .casebook-index { + position: static; + } + + .casebook-index-row { + display: grid; + grid-template-columns: minmax(300px, 1fr) 1fr; + column-gap: 30px; + } + + .casebook-index-time, + .casebook-index-match { + grid-column: 1; + } + + .casebook-index-tags, + .casebook-index-outcome { + grid-column: 2; + } + + .casebook-index-tags { + align-self: end; + margin-top: 10px; + } + + .casebook-index-outcome { + align-self: start; + } +} + +@media (max-width: 760px) { + .casebook-content { + padding: 12px 12px 104px; + } + + .casebook-hero { + border-radius: 12px 12px 3px 3px; + } + + .casebook-hero-copy { + min-height: 265px; + padding: 30px 24px 28px; + } + + .casebook-hero h2 { + font-size: clamp(32px, 10vw, 44px); + } + + .casebook-stat-deck { + grid-template-columns: 1fr 1fr; + grid-template-rows: repeat(2, 112px) 62px; + } + + .casebook-stat-deck > div:nth-child(even) { + border-right: 0; + } + + .casebook-verified-stat { + grid-row: 3; + } + + .casebook-filters { + grid-template-columns: 1fr auto; + } + + .casebook-search { + min-width: 0; + } + + .casebook-search kbd { + display: none; + } + + .casebook-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .casebook-index-row { + display: block; + } + + .casebook-index-tags { + margin-top: 16px; + } + + .casebook-detail-masthead { + min-height: 0; + grid-template-columns: 1fr; + grid-template-rows: auto; + gap: 25px; + padding: 25px 20px; + } + + .casebook-detail-id { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px 12px; + padding: 0 0 14px; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .casebook-detail-id > b { + grid-row: 1 / 3; + grid-column: 2; + margin: 0; + } + + .casebook-detail-match { + grid-column: 1; + } + + .casebook-detail-verdict { + grid-column: 1; + } + + .casebook-detail-verdict h2 { + font-size: 27px; + } + + .casebook-matchroom-link { + grid-column: 1; + width: 100%; + justify-content: center; + } + + .casebook-decision-rail, + .casebook-readout, + .casebook-evidence-sequence { + padding: 22px 18px; + } + + .casebook-rail-stages { + grid-template-columns: 1fr 1fr; + } + + .casebook-rail-stages::before { + display: none; + } + + .casebook-readout-grid { + grid-template-columns: 1fr 1fr; + } + + .casebook-readout-grid > div { + border-bottom: 1px solid var(--border); + } + + .casebook-readout-grid > div:nth-child(odd) { + border-right: 1px solid var(--border); + } + + .casebook-readout-grid > div:nth-child(even) { + border-right: 0; + } + + .casebook-readout-grid > div:nth-last-child(-n + 2) { + border-bottom: 0; + } + + .casebook-market-verdict { + grid-template-columns: 36px 1fr; + } + + .casebook-market-verdict p { + grid-column: 1 / 3; + padding: 11px 0 0; + border-top: 1px solid rgba(233, 185, 104, 0.2); + border-left: 0; + } + + .casebook-analysis-grid { + grid-template-columns: 1fr; + } + + .casebook-analysis-card { + padding: 22px 18px; + } + + .casebook-analysis-card + .casebook-analysis-card { + border-top: 1px solid var(--border); + border-left: 0; + } + + .casebook-evidence-head { + display: none; + } + + .casebook-evidence-row { + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 9px; + padding: 13px; + border: 1px solid var(--border); + border-radius: 8px; + } + + .casebook-evidence-row > span:nth-child(3) { + grid-column: 1 / 3; + grid-row: 2; + padding: 9px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + } + + .casebook-evidence-row > span:nth-child(4) { + grid-column: 1; + } + + .casebook-evidence-row > span:nth-child(5) { + grid-column: 2; + justify-self: end; + } + + .casebook-proof-line { + grid-template-columns: 1fr 1fr; + padding: 20px 18px; + } + + .casebook-proof-status { + grid-column: 1 / 3; + } +} + +@media (max-width: 420px) { + .casebook-hero-copy { + min-height: 290px; + } + + .casebook-stat-deck { + grid-template-columns: 1fr 1fr; + } + + .casebook-stat-deck > div { + padding: 17px 14px; + } + + .casebook-stat-deck > div > b { + font-size: 22px; + } + + .casebook-stat-deck .casebook-primary-stat b { + font-size: 39px; + } + + .casebook-filters { + grid-template-columns: 1fr; + } + + .casebook-search, + .casebook-filter-grid, + .casebook-clear { + grid-column: 1; + } + + .casebook-search { + grid-row: 1; + } + + .casebook-filter-grid { + grid-row: 2; + } + + .casebook-clear { + grid-row: 3; + } + + .casebook-detail-match { + gap: 8px; + } + + .casebook-detail-match .command-crest { + width: 49px; + height: 49px; + } + + .casebook-detail-verdict > span:first-child { + display: none; + } + + .casebook-section-head { + align-items: flex-start; + } + + .casebook-rail-stages { + grid-template-columns: 1fr; + } + + .casebook-readout-grid { + grid-template-columns: 1fr; + } + + .casebook-readout-grid > div, + .casebook-readout-grid > div:nth-child(odd), + .casebook-readout-grid > div:nth-last-child(-n + 2) { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .casebook-readout-grid > div:last-child { + border-bottom: 0; + } + + .casebook-analysis-card { + flex-direction: column; + } + + .casebook-proof-line { + grid-template-columns: 1fr; + } + + .casebook-proof-status { + grid-column: 1; + } + + .casebook-policy { + width: fit-content; + } +} + +/* Study: invalidated protocol audit and separated evidence classes */ +.study-shell { + background: + radial-gradient(circle at 72% 3%, rgba(159, 248, 80, 0.065), transparent 29rem), + radial-gradient(circle at 93% 58%, rgba(100, 137, 246, 0.055), transparent 32rem), + var(--bg); +} + +.study-content { + width: min(1480px, 100%); + margin: 0 auto; + padding: 26px 28px 76px; +} + +.study-hero { + position: relative; + min-height: 340px; + display: grid; + grid-template-columns: minmax(390px, 0.84fr) minmax(580px, 1.16fr); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 18px 18px 4px 4px; + background: + linear-gradient(90deg, rgba(159, 248, 80, 0.035) 1px, transparent 1px) 0 0 / 56px 56px, + linear-gradient(rgba(159, 248, 80, 0.035) 1px, transparent 1px) 0 0 / 56px 56px, + radial-gradient(circle at 22% 24%, rgba(100, 137, 246, 0.11), transparent 25rem), + #0b0f21; + box-shadow: var(--shadow); +} + +.study-hero::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--lime); + content: ""; +} + +.study-hero::after { + position: absolute; + top: -190px; + left: 25%; + width: 420px; + height: 420px; + border: 1px solid rgba(159, 248, 80, 0.09); + border-radius: 50%; + content: ""; +} + +.study-hero-copy { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding: 40px 42px; +} + +.study-kicker { + display: flex; + align-items: center; + gap: 9px; + color: var(--lime); + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.study-kicker i { + width: 22px; + height: 1px; + background: currentColor; +} + +.study-hero h2 { + margin: 17px 0 16px; + font-size: clamp(42px, 4.4vw, 70px); + letter-spacing: -0.075em; + line-height: 0.91; +} + +.study-hero h2 em { + color: var(--muted-strong); + font-style: normal; + font-weight: 500; +} + +.study-hero-copy > p { + max-width: 560px; + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.72; +} + +.study-primary-question { + display: flex; + align-items: center; + gap: 12px; + margin-top: 27px; + padding-top: 21px; + border-top: 1px solid var(--border); +} + +.study-primary-question > span:first-child { + width: 38px; + height: 38px; + display: grid; + flex: 0 0 38px; + place-items: center; + border-radius: 9px; + background: rgba(159, 248, 80, 0.08); + color: var(--lime); +} + +.study-primary-question svg, +.lane-mark svg, +.candidate-lead svg, +.candidate-selector svg, +.risk-grid svg { + width: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.study-primary-question > span:last-child { + display: flex; + flex-direction: column; + gap: 3px; +} + +.study-primary-question small, +.study-clock small, +.study-lane small, +.candidate-lead small, +.candidate-selector small { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.study-primary-question b { + font-size: 10px; +} + +.study-clock { + position: relative; + z-index: 1; + display: grid; + grid-template-rows: auto 1fr auto auto; + border-left: 1px solid var(--border); + background: rgba(4, 7, 17, 0.3); +} + +.study-clock > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 17px 23px; + border-bottom: 1px solid var(--border); +} + +.study-clock > header > span { + display: flex; + align-items: center; + gap: 7px; + color: var(--lime); + font-size: 9px; + font-weight: 800; +} + +.study-clock > header > span i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 12px rgba(159, 248, 80, 0.5); +} + +.study-clock > header em { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + font-style: normal; +} + +.study-clock-main { + display: grid; + grid-template-columns: 74px 1fr 1fr; + gap: 22px; + align-items: center; + padding: 24px 25px 18px; +} + +.study-clock-lock { + width: 64px; + height: 64px; + display: grid; + place-items: center; + border: 1px solid rgba(159, 248, 80, 0.18); + border-radius: 50%; + background: radial-gradient(circle, rgba(159, 248, 80, 0.12), rgba(159, 248, 80, 0.025)); + color: var(--lime); + box-shadow: 0 0 50px rgba(159, 248, 80, 0.06); +} + +.study-clock-lock svg { + width: 25px; + fill: none; + stroke: currentColor; + stroke-width: 1.4; +} + +.study-clock-main > div { + min-width: 0; + display: flex; + flex-direction: column; +} + +.study-clock-main strong { + display: flex; + align-items: baseline; + gap: 7px; + margin: 7px 0 2px; + color: var(--muted-strong); + font-family: var(--mono); + font-size: clamp(28px, 3.15vw, 50px); + font-weight: 400; + letter-spacing: -0.08em; + line-height: 1; +} + +.study-clock-main strong b { + color: var(--lime); + font-weight: 500; +} + +.study-clock-main strong em { + color: var(--border-strong); + font-size: 0.55em; + font-style: normal; +} + +.study-clock-main > div > span { + color: var(--muted); + font-size: 8px; +} + +.study-clock-progress { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22px; + padding: 0 25px 22px; +} + +.study-progress-meter > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.study-progress-meter > div > span { + color: var(--muted-strong); + font-family: var(--mono); + font-size: 8px; +} + +.study-progress-meter > div > span b { + color: var(--text); +} + +.study-progress-meter > div small { + overflow: hidden; + color: var(--muted); + font-size: 7px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.study-progress-meter > i { + height: 3px; + display: block; + margin-top: 8px; + overflow: hidden; + border-radius: 3px; + background: rgba(157, 169, 218, 0.13); +} + +.study-progress-meter > i em { + height: 100%; + display: block; + background: var(--lime); + box-shadow: 0 0 9px rgba(159, 248, 80, 0.5); +} + +.study-clock > footer { + display: grid; + grid-template-columns: 1fr 1.2fr auto; + gap: 15px; + align-items: center; + padding: 14px 23px; + border-top: 1px solid var(--border); + background: rgba(7, 10, 24, 0.65); + color: var(--muted); + font-size: 8px; +} + +.study-clock > footer b { + color: var(--text-soft); +} + +.study-clock > footer > span:last-child { + display: flex; + align-items: center; + gap: 6px; + color: var(--lime); + font-weight: 800; +} + +.study-clock > footer svg { + width: 13px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.study-lanes { + display: grid; + grid-template-columns: 1fr 1fr minmax(260px, 0.76fr); + margin-top: 12px; + overflow: hidden; + border-radius: 4px 4px 12px 12px; +} + +.study-lane, +.lane-boundary { + min-width: 0; + display: flex; + align-items: center; + gap: 13px; + padding: 17px 19px; + border-right: 1px solid var(--border); +} + +.study-lane.long-run { + background: linear-gradient(90deg, rgba(159, 248, 80, 0.08), transparent); +} + +.lane-mark { + width: 36px; + height: 36px; + display: grid; + flex: 0 0 36px; + place-items: center; + border-radius: 9px; + background: rgba(159, 248, 80, 0.09); + color: var(--lime); +} + +.study-lane.bounty .lane-mark { + background: rgba(100, 137, 246, 0.1); + color: var(--blue); +} + +.study-lane > span:nth-child(2), +.lane-boundary > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.study-lane b, +.lane-boundary b { + font-size: 9px; +} + +.study-lane em, +.lane-boundary small { + overflow: hidden; + color: var(--muted); + font-size: 7px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.study-lane > strong { + margin-left: auto; + padding: 5px 7px; + border: 1px solid rgba(159, 248, 80, 0.18); + border-radius: 5px; + color: var(--lime); + font-family: var(--mono); + font-size: 7px; + text-transform: uppercase; +} + +.study-lane.bounty > strong { + border-color: rgba(100, 137, 246, 0.2); + color: var(--blue); +} + +.lane-boundary { + border-right: 0; + background: var(--surface-soft); +} + +.lane-boundary > svg { + width: 19px; + flex: 0 0 19px; + fill: none; + stroke: var(--violet); + stroke-width: 1.6; +} + +.study-corrected-evidence { + margin-top: 12px; + overflow: hidden; + border-color: rgba(100, 137, 246, 0.2); + border-radius: 14px; + background: linear-gradient(125deg, rgba(100, 137, 246, 0.07), rgba(9, 13, 29, 0.95) 45%); +} + +.study-unregistered-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 1px solid rgba(100, 137, 246, 0.24); + border-radius: 6px; + background: rgba(100, 137, 246, 0.08); + color: var(--blue); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.study-unregistered-chip svg, +.corrected-evidence-boundary svg { + width: 13px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.corrected-evidence-grid { + display: grid; + grid-template-columns: 1.35fr repeat(3, 1fr); +} + +.corrected-evidence-grid > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; + padding: 22px; + border-right: 1px solid var(--border); +} + +.corrected-evidence-grid > div:last-child { + border-right: 0; +} + +.corrected-evidence-grid small, +.corrected-evidence-grid em { + color: var(--muted); + font-size: 7px; + font-style: normal; + line-height: 1.45; +} + +.corrected-evidence-grid b { + color: var(--text-soft); + font-size: 18px; + letter-spacing: -0.04em; +} + +.corrected-evidence-primary b { + color: var(--lime); + font-family: var(--mono); + font-size: 29px; +} + +.corrected-evidence-boundary { + display: flex; + align-items: center; + gap: 11px; + padding: 14px 22px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: rgba(145, 131, 229, 0.06); + color: var(--violet); +} + +.corrected-evidence-boundary > span { + display: flex; + flex-direction: column; + gap: 3px; +} + +.corrected-evidence-boundary b { + color: var(--text-soft); + font-size: 9px; +} + +.corrected-evidence-boundary small { + color: var(--muted); + font-size: 7px; +} + +.study-corrected-evidence > footer { + display: flex; + justify-content: space-between; + gap: 18px; + padding: 12px 22px; + color: var(--muted); + font-size: 7px; +} + +.study-corrected-evidence > footer code { + margin-left: 6px; + color: var(--muted-strong); + font-family: var(--mono); +} + +.study-synthetic-proof { + margin-top: 12px; + overflow: hidden; + border-color: rgba(159, 248, 80, 0.18); + border-radius: 14px; + background: linear-gradient(120deg, rgba(159, 248, 80, 0.055), rgba(9, 13, 29, 0.97) 48%); +} + +.synthetic-verified-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 1px solid rgba(159, 248, 80, 0.24); + border-radius: 6px; + background: rgba(159, 248, 80, 0.07); + color: var(--lime); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.synthetic-verified-chip svg, +.synthetic-proof-mark svg, +.study-synthetic-proof > footer svg { + width: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.synthetic-proof-body { + display: grid; + grid-template-columns: minmax(300px, 1.25fr) minmax(420px, 1fr); +} + +.synthetic-proof-story { + display: flex; + align-items: center; + gap: 15px; + padding: 22px; + border-right: 1px solid var(--border); +} + +.synthetic-proof-mark { + width: 42px; + height: 42px; + flex: 0 0 42px; + display: grid; + place-items: center; + border: 1px solid rgba(159, 248, 80, 0.22); + border-radius: 11px; + background: rgba(159, 248, 80, 0.07); + color: var(--lime); +} + +.synthetic-proof-mark svg { + width: 20px; +} + +.synthetic-proof-story > div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.synthetic-proof-story small, +.synthetic-proof-facts small { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.synthetic-proof-story b { + color: var(--text-soft); + font-size: 12px; +} + +.synthetic-proof-story p { + max-width: 580px; + margin: 0; + color: var(--muted-strong); + font-size: 8px; + line-height: 1.55; +} + +.synthetic-proof-facts { + display: grid; + grid-template-columns: repeat(4, 1fr); +} + +.synthetic-proof-facts > span { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + padding: 18px 14px; + border-right: 1px solid var(--border); +} + +.synthetic-proof-facts > span:last-child { + border-right: 0; +} + +.synthetic-proof-facts b { + color: var(--text-soft); + font-size: 9px; +} + +.synthetic-proof-flow { + display: grid; + grid-template-columns: repeat(7, 1fr); + padding: 13px 22px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: rgba(159, 248, 80, 0.025); +} + +.synthetic-proof-flow > span { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--muted-strong); +} + +.synthetic-proof-flow b { + font-family: var(--mono); + font-size: 7px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.synthetic-proof-flow i { + display: grid; + place-items: center; + color: rgba(159, 248, 80, 0.48); +} + +.synthetic-proof-flow svg { + width: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.study-synthetic-proof > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 12px 22px; + color: var(--muted-strong); + font-size: 7px; +} + +.study-synthetic-proof > footer > span, +.study-synthetic-proof > footer a { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.study-synthetic-proof > footer > span svg { + color: var(--muted); +} + +.study-synthetic-proof > footer a { + color: var(--lime); + font-weight: 800; + text-decoration: none; + white-space: nowrap; +} + +.study-synthetic-proof > footer a svg { + width: 12px; +} + +.study-primary-grid { + display: grid; + grid-template-columns: minmax(0, 1.9fr) minmax(285px, 0.72fr); + gap: 12px; + align-items: stretch; + margin-top: 12px; +} + +.study-endpoint, +.study-guardrails, +.study-candidate, +.study-risk, +.study-queue, +.study-rules, +.study-rows { + overflow: hidden; + border-radius: 14px; +} + +.study-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 21px 23px 17px; + border-bottom: 1px solid var(--border); +} + +.study-panel-head > div > span { + color: var(--lime); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.study-panel-head h2 { + margin: 4px 0 0; + font-size: 16px; + letter-spacing: -0.04em; +} + +.study-sealed-chip, +.study-result-chip, +.config-frozen, +.paper-only, +.rules-note { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 1px solid rgba(145, 131, 229, 0.25); + border-radius: 6px; + background: rgba(145, 131, 229, 0.08); + color: var(--violet); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.study-sealed-chip svg { + width: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.sealed-endpoint-stage { + position: relative; + min-height: 310px; + padding: 32px 34px 25px; + background: + linear-gradient(90deg, rgba(157, 169, 218, 0.035) 1px, transparent 1px) 0 0 / 56px 56px, + linear-gradient(rgba(157, 169, 218, 0.035) 1px, transparent 1px) 0 0 / 56px 56px, + var(--surface-soft); +} + +.endpoint-axis-labels { + display: grid; + grid-template-columns: 1fr auto 1fr; + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + text-transform: uppercase; +} + +.endpoint-axis-labels span:nth-child(2) { + color: var(--text-soft); +} + +.endpoint-axis-labels span:last-child { + text-align: right; +} + +.endpoint-axis { + position: relative; + height: 18px; + margin: 10px 0 27px; +} + +.endpoint-axis > i { + position: absolute; + top: 8px; + right: 0; + left: 0; + height: 2px; + border-radius: 2px; + background: linear-gradient(90deg, rgba(241, 110, 122, 0.55), rgba(157, 169, 218, 0.25) 50%, rgba(159, 248, 80, 0.7)); +} + +.endpoint-axis > em { + position: absolute; + top: 1px; + left: 50%; + width: 1px; + height: 16px; + background: var(--text-soft); +} + +.endpoint-axis > span { + position: absolute; + top: 4px; + left: calc(50% - 4px); + width: 9px; + height: 9px; + border: 2px solid var(--surface-soft); + border-radius: 50%; + background: var(--text-soft); +} + +.endpoint-vault { + display: flex; + align-items: center; + gap: 18px; + padding: 20px; + border: 1px solid rgba(145, 131, 229, 0.22); + border-radius: 11px; + background: linear-gradient(110deg, rgba(145, 131, 229, 0.1), rgba(145, 131, 229, 0.025)); +} + +.endpoint-vault > span { + width: 48px; + height: 48px; + display: grid; + flex: 0 0 48px; + place-items: center; + border-radius: 50%; + background: rgba(145, 131, 229, 0.13); + color: var(--violet); +} + +.endpoint-vault svg { + width: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.6; +} + +.endpoint-vault > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.endpoint-vault small { + color: var(--violet); + font-family: var(--mono); + font-size: 7px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.endpoint-vault b { + font-size: 13px; +} + +.endpoint-vault p { + margin: 0; + color: var(--muted); + font-size: 8px; + line-height: 1.5; +} + +.endpoint-method { + display: grid; + grid-template-columns: 1fr 1fr 1.4fr; + gap: 20px; + margin-top: 21px; +} + +.endpoint-method > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.endpoint-method small { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + text-transform: uppercase; +} + +.endpoint-method b { + overflow: hidden; + color: var(--text-soft); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.study-accept-criteria { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid var(--border); +} + +.study-accept-criteria > div { + min-height: 101px; + display: grid; + grid-template-columns: 20px 27px 1fr; + gap: 9px; + align-items: start; + padding: 17px; + border-right: 1px solid var(--border); +} + +.study-accept-criteria > div:last-child { + border-right: 0; +} + +.study-accept-criteria > div > span { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; +} + +.study-accept-criteria > div > i { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 50%; + background: rgba(145, 131, 229, 0.1); + color: var(--violet); +} + +.study-accept-criteria svg { + width: 11px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.study-accept-criteria p { + margin: 0; + color: var(--muted-strong); + font-size: 8px; + line-height: 1.48; +} + +.study-guardrails { + display: flex; + flex-direction: column; +} + +.study-guardrails .study-panel-head > svg { + width: 20px; + fill: none; + stroke: var(--lime); + stroke-width: 1.6; +} + +.study-guardrails > p { + margin: 0; + padding: 15px 20px; + border-bottom: 1px solid var(--border); + color: var(--muted); + font-size: 8px; + line-height: 1.55; +} + +.study-guardrail-list { + padding: 5px 20px; +} + +.study-guardrail-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 13px; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} + +.study-guardrail-list > div > span { + display: flex; + align-items: center; + gap: 9px; +} + +.study-guardrail-list > div > span > i { + width: 27px; + height: 27px; + display: grid; + flex: 0 0 27px; + place-items: center; + border-radius: 7px; + background: rgba(145, 131, 229, 0.09); + color: var(--violet); +} + +.study-guardrail-list > div.pass > span > i { + background: rgba(159, 248, 80, 0.09); + color: var(--lime); +} + +.study-guardrail-list > div.fail > span > i { + background: var(--danger-soft); + color: var(--danger); +} + +.study-guardrail-list svg { + width: 13px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.study-guardrail-list > div > span > span { + display: flex; + flex-direction: column; + gap: 2px; +} + +.study-guardrail-list b { + font-size: 9px; +} + +.study-guardrail-list small { + color: var(--muted); + font-size: 7px; +} + +.study-guardrail-list em { + color: var(--text-soft); + font-family: var(--mono); + font-size: 8px; + font-style: normal; + white-space: nowrap; +} + +.study-guardrails > footer, +.study-risk > footer { + display: flex; + gap: 10px; + margin-top: auto; + padding: 16px 20px; + border-top: 1px solid var(--border); + background: var(--surface-soft); +} + +.study-guardrails > footer svg, +.study-risk > footer svg { + width: 17px; + flex: 0 0 17px; + fill: none; + stroke: var(--violet); + stroke-width: 1.6; +} + +.study-guardrails > footer span, +.study-risk > footer span { + display: flex; + flex-direction: column; + gap: 3px; +} + +.study-guardrails > footer b, +.study-risk > footer b { + font-size: 8px; +} + +.study-guardrails > footer small, +.study-risk > footer small { + color: var(--muted); + font-size: 7px; + line-height: 1.45; +} + +.study-secondary-grid { + display: grid; + grid-template-columns: minmax(0, 1.16fr) minmax(260px, 0.8fr) minmax(280px, 1fr); + gap: 12px; + align-items: stretch; + margin-top: 12px; +} + +.config-frozen { + border-color: rgba(159, 248, 80, 0.2); + background: rgba(159, 248, 80, 0.07); + color: var(--lime); +} + +.config-frozen i { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +.candidate-lead, +.candidate-selector { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 22px; + border-bottom: 1px solid var(--border); +} + +.candidate-lead > span:first-child, +.candidate-selector > span:first-child { + width: 37px; + height: 37px; + display: grid; + flex: 0 0 37px; + place-items: center; + border-radius: 9px; + background: rgba(159, 248, 80, 0.09); + color: var(--lime); +} + +.candidate-selector > span:first-child { + background: rgba(100, 137, 246, 0.09); + color: var(--blue); +} + +.candidate-lead > span:last-child, +.candidate-selector > span:last-child { + display: flex; + flex-direction: column; + gap: 3px; +} + +.candidate-lead b, +.candidate-selector b { + font-size: 10px; +} + +.candidate-lead em, +.candidate-selector em { + color: var(--muted); + font-size: 7px; + font-style: normal; +} + +.candidate-parameters { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-bottom: 1px solid var(--border); +} + +.candidate-parameters > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 7px; + padding: 17px 12px; + border-right: 1px solid var(--border); +} + +.candidate-parameters > span:last-child { + border-right: 0; +} + +.candidate-parameters small, +.candidate-hash > span { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + text-transform: uppercase; +} + +.candidate-parameters b { + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.candidate-hash { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 13px 18px; + background: var(--surface-soft); +} + +.candidate-hash code { + color: var(--muted-strong); + font-family: var(--mono); + font-size: 7px; +} + +.paper-only { + border-color: rgba(100, 137, 246, 0.22); + background: rgba(100, 137, 246, 0.07); + color: var(--blue); +} + +.study-risk { + display: flex; + flex-direction: column; +} + +.risk-orbit { + position: relative; + min-height: 158px; + display: grid; + place-items: center; + overflow: hidden; + border-bottom: 1px solid var(--border); + background: radial-gradient(circle, rgba(100, 137, 246, 0.1), transparent 60%); +} + +.risk-bankroll { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; +} + +.risk-bankroll span, +.risk-bankroll small, +.risk-grid small { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + text-transform: uppercase; +} + +.risk-bankroll b { + margin: 4px 0; + font-size: 32px; + letter-spacing: -0.06em; +} + +.risk-ring { + position: absolute; + border: 1px solid rgba(100, 137, 246, 0.12); + border-radius: 50%; +} + +.risk-ring.one { + width: 128px; + height: 128px; +} + +.risk-ring.two { + width: 205px; + height: 205px; +} + +.risk-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid var(--border); +} + +.risk-grid > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; + padding: 14px 11px; + border-right: 1px solid var(--border); +} + +.risk-grid > span:last-child { + border-right: 0; +} + +.risk-grid b { + font-size: 11px; +} + +.study-queue .study-panel-head > span { + color: var(--muted); + font-family: var(--mono); + font-size: 8px; +} + +.queue-flow { + display: grid; + grid-template-columns: 1fr 18px 1fr 18px 1fr; + gap: 8px; + align-items: center; + padding: 25px 20px; + border-bottom: 1px solid var(--border); +} + +.queue-flow > div { + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + text-align: center; +} + +.queue-flow b { + font-family: var(--mono); + font-size: 25px; + font-weight: 500; +} + +.queue-flow span { + color: var(--muted); + font-size: 7px; +} + +.queue-flow .eligible b { + color: var(--lime); +} + +.queue-flow > i { + color: var(--muted); +} + +.queue-flow svg, +.study-queue > a svg { + width: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.queue-rejection { + display: flex; + gap: 11px; + padding: 17px 20px; + border-bottom: 1px solid var(--border); +} + +.queue-rejection > span:first-child { + width: 29px; + height: 29px; + display: grid; + flex: 0 0 29px; + place-items: center; + border-radius: 7px; + background: var(--amber-soft); + color: var(--amber); +} + +.queue-rejection svg { + width: 13px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.queue-rejection > span:last-child { + display: flex; + flex-direction: column; + gap: 4px; +} + +.queue-rejection b { + font-size: 8px; +} + +.queue-rejection small { + color: var(--muted); + font-size: 7px; + line-height: 1.5; +} + +.study-queue > a { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + color: var(--text-soft); + font-size: 8px; + font-weight: 800; + text-decoration: none; +} + +.study-queue > a svg { + color: var(--lime); +} + +.study-rules { + margin-top: 12px; +} + +.rules-note { + border-color: var(--border); + background: var(--surface-soft); + color: var(--muted); +} + +.study-rule-grid { + display: grid; + grid-template-columns: 1.25fr 0.9fr 0.9fr; +} + +.study-rule-grid > div { + min-height: 188px; + padding: 21px 23px; + border-right: 1px solid var(--border); +} + +.study-rule-grid > div:last-child { + border-right: 0; +} + +.study-rule-grid header { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 15px; +} + +.study-rule-grid header > span { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 7px; +} + +.study-rule-grid .accept header > span { + background: rgba(159, 248, 80, 0.09); + color: var(--lime); +} + +.study-rule-grid .reject header > span { + background: var(--danger-soft); + color: var(--danger); +} + +.study-rule-grid .inconclusive header > span { + background: var(--amber-soft); + color: var(--amber); +} + +.study-rule-grid svg { + width: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; +} + +.study-rule-grid header b { + font-size: 11px; +} + +.study-rule-grid p { + position: relative; + margin: 0; + padding: 8px 0 8px 15px; + border-top: 1px solid var(--border); + color: var(--muted-strong); + font-size: 8px; + line-height: 1.5; +} + +.study-rule-grid p::before { + position: absolute; + top: 13px; + left: 1px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--muted); + content: ""; +} + +.study-rules > footer { + display: flex; + align-items: center; + gap: 10px; + padding: 15px 23px; + border-top: 1px solid rgba(145, 131, 229, 0.16); + background: rgba(145, 131, 229, 0.07); + color: var(--text-soft); +} + +.study-rules > footer svg { + width: 17px; + flex: 0 0 17px; + fill: none; + stroke: var(--violet); + stroke-width: 1.7; +} + +.study-rules > footer b { + font-size: 8px; +} + +.study-proof { + display: grid; + grid-template-columns: minmax(205px, 1.2fr) 1fr 1fr 1fr auto; + gap: 21px; + align-items: center; + margin-top: 12px; + padding: 19px 23px; + border: 1px solid var(--border); + border-radius: 12px; + background: #090d1d; +} + +.study-proof > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.study-proof-status { + flex-direction: row !important; + align-items: center; + gap: 10px !important; +} + +.study-proof-status > svg, +.study-proof-boundary svg { + width: 20px; + flex: 0 0 20px; + fill: none; + stroke: var(--lime); + stroke-width: 1.6; +} + +.study-proof-status > span { + display: flex; + flex-direction: column; + gap: 3px; +} + +.study-proof b { + font-size: 9px; +} + +.study-proof small { + color: var(--muted); + font-size: 7px; +} + +.study-proof code { + overflow: hidden; + color: var(--muted-strong); + font-family: var(--mono); + font-size: 7px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.study-proof-boundary { + flex-direction: row !important; + align-items: center; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted-strong); + font-size: 7px; + white-space: nowrap; +} + +.study-proof-boundary svg { + width: 13px; + flex-basis: 13px; + stroke: var(--muted); +} + +.study-open-endpoints { + display: grid; + grid-template-columns: 1.4fr 1fr 1fr 1fr; +} + +.study-open-endpoints > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 29px 22px; + border-right: 1px solid var(--border); +} + +.study-open-endpoints > div:last-child { + border-right: 0; +} + +.study-open-endpoints span, +.study-open-endpoints small { + color: var(--muted); + font-size: 8px; +} + +.study-open-endpoints b { + margin: 10px 0 3px; + font-size: 27px; +} + +.study-endpoint-primary b { + color: var(--lime); +} + +.study-incomplete-endpoint { + display: flex; + gap: 12px; + margin: 26px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 9px; +} + +.study-incomplete-endpoint svg { + width: 18px; + fill: none; + stroke: var(--amber); + stroke-width: 1.7; +} + +.study-incomplete-endpoint span { + display: flex; + flex-direction: column; + gap: 4px; +} + +.study-incomplete-endpoint small { + color: var(--muted); + font-size: 8px; +} + +.study-result-chip.accept { + border-color: rgba(159, 248, 80, 0.2); + background: rgba(159, 248, 80, 0.08); + color: var(--lime); +} + +.study-result-chip.reject { + border-color: rgba(241, 110, 122, 0.2); + background: var(--danger-soft); + color: var(--danger); +} + +.study-rows { + margin-top: 12px; +} + +.study-rows .study-panel-head > span { + color: var(--muted); + font-family: var(--mono); + font-size: 8px; +} + +.study-row-head, +.study-row { + display: grid; + grid-template-columns: minmax(130px, 1.2fr) repeat(7, minmax(70px, 1fr)); + gap: 12px; + align-items: center; + padding: 12px 20px; +} + +.study-row-head { + color: var(--muted); + font-family: var(--mono); + font-size: 7px; + text-transform: uppercase; +} + +.study-row { + border-top: 1px solid var(--border); + color: var(--text-soft); + font-size: 8px; +} + +.study-row > span:first-child { + display: flex; + flex-direction: column; +} + +.study-row small { + color: var(--muted); + font-size: 7px; +} + +@media (max-width: 1260px) { + .study-hero { + grid-template-columns: minmax(340px, 0.72fr) minmax(500px, 1.28fr); + } + + .study-hero-copy { + padding: 34px; + } + + .study-clock-main { + grid-template-columns: 58px 1fr 1fr; + gap: 16px; + } + + .study-clock-lock { + width: 52px; + height: 52px; + } + + .study-secondary-grid { + grid-template-columns: 1.2fr 0.8fr; + } + + .study-queue { + grid-column: 1 / 3; + } +} + +@media (max-width: 1060px) { + .study-content { + padding-right: 20px; + padding-left: 20px; + } + + .study-hero { + grid-template-columns: 1fr; + } + + .study-hero-copy { + min-height: 310px; + } + + .study-clock { + min-height: 330px; + border-top: 1px solid var(--border); + border-left: 0; + } + + .study-lanes { + grid-template-columns: 1fr 1fr; + } + + .corrected-evidence-grid { + grid-template-columns: 1fr 1fr; + } + + .corrected-evidence-grid > div:nth-child(2) { + border-right: 0; + } + + .corrected-evidence-grid > div:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + + .synthetic-proof-body { + grid-template-columns: 1fr; + } + + .synthetic-proof-story { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .lane-boundary { + grid-column: 1 / 3; + border-top: 1px solid var(--border); + } + + .study-primary-grid { + grid-template-columns: 1fr; + } + + .study-guardrails { + display: grid; + grid-template-columns: minmax(230px, 0.7fr) 1fr; + } + + .study-guardrails .study-panel-head, + .study-guardrails > p, + .study-guardrails > footer { + grid-column: 1; + } + + .study-guardrails > p { + border-right: 1px solid var(--border); + } + + .study-guardrail-list { + grid-column: 2; + grid-row: 1 / 4; + } + + .study-rule-grid { + grid-template-columns: 1fr; + } + + .study-rule-grid > div { + min-height: 0; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .study-proof { + grid-template-columns: 1.2fr 1fr 1fr; + } + + .study-proof-boundary { + width: fit-content; + } +} + +@media (max-width: 760px) { + .study-content { + padding: 12px 12px 104px; + } + + .study-hero { + border-radius: 12px 12px 3px 3px; + } + + .study-hero-copy { + min-height: 340px; + padding: 30px 24px; + } + + .study-hero h2 { + font-size: clamp(44px, 14vw, 61px); + } + + .study-clock { + min-height: 0; + } + + .study-clock-main { + grid-template-columns: 1fr 1fr; + padding: 24px 19px 18px; + } + + .study-clock-lock { + grid-column: 1 / 3; + } + + .study-clock-progress { + grid-template-columns: 1fr; + padding-right: 19px; + padding-left: 19px; + } + + .study-clock > footer { + grid-template-columns: 1fr; + padding-right: 19px; + padding-left: 19px; + } + + .study-lanes { + grid-template-columns: 1fr; + } + + .study-lane, + .lane-boundary { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .lane-boundary { + grid-column: 1; + } + + .corrected-evidence-grid { + grid-template-columns: 1fr; + } + + .corrected-evidence-grid > div { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .study-corrected-evidence > footer { + flex-direction: column; + } + + .synthetic-proof-facts { + grid-template-columns: 1fr 1fr; + } + + .synthetic-proof-facts > span:nth-child(2) { + border-right: 0; + } + + .synthetic-proof-facts > span:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + + .synthetic-proof-flow { + grid-template-columns: 1fr; + gap: 8px; + } + + .synthetic-proof-flow > span { + justify-content: flex-start; + } + + .synthetic-proof-flow i { + transform: rotate(90deg); + } + + .study-synthetic-proof > footer { + align-items: flex-start; + flex-direction: column; + } + + .sealed-endpoint-stage { + padding: 27px 18px 22px; + } + + .endpoint-method { + grid-template-columns: 1fr; + gap: 11px; + } + + .study-accept-criteria { + grid-template-columns: 1fr; + } + + .study-accept-criteria > div { + min-height: 0; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .study-guardrails { + display: flex; + } + + .study-guardrails > p { + border-right: 0; + } + + .study-secondary-grid { + grid-template-columns: 1fr; + } + + .study-queue { + grid-column: 1; + } + + .study-proof { + grid-template-columns: 1fr 1fr; + padding: 19px; + } + + .study-proof-status { + grid-column: 1 / 3; + } + + .study-open-endpoints { + grid-template-columns: 1fr 1fr; + } + + .study-open-endpoints > div:nth-child(2) { + border-right: 0; + } + + .study-open-endpoints > div:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + + .study-row-table { + overflow-x: auto; + } + + .study-row-head, + .study-row { + min-width: 760px; + } +} + +@media (max-width: 420px) { + .study-hero-copy { + min-height: 370px; + } + + .study-clock > header { + align-items: flex-start; + flex-direction: column; + } + + .study-clock-main strong { + font-size: 33px; + } + + .study-lane { + align-items: flex-start; + } + + .study-lane > strong { + margin-left: 0; + } + + .endpoint-vault { + align-items: flex-start; + flex-direction: column; + } + + .candidate-parameters { + grid-template-columns: 1fr 1fr; + } + + .candidate-parameters > span:nth-child(2) { + border-right: 0; + } + + .candidate-parameters > span:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + + .risk-grid { + grid-template-columns: 1fr; + } + + .synthetic-proof-facts { + grid-template-columns: 1fr; + } + + .synthetic-proof-facts > span { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .risk-grid > span { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .queue-flow { + grid-template-columns: 1fr; + } + + .queue-flow > i { + transform: rotate(90deg); + justify-self: center; + } + + .study-proof { + grid-template-columns: 1fr; + } + + .study-proof-status { + grid-column: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json new file mode 100644 index 0000000..d08f209 --- /dev/null +++ b/apps/dashboard/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts", "../../src/dash/public-contract.ts"] +} diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts new file mode 100644 index 0000000..c01d66b --- /dev/null +++ b/apps/dashboard/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath, URL } from "node:url"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type Plugin } from "vite"; +import { handleDashboardApi, writeApiResult } from "../../src/dash/api.js"; + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + +function dashboardApi(): Plugin { + return { + name: "samaritan-dashboard-api", + configureServer(server) { + server.middlewares.use(async (request, response, next) => { + const url = new URL(request.url ?? "/", "http://localhost"); + const result = await handleDashboardApi(url.pathname, repoRoot); + if (!result) { + next(); + return; + } + writeApiResult(response, result, request.method === "HEAD"); + }); + } + }; +} + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + publicDir: fileURLToPath(new URL("../../public", import.meta.url)), + plugins: [react(), dashboardApi()], + build: { + outDir: "dist", + emptyOutDir: true + }, + server: { + host: "127.0.0.1", + port: 4173 + } +}); diff --git a/config/captures/england-argentina-2026-07-15.json b/config/captures/england-argentina-2026-07-15.json new file mode 100644 index 0000000..3016630 --- /dev/null +++ b/config/captures/england-argentina-2026-07-15.json @@ -0,0 +1,34 @@ +{ + "captureId": "paired-england-argentina-2026-07-15", + "status": "human_confirmed_for_capture_only", + "tradeable": false, + "confirmedBy": "Deborah", + "confirmedAt": "2026-07-12", + "txline": { + "fixtureId": "18241006", + "home": "England", + "away": "Argentina", + "kickoffUtc": "2026-07-15T19:00:00.000Z" + }, + "polymarket": { + "eventId": "694581", + "eventSlug": "fifwc-eng-arg-2026-07-15", + "totalsEventId": "694786", + "totalsEventSlug": "fifwc-eng-arg-2026-07-15-more-markets", + "home": "England", + "away": "Argentina", + "kickoffUtc": "2026-07-15T19:00:00.000Z", + "rulesPeriod": "first_90_minutes_plus_stoppage_time" + }, + "capture": { + "scheduledStartUtc": "2026-07-15T16:00:00.000Z", + "durationMinutes": 360, + "runLabel": "paired-england-argentina-2026-07-15" + }, + "evidence": { + "txlineFixtures": "samples/fixtures/mainnet-world-cup-fixtures.json", + "polymarketEvents": "data/live/gamma-discovery/open-world-cup-events.json", + "readinessReport": "docs/research/capture-readiness.md" + }, + "note": "Deborah confirmed public-data capture only. This config cannot authorize authentication beyond existing TXLine data access, token activation, wallet access, trading, approval, deposit, order placement, or money movement." +} diff --git a/config/captures/france-spain-2026-07-14.json b/config/captures/france-spain-2026-07-14.json new file mode 100644 index 0000000..7e68597 --- /dev/null +++ b/config/captures/france-spain-2026-07-14.json @@ -0,0 +1,34 @@ +{ + "captureId": "paired-france-spain-2026-07-14", + "status": "human_confirmed_for_capture_only", + "tradeable": false, + "confirmedBy": "Deborah", + "confirmedAt": "2026-07-12", + "txline": { + "fixtureId": "18237038", + "home": "France", + "away": "Spain", + "kickoffUtc": "2026-07-14T19:00:00.000Z" + }, + "polymarket": { + "eventId": "691040", + "eventSlug": "fifwc-fra-esp-2026-07-14", + "totalsEventId": "691131", + "totalsEventSlug": "fifwc-fra-esp-2026-07-14-more-markets", + "home": "France", + "away": "Spain", + "kickoffUtc": "2026-07-14T19:00:00.000Z", + "rulesPeriod": "first_90_minutes_plus_stoppage_time" + }, + "capture": { + "scheduledStartUtc": "2026-07-14T16:00:00.000Z", + "durationMinutes": 360, + "runLabel": "paired-france-spain-2026-07-14" + }, + "evidence": { + "txlineFixtures": "samples/fixtures/mainnet-world-cup-fixtures.json", + "polymarketEvents": "data/live/gamma-discovery/open-world-cup-events.json", + "readinessReport": "docs/research/capture-readiness.md" + }, + "note": "Deborah confirmed public-data capture only. This config cannot authorize authentication beyond existing TXLine data access, token activation, wallet access, trading, approval, deposit, order placement, or money movement." +} diff --git a/docs/01-research.md b/docs/01-research.md index 6529704..82cad40 100644 --- a/docs/01-research.md +++ b/docs/01-research.md @@ -13,13 +13,13 @@ | Track prize | **16,000 USDT** — 10,000 / 4,000 / 2,000 | | Hackathon window | June 24 – **July 19, 2026** (submission deadline; final match day) | | Winners announced | July 29, 2026 | -| Competition | ~13 submissions in the track as of July 3 | -| Eligibility | Global. Explicitly open to individual devs, teams, **and autonomous AI agents** | +| Competition | **51 submissions shown on the live track page as of July 14**; the number is volatile and must be rechecked before submission | +| Eligibility | Global listing; the brief mentions AI agents, while the binding terms restrict participation/submission to natural persons. Deborah is the sole participant and owner. | | Other tracks | Prediction Markets & Settlement; Consumer & Fan Experiences (not ours) | | Contact | Telegram: TxLINEChat | | Hackathon pitch | "Real products powered by TxODDS live football API on Solana" — real-time World Cup data wired into on-chain products. TxODDS is waiving data fees and the token payment requirement for the World Cup tier. | -**Reading the judges:** it's a TxODDS + Solana event. Winning entries will (a) actually use TXLine deeply, (b) have a crypto-native/on-chain angle, (c) be real products, not demos. Our on-chain verifiable decision ledger and Merkle-proof validation hit (b) directly; the replay-backtested strategy engine hits (a) and (c). +**Reading the judges:** this is a TxODDS + Solana event, but the brief is more specific than that shorthand. It scores core ingestion, autonomous operation, defensible logic/architecture, novelty, and production readiness, and says the demo video is weighted heavily because live match activity may be absent during review. Samaritan must therefore show a working strategy lifecycle, not merely architecture, while distinguishing local hash-chain verification from any Solana anchor that has actually been submitted and verified. ### 1.1 Hackathon legal terms — VERIFIED ([terms page](https://txline.txodds.com/documentation/legal/hackathon-terms)) @@ -28,7 +28,7 @@ These are binding and shape the build: | Rule | Exact/near-exact language | Consequence for Samaritan | |---|---|---| | Originality | "Original work created during the Hackathon period"; open-source components OK with attribution; "significant portions … developed during the Hackathon" | We start clean July 4 — fully compliant | -| **AI clause** | "Entries must be created, developed and submitted by **human participants**" — AI-generated submissions prohibited | TxLINE clarified that AI assistance is allowed. **Deborah remains the participant/project owner and submits Samaritan; Claude is development assistance and a runtime product component.** Never market the entry as “AI-built.” | +| **AI clause** | "Entries must be created, developed and submitted by **human participants**"; TxODDS may disqualify an entry materially controlled by a bot or autonomous process | **Deborah must remain the participant, product decision-maker, narrator, reviewer, and submitter.** Claude is a bounded runtime component and development assistant, never the entrant or autonomous submitter. The listing's broader “AI agents” language does not erase the stricter legal terms; retain written sponsor clarification before relying on any interpretation. | | **Judge access** | TxODDS "shall not be required to purchase any software, subscription, licence, token, cryptocurrency, digital asset or third-party service, **nor establish any blockchain wallet or account**" | Demo must be a **hosted app with our credentials server-side** + bundled replay dataset. Judges never touch a wallet. Non-compliance = disqualification. | | Data license | No redistribution/publishing/sublicensing of Data; no reconstructing competing data products; hackathon data license **terminates when the hackathon ends** | Public-facing surfaces must not re-serve raw TXLine feeds. Post-hackathon we operate under the normal free/paid tier terms (which explicitly allow commercial use) — separate license from the hackathon one. | | Compliance | Must comply with all gambling/gambling-tech laws; restricted jurisdictions may be excluded | Real-money leg (Polymarket) is our own account/jurisdiction responsibility, separate from the submission itself | @@ -57,12 +57,13 @@ TXLine = "cryptographically verifiable sports data through a hybrid Solana on-ch | Service Level | Data | Latency | Networks | |---|---|---|---| -| **SL1** | World Cup + Int'l Friendlies | **60-second delay** | mainnet + devnet | +| **SL1 mainnet** | World Cup + Int'l Friendlies | **60-second delay** | mainnet | +| **SL1 devnet** | World Cup + Int'l Friendlies | Current on-chain row reports `samplingIntervalSec = 0`; re-read the pricing matrix at subscription time | devnet | | **SL12** | World Cup + Int'l Friendlies | **Real-time** | **mainnet only** | Both include **full historical replay access**. Free tier is explicitly allowed for commercial use. -**Implication:** in-play trading on a 60s delay is dead on arrival — the market has already repriced. We run **SL12 on mainnet** for live trading and can use devnet SL1 for integration testing. (Our wallet situation supports both.) +**Implication:** mainnet SL1's 60-second delay is unsuitable for time-sensitive decisions. We run **SL12 on mainnet** for authoritative real-time evidence and use devnet SL1 for integration/on-chain validation. The official July 12 page now reports devnet SL1 `samplingIntervalSec = 0`, contradicting the earlier blanket 60-second description; code must inspect the current on-chain pricing row rather than infer devnet latency from the service-level number. ### 2.3 Endpoints (from the OpenAPI spec) @@ -99,7 +100,7 @@ SSE notes: standard `id`/`event`/`data`/`retry` format; heartbeat events; resuma | `SuperOddsType` | Market: Match Result, Over/Under, Handicap | The liquid core markets | | `MarketParameters` | e.g. the O/U line or handicap value | Line-move vs price-move distinction | | `MarketPeriod` | Full Time, Half Time, … | Period-specific markets | -| `PriceNames`, `Prices` | Outcome labels + raw odds (int32, ×1000) | Price stream | +| `PriceNames`, `Prices` | Outcome labels + raw odds (int32, ×1000) | Price stream. The Spain-Belgium synchronized SSE capture also contained mapped market rows with intact `PriceNames` but both `Prices: []` and `Pct: []`; these are no-quote transitions and are skipped rather than normalized as zero prices. Partially populated/misaligned arrays still fail validation. | | `Pct` | **De-vigged implied probabilities** as 3dp strings on a 0–100 percent scale | The margin removal is DONE FOR US, but ingest must divide by 100 for Samaritan's internal 0–1 probability convention. Phase 0 devnet + mainnet rows summed near 100, not near 1. | | `InRunning` | Live-market flag | Pre-match vs in-play routing | | `GameState` | Match state at time of odds | Context for every tick when populated. Phase 0 devnet + mainnet historical odds rows had this blank on all scanned rows. | @@ -120,8 +121,9 @@ SSE notes: standard `id`/`event`/`data`/`retry` format; heartbeat events; resuma - **CLOB V2 is live on production** at `https://clob.polymarket.com` as of Apr 28, 2026; legacy V1 SDKs/V1-signed orders are no longer production-compatible. Build against `@polymarket/clob-client-v2` / V2 REST only ([changelog](https://docs.polymarket.com/changelog)). - **Public historical prices are recoverable by outcome token ID:** `GET https://clob.polymarket.com/prices-history?market=&startTs=&endTs=&fidelity=1` returns `history: [{t,p}]`. Despite the parameter name, `market` is the CLOB asset/token ID. Absolute `startTs`/`endTs` must not be combined with `interval`. Phase 0.5 captured official HTTP 400 responses for multi-week requests (`interval is too long`), so the rescue archive uses atomic five-day segments and timestamp de-duplication. The response has no bid, ask, size, or spread fields and must not be treated as an executable quote archive. - **Public live order books require no authentication:** subscribe asset IDs at `wss://ws-subscriptions-clob.polymarket.com/ws/market`, set `custom_feature_enabled: true` for `best_bid_ask`, `new_market`, and `market_resolved`, and send `PING` every 10 seconds. Initial `book`, `price_change`, `last_trade_price`, and tick-size events are recorded separately from TXLine until the shared Phase 1 bus exists. +- **Public V2 execution metadata requires no authentication:** `GET /clob-markets/{condition_id}` returns compact condition/token identity plus `mos` minimum order size, `mts` tick size, and `fd = {r,e,to}` fee-curve details. A July 12 smoke returned sports rate `0.05`, exponent `1`, taker-only, and minimum size `5`. The OpenAPI lists a staging hostname, but it returned 503 during validation and no official test-funds workflow was documented; do not treat it as a Polymarket devnet. - **Phase 0.5 archive scale:** 799 match-family Gamma records representing 100 unique matches; 300 Match Result and 861 full-time totals conditions; 2,320 non-empty outcome-token histories containing 90,795,313 points. Two tokens for one Morocco–Haiti O/U 8.5 condition returned empty HTTP 200 histories. Candidate mapping covers 98/102 captured TXLine fixtures and is research-only. -- Live in-play markets exist across matches. The proposed repricing lag after goals/reds is still a hypothesis, not a Phase 0 result: one-minute price history cannot establish a seconds-level stale-order edge, and the captured history is not bid/ask data. It requires synchronized TXLine + live Polymarket order-book measurement. +- Live in-play markets exist across matches. The July 10 synchronized Spain-Belgium capture tested the proposed post-goal repricing lag with executable books: across three goals and six exact market groups, 12/18 exploratory cases had already moved at least 50 probability bps before TXLine first delivery, 6/18 had no material 30-second move, and 0/18 showed a clean post-TXLine stale window. One match is not a fitted distribution, but it does not support STALE_QUOTE; the detector remains disabled and paper-only. --- diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 1967baa..f278fe2 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -1,6 +1,8 @@ # 02 — Architecture: The Samaritan Harness -*Design doc, v0.2 (July 10, 2026). Status: LOCKED FOR V1 — updated from Phase 0/0.5 ground truth.* +*Design doc, v0.3 (July 14, 2026). Status: target architecture plus an explicitly identified bounty slice.* + +> **Implementation boundary:** the bounty build currently ships the canonical live/replay data path, three deterministic detectors, single-turn Haiku triage, a single-turn Opus structured evaluator over a code-assembled signal bundle, deterministic paper risk/execution, a v2 local decision ledger, and offline-safe receipt/anchor tooling. The read-only analyst tools, risk-agent judgment pass, strategy tournament, real-money adapter, Data Doctor, and submitted Solana anchor remain roadmap work and must not be described as implemented. ## Design thesis @@ -18,15 +20,15 @@ Everyone else connecting Claude to a sports API will build: `odds → prompt → │ signals (rare, structured) ┌───────────────▼──────────────────────────────────────────┐ │ LAYER 2 — REASONING (Claude, invoked on signal only) │ - │ triage agent (Haiku) → analyst agent (Opus 4.8 + tools) │ - │ → structured TRADE THESIS → risk manager (veto power) │ + │ triage (Haiku) → analyst (Opus 4.8, bounded bundle) │ + │ → structured TRADE THESIS → deterministic paper risk │ └───────────────┬──────────────────────────────────────────┘ │ approved tickets ┌───────────────▼──────────────────────────────────────────┐ │ LAYER 3 — EXECUTION & SCORING │ │ strategy tournament (paper bankrolls, CLV/Brier scored) │ - │ execution adapters: PAPER │ POLYMARKET CLOB (real $) │ - │ decision ledger → hashed → anchored on Solana │ + │ bounty adapter: PAPER; real CLOB remains gated roadmap │ + │ decision ledger → hashed → receipt; anchor is human-gated│ └──────────────────────────────────────────────────────────┘ ``` @@ -60,20 +62,16 @@ Polymarket Match Result is represented as three binary conditions per match (hom **Triage agent — `claude-haiku-4-5`.** First contact for every signal: dedupe (the same move seen through multiple outcomes = one case), classify against known context, kill obvious noise. Cheap enough to run on every signal. Output: drop / escalate, with a one-line rationale. -**Analyst agent — `claude-opus-4-8`, adaptive thinking, prompt-cached system context.** Gets escalated cases with the evidence bundle. Tools (strict schemas): -- `query_series` — interrogate our own time-series store (odds history, features, comparable past cases) -- `get_match_state` — live score/state for the fixture -- `web_search` — team news, lineups, injury reports; the *cause* behind unexplained moves -- `get_polymarket_book` — depth/spread on the target market (is the edge actually captureable after slippage?) -- `submit_thesis` — the only exit: a structured trade thesis +**Analyst agent — implemented bounty slice.** `claude-opus-4-8` receives the strict triage decision plus one code-assembled detector signal/evidence bundle and may emit only `submit_thesis`. It has adaptive thinking, fixed input/output/time bounds, schema validation, identity checks, deterministic timestamps, and append-only cost accounting. It does **not** currently expose `query_series`, `get_match_state`, `web_search`, `get_polymarket_book`, or episodic-search tools. Those remain a post-bounty extension unless implemented and demonstrated before release. **Trade thesis (strict schema — this is the API between AI and money):** ``` -{ market, venue, side, fair_prob, market_prob, edge_bps, confidence, - thesis: string, invalidation_condition: string, ttl_seconds, - suggested_stake_fraction, strategy_attribution } +{ schemaVersion, signalId, fixtureId, marketKey, outcome, direction, + recommendation: "paper_trade" | "no_trade", fairProbability, + thesisSummary, evidenceFor[], steelmanAgainst, invalidationConditions[], + submittedAtTsMs, expiresAtTsMs, analystModel } ``` -No thesis, no trade. Free-text never touches the execution path. +No thesis, no trade. The thesis contains no stake, order, venue credential, wallet, or execution field; deterministic code downstream owns the paper stake and every execution decision. Free text never becomes an order. **Risk manager — separate agent + hard-coded rule layer, veto power.** Deliberately adversarial split: the agent that wants the trade never sizes the trade. Two-stage: (1) deterministic hard rules that no LLM can override — max exposure per fixture, per strategy, per day; fractional Kelly cap; correlation limits (no stacking correlated positions across a match's markets); real-money kill switch; (2) an agent pass for judgment calls (does this thesis actually support this size? is the invalidation condition checkable?). Rejections are logged with reasons — they're training data for the playbook. @@ -90,7 +88,7 @@ No thesis, no trade. Free-text never touches the execution path. 2. `PolymarketAdapter` — real orders via CLOB **V2** SDK. Real-money flow is gated: only pre-match theses that pass both risk stages, carry `venue: polymarket`, use a human-confirmed mapping, and fit within every bankroll/exposure/minimum-order constraint. Kill switch halts it globally. If no valid order fits, no trade is the correct output. 3. `ReplayAdapter` — used by the backtest runner; fills against historical series. -**Decision ledger + on-chain anchoring.** Every thesis, veto, fill, and outcome is an append-only ledger row. Periodically (per match / per hour), the ledger segment is hashed and the hash written to Solana via a memo tx. Inbound TXLine data we acted on is spot-validated against the Merkle-proof endpoints. Result: a **provably un-cherry-picked track record** on verified data — "proof of alpha." This is the crypto-native feature that fits the judges, and post-hackathon it's the trust layer if we ever sell signals. +**Decision ledger + on-chain anchoring.** Every thesis, veto, fill, and outcome is an append-only ledger row. The target proof path hashes a versioned canonical record and writes the verified segment head to Solana, while inbound TXLine evidence is spot-validated where the official proof endpoints permit it. Local hash-chain integrity, deterministic replay parity, and external timestamping are reported as distinct guarantees. None of them alone is called proof of alpha; profitability requires separate valid evidence. **Replay/backtest harness.** The rescued archive supplies five-minute TXLine odds/scores plus one-minute Polymarket sampled prices. The replay engine re-emits normalized records onto the same bus as live data; the stack runs unchanged. Historical Polymarket `t,p` points have no bid/ask/depth and therefore support signal/CLV research with conservative cost proxies, not claims of executable fills. Synchronized live books provide the spread/depth evidence. Outputs: per-strategy signal CLV, conservative simulated P&L, detector precision/recall, and calibration plots. diff --git a/docs/04-algorithms.md b/docs/04-algorithms.md index 98726e0..8eb4667 100644 --- a/docs/04-algorithms.md +++ b/docs/04-algorithms.md @@ -56,6 +56,8 @@ e_exec,o(t) = C_o(t) − PM_ask,o(t) − fees − slip(D(t), stake) ## 3. STALE_QUOTE — post-event stale orders (in-play; paper-only in v1) +**Current gate evidence (Spain-Belgium, July 10).** At an exploratory 50-probability-bps move threshold, 0/18 goal/market cases showed a clean post-TXLine stale window: 12 had already moved before TXLine first delivery and 6 showed no material move within 30 seconds. This detector is disabled. The specification below remains a measurement definition for additional captures, not an enabled signal. + **Setup.** Score event `E` at `t0` (goal or red card — the two largest instant repricers). Model jump: `Δp_model = p_model(post-state) − p_model(pre-state)` from MODELER's state machine (available instantly — it's a lookup on the fitted state grid, no fitting at trade time). **Trigger:** at `t0 + δ`: the synchronized live PM book still shows resting orders within `θ_stale` of pre-event prices, size ≥ `s_min`, on the side that the event moved against. `δ` is fitted from live evidence, never assumed to be single-digit. diff --git a/docs/05-harness.md b/docs/05-harness.md index d351c3a..fb197d1 100644 --- a/docs/05-harness.md +++ b/docs/05-harness.md @@ -1,6 +1,8 @@ # 05 — The Agent Harness -*v0.2 (July 10, 2026). Status: V1 HARNESS DECISIONS LOCKED. This is the machine room: deterministic context assembly, strict tools, bounded agent stages, persistent memory, and hooks that code—not the model—enforces.* +*v0.3 (July 14, 2026). Status: target harness with the implemented bounty slice identified below.* + +> **Implementation boundary:** the current analyst is one bounded Opus call over the detector signal bundle and its only tool is `submit_thesis`. The multi-call investigative loop, pull-based evidence tools, episodic retrieval, risk-agent pass, debate council, learned-memory workflow, and Data Doctor are design targets, not current submission claims. The implemented path is deterministic scheduling, strict Haiku/Opus outputs, paper risk/execution, portfolio lifecycle, restart reconstruction, spend/decision ledgers, and a synthetic end-to-end proving case. ## 0. The core abstraction: the CASE @@ -33,14 +35,16 @@ while case.alive: invariants.check(case) # hooks — code, unoverridable ``` -Within an agent stage (e.g. INVESTIGATING), the analyst runs a bounded tool-use loop: max `K` tool calls, max token budget, hard wall-clock deadline derived from the case TTL. Exceeding any bound ends the stage with whatever the model has — a late thesis is a dead thesis, so the deadline is real. +The implemented INVESTIGATING stage is deliberately smaller: one bounded call, one strict exit tool, a maximum output budget, and a hard request timeout. A future multi-call loop may add a maximum `K`, but it is not part of the bounty runtime. + +The paper runtime splits judgment from execution. It appends `signal_received` before model I/O, measures real wall-clock time through triage and analysis, maps completion back onto event time, and appends `analysis_completed`. Only then can the first later canonical book become an execution candidate. Model-supplied timestamps are replaced by deterministic completion/expiry stamps, so an analyst cannot shorten reported latency or extend its own validity window. **Stage table:** | Stage | Executor | Bounded by | Exit | |---|---|---|---| -| TRIAGE | Haiku 4.5, single turn, no tools | 1 call | drop / escalate(+priority) | -| INVESTIGATING | Opus 4.8, tool loop | K calls, tokens, deadline | `submit_thesis` or `no_trade` | +| TRIAGE | Haiku 4.5, single turn, submission tool only | 1 call, 512 output tokens, 60 s | drop / escalate(+priority) | +| INVESTIGATING | Opus 4.8 adaptive thinking, bounded evidence bundle | 1 call initially, 8,192 output tokens, 180 s | `submit_thesis` carrying paper-trade or no-trade | | RISK_REVIEW | Rules engine (code) → Opus judgment pass | 1 call | veto / resize-down / approve | | EXECUTING | Adapter (code only) | price-tolerance recheck | filled / expired | | POST_MORTEM | Batch job (Haiku/Opus, off-peak) | daily budget | lesson entries | @@ -59,7 +63,7 @@ The single biggest quality-and-cost lever. The model never "goes and fetches eve - **k nearest historical cases** (same detector, similar market/state) with their outcomes and CLV — retrieved from the ledger by feature similarity. This is episodic memory doing real work: the analyst sees how prior CONSENSUS_MOVE cases with similar magnitude/persistence behaved. 4. **Freshness stamp:** every evidence item carries its timestamp; the thesis must reference evidence ≤ TTL-fresh, enforced at submit. -Tools available during INVESTIGATING are **pull-based extensions** of the bundle, not replacements: `query_series` (ad-hoc slices of our store), `get_match_state`, `get_polymarket_book` (live re-read), `web_search` (news/lineups — the only external tool, allowlisted), `find_similar_cases` (deeper episodic search), `submit_thesis` / `no_trade` (the only exits). +The current INVESTIGATING call receives the signal and triage output assembled by code. Its only tool is `submit_thesis`, whose recommendation is either `paper_trade` or `no_trade`. Proposed pull-based extensions—`query_series`, `get_match_state`, `get_polymarket_book`, `web_search`, and `find_similar_cases`—are not wired into the current runtime. ## 3. Deliberation design — when agents debate (the two-speed answer) @@ -92,8 +96,9 @@ One mechanism, honest accounting: debate is a *governance* tool, not a *trading* - **Priority queue** over live cases: `priority = f(expected_edge × stake_capacity, ttl_remaining)`. V1 concurrency cap is **3** concurrent INVESTIGATING cases. - **Preemption:** a score event on a fixture instantly invalidates that fixture's in-flight cases back to TRIAGE (the world changed; the evidence is stale). This is a hook, not a model decision. -- **Token budgets:** per-case caps (triage ~500 out; investigation ~8K out), per-day controls, **$200 operating target**, and **$300 hard project ceiling**. Degradation ladder: raise triage escalation bar → shrink K → pause lowest-CLV persona → detectors-only mode. At the hard ceiling all new model investigations stop; signals continue to be logged and no model-dependent real trade can proceed. +- **Token budgets:** per-case caps (triage 512 out; investigation 8,192 out), per-day controls, **$200 operating target**, and **$300 hard project ceiling**. An append-only SQLite spend ledger atomically reserves worst-case request cost before network I/O, settles measured usage afterward, and conservatively charges the reservation when billing is unknown. Degradation ladder: raise triage escalation bar → shrink K → pause lowest-CLV persona → detectors-only mode. At the hard ceiling all new model investigations stop; signals continue to be logged and no model-dependent real trade can proceed. - **Prompt-cache discipline:** static prefix byte-stable per deploy; per-case content strictly after the cache breakpoint; cache hit rate is a monitored metric (a silent invalidator is a 10× cost bug). +- **Clock discipline:** market/book freshness is checked in canonical event time; public fee-metadata freshness is checked in processing wall time. Neither clock is rewritten to make replay pass. ## 6. Hooks — the unoverridable layer diff --git a/docs/06-gate-study.md b/docs/06-gate-study.md new file mode 100644 index 0000000..cc4952c --- /dev/null +++ b/docs/06-gate-study.md @@ -0,0 +1,107 @@ +# Phase 3 Gate Study + +*Drafted July 11, 2026. Invalidated for decision use on July 14 after a causal-selector audit. The original measurements remain below as an audit record, not evidence of edge. This report does not authorize paper promotion or real-money execution. See `docs/research/historical-gate-study-v1-invalidation.md`.* + +## Decision + +The real-money gate remains **closed**. The corrected rerun supports preparing a fresh Total Goals–only forward paper protocol for Deborah's review, but no paper study is active or registered. + +The v1 historical lane does not currently support promoting any detector. Its dynamic-total market was selected with a Polymarket observation after the last eligible signal time for 95 of 98 fixtures; coverage was also counted without the same cutoff. The reported signal sample additionally mixed unsupported sells and complementary expressions of the same economic move. The synchronized Spain-Belgium evidence remains valid for its narrow stale-quote feasibility conclusion because it does not depend on the historical selector. + +| Lane | Evidence decision | Reason | +|---|---|---| +| `CONSENSUS_MOVE`, dynamic total | **Historical signal candidate for forward paper review** | Corrected v4: 38 held-out normalized Total Goals buys / 18 fixtures; `+132.7 bps` after the 100 bps proxy; fixture-clustered 95% CI `+14.3` to `+243.9 bps`. Sampled prices still cannot prove fills or profitability; v2 remains unregistered | +| `CONSENSUS_MOVE`, Match Result | No-go for v1 | 34 heldout signals; mean after-cost CLV `-67.0 bps` | +| `XMARKET_DIVERGENCE` | No-go for v1 | Overall mean after-cost CLV `-2.1 bps`; Match Result was `-15.2 bps`. The positive total slice does not justify promoting a detector whose sealed aggregate result failed | +| `FADER_CANDIDATE` | No-go for v1 | Overall mean after-cost CLV `-46.0 bps`, with both market families negative | +| `STALE_QUOTE` | Disabled | The synchronized Spain-Belgium study found zero clean post-TXLine repricing windows in 18 market-event cases | +| `MODEL_MARKET_GAP` | Not evaluated | No fitted goal model or calibration certificate exists yet | + +The prior paper approval remains suspended. The corrected selector, economic-case definition, versioned rerun, and candidate protocol draft now exist, but only Deborah can register a fresh forward study. Historical Polymarket observations are sampled prices, not executable books, so even the corrected historical study cannot establish spread, slippage, fill probability, or settlement correctness. + +## Corrected V4 Result — Research Evidence Only + +Protocol `historical-gate-causal-economic-v4-2026-07-14` preserves the 68/30 chronological split and records configuration SHA-256 `9a4eeff928f697fc55ab5147a4dc07f611c40bb749501fc3bd92b211f24b2e54` plus hashes of the archive, mappings, and causal selector evidence. + +- All 590 selector rows pass the causal audit: zero future probabilities, future coverage rows, late selector cutoffs, or nonzero untimestamped liquidity/volume inputs. +- The training-selected `CONSENSUS_MOVE` configuration had 135 normalized Total Goals training cases before held-out Total Goals review. +- Held-out Total Goals produced 38 normalized buy cases across 18 fixtures. +- Mean directional CLV was `+232.7 bps`; after the fixed 100 bps historical proxy it was `+132.7 bps`. +- The 10,000-iteration whole-fixture bootstrap interval was `+14.3` to `+243.9 bps` after the proxy. +- The aggregate detector, including Match Result, did not pass its clustered interval; Match Result stays rejected. +- No model calls occurred in the deterministic historical replay, so model operating cost was `$0.00`; a forward paper case must report inference economics separately. + +This result clears only a historical signal threshold for a **fresh forward paper review**. It is not an executable backtest: the Polymarket observations are sampled prices without bid/ask, depth, or fill probability. The proposed replacement protocol is documented at `docs/09-paper-study-v2-candidate.md` and remains deliberately unsigned. + +## Historical Lane — Invalidated V1 Audit Record + +The study used 98 mapped World Cup fixtures and selected configurations only on a chronological training partition. Identical kickoff timestamps were kept in the same partition. + +- Training: 68 fixtures, 136/136 source-aligned market groups, 164,655 canonical events, and 227,368 feature snapshots. +- Heldout: 30 fixtures, 52/60 source-aligned market groups, 60,092 canonical events, and 77,445 feature snapshots. +- Window: final three hours before kickoff through kickoff; in-running TXLine records excluded. +- Labels: 15-minute forward horizon and 25 probability-bps minimum movement. +- Selection: labeled training F1 with at least 30 predicted-positive training cases and detector gaps no smaller than the 100 probability-bps cost floor. +- Cost handling: 100 probability bps subtracted per signal, with 50/150/200 bps sensitivity checks. + +The four fixtures missing from heldout source alignment were France-Morocco (`18209181`), Spain-Belgium (`18218149`), Norway-England (`18213979`), and Argentina-Switzerland (`18222446`). Missing groups were excluded rather than imputed. + +### Original Sealed Results — Not Valid for Decision Use + +| Detector | Train-selected candidate | Heldout precision | Heldout recall | CLV signals | Fixtures | Raw CLV | Net at 100 bps | +|---|---|---:|---:|---:|---:|---:|---:| +| `XMARKET_DIVERGENCE` | gap `100 bps`, persistence `0s`, stable z `< 1` | 50.0% | 1.9% | 372 | 25 | `+97.9 bps` | `-2.1 bps` | +| `CONSENSUS_MOVE` | z `>= 1`, CUSUM `10 bps`, gap `100 bps`, updates `5` | 50.5% | 0.6% | 107 | 23 | `+159.9 bps` | `+59.9 bps` | +| `FADER_CANDIDATE` | PM z `>= 1`, gap `100 bps`, persistence `0s`, stable z `< 1` | 5.6% | 0.1% | 72 | 16 | `+54.0 bps` | `-46.0 bps` | + +These are the original v1 outputs retained for traceability. They must not be described as held-out edge, alpha, or an executable opportunity. The causal-selector and economic-case defects require a full rerun rather than a numerical adjustment. + +### Cost Sensitivity + +| Detector | 50 bps | 100 bps | 150 bps | 200 bps | +|---|---:|---:|---:|---:| +| `XMARKET_DIVERGENCE` | `+47.9` | `-2.1` | `-52.1` | `-102.1` | +| `CONSENSUS_MOVE` | `+109.9` | `+59.9` | `+9.9` | `-40.1` | +| `FADER_CANDIDATE` | `+4.0` | `-46.0` | `-96.0` | `-146.0` | + +These values are mean directional signal CLV after each assumed cost. They are sensitivity estimates, not historical P&L. + +## Dynamic Total — Defective V1 Method + +V1 selected the exact mapped full-time total closest to 50/50 using a point as late as five minutes before kickoff, while eligible signals ended at kickoff minus fifteen minutes. That is future-informed market selection. Its 1,000-point coverage calculation was also not filtered to the same cutoff. The following distribution is retained only to identify the invalidated artifact: + +| Line | Fixtures | +|---|---:| +| O/U 1.5 | 5 | +| O/U 2.5 | 77 | +| O/U 3.5 | 15 | +| O/U 4.5 | 1 | + +Volume and liquidity were intentionally unweighted because the captured Gamma values are post-close and zero for most lines. No part of this selector is a production or paper default. The corrected selector must use only evidence available before detector evaluation begins and must apply one as-of cutoff to every eligibility input. + +## Live Lane + +The synchronized Spain-Belgium capture normalized 2,262,950 Polymarket book events across Match Result and five exact full-time totals. Across three unique goals and six market groups, Polymarket had already moved in the five seconds before first TXLine delivery in 12/18 cases. The remaining 6/18 had no material move within 30 seconds. No case showed a clean post-TXLine stale window without a prior Polymarket move. + +Eight public-WebSocket outages totaling 91.171 seconds were excluded rather than classified as unchanged markets. This evidence disables `STALE_QUOTE`; it does not prove that such a window can never occur. + +## Pending Gate Work + +The following items block any signed-off Phase 3 gate: + +1. Deborah must review the corrected v4 evidence and either register or reject the unsigned v2 paper candidate. +2. Only fresh post-signature v2 ledgers may pursue the 20-match/40-fill stopping rule without endpoint peeking. +3. Polymarket settlement rules and every live mapping need human confirmation. +4. `MODEL_MARKET_GAP` needs a fitted group-stage model and Brier comparison against the market. +5. Any real-money proposal needs separate per-trade/drawdown approval; the locked `$3` stake and `$20` stop apply only to paper validation. + +Until all required review items pass, thresholds remain research-only, no real-money adapter may act on them, and the paper components may be used only for clearly labelled engineering or bounty demonstrations—not a qualifying profitability study. + +## Evidence + +- Historical detector report: `docs/research/historical-gate-study.md` +- Historical machine-readable output: `data/research/historical-gate-study.json` +- Corrected causal/economic report: `docs/research/historical-gate-study-causal-economic-v4.md` +- V3→V4 methodology note: `docs/research/historical-gate-study-v3-to-v4-methodology-note.md` +- Synchronized live report: `docs/research/paired-spain-belgium-2026-07-10-live-lane.md` +- Synchronized live machine-readable output: `data/research/paired-spain-belgium-2026-07-10-live-lane.json` diff --git a/docs/07-paper-study-preregistration.md b/docs/07-paper-study-preregistration.md new file mode 100644 index 0000000..7cb19f1 --- /dev/null +++ b/docs/07-paper-study-preregistration.md @@ -0,0 +1,185 @@ +# Paper-Study Pre-Registration — Totals-Only CONSENSUS_MOVE + +*Registered July 11, 2026 and signed by Deborah July 12. **Invalidated and suspended July 14 before producing any paper observation:** its upstream historical market selector used information later than the eligible signal window. This document is preserved as the immutable v1 audit record; it must not admit new cases. A corrected study requires a new protocol ID and signature. The real-money gate remains **closed**.* + +The corrected implementation is currently identified as `paper-study-v2-candidate-2026-07-14` with status `engineering_candidate_unregistered`. That label is deliberately not a registration. Operational Claude readiness and new ledger initialization remain fail-closed until Deborah reviews and signs the complete corrected protocol after economic-case normalization and the causal rerun are finalized. + +## Purpose + +The historical gate study originally appeared to produce a pre-match `CONSENSUS_MOVE` candidate on a dynamically selected full-time total. A July 14 audit found that the selector chose the market using a price observed after the last eligible signal for 95 of 98 fixtures. The v1 sample also mixed unsupported sells and complementary expressions of the same economic move. Its previously reported positive measurements are therefore audit history, not valid held-out edge evidence. + +That number rests on three proxies that a backtest cannot remove: + +- entry at a **sampled Polymarket midpoint** rather than an executable ask/bid, +- fair value proxied by the **bookmaker consensus close** rather than the real Polymarket close/settlement, +- a **flat 100 bps** stand-in for spread, slippage, fees, and fill risk. + +This v1 paper study is suspended and cannot receive new cases. Its ledgers remain preserved. A v2 registration may be created only after the causal selector and executable-case definition are fixed and the historical study is rerun without using future information. + +## Locked candidate (immutable for this study) + +No parameter below may be re-tuned during or after the run. Any change voids this registration and requires a new one. + +### Detector — `CONSENSUS_MOVE`, totals markets only + +```json +{ + "velocityWindowMs": 300000, + "consensusMoveAbsZ": 1, + "consensusCusumThreshold": 0.001, + "consensusMinimumUpdates": 5, + "consensusMinimumRawGap": 0.01, + "consensusStableAbsZ": 1 +} +``` + +- Runs **only** on the `total_goals:full_time` market selected by the dynamic-total selector below. +- `XMARKET_DIVERGENCE`, `FADER_CANDIDATE`, match-result `CONSENSUS_MOVE`, `STALE_QUOTE`, and `MODEL_MARKET_GAP` stay disabled / research-only. They are **not** in this study. + +### Dynamic-total selector + +```json +{ + "minimumCoveragePoints": 1000, + "minimumVolume": 0, + "minimumLiquidity": 0, + "maximumDistanceFromEven": 0.15, + "weights": { "balance": 1, "volume": 0, "liquidity": 0, "coverage": 0 } +} +``` + +Selects the exact mapped full-time total whose pre-kickoff Over probability is closest to 0.50, at least five minutes before kickoff; fails closed if no eligible line is within 0.15 of even. **Note:** liquidity is unweighted only because historical Gamma depth was post-close/zero. The paper study must record the live executable depth of the line it picks (see Guardrails) so we learn whether "most balanced" is also tradeable. + +### Feature and forward-label config + +```json +{ + "feature": { + "velocityWindowsMs": [60000, 300000, 900000], + "velocityEwmaHalfLifeMs": 900000, + "cusumDriftProbability": 0.0005, + "scoreContextWindowMs": 300000, + "freshnessMaxAgeMs": 360000 + }, + "label": { "horizonMs": 900000, "minimumGapClosure": 0.0025 }, + "window": { "beforeKickoffMs": 10800000, "includeInRunning": false } +} +``` + +Signals are eligible only when detected at least 15 minutes before kickoff (one forward-label horizon), pre-match, in-running rows excluded — identical to the study that produced the candidate. + +## What the paper study must replace + +| Proxy in the backtest | Reality the paper study must use | +|---|---| +| Sampled Polymarket midpoint as entry | A conservative simulated marketable order against the first canonical book observed after measured signal-to-order latency: buy by walking **asks**, sell only against owned inventory by walking **bids** | +| Consensus close as fair value | The real Polymarket **closing midpoint at kickoff** as the primary CLV reference, directional closing bid/ask as a supporting executable-liquidation mark, and actual **settlement** where available | +| Flat 100 bps cost | Measured spread and book-walk slippage plus the official per-market fee parameters loaded and recorded at runtime | +| Assumed 100% fill | Depth- and latency-aware fills, including partial/no fills; feed outages, insufficient depth, stale books, and invalid mappings are no-trades rather than wins | + +The harness extends the existing live-lane path (production normalizers, reconstructed top-of-book and best-level depth, explicit reconnect-window exclusion) already used in `src/research/paired-live-study.ts`. + +## Sample and stopping rule + +- **Unit of analysis is the match**, not the signal. Signals inside one match are correlated; they do not count as independent bets. +- **Primary plan:** run forward on live mapped fixtures as they occur. Where live match supply is insufficient, the study may extend to the next mapped competition or replay **captured live order books** (not sampled history), but the executable-price and depth requirements above still apply. +- **Minimum to decide:** ≥ **20 matches** that each produce ≥ 1 executed paper signal, **and** ≥ **40 executed signals** total. **Target: 30 matches.** +- **No peeking:** primary endpoints are not computed or inspected until the minimum is reached. If anyone reads interim P&L to decide whether to continue, the run is disclosed as exploratory and does not count toward the gate. + +### Two separate lanes + +- **Bounty demonstration lane:** runs through submission using remaining live fixtures and captured-book replay to demonstrate the complete autonomous product. Its individual trades and metrics are explicitly exploratory and excluded from the registered profitability decision because the demo must expose them before the minimum sample exists. +- **Long-run profitability lane:** starts with a fresh ledger and unseen fixtures under this unchanged protocol. It alone can satisfy the 20-match/40-fill stopping rule. It continues after the hackathon if necessary. +- The long-run fixture universe is every eligible mapped full-time total exposed through Samaritan's lawful TXLine access after the lane starts. Any competition expansion requires a dated protocol addendum before its first included fixture and cannot be chosen using observed strategy results. + +## Primary endpoint and decision rule + +The primary endpoint is **executable CLV**, not realized settlement P&L. On ~20–30 matches, binary settlement P&L is too high-variance to gate a decision; CLV against the executable close is the standard, higher-power leading indicator. + +**Executable CLV per signal** = (Polymarket closing midpoint at kickoff − simulated fill price) in the traded direction, net of measured entry fees and book-walk slippage. Directional closing bid/ask CLV is reported separately as a supporting liquidation-cost sensitivity check; it is not called a midpoint. + +**ACCEPT (advance to a real-money discussion — not to real money) requires all of:** + +1. Mean executable CLV net of costs `> 0`, with the **lower bound of the match-clustered 95% CI `> 0`**. +2. Mean realized settlement P&L (where markets resolved) `> 0` — reported with its CI, not required to reach significance. +3. Strategy net result **beats the no-trade baseline (0)** and a **random-direction control**. +4. All guardrails below satisfied. + +**REJECT** if the match-clustered 95% CI for executable CLV includes or sits below 0, or any guardrail fails materially. + +**INCONCLUSIVE** if the minimum sample is not reached, or required close/settlement evidence is incomplete — in which case extend the sample under the same registration or shelve the candidate. Inconclusive is not a pass. To resolve the original wording conflict conservatively, after the minimum is reached the explicit REJECT rule above controls: a CLV interval that touches or straddles 0 is a REJECT, not an ACCEPT. + +The reproducible random-direction control uses a seeded SHA-256 sign assignment per signal. It applies that sign to gross directional midpoint CLV and subtracts the same observed entry cost as the strategy. This is a matched-cost synthetic control, not a claim that the untraded complement token had identical depth; the seed and method are reported. + +## Required outputs (both are gating deliverables) + +### 1. Match-clustered confidence interval + +Compute the 95% CI by **block bootstrap over matches**, not over signals: + +- Resample the set of matches **with replacement**, `B = 10000` iterations (seeded and recorded for reproducibility). +- Within each resampled match, take all of its signals; recompute the mean per-signal executable CLV across the resample. +- Report the 2.5th / 50th / 97.5th percentiles of the bootstrap distribution as [CI low, median, CI high]. +- Report the same match-clustered CI for realized settlement P&L. + +This is the mechanism that prevents ~20 matches from masquerading as 40+ independent bets. Report the number of matches and signals alongside every interval. + +### 2. Per-match P&L table + +One row per match, columns: + +`fixtureId · kickoff · selected total line · signals · fills · fill rate · mean half-spread bps · mean realized slippage bps · gross CLV bps · net CLV bps · realized settlement P&L ($) · net return (bps)` + +Plus a footer with: match count, signal count, fraction of matches net-positive, aggregate net CLV with match-clustered CI, aggregate settlement P&L with CI, and the no-trade / random-direction baseline rows. + +### Supporting metrics (report, non-gating) + +CLV distribution (mean, median, p25, p75, positive rate); hit rate; **Brier score** of entry-implied probabilities vs outcomes, compared to the market's Brier; equity curve and **max drawdown**; realized vs assumed (100 bps) cost, so we learn whether the historical proxy was optimistic. + +## Guardrails (each must hold for ACCEPT) + +- **Fill rate ≥ 60%** of eligible signals filled at or through the modeled price at stake size; misses logged, never scored as wins. +- **Mean realized slippage ≤ 100 bps** (the historical cost proxy). If realized costs run higher, the edge is presumed gone regardless of CLV point estimate. +- **Max drawdown within the risk cap** below. +- **Selected-line depth recorded** for every trade, so we can see whether the balance-only selector routinely picks thin books. + +Source timestamps may regress slightly in capture order. Such observations are counted and rejected atomically before feature or quote state mutation. Kickoff marks select the latest canonical venue/source timestamp at or before kickoff; source, local observation, and processing timestamps are all retained so delayed delivery is visible. + +## Risk caps (paper, mirroring intended live limits) + +Paper stakes are **simulated USD notional** — no wallet, no tokens, no chain. The paper study reads real live order books and simulates fills in software; it needs nothing to spend. Stake size still mirrors the intended live limits because slippage and fill probability scale with order size, so the simulation must trade the size we would really trade. (For context: the only devnet in this project is the TXLine **data** feed on Solana used for integration testing and on-chain anchoring; the trading venue, Polymarket, is real-money USDC on Polygon with no test-token equivalent. See the handoff note.) + +- Bankroll: **$50** (locked). +- Aggregate open exposure ceiling: **$15** (locked). +- Per-trade stake: **$3** (locked; up to five concurrent positions within the $15 ceiling). +- Max drawdown rejection stop: **$20** (locked; 40% of bankroll). Breaching it halts and rejects that paper lane; only Deborah may authorize a new, separately registered study. + +All money values are stored as integer USD micro-units. The analyst cannot propose or modify stake, exposure, or drawdown values; deterministic risk code owns them. + +## Fee protocol + +The July 12 official Polymarket documentation lists the sports taker fee rate as `0.05`, maker fee as `0`, and the formula `fee = shares × feeRate × price × (1 − price)`. Fees are enabled per market and can change. Therefore Samaritan records the V2 fee rate, exponent, taker-only flag, minimum order size, and tick size returned by `getClobMarketInfo(conditionID)` for every simulated fill; runtime market parameters win over this dated documentation snapshot. Missing, legacy-only, malformed, wrong-token, unsupported-exponent, sub-minimum, or off-grid metadata fails closed. Fees are conservatively rounded to the documented `0.00001 USDC` precision. + +## Out of scope / still closed + +- **Real money.** ACCEPT here only unlocks a *separate* human decision about tiny real-money validation; it is not that decision. +- Polymarket **settlement-rule and mapping confirmation** for the totals markets remains a human prerequisite before any real-money step. +- Other detectors and `MODEL_MARKET_GAP` remain disabled/research-only. + +## Locked run inputs + +1. Paper stake is `$3`; aggregate exposure is `$15`; the lane is rejected at `$20` drawdown. +2. Fee parameters are loaded from the official per-market CLOB metadata at runtime and recorded with each fill. +3. The bounty lane and long-run lane use separate ledgers. The bounty lane may be viewed freely; the long-run primary endpoints remain sealed until its stopping rule is met. +4. Real money may be discussed only after a long-run `ACCEPT` result and still requires a separate Deborah approval. +5. The persistent long-run ledger began at `2026-07-12T09:45:17.212Z` under frozen config SHA-256 `7dcb4c20ac05195bdb4a6c8803432079eeefb8cc747c186f576084dc772fd2a2`. Earlier fixtures and captures are bounty/research evidence only and cannot count toward its stopping rule. + +## Sign-off + +- [x] Deborah accepts the locked candidate, sample rule, and decision thresholds above (July 12, 2026). +- [x] `$3` paper stake, `$15` aggregate exposure, and `$20` drawdown rejection stop are fixed. +- [x] Two-lane bounty/long-run protocol is fixed. +- [x] Real-money gate confirmed **closed** for the duration of this study and until separate post-`ACCEPT` approval. + +Registered by: research validation review, July 11, 2026. Signed off by Deborah, July 12, 2026. +Evidence basis: `docs/06-gate-study.md`, `docs/research/historical-gate-study.md`, `data/research/historical-gate-study.json`. diff --git a/docs/08-winning-submission-plan.md b/docs/08-winning-submission-plan.md new file mode 100644 index 0000000..01ce037 --- /dev/null +++ b/docs/08-winning-submission-plan.md @@ -0,0 +1,816 @@ +# Samaritan Winning Submission Plan + +**Status:** Active war-room plan
+**Owner and hackathon participant:** Deborah
+**Internal ship date:** July 18, 2026
+**Hard deadline:** July 19, 2026 at 23:59:59 UTC
+**Real-money gate:** **CLOSED** + +This document converts the July 14 product, code, evidence, competition, legal, and submission audit into the shortest credible path to a winning TxODDS Trading Tools and Agents submission. + +It is a bounty-critical overlay on `plan.md`, not a replacement for the long-term Samaritan roadmap. It does not authorize real-money trading, relax any risk invariant, or permit raw TXLine data to be published. + +## 0. July 14 release-candidate checkpoint + +The product is no longer an idea looking for a demo. The current local release candidate has the core judge story implemented and verified: + +| Surface | Current evidence | Status | +|---|---|---| +| Real TXLine use | Mainnet SL12 ingestion and a synchronized Spain–Belgium replay are documented; the public case ends in a disciplined no-trade | **Ready, narrowly claimed** | +| Complete judge lifecycle | `pnpm demo` publishes 20 canonical events and completes signal → deterministic agent stubs → hard risk → paper fill → close → settlement | **Ready, synthetic and performance-excluded** | +| Decision proof | 12 v2 ledger rows; ledger head `a034cd86…1575dc4`; receipt hash `130f1ee7…883f265`; offline verification passes | **Ready, local integrity only** | +| Public evidence bundle | Six strict, hash-checked files; bundle hash `4950d9a9…5366276`; derived-only audit passes | **Ready for final public-commit audit** | +| Dashboard | Command, Matchroom, Casebook, Study, and separated synthetic-proof panel build and serve read-only | **Ready locally; not deployed** | +| Historical claim | Corrected causal/economic v4 reports 38 held-out normalized buys / 18 fixtures / `+132.7` bps after proxy / clustered interval `+14.3` to `+243.9` | **Signal research only; v2 unregistered** | +| External anchor | Unsigned devnet intent `cb921e05…e0bc03` generated offline; no signer, RPC, wallet, or transaction used | **Awaiting Deborah's explicit authorization** | +| Capture expansion | France–Spain capture is scheduled for July 14 at 16:00 UTC; England–Argentina follows July 15 | **Pending verification; capture-only** | + +Release-gate evidence recorded July 14: + +- root typecheck and dashboard typecheck passed; +- 37 root test files / 214 tests passed; +- deterministic demo and receipt verifier passed; +- public artifact audit passed over 6 files; +- production dashboard build passed; +- the root judge/runtime graph passed the high/critical dependency-advisory gate; +- Phase 0 typecheck and capture deadline test passed; and +- both future capture configurations passed strict validation as `tradeable: false`. + +A separate 223-file release-candidate tree containing every intended tracked/untracked file—but no `data/`, `samples/`, `content/`, `design/`, `.env`, or private archive—installed from the frozen lock and passed the judge gate. Its 7 private-projection tests skipped by design; all 207 clean-tree tests passed. The isolated Phase 0 install, typecheck, and test also passed. + +The remaining path to submission is deliberately small and authority-sensitive: + +1. Preserve and verify the two scheduled synchronized captures; do not auto-promote either into a study. +2. Freeze an intentional public Git commit, run CI from that exact commit, add a public remote, and deploy the no-login dashboard. +3. Deborah decides whether to submit the exact frozen synthetic commitment to Solana devnet. Until then, every public surface must say **unanchored**. +4. Deborah sends and retains the sponsor clarification request on human participation and derived-data display. +5. Deborah reviews the claims in her own words, narrates the sub-five-minute video, verifies the deployed URL incognito/mobile, and submits the entry herself. + +The current environment exposed no controllable browser target, so the final rendered/incognito visual pass is a real open gate—not a box to mark from build output. + +--- + +## 1. The decision + +Samaritan will not compete as another AI betting bot, generic sharp-move detector, or unfinished autonomous hedge fund. + +It will compete as: + +> **The governed autonomous sports-trading system: Claude investigates uncertainty, deterministic code alone controls risk and execution, and every action—or refusal—is committed before it happens.** + +The bounty submission wins by proving four things extremely well: + +1. Samaritan consumes real TXLine data through a production-quality live/replay path. +2. It completes one autonomous paper-trading lifecycle end to end. +3. An LLM can contribute judgment without receiving authority over money. +4. A stranger can independently verify what the system saw, decided, permitted, and executed. + +The submission will not depend on: + +- real-money Polymarket execution; +- four personas or a tournament; +- MODELER; +- a Head Trader; +- a Data Doctor; +- additional detectors; +- unsupported profitability claims. + +Those remain post-submission roadmap items. + +--- + +## 2. Winning position + +### Category + +**Governed autonomous sports-market intelligence** + +### One-line pitch + +> **Samaritan turns verified TXLine data into bounded, replayable market decisions. Claude investigates; deterministic code controls risk and execution; every action—or refusal—is independently auditable.** + +### Judge-facing promise + +Most agents prove that they made a prediction. Samaritan proves why an autonomous system was permitted—or forbidden—to act. + +### Buyer + +The primary buyer is a prediction-market desk, sportsbook trading team, market operator, or B2B intermediary that wants agent automation without surrendering risk control, reproducibility, or auditability. + +### Defensible moat + +- Identical canonical event contracts in replay and live operation. +- Evidence-gated strategy promotion instead of story-driven backtests. +- A strict LLM authority boundary: thesis only, never size or order. +- Deterministic risk and execution after the thesis. +- Append-only lifecycle records written before action. +- Externally timestamped decision receipts and independent verification. +- Honest refusal of unsupported strategies. + +### Words we will not use without completed evidence + +- “Proof of alpha” +- “Profitable strategy” +- “Production real-money trader” +- “Every decision is anchored on-chain” +- “Provably un-cherry-picked” +- “73 executable trades” +- “AI-built submission” + +--- + +## 3. Definition of winner-ready + +Samaritan is winner-ready only when all of the following are true: + +- [ ] A clean public clone installs and launches the judge demo with one documented command. +- [x] The demo does not require TXLine credentials, a wallet, paid access, or local private archives. +- [x] A live-connectivity proof demonstrates that the same canonical runtime accepts TXLine SL12 events. +- [x] One explicitly synthetic case visibly completes signal → triage stub → thesis stub → risk → intent → paper result → close → settlement. +- [x] One real captured case visibly ends in a justified no-trade or veto. +- [x] Any synthetic proving case is unmistakably labelled and excluded from performance results. +- [x] The corrected evidence study contains no future-informed market selection. +- [x] Historical counts represent unique executable economic decisions, not duplicate detector emissions. +- [x] Paper fills use actual observation readiness plus model latency plus venue delay. +- [x] Restarting the runtime reconstructs dedupe, positions, exposure, P&L, peak equity, and drawdown. +- [x] The decision hash authenticates the full canonical record, including event type and timestamps. +- [ ] At least one corrected ledger head is anchored on Solana devnet and has a public explorer link. +- [ ] An independent verifier validates the receipt and anchor from frozen public artifacts. +- [ ] The deployed dashboard exposes only licence-safe derived data. +- [x] The current README, dashboard, video script, and submission draft make no claim beyond implemented evidence; rerun on the final public commit/video. +- [ ] Deborah can explain and defend the architecture, evidence, limitations, and major design decisions. +- [ ] Sponsor clarification on human participation and public derived data is retained in writing. +- [ ] The video is below five minutes and every URL works in an incognito browser. + +--- + +## 4. P0 — Repair the truth before building the show + +These items block every profit, edge, or production-readiness claim. + +### 4.1 Invalidate gate-study v1 + +The current dynamic-total study chooses the market using a price observed as late as kickoff minus five minutes, even though eligible signals stop at kickoff minus fifteen minutes. In the audited artifact, 95 of 98 selected lines used information after the signal window. Coverage also counts observations without the same as-of cutoff. + +Actions: + +- [x] Mark the current `+119 probability bps` result as causally invalidated. +- [x] Preserve the old artifact for audit history; do not silently overwrite it. +- [x] Add a dated methodology note explaining the defect and its impact. +- [x] Create a new protocol/config version before recomputing results. +- [x] Remove the invalidated number from the README, dashboard, video, and submission copy until a corrected result exists. + +Acceptance tests: + +- The old result is clearly labelled invalidated anywhere it remains visible. +- No current product surface calls it held-out edge, alpha, or profitability evidence. +- The corrected protocol has a distinct identifier and config hash. + +### 4.2 Make total-line selection causal + +Choose the exact market using only information available before the first eligible detector decision. A fixed T−180-minute cutoff or the first eligible jointly observable book are acceptable starting designs; the rule must be deterministic, frozen, and applied before labels or future prices are inspected. + +Actions: + +- [x] Define one causal selector timestamp and document why it is live-reproducible. +- [x] Apply the same cutoff to probability, coverage, volume, liquidity, and market availability. +- [x] Fail closed when no line is eligible at the cutoff. +- [x] Prohibit selector inputs later than the earliest evaluated signal. +- [x] Record selector evidence and cutoff in every fixture artifact. +- [x] Add tests that inject a highly attractive future line and prove it cannot affect selection. +- [x] Add tests that future coverage cannot make a line eligible. + +Acceptance tests: + +- Every selected fixture satisfies `selectorEvidenceTsMs <= detectorEvaluationStartTsMs`. +- Replacing all post-cutoff market data produces the same selected line. +- A fixture with insufficient as-of evidence is rejected rather than rescued by future observations. + +### 4.3 Count executable economic decisions + +The current totals result contains buys, unsupported sells, and simultaneous complementary Over/Under expressions of the same move. + +Actions: + +- [x] Define a deterministic economic-case identity for fixture, market condition, direction, and decision window. +- [x] Collapse complementary same-move emissions into one case. +- [x] Either convert a sell into a verified complement-token buy or exclude it from executable results. +- [x] Prevent both sides of one binary condition from being counted as independent opportunities. +- [x] Report raw detector emissions separately from cases, orders, fills, and unique fixtures. +- [x] Cluster uncertainty by fixture and disclose repeated decisions within a match. + +Acceptance tests: + +- Every performance row maps to an order the current paper executor can actually express. +- One underlying movement cannot inflate the primary sample through complementary tokens. +- The report shows emissions → unique cases → approved intents → fills as separate counts. + +### 4.4 Rerun and report the corrected study + +Actions: + +- [x] Rerun chronological training/held-out evaluation from scratch. +- [x] Preserve the train/test boundary. +- [x] Report gross CLV, venue-cost sensitivities, unique cases, unique fixtures, and confidence intervals. +- [x] Report model operating cost separately from executable CLV. +- [x] Keep the real-money gate closed regardless of the bounty result. +- [x] Reject unsupported strategy families and make the rejection part of the demo; the one positive Total Goals family remains only a forward-paper candidate. + +Acceptance tests: + +- A single command regenerates the corrected JSON and Markdown artifacts. +- The generated report contains protocol ID, config hash, source hashes, and causal selector diagnostics. +- No manual result selection occurs after held-out metrics are visible. + +--- + +## 5. P0 — Make paper execution causally honest + +### 5.1 Separate market time from knowledge time + +`sourceTsMs` says when a market event occurred. `observedTsMs` or processing time says when Samaritan could act on it. Execution readiness must use knowledge time. + +Actions: + +- [x] Carry signal source time and signal observed/processing time separately. +- [x] Start measured decision latency from actual signal observation/processing. +- [x] Compute `readyAtTsMs` from observation time, not source time. +- [x] Retain source time for provenance, market ordering, and latency measurement. +- [x] Fail closed on invalid, regressing, or impossible observed timestamps. +- [x] Test delayed delivery and reconnect catch-up explicitly. + +Acceptance tests: + +- A five-second-late event cannot produce an execution before its actual receipt. +- Replaying the same captured source and observed timestamps yields the same eligibility outcome. +- Dashboard evidence shows source → observed → signal → decision-ready → execution timestamps. + +### 5.2 Model current Polymarket sports-order behavior + +Actions: + +- [x] Add the current sports marketable-order placement delay to paper readiness. +- [x] Use the first eligible book after analysis latency **and** venue delay. +- [ ] Parse and validate current accepting-orders, start-time, clear-book, tick, size, and fee metadata required for safe execution. +- [x] Represent delayed, unmatched, partial, filled, and no-fill outcomes explicitly. +- [x] Fail closed if required fee/tick/minimum-size metadata is absent or unsupported. +- [x] Freeze the current sports-order delay and execution-metadata contract in the study configuration. + +Acceptance tests: + +- A favorable book inside the delay window cannot fill the simulated order. +- A market that stops accepting orders cannot produce an intent or fill. +- A later adverse book is used when it is the first actually eligible execution state. + +### 5.3 Attribute AI operating cost + +Actions: + +- [ ] Record triage and analyst cost per case. +- [ ] Report executable CLV and operating economics as different metrics. +- [ ] Add a deterministic pre-Opus economic-value gate. +- [ ] Allow the gate only to avoid model spend; it must never create or enlarge a trade. +- [ ] Prefer deterministic or Haiku-only handling when Opus cannot plausibly earn its cost. + +Acceptance tests: + +- Every completed case reports model cost, venue cost, and their units. +- The dashboard never subtracts dollar inference cost directly from probability CLV. +- Opus is not invoked when the configured value threshold fails. + +--- + +## 6. P0 — Make the runtime autonomous and restart-safe + +### 6.1 Build one production orchestration entrypoint + +The bounty-critical service must connect the implemented pieces without test-only assembly. + +Required path: + +```text +TXLine/Polymarket events +→ canonical bus +→ feature engine +→ frozen detector +→ case scheduler +→ Haiku +→ Opus +→ deterministic risk +→ paper executor +→ portfolio lifecycle +→ ledger +→ dashboard projection +``` + +Actions: + +- [ ] Add one documented service command for live and one for frozen judge replay. +- [ ] Inject sources at the edge; do not expose a replay/live flag to strategies. +- [ ] Surface feed health, queue state, current cases, terminal failures, and ledger head. +- [ ] Shut down cleanly and finish durable writes before exit. + +Acceptance tests: + +- One command runs the frozen judge case from input through final receipt. +- The same orchestration accepts live canonical sources without strategy-code changes. +- An agent/API failure becomes a ledgered terminal state, not an unhandled crash. + +### 6.2 Reconstruct state from the append-only ledger + +Actions: + +- [x] Rehydrate seen signals and case terminal states. +- [x] Rehydrate open, closed, and settled positions. +- [x] Recompute exposure, realized P&L, equity peak, and drawdown. +- [x] Recover or deterministically expire pending cases. +- [x] Reject inconsistent or incomplete ledger histories. +- [x] Resume only after local ledger-chain verification and state reconstruction succeed; external Solana verification remains a separate release gate. + +Acceptance tests: + +- Restarting with an open position retains exactly the same exposure and drawdown. +- Replaying an already-seen signal after restart cannot spend Claude tokens or create another order. +- Corrupt lifecycle ordering prevents startup. + +--- + +## 7. P0 — Turn local integrity into external proof + +### 7.1 Version the canonical decision envelope + +The record hash must authenticate everything a judge relies on. + +Minimum committed fields: + +- schema version; +- sequence; +- entry ID; +- case ID; +- event kind; +- event/source timestamp; +- observed timestamp where applicable; +- durable insertion timestamp; +- canonical payload; +- previous hash. + +Actions: + +- [x] Define one stable canonical serialization. +- [x] Introduce a new hash schema version without destructively rewriting the old ledger. +- [x] Verify every field used by UI or proof claims. +- [x] Add tamper tests for kind, case ID, timestamps, payload, order, and prior hash. + +Acceptance tests: + +- Changing any committed field breaks verification. +- Old v1 evidence remains identifiable and readable but cannot masquerade as v2 proof. +- The verifier rejects reordered, omitted, duplicated, or rewritten events. + +### 7.2 Produce the Samaritan Decision Receipt + +Every demonstrated case should export one small, licence-safe receipt containing: + +- source proof reference or derived source hash; +- code/config hash; +- signal evidence; +- model and prompt version; +- model cost; +- structured thesis; +- deterministic risk verdict; +- execution intent; +- post-readiness book evidence; +- fill/no-fill; +- close/settlement evidence; +- final ledger head; +- Solana network and transaction signature. + +Actions: + +- [x] Define the receipt schema. +- [x] Generate receipts directly from verified ledger entries. +- [x] Add a CLI verifier that needs no private database. +- [x] Make receipt verification visible in the dashboard as a separate synthetic evidence class. + +### 7.3 Anchor the corrected proof externally + +Actions: + +- [ ] Anchor at least one corrected v2 ledger head on Solana devnet before the demonstrated outcome is known where possible. +- [ ] Store network, signature, slot, commitment status, and anchored head. +- [ ] Surface a working public explorer link. +- [ ] Verify that the public receipt head equals the anchored value. +- [ ] Add one TXLine Merkle/validation demonstration where the official API permits it. + +Acceptance tests: + +- A stranger can run the verifier and reach the explorer transaction without a wallet. +- The dashboard distinguishes local integrity, replay parity, and external anchoring. +- No page calls a replay-manifest hash a decision-ledger head. + +--- + +## 8. P0 — Demonstrate one complete product, not ten partial products + +### Required evidence pair + +#### A. Real captured refusal + +Use Spain–Belgium or another verified synchronized capture to demonstrate: + +- exact source timing; +- what hypothesis was tested; +- contrary evidence; +- deterministic reason for no trade; +- zero money moved; +- no claim that the case passed through Claude if it did not. + +Correct the case classification: stale-quote feasibility evidence must not be labelled `CONSENSUS_MOVE`. + +#### B. Complete lifecycle case + +Preferred order: + +1. A real captured eligible paper case. +2. A captured-book replay of a real eligible case. +3. A clearly labelled synthetic proving fixture if no real case qualifies before recording. + +The lifecycle case must show: + +- detector signal; +- Haiku decision; +- Opus `submit_thesis` result; +- deterministic risk verdict; +- execution intent recorded before execution; +- post-readiness book; +- fill, partial fill, no-fill, or veto; +- close and settlement; +- decision receipt and external anchor. + +Synthetic data must be excluded from every research, profitability, and strategy-performance metric. + +### Claude's visible value + +Claude should contribute judgment a deterministic detector does not already provide: + +- cause classification; +- strongest argument against the trade; +- evidence limitations; +- invalidation conditions; +- market/settlement-rule interpretation where relevant. + +Before submission, either: + +- [ ] implement the promised minimal read-only evidence tools; or +- [x] change all documentation to describe the analyst accurately as a bounded structured evaluator over a code-assembled evidence bundle. + +Never claim tools the runtime does not expose. + +--- + +## 9. P0 — Ship a clean, safe public product + +### 9.1 Repository recovery and reproducibility + +Actions: + +- [x] Review modified and untracked release categories deliberately and freeze the inclusion allowlist in `docs/submission/public-release-scope.md`. +- [x] Remove temporary root scripts, design explorations, and social-render projects from submission scope through `.gitignore` without deleting local work. +- [x] Confirm the current two-commit Git history contains no secret-bearing path or known credential signature; only the safe `phase0/.env.example` is tracked. +- [ ] Commit the intended Phase 3/4/runtime/dashboard work in understandable commits. +- [ ] Add a public remote and push the submission branch. +- [x] Add CI for install, typecheck, tests, build, and frozen-demo verification. +- [x] Pin Node and pnpm requirements. +- [x] Provide a clean-clone quickstart. + +Clean-clone acceptance command: + +```bash +pnpm install --frozen-lockfile +pnpm check +pnpm demo +``` + +Expected result: a safe local judge surface with a verified frozen receipt, without private archives or credentials. + +### 9.2 Derived-only public artifact bundle + +The public demo must not depend on the ignored 8.6 GB `data/` tree or 41 GB `samples/` tree. + +Actions: + +- [x] Export the minimum judge fixtures from verified receipts. +- [x] Include only transformed, non-reconstructive, licence-safe evidence. +- [x] Replace exact raw/fair-probability histories with movement, deltas, z-scores, aggregates, and decision evidence unless written sponsor approval says otherwise. +- [x] Add a build-time check that raw TXLine payload shapes are absent. +- [x] Document which artifacts are synthetic, captured-derived, and externally verifiable. + +### 9.3 Dashboard correction + +Required routes: + +- Command +- Matchroom +- Casebook +- Study +- Proof/Receipt + +Actions: + +- [x] Replace offline-only projection assumptions with the frozen public bundle plus runtime API. +- [x] Bind the service to a validated platform-provided interface/port configuration; deployment remains open. +- [x] Correct detector and proof labels. +- [ ] Show zero-state, failure, veto, no-trade, and fill states honestly. +- [x] Surface model cost and execution latency in the captured case and downloadable receipt. +- [x] Surface the exact Solana status (`not_submitted`) and offline verifier result without implying network proof. +- [x] Keep long-run sealed results sealed. +- [x] Remove any present-tense claim for an unimplemented feature from current judge-facing copy. + +Acceptance tests: + +- The deployed dashboard works on mobile and desktop in an incognito session. +- No wallet, subscription, API key, or authentication is required. +- Every displayed metric can be traced to a frozen public receipt or live derived runtime state. + +--- + +## 10. Legal and sponsor gates + +This section is an operational checklist, not legal advice. + +### Human participation + +The track markets agent products, but the listing metadata and binding terms create ambiguity around agent participation. + +- [x] Deborah remains the participant, submitter, narrator, and interview representative in every current submission artifact. +- [x] Claude is described as a constrained runtime component inside Deborah's product. +- [x] The entry is never described as AI-built or autonomously submitted. +- [ ] Obtain written sponsor clarification and retain the response. +- [ ] Deborah can explain every important product and architecture decision. + +### TXLine licence + +- [x] Publish no raw captures, raw replay files, tokens, or reconstructive feed output in the frozen public bundle. +- [ ] Obtain written clarification for any exact normalized probability series proposed for the public UI. +- [x] Prefer derived signals, transformations, decisions, aggregates, and hashes. +- [x] Document that continued post-hackathon use requires the appropriate licence. + +### Submission safety + +- [x] No secrets in the intended release tree or current Git history under the recorded path/signature scans; rerun on the final commit. +- [x] No FIFA logo, tournament emblem, team crest, or implication of endorsement; public display copy uses neutral “World Cup” wording. +- [x] No real-money dependency. +- [x] No judge payment, token purchase, wallet, or subscription requirement. +- [x] All judge-facing third-party data services, fonts, assets, and software boundaries are documented in `docs/submission/third-party-notices.md`. + +--- + +## 11. Scope cuts that protect the win + +The following work is frozen until every P0 acceptance test above passes: + +- Polymarket real-money adapter +- Mainnet execution +- Four-persona tournament +- Head Trader allocation +- MODELER calibration +- Data Doctor agent +- Additional strategy families +- Risk-manager judgment model +- Light theme +- General-purpose plugin architecture +- Multi-competition expansion +- Nonessential animation or dashboard ornament + +Exception: a frozen item may only be resumed before submission if all winner-ready gates are green and it materially improves the five-minute demonstration. + +--- + +## 12. Four-day execution order + +### July 14 — Truth and causality + +- [x] Declare the old gate result invalidated. +- [x] Fix causal total selection and as-of coverage. +- [x] Collapse economic duplicates and resolve sell semantics. +- [x] Correct the execution clock and model venue delay. +- [ ] Verify the scheduled synchronized capture. +- [ ] Send the sponsor clarification questions. +- [ ] Recover and commit the intended working tree. + +**End-of-day gate:** corrected study can run; no invalid claim remains judge-facing. + +### July 15 — Golden path and durability + +- [ ] Add the production orchestration entrypoint. +- [x] Reconstruct state from the ledger on startup. +- [ ] Produce one complete ledgered Claude paper case. +- [x] Produce one real evidence-based refusal. +- [x] Record case-level latency and model cost in the receipt contract; the synthetic proving case records zero stub cost. +- [x] Complete close and settlement evidence for the synthetic proving fixture. + +**End-of-day gate:** one command completes the entire product lifecycle. + +### July 16 — Proof and public deployment + +- [x] Version the complete hash envelope. +- [x] Generate the Decision Receipt. +- [ ] Anchor the corrected head on Solana devnet. +- [x] Ship the independent offline receipt verifier. +- [x] Freeze the licence-safe public artifact bundle. +- [ ] Deploy the read-only dashboard. +- [x] Add CI and verify a clean-clone-shaped bundle without private data; rerun from the final public commit. + +**End-of-day gate:** a stranger can reproduce and verify the case without private access. + +### July 17 — Submission packaging + +- [x] Rewrite README around the judge's 90-second path. +- [x] Draft the required TXLine API feedback for Deborah's final review. +- [ ] Record and edit the demo below five minutes. +- [ ] Rehearse Deborah's narration and likely judge questions. +- [ ] Test every link, command, explorer URL, and mobile layout. + +**End-of-day gate:** submission package is complete and frozen. + +### July 18 — Submit + +- [ ] Run the final clean-clone test. +- [ ] Run CI from the public branch. +- [ ] Watch the uploaded video from beginning to end. +- [ ] Test the deployed MVP incognito. +- [ ] Submit before the internal deadline. +- [ ] Preserve a local copy of the exact submission text and URLs. + +July 19 is buffer, not feature-development time. + +--- + +## 13. Five-minute demo script + +### 0:00–0:20 — Hook + +> “The dangerous thing about an AI trader is not a wrong prediction. It is that nobody can prove what it saw or why it was allowed to touch money.” + +### 0:20–0:50 — Authority boundary + +Show: + +```text +TXLine → deterministic detector → bounded Claude thesis +→ deterministic risk → paper execution → decision receipt +``` + +State that Claude has no wallet, order, or position-sizing capability. + +### 0:50–1:40 — Real captured refusal + +Show the synchronized real case: + +- event and observation timestamps; +- tested hypothesis; +- market moved first or no stale window existed; +- deterministic no-trade conclusion; +- zero money moved. + +Close the segment with: **“Every decision. Especially the pass.”** + +### 1:40–3:00 — Complete autonomous lifecycle + +Show: + +- signal; +- Haiku triage; +- Opus thesis and argument against itself; +- deterministic risk verdict; +- intent recorded before execution; +- latency- and venue-adjusted book; +- fill/no-fill; +- close and settlement. + +### 3:00–3:35 — Falsification + +Show “Strategies Samaritan refused to deploy”: + +- XMARKET rejected; +- FADER rejected; +- Match Result momentum rejected; +- STALE_QUOTE unsupported; +- totals result reported exactly as the corrected study found it. + +Do not hide a corrected negative result. + +### 3:35–4:20 — Independent proof + +Show: + +- ordered lifecycle entries; +- receipt hash; +- verifier command; +- Solana explorer transaction; +- TXLine validation reference; +- distinction between local integrity and external anchoring. + +### 4:20–4:50 — Production posture + +Show: + +- passing tests and CI; +- live/replay identity; +- restart reconstruction; +- fail-closed boundaries; +- licence-safe public deployment; +- no-wallet judge access. + +### 4:50–5:00 — Close + +> **“Samaritan does not promise certainty. It makes autonomous trading accountable.”** + +--- + +## 14. README and submission structure + +The public README should answer these questions in this order: + +1. What is Samaritan? +2. Why is it different from an AI betting bot? +3. How can a judge run or open it in under 90 seconds? +4. Which TXLine endpoints and fields does it use? +5. What complete case can the judge inspect? +6. What does the Decision Receipt prove? +7. What evidence was rejected or remains inconclusive? +8. What is paper-only and what is unimplemented? +9. What are the legal/data boundaries? +10. Where are the demo, deployed MVP, repository, verifier, and Solana transaction? + +Required links near the top: + +- Hosted judge MVP +- Demo video +- Public repository +- Quickstart +- Architecture +- Corrected evidence report +- Example Decision Receipt +- Solana explorer transaction +- TXLine API feedback + +--- + +## 15. Judge questions Deborah must be ready to answer + +### “Where is the alpha?” + +The correct answer is the corrected evidence result, including limitations. The product's novelty is governed autonomous execution and falsification, not an unsupported promise of profit. + +### “Why use Claude?” + +Claude handles bounded interpretation: competing explanations, contrary evidence, invalidation conditions, and rules ambiguity. It cannot size, approve risk, or place an order. + +### “Why TXLine?” + +TXLine supplies normalized, low-latency, verifiable sports-market and score evidence suitable for reproducible decisions. Samaritan demonstrates its value through timing, probability normalization, replay parity, health monitoring, and proof references. + +### “Can the agent lose money?” + +The bounty build is paper-only. In the architecture, deterministic code owns caps, eligibility, execution, drawdown, and the manual kill switch. The real-money gate remains closed. + +### “What is actually on-chain?” + +Only claim the corrected externally anchored ledger head and its exact receipt. Explain what is locally hash-chained, what is replay-verified, and what Solana timestamps. + +### “Can I reproduce this?” + +Yes: use the public clean-clone command or hosted no-wallet demo, then verify the frozen receipt independently. + +### “What failed?” + +Explain the rejected strategies and the selector causality defect openly. The system is designed to reject weak or invalid evidence before money is authorized. + +--- + +## 16. Final submission kill criteria + +Do not submit a claim or feature if any of these is true: + +- It requires private raw TXLine data to understand or reproduce. +- It cannot be demonstrated from the public branch. +- It relies on the invalidated historical selector. +- It counts a decision the current executor cannot express. +- It uses source time as though it were observation time. +- It ignores the current venue delay. +- It calls an unanchored or incomplete hash external proof. +- It resets risk state after restart. +- It implies real-money readiness. +- It requires a judge wallet, payment, token, API key, or subscription. +- Deborah cannot explain it confidently in a live interview. + +--- + +## 17. Final rally point + +The winning move is not to look like the biggest system in the competition. + +It is to be the most complete, honest, and verifiable autonomous system a judge can actually run. + +> **Fix the evidence. Finish one vertical slice. Prove every boundary. Ship the receipt.** diff --git a/docs/09-paper-study-v2-candidate.md b/docs/09-paper-study-v2-candidate.md new file mode 100644 index 0000000..e55089c --- /dev/null +++ b/docs/09-paper-study-v2-candidate.md @@ -0,0 +1,111 @@ +# Paper Study V2 Candidate — Deborah Review Draft + +**Status:** `engineering_candidate_unregistered`
+**Code protocol:** `paper-study-v2-candidate-2026-07-14`
+**Frozen candidate config SHA-256:** `93e61c1903d0a13bbeb1dbbd3ad9b11af0335b96c82bd2ca7aa9ddedeeabf3ce`
+**Prepared:** July 14, 2026
+**Real-money gate:** **closed** + +This is a review draft, not a registration. It cannot admit a qualifying observation, spend Claude tokens through the operational study command, initialize replacement ledgers, authorize a paper candidate, or unlock real money until Deborah explicitly signs the final protocol. The invalidated v1 ledgers remain preserved and must never be relabelled. + +## Why a fresh protocol is justified + +The corrected historical study is: + +- protocol `historical-gate-causal-economic-v4-2026-07-14`; +- configuration SHA-256 `9a4eeff928f697fc55ab5147a4dc07f611c40bb749501fc3bd92b211f24b2e54`; +- 68 training fixtures / 30 held-out fixtures with an unchanged chronological boundary; +- zero future-probability, future-coverage, late-cutoff, or untimestamped-liquidity selector violations across 590 evidence rows; +- normalized into executor-expressible Total Goals buys, with complementary expressions collapsed and sell-only cases excluded. + +The aggregate `CONSENSUS_MOVE` detector did not pass its fixture-clustered interval. The **prespecified Total Goals–only family** did meet the historical signal criteria under the training-selected configuration: + +- 135 normalized Total Goals training cases; +- 38 held-out normalized buy cases across 18 fixtures; +- mean `+132.7` probability bps after the 100 bps historical cost proxy; +- fixture-clustered 95% interval `+14.3` to `+243.9` bps. + +This is not alpha, profitability, or fill evidence. Historical Polymarket observations are sampled prices without executable bid/ask depth. It supports only a fresh forward paper study using real observed books, measured costs, and no endpoint peeking. + +## Frozen candidate + +### Market and detector + +- Full-time `total_goals` only. +- `CONSENSUS_MOVE` only. +- Move absolute z-score ≥ `1`. +- CUSUM threshold `0.001` probability (`10` bps). +- Minimum raw consensus/market gap `0.01` probability (`100` bps). +- Minimum consensus updates `5`. +- Every other detector and Match Result remain disabled for this study. + +### Causal total-line selection + +- Exact line frozen at kickoff minus `180` minutes, before the first detector snapshot. +- Probability and coverage both require `source_ts_ms <= selector_cutoff_ts_ms`. +- At least `1,000` as-of coverage points. +- Closest-to-even balance rule, within `0.15` probability of 50/50. +- Historical volume and liquidity are zero-weighted and forbidden because no timestamped as-of values exist. +- Missing or late selector evidence fails closed. + +### Economic-case identity + +- Retain only an actual Total Goals `buy` the paper executor can express. +- `buy Over + sell Under` is one Over case; `buy Under + sell Over` is one Under case. +- Collapse duplicate buys and complementary sell expressions in the same detector/fixture/market/source-time/observation-time/economic-outcome case. +- Drop sell-only cases unless a future protocol version can prove the complementary token's executable ask. This version cannot synthesize that proof. +- Match Result is outside the candidate and is never treated as a binary complement. + +### Execution timing + +- `detectedAtTsMs` records source market time. +- `observedAtTsMs` records Samaritan's knowledge time. +- Model readiness begins at observation time and uses the greater of measured wall latency or the configured minimum. +- A fixed `1,000 ms` Polymarket sports placement delay is then added. +- Only the first canonical book observed after readiness **and** placement delay can fill. +- Small negative source/observation skew remains diagnostic; impossible or regressing knowledge time fails closed. + +### Paper risk proposal carried from v1 — requires Deborah's renewed approval + +- Simulated bankroll: `$50` (`50,000,000` micro-USD). +- Fixed paper stake: `$3` (`3,000,000` micro-USD). +- Aggregate open exposure cap: `$15` (`15,000,000` micro-USD). +- Drawdown rejection stop: `$20` (`20,000,000` micro-USD). +- No LLM can create, enlarge, or override these values. +- Real-money gate remains closed regardless of outcome. + +### Stopping and evaluation proposal + +- Minimum `20` filled matches and `40` fills; target `30` matches. +- Unit of uncertainty is the match, not the signal. +- Primary endpoint: executable kickoff CLV net of measured entry cost. +- `10,000` whole-match bootstrap iterations, seed `20260714`. +- Minimum fill rate `60%`. +- Mean slippage no greater than `100` bps. +- Every selected depth, close, and settlement field complete. +- Max drawdown within the proposed `$20` stop. +- Bounty/demo output remains exploratory; qualifying long-run endpoints remain sealed until both stopping thresholds are met. + +## Operational gates before registration + +- [x] Causal selector repaired and independently diagnosed. +- [x] Economic cases normalized and sample gates changed from emissions to cases. +- [x] Observation-time readiness and venue delay implemented. +- [x] Restart rehydration implemented for dedupe, pending cases, positions, exposure, P&L, peak, and drawdown. +- [x] Full v2 decision hash envelope and tamper tests implemented. +- [ ] Deborah confirms the proposed paper risk values above. +- [ ] Deborah confirms the stopping/decision rules above. +- [ ] Deborah confirms that the corrected historical result is sufficient only to begin forward paper observation—not to claim alpha or unlock money. +- [ ] Fresh v2 bounty and long-run ledgers are initialized after signature; preserved v1 ledgers remain untouched. +- [ ] The operational protocol status changes from `engineering_candidate_unregistered` to a new explicit registered identifier in code and documentation. + +## Deborah decision — intentionally unsigned + +- [ ] **REGISTER V2 FOR FORWARD PAPER ONLY** under the exact config hash above. +- [ ] **DO NOT REGISTER**; revise or shelve the candidate. + +Name: Deborah
+Decision date/time (UTC): ____________________
+Signature/confirmation reference: ____________________ + +Registration, if granted, authorizes only a paper study and bounded model spend under existing project controls. It does not authorize Polymarket authentication, a wallet, token approval, deposit, order placement, real-money trading, or any risk-limit change. diff --git a/docs/PHASE-2-STATUS.md b/docs/PHASE-2-STATUS.md index 64842d1..ca63ea7 100644 --- a/docs/PHASE-2-STATUS.md +++ b/docs/PHASE-2-STATUS.md @@ -12,6 +12,7 @@ - Threshold grids are injected, hashed, and evaluated in parallel. Future labels are withheld until an explicit horizon elapses, then applied to the earlier prediction; pending cases are bounded by the horizon and discarded if no fresh resolution point arrives. - The current post-hoc labels distinguish cross-market gap convergence, Polymarket reversion while consensus stays fixed, and Polymarket following a sustained consensus direction. Label horizons and minimum movements remain Phase-3 parameters. - The dynamic main-total selector is evidence-driven and parameterized. It can weight distance from 50/50, log volume, log liquidity, and history coverage; it fails closed when no exact TX-observed full-time line passes the injected eligibility requirements. +- The paired-capture live-lane analyzer streams captured TXLine frames and Polymarket NDJSON through the production normalizers, reconstructs canonical top-of-book state and best-level depth, excludes explicit reconnect windows, and measures score-event delivery against every exact mapped market group without loading the 3.3 GB capture into memory. ## Real-archive smoke evidence @@ -34,13 +35,25 @@ This early result is useful precisely because it rejects an assumed “3–4 poi The total-line evidence builder produced 590 TX-observed full-time lines across all 98 mapped fixtures, with a latest Over probability at least five minutes before kickoff for every line. A balance-only exploratory rule exactly reproduced the Phase-0 closest-to-50/50 distribution: O/U 2.5 for 77 fixtures, 3.5 for 15, 1.5 for 5, and 4.5 for 1. Across balance-only, equal-weight, and balance-heavy exploratory schemes, weighting changed the winner for one fixture (`17588320`) and 69/98 fixtures still showed disagreement among the raw balance/volume/liquidity/coverage criteria. This is evidence for a stable candidate rule, not approval to freeze it. Archived post-close Gamma liquidity is often zero, so the gate must not treat it as historical executable depth. +The synchronized Spain-Belgium run added 2,262,950 normalized canonical Polymarket events across Match Result and five exact full-time totals groups. TXLine delivered three unique goals with first-seen receive latency of -8 to 49 ms; confirmation revisions arrived 74–162 seconds later. At an exploratory 50-probability-bps material-move threshold, Polymarket had already moved during the five seconds before TXLine first delivery in 12/18 market-event cases. The other 6/18 showed no material move inside 30 seconds, and 0/18 showed a clean post-TXLine repricing case without a prior move. This match does not support the STALE_QUOTE hypothesis. Eight public-WebSocket outages totaled 91.171 seconds and are marked unavailable rather than unchanged-market periods. Full evidence is in `docs/research/paired-spain-belgium-2026-07-10-live-lane.md`. + +## Historical gate evidence + +**July 14 correction:** the dynamic-total historical result described below is invalidated for decision use. The selector used a Polymarket observation after the eligible signal window for 95/98 fixtures, and its coverage calculation was not constrained to the same as-of cutoff. The reported sample also mixed unsupported sells and complementary expressions of the same economic move. The original values remain below as audit history only; no totals candidate is currently approved. See `docs/research/historical-gate-study-v1-invalidation.md`. + +**Corrected rerun:** `docs/research/historical-gate-study-causal-economic-v4.md` uses a T−180-minute selector cutoff, zero future/coverage violations across 590 evidence rows, normalized buy-only Total Goals cases, and fixture-clustered uncertainty. The prespecified Total Goals `CONSENSUS_MOVE` family supports a fresh forward paper review (38 held-out cases / 18 fixtures; `+132.7 bps` after the proxy; 95% CI `+14.3` to `+243.9`), but it is sampled-price signal evidence, not alpha or fill proof. Paper v2 remains `engineering_candidate_unregistered`. + +The chronological historical study used 68 training fixtures and 30 sealed heldout fixtures. Training had both sources for 136/136 market groups; heldout had both for 52/60 groups. Configurations were selected only on training labels, required at least 30 predicted-positive training cases, and could not use a detector gap below the 100 probability-bps cost proxy. + +The original v1 run produced the numerical outputs retained in `docs/research/historical-gate-study.md`, but the dynamic-total slice does not advance to human review after the causal audit. `XMARKET_DIVERGENCE`, `FADER_CANDIDATE`, and Match Result `CONSENSUS_MOVE` remain rejected on their existing evidence. Dynamic-total `CONSENSUS_MOVE` returns to unapproved research status until a new causal study is completed. The real-money gate remains closed. + ## Verification -`pnpm check` passes under Node `22.23.1` with 33 tests across ten files. `pnpm build` also passes. Tests cover acceleration, sampled-history research gating, archive event reconstruction, no-mode replay identity, future-label isolation, bounded deferred scoring, grid expansion, dynamic total-line ranking/fail-closed behavior, detector behavior, storage integrity, mappings, and source normalization. +`pnpm check` passes under Node `22.23.1` with 39 tests across twelve files. `pnpm build` also passes. The Phase 0 recorder has an additional Node test that proves an open SSE response is aborted at the capture deadline. Tests cover acceleration, sampled-history research gating, archive event reconstruction, no-mode replay identity, future-label isolation, bounded deferred scoring, grid expansion, dynamic total-line ranking/fail-closed behavior, chronological split isolation, detector behavior, storage integrity, mappings, source normalization, outage pairing, latency histograms, and recorder shutdown. ## Still open -- Fit and freeze the dynamic main-full-time-total selector on train/test evidence; do not hard-code O/U 2.5 or treat current exploratory weights as production values. +- Review and either freeze or reject the dynamic main-full-time-total candidate; do not hard-code O/U 2.5 or treat the candidate as production before Deborah's decision. - Add `MODEL_MARKET_GAP` only behind a fitted model and valid calibration certificate. No hand-set match-state multipliers are allowed. -- Add `STALE_QUOTE` and score-event latency labels from synchronized TXLine plus executable Polymarket book data. Historical one-minute samples cannot support this detector. -- Run the full train/test Phase-3 gate study and freeze thresholds only after review with Deborah. +- Repeat synchronized live-lane measurement on any remaining mapped matches if practical. `STALE_QUOTE` remains disabled: Spain-Belgium supplied score-event latency labels but no supporting post-TXLine stale-window case. +- Review the totals-only `CONSENSUS_MOVE` candidate with Deborah, collect executable-book evidence, and freeze or reject its threshold. `XMARKET_DIVERGENCE`, Match Result `CONSENSUS_MOVE`, and `FADER_CANDIDATE` are no-go for v1 on current evidence. diff --git a/docs/PHASE-4-STATUS.md b/docs/PHASE-4-STATUS.md new file mode 100644 index 0000000..0a29643 --- /dev/null +++ b/docs/PHASE-4-STATUS.md @@ -0,0 +1,79 @@ +# Phase 4 — Reasoning and Paper Execution Status + +*Started July 12, 2026. The real-money gate is closed.* + +> **July 14 governance correction:** the signed v1 paper protocol is invalidated and suspended because its upstream market selector was future-informed. Its zero-observation ledgers remain preserved as audit history and cannot admit cases. Execution-clock repairs now live under `paper-study-v2-candidate-2026-07-14`, status `engineering_candidate_unregistered`; the operational Claude-readiness command is deliberately disabled until Deborah signs a corrected protocol. Statements below describing the registered runtime document the preserved v1 build history, not an active profitability study. + +## Implemented vertical slice + +- Strict Zod contracts define triage and analyst outputs. The thesis schema is closed to unknown fields and contains no stake, bankroll, order, wallet, or venue-authentication field. +- Bounded Anthropic Messages adapters use `claude-haiku-4-5` for triage and `claude-opus-4-8` with adaptive thinking for analysis. Haiku is forced through one strict `submit_triage` call; Opus cannot combine adaptive thinking with forced tool choice, so code accepts exactly one strict `submit_thesis` call and rejects prose, missing/duplicate/wrong tools, refusal, or truncation. +- Prompt versions, input/output limits, request deadlines, retry limits, and idempotency keys are code-owned. The analyst schema also binds model identity and a 15-minute thesis window; sizing or order fields remain impossible. +- A separate append-only SQLite spend ledger atomically reserves conservative worst-case cost before each API request, settles measured uncached/cache/output usage at the current model rates, and charges the full reservation when billing is unknown. The `$300` hard project ceiling is checked before network I/O. +- One production composition factory shares the bounded Claude client and spend ledger across isolated bounty/long-run decision ledgers. It rejects multiple credential sources, a spend ceiling above `$300`, shared lane ledgers, or a fixture universe built for a different long-run start. +- The thesis identity, market, outcome, and direction must exactly match the originating detector signal. +- The signed paper risk configuration is code-owned: `$50` bankroll, `$3` fixed stake, `$15` aggregate exposure, `$20` drawdown rejection stop, totals-only `CONSENSUS_MOVE`, live-book evidence, and a closed real-money gate. +- Deterministic risk rechecks the locked gap/z-score/CUSUM evidence, market family, score context, thesis expiry, post-latency book identity/freshness, current fee metadata, exposure, drawdown, and owned inventory for sells. +- Deterministic risk independently vetoes every `research_only` signal even if upstream fixture filtering is bypassed. +- The paper adapter never calls a venue. It walks canonical asks/bids, honors the deterministic limit, supports partial/no fills, applies per-market taker fees, records entry half-spread, executable displayed depth, and realized slippage, and stores all USD values as integer micro-units. +- A separate SQLite decision ledger hash-chains signal, triage, thesis, risk verdict, execution intent, paper result, and terminal state. Update/delete triggers enforce append-only storage. +- The orchestrator records the risk verdict and execution intent before invoking the paper adapter. Agent failure, schema failure, mismatch, stale evidence, malformed fees, and execution errors terminate fail-closed. +- The scheduler creates lane-specific bounty/long-run case identities, records the signal before model I/O, measures actual triage+analysis wall time, deterministically stamps thesis completion/expiry in event time, ignores books before that completion, executes against the first eligible pre-kickoff canonical book, updates paper exposure from actual simulated consideration, and terminally expires cases with no executable book. Analysis that reaches kickoff cannot become an in-play entry. +- One shared frozen config now owns the signed feature windows, detector threshold, 15-minute pre-kickoff cutoff, and dynamic-total selector. The canonical runtime sends every event to existing pending cases first, then features/detectors, and permanently suppresses duplicate signal IDs. +- The unauthenticated V2 CLOB resolver validates condition/token identity and caches explicit fee rate/exponent/taker-only, minimum-order, and tick-size parameters. Legacy `base_fee`-only shapes are rejected rather than converted by assumption. +- Captured book freshness remains event-time based while fee-metadata freshness is checked against processing wall time, so offline replay neither rewrites captured timestamps nor falsely rejects newly fetched condition metadata. +- Paper execution enforces the venue's minimum shares and tick grid and rounds fees conservatively to the documented five-decimal USDC precision. +- The paper portfolio records actual entry cost, distinguishes kickoff midpoint CLV from executable bid-side liquidation CLV, settles binary payouts into integer-micro-unit P&L/Brier, and feeds realized drawdown and open exposure back into risk state. +- Capture-order replay rejects regressing source timestamps atomically before stale TXLine observations can affect CUSUM/de-vig state or stale Polymarket books can overwrite the current quote. Per-source rejection counts remain observable. +- The kickoff close scheduler caches only canonical books whose venue timestamp is at or before kickoff, refuses capture-order regressions, and records cutoff, source, observation, and processing timestamps separately. +- The public Polymarket `market_resolved` frame is canonicalized with condition and winning asset identity. The settlement scheduler pays a paper position only when its exact asset won and refuses to settle a position that lacks its registered kickoff close. +- The registered evaluator rebuilds all eligible signals, fills, closes, and settlements from the append-only ledger. Bounty output is always exploratory; long-run rows and endpoints remain sealed until 20 filled matches and 40 fills, then use a seeded 10,000-iteration match-block bootstrap plus fill-rate, slippage, depth, settlement, and drawdown guardrails. +- Separate persistent bounty and long-run SQLite ledgers start with one hash-chained `study_initialized` record containing every frozen feature, detector, selector, risk, stopping, bootstrap, and real-money-gate setting. The long-run start is `2026-07-12T09:45:17.212Z`; its initial report is sealed at zero matches/signals/fills. +- The generated fixture universe physically separates evidence grades. Spain-Belgium has synchronized paired books but starts inside the 15-minute cutoff, so it is lifecycle-only; Norway-England and Argentina-Switzerland have sampled prices only, so they are signal-research-only. None can enter the long-run stopping count. +- The persistent lane factory exposes only strategy-executable fixture keys to the paper scheduler. Sampled-history and late-start paired captures cannot reach risk or execution even if replayed on the canonical bus. +- Rolling admission plans both lanes before mutation, then adds only post-start fixtures with verified mappings and pre-match paired executable books to the scheduler and kickoff-close maps. Removing or changing an admitted fixture fails closed; `research_only` signals are rejected before any Claude request. +- `paper:report` reconstructs machine-readable JSON and the exact preregistered per-match Markdown evidence table from the decision ledgers. Bounty evidence stays visibly exploratory and sealed long-run reports carrying rows/endpoints are rejected as invalid. +- A read-only authenticated TXLine refresh exact-matched France-Spain fixture `18237038` and England-Argentina fixture `18241006` to their public Polymarket match/totals families at 19:00 UTC on July 14 and 15. Deborah confirmed capture-only use on July 12. Six-hour `tradeable: false` captures and post-run verification are scheduled through four fail-closed local automations. + +## Explicit boundary + +This slice starts from an emitted `DetectorSignal`; code ledgers it, runs bounded triage/analysis, and records measured readiness before the scheduler can accept the first later pre-kickoff `PolymarketBookEvent`. Triage and analyst executors remain injected interfaces, with bounded Anthropic implementations and deterministic test doubles. The Claude path has no Polymarket authentication, wallet access, approval, token action, deposit, or order-placement capability. + +The following work remains before the Phase 4 replay exit criterion is met: + +- Expand the analyst's code-assembled evidence bundle and only then evaluate whether bounded read-only evidence tools earn their latency and cost. +- Execute and verify the scheduled semifinal captures as capture-only evidence. Do not append them to a study ledger or spend Claude tokens until a corrected v2 protocol is signed and fresh ledgers are initialized. +- Add the dashboard view over the generated ledger-derived evidence artifact. +- Run the complete canonical replay: event → feature → detector → case → agents → risk → paper fill → score. + +## Verification + +`pnpm test` passes under Node `22.23.1` with 102 tests across 27 files; full build and Phase 0 verification are rerun after each integration change. Production composition readiness opened the actual spend, bounty, long-run, and fixture-universe stores with the key configured, made zero API requests, and left all three chain heads unchanged. Both eligible fixture lists are correctly empty until a post-start paired capture passes admission. `paper:report` generated `docs/research/paper-study-current.md` from the real one-row lane ledgers: bounty is exploratory at zero observations and long-run is sealed at zero without leaking rows or endpoints. A live Haiku 4.5 synthetic triage smoke correctly dropped the non-executable signal for `$0.001641`. A live Opus 4.8 adaptive-thinking smoke returned exactly one valid `submit_thesis`, selected `no_trade` for research-only proxy evidence, and cost `$0.0460475`. Successful-call measured cost is `$0.0476885`; the append-only ledger conservatively reports `$0.29935725` because the first pre-classification 400 schema rejection was charged at its full `$0.25166875` reservation rather than rewritten after error classification was added. No reservation remains outstanding and the eight-entry chain verifies. A public, unauthenticated CLOB smoke resolved a captured condition to fee rate `0.05`, exponent `1`, taker-only fees, minimum order size `5`, and its condition-specific tick size. + +An actual Spain-Belgium capture frame normalized to fixture `18218149`, full-time total `1.5`, condition `0xd143...00c9`, and winning asset `138369...93641` (`Over`). Its venue timestamp was 64 ms ahead of local receipt; both timestamps are retained rather than rewritten. + +Focused paper-pipeline tests prove: + +- unknown analyst sizing fields are rejected; +- missing, duplicate, wrong, prose-only, and truncated Claude submissions fail closed; budget exhaustion prevents the Anthropic client call; +- API failure is redacted and conservatively charged at the reserved maximum; +- ineligible fixtures invoke neither Haiku nor Opus, while both eligible lanes share one bounded spend chain and retain separate decision chains; +- `signal_received` is durable while a slow triage request is unresolved, measured analysis latency is ledgered, and a book one millisecond before readiness cannot execute; +- a pre-match signal whose analysis reaches kickoff is terminally closed and cannot become an in-play paper entry; +- long-run admission requires all three gates: post-start kickoff, verified mapping, and pre-match paired executable books; +- admitted fixture identities cannot be removed or changed during refresh, and repeated admission is idempotent; +- evidence rendering refuses sealed long-run output containing rows, endpoints, or guardrails; +- fee freshness uses processing time during captured replay without changing canonical event timestamps; +- research-only signals are vetoed again inside deterministic risk, not merely filtered by the scheduler; +- a `$3` marketable buy walks multiple ask levels and includes the current sports taker-fee formula; +- malformed fee metadata fails closed inside the adapter itself; +- legacy fee shapes, wrong condition assets, off-grid books, and sub-minimum orders cannot become fills; +- the `$20` drawdown stop vetoes the case; +- the execution intent is present in the verified hash chain before the adapter runs; +- early books are ignored, signals inside the 15-minute cutoff are rejected, and no-book cases expire with terminal ledger evidence; +- kickoff midpoint/executable CLV and settlement P&L remain distinct, with settled losses updating the drawdown halt state. +- regressing source timestamps are observable no-ops, not feature or detector inputs; +- only a pre-kickoff canonical book can become the kickoff mark, even when capture order regresses; +- public winning-asset resolution settles marked positions and cannot bypass a missing close; +- long-run metrics remain sealed below either stopping threshold, while bounty metrics stay explicitly exploratory; +- match-clustered bootstrap output is deterministic and a positive point estimate cannot override failed guardrails. diff --git a/docs/UI.md b/docs/UI.md new file mode 100644 index 0000000..a829582 --- /dev/null +++ b/docs/UI.md @@ -0,0 +1,609 @@ +# Samaritan UI/UX Specification + +*Status: Matchroom v2 dark hybrid approved by Deborah as the binding production direction on July 13, 2026. V1 remains preserved as a comparison baseline. Production frontend implementation may proceed against this contract; real-money gates are unchanged.* + +> **July 14 governance correction:** paper-study v1 is invalidated, suspended, and preserved only as zero-observation audit history. The dashboard must not call it an active registered profitability test. Corrected v2 remains `engineering_candidate_unregistered` until Deborah signs it. + +## 1. Product position + +Samaritan is a **sports-market intelligence command center**. It is not an exchange, sportsbook, tipster feed, wallet, or generic AI assistant. + +The interface is optimized for this sequence: + +1. **Detect:** what changed in the sports and market data? +2. **Judge:** why might the change matter? +3. **Gate:** what did deterministic risk allow or reject? +4. **Act:** what paper action was attempted, and was it filled? +5. **Measure:** did the decision earn executable closing-line value after costs? +6. **Prove:** can a user verify that the decision and result were not rewritten later? + +The public product promise is not "Samaritan predicts winners." The honest promise is: + +> Samaritan detects cross-market sports signals, subjects them to bounded machine judgment and deterministic risk, and produces a replayable, measurable, verifiable decision record. + +The primary visual object is a **match under analysis**, not a P&L number. + +## 2. Binding UX constraints + +These constraints come from Samaritan's architecture, evidence protocol, and hackathon terms. They are not optional visual preferences. + +1. **Read-only public access.** Judges and public observers must not need an account, wallet, token, payment, or browser wallet extension. +2. **No public order entry.** The public app never displays a trade button, wallet connection, deposit flow, approval flow, or controls that imply Samaritan holds user funds. +3. **No raw TXLine redistribution.** Public screens expose derived probabilities, signals, decisions, source labels, message references, validation status, and hashes. They do not expose or reconstruct the licensed raw feed. +4. **No fake activity.** Live, captured replay, paper, exploratory, sealed, vetoed, expired, and no-trade states must be explicit. +5. **No profitability claim before evidence.** CLV, fill rate, P&L, Brier score, costs, drawdown, and sample size appear together. Empty or sealed endpoints remain visibly empty or sealed. +6. **Every action has provenance.** A signal cannot appear as a fill without the intervening triage, thesis, deterministic risk, and execution states. +7. **Risk state is prominent.** `PAPER MODE` and `REAL-MONEY GATE CLOSED` remain persistent while those conditions are true. +8. **Replay and live share one presentation model.** Replay controls time; they do not change the meaning, ordering, or shape of decision data. +9. **Failure is visible.** Feed degradation, stale projections, unavailable books, model errors, and rejected cases are first-class states, not hidden behind blank charts. +10. **Submission framing remains compliant.** Public copy presents Deborah as the participant/project owner and Claude as a bounded component. The entry is never described as "AI-built." + +## 3. Audiences and product surfaces + +### Public Observer - build first + +The observer is a judge, analyst, potential user, or teammate who needs to understand Samaritan without configuration or credentials. + +The observer can: + +- inspect live and captured matches; +- follow a case from signal to result; +- understand no-trade and risk-veto decisions; +- review paper-study progress and measured outcomes; +- inspect ledger integrity and on-chain proof links; +- replay a verified sequence deterministically. + +The observer cannot mutate configuration, reveal credentials, alter the replay source, resume a halted system, or submit an order. + +### Private Control - later + +The operator surface belongs to Deborah and is separate from the public product. It will eventually contain feed controls, configuration, mapping confirmation, kill-switch state, and system governance. It must not be implemented by placing hidden admin controls inside public components. + +### Mobile Companion + +Mobile is an observation and alert surface, not a compressed desktop terminal. It prioritizes match state, the active signal, the Decision Rail, study progress, and proof links. Dense comparative analysis remains desktop-first. + +## 4. Information architecture + +The public navigation contains five destinations. + +| Destination | Job | +|---|---| +| **Command** | Answer "what is Samaritan watching and doing now?" | +| **Matchroom** | Explain one fixture, its selected market, probability movement, and active decision lifecycle | +| **Casebook** | Search and inspect every signal, escalation, veto, no-trade, fill, close, and settlement | +| **Study** | Explain the invalidated v1 audit, corrected historical signal evidence, and the human gate before any v2 paper observations | +| **Proof** | Verify ledger hashes, replay identity, source references, validation records, and Solana anchors | + +There is no marketing-only landing page in v1. The root route opens **Command**, with a compact explainer integrated into the product. + +## 5. Global shell + +Every primary screen uses the same shell: + +- compact Samaritan wordmark and section navigation; +- global search for a fixture or case ID; +- persistent system strip; +- main content canvas; +- last-updated timestamp and projection freshness; +- direct access to the current demo replay when no live fixture is active. + +The system strip displays, at minimum: + +- mode: `LIVE`, `CAPTURED REPLAY`, or `OFFLINE ARTIFACT`; +- execution: `PAPER MODE` or an accurately gated alternative; +- feed health: TXLine, Polymarket, decision runtime, and proof writer; +- real-money gate state; +- last canonical event time; +- end-to-end signal decision latency when available; +- Claude spend state as a bounded operational metric, not as a promotional gimmick. + +Status must use text and iconography in addition to color. + +## 6. Signature components + +### Match Masthead + +Displays teams, home/away identity, competition text, scheduled kickoff or live clock, current score, market period, and selected exact market line. It must distinguish full-time Match Result from full-time totals and must never imply an extra-time market. + +### Probability Field + +The main chart compares derived fair consensus with the executable Polymarket market: + +- TXLine-derived fair probability; +- executable best bid and ask, not only midpoint; +- optional midpoint as secondary context; +- selected signal direction and threshold; +- market gap in probability points and basis points; +- feed outages and unavailable-book intervals; +- score and material match events when licensed derived context permits; +- entry, close, and settlement markers; +- chart time and replay time locked to the same clock. + +This is a probability chart, not a crypto candlestick chart. + +### Decision Rail + +The Decision Rail is Samaritan's signature interaction. It renders the append-only case lifecycle as an ordered, timestamped sequence: + +1. `SIGNAL DETECTED` +2. `TRIAGE DROPPED` or `TRIAGE ESCALATED` +3. `THESIS SUBMITTED` or `NO TRADE` +4. `RISK VETOED`, `RISK SHRUNK`, or `RISK APPROVED` +5. `PAPER INTENT LEDGERED` +6. `FILLED`, `PARTIALLY FILLED`, `NOT FILLED`, or `EXPIRED` +7. `CLOSE CAPTURED` +8. `SETTLED` +9. `PROOF ANCHORED` + +Each stage expands to show only the evidence appropriate to that stage. Claude output appears as a structured thesis with confidence, invalidation, and evidence references. It is never presented as a chatbot conversation. + +### Signal Tape + +A chronological tape of candidate cases across fixtures. Each row includes time, fixture, market, detector, direction, gap, disposition, and age. Repeated movements that triage deduplicated remain traceable to the surviving case. + +### Evidence Stamp + +A compact, high-contrast label used on cases and reports: + +- `PAPER` +- `EXPLORATORY` +- `LONG-RUN SEALED` +- `VERIFIED REPLAY` +- `NO TRADE` +- `RISK VETO` +- `FEED DEGRADED` +- `REAL-MONEY GATE CLOSED` + +Stamps are semantic states, not decorative badges. + +### Study Meter + +Shows the stopping rule without revealing sealed endpoints early: + +- matches with an eligible signal vs required matches; +- signals vs required signals; +- study start time; +- lane identity: bounty or long-run; +- sealed/unsealed state; +- drawdown-stop state; +- config hash. + +### Integrity Panel + +Links the visible case to ledger sequence, previous hash, record hash, replay identity, source message references, validation status, and Solana anchor when available. Long hashes truncate visually but remain copyable and fully available in the detail view. + +## 7. Screen specifications + +### Command + +**Purpose:** establish system status and route the user to the most important fixture or case within ten seconds. + +Required content: + +- current system strip; +- active and upcoming verified fixtures; +- strongest recent signals, including vetoes and no-trades; +- current paper-study stopping-rule progress; +- latest verified evidence artifact; +- one sentence explaining what Samaritan does; +- one primary action: `Watch active match` or `Replay verified case`. + +The Command screen must not be a grid of equal-weight KPI cards. One active match or verified replay is the visual lead. + +Low-fidelity composition: + +```text ++-----------------------------------------------------------------------+ +| SAMARITAN | COMMAND | MODE | FEEDS | GATE | LAST EVENT | ++-----------------------------------------------------------------------+ +| | +| ACTIVE MATCH / FEATURED VERIFIED REPLAY | +| France 0 - 0 Spain | 42:18 | Full-time O/U 2.5 | +| | +| Fair 55.2% | Ask 51.4% | Gap +3.8pp | SIGNAL UNDER REVIEW | +| [Watch match] mini probability | +| field | ++--------------------------------------+--------------------------------+ +| RECENT CASES | PAPER STUDY | +| 12:41 CONSENSUS_MOVE ESCALATED | 8 / 20 matches | +| 12:32 CONSENSUS_MOVE NO TRADE | 17 / 40 signals | +| 12:08 FEED DEGRADED HALTED | LONG-RUN SEALED | +| [Open Casebook] | [Open Study] | ++--------------------------------------+--------------------------------+ +| LATEST PROOF | ledger head | replay identity | anchor status | ++-----------------------------------------------------------------------+ +``` + +### Matchroom + +**Purpose:** make one autonomous decision understandable and auditable. + +Desktop composition: + +```text ++-----------------------------------------------------------------------+ +| SAMARITAN | MODE | FEEDS | GATE | LATENCY | LAST EVENT | ++-----------------------------------------------------------------------+ +| FRANCE 0 FULL-TIME O/U 2.5 0 SPAIN 42:18 | ++---------------------------------------------+-------------------------+ +| | DECISION RAIL | +| CONSENSUS VS EXECUTABLE MARKET | | +| | 12:40 signal detected | +| TXLINE FAIR 55.2% | 12:40 triage escalated | +| BEST ASK 51.4% | 12:41 thesis submitted | +| EXECUTABLE GAP +3.8pp | 12:41 risk approved | +| | 12:42 paper fill | ++---------------------------------------------+-------------------------+ +| SIGNAL TAPE | ++-----------------------------------------------------------------------+ +| STUDY 8/20 MATCHES | 17/40 SIGNALS | LONG-RUN SEALED | CONFIG HASH | ++-----------------------------------------------------------------------+ +``` + +The chart receives the largest area. The Decision Rail remains visible while inspecting chart points or replaying the case. + +Mobile composition: + +```text ++--------------------------------+ +| SAMARITAN | PAPER | GATE CLOSED| ++--------------------------------+ +| FRANCE 0 - 0 SPAIN 42:18 | +| FULL-TIME O/U 2.5 | ++--------------------------------+ +| ACTIVE SIGNAL | +| Fair 55.2% Ask 51.4% +3.8pp | ++--------------------------------+ +| COMPACT PROBABILITY FIELD | +| | +| | ++--------------------------------+ +| DECISION RAIL | +| 12:40 Signal detected | +| 12:40 Triage escalated | +| 12:41 Thesis submitted | +| 12:41 Risk approved | ++--------------------------------+ +| STUDY 8/20 | 17/40 | SEALED | +| [Inspect proof] | ++--------------------------------+ +``` + +### Casebook + +**Purpose:** prove that Samaritan records unfavorable, rejected, and incomplete cases as faithfully as successful ones. + +Filters: + +- fixture; +- market family and exact line; +- detector; +- lifecycle disposition; +- execution outcome; +- evidence lane; +- live/replay source; +- date range. + +Default sorting is newest decision time. Success-only filtering is never the default. Case detail opens the complete Decision Rail, executable book evidence, costs, thesis, steelman/invalidation, risk reasons, hashes, and measured outcome. + +Low-fidelity composition: + +```text ++-----------------------------------------------------------------------+ +| SAMARITAN | CASEBOOK | MODE | FEEDS | GATE | ++-----------------------------------------------------------------------+ +| Search cases... | Fixture | Detector | Disposition | Lane | Date | ++--------------------------------------+--------------------------------+ +| CASE INDEX | SELECTED CASE | +| | | +| 12:41 France-Spain | CASE PS-000184 | +| O/U 2.5 | CONSENSUS_MOVE | France-Spain | O/U 2.5 | +| ESCALATED -> RISK APPROVED | EXECUTABLE GAP +3.8pp | +| | | +| 12:32 England-Argentina | DECISION RAIL | +| O/U 2.5 | CONSENSUS_MOVE | signal -> triage -> thesis | +| NO TRADE | -> risk -> fill -> close | +| | | +| 12:08 Spain-Belgium | THESIS / INVALIDATION | +| FEED DEGRADED -> HALTED | structured public evidence | +| | | +| [Load older cases] | COSTS / RESULT / PROOF | ++--------------------------------------+--------------------------------+ +``` + +### Study + +**Purpose:** show why v1 was withdrawn, what corrected historical evidence supports, and why no active paper protocol exists until Deborah registers v2. + +Required views: + +- stopping-rule progress; +- executable CLV distribution and match-clustered confidence interval; +- random-direction and no-trade baselines; +- fill rate and modeled/actual slippage; +- spread, fees, and total cost decomposition; +- per-match P&L table; +- equity and drawdown curve; +- Brier score where applicable; +- clear `ACCEPT`, `REJECT`, `INCOMPLETE`, or `SEALED` conclusion generated from deterministic protocol rules. + +The page must keep sample size and lane identity visible alongside every headline result. + +### Proof + +**Purpose:** show that the visible history is append-only and replayable. + +Required views: + +- ledger head and segment identity; +- hash-chain verification status; +- replay event count and identity hash; +- source-reference validation status; +- Solana anchor status and explorer links; +- explicit distinction between local verification, TXLine validation, and on-chain anchoring. + +An unavailable anchor is displayed as `NOT YET ANCHORED`, not as an error if anchoring has not run. + +## 8. Visual direction: Live Intelligence + +Live Intelligence is a polished sports-analytics product, not a trading terminal or editorial match report. Matchroom v2 uses approximately **70% SportsTensor spatial hierarchy** for composition, whitespace, chart organization, evidence tables, and modular rhythm, with approximately **30% Live Matches sporting energy** for the score-first masthead, dark operational character, lime active states, and compact match tension. + +Neither reference is reproduced directly. Samaritan's Decision Rail, replay identity, evidence provenance, paper state, and deterministic risk boundaries remain the product's signature. + +The interface should feel native to an elite match-analysis room: immediate score context, restrained market data, and a visible decision outcome. It must not resemble a casino, crypto exchange, generic admin template, or faux command center. There are no photographic hero sections, oversized editorial headlines, beige report canvases, glowing sci-fi grids, or decorative market theatrics. + +### Color tokens + +| Token | Value | Use | +|---|---:|---| +| `navy-1000` | `#050716` | Sidebar and deepest application surface | +| `navy-950` | `#080B20` | Global workspace | +| `navy-900` | `#0D112B` | Primary cards and analytical surfaces | +| `navy-750` | `#1B244B` | Selected tabs and raised controls | +| `text` | `#F7F8FC` | Primary text and score data | +| `text-soft` | `#BBC2DD` | Secondary headings and table values | +| `muted` | `#7D87AA` | Labels, timestamps, and explanations | +| `signal` | `#98F244` | Active navigation, verified replay, TXLine fair series | +| `market` | `#927FF2` | Polymarket ask series and spread region | +| `context` | `#5B86FF` | Supporting metrics and selected evidence | +| `risk` | `#FF6B74` | Veto, invalidity, closed execution, or failure | +| `degraded` | `#FFAF58` | Delayed, partial, outage, or observational warning | + +Color meanings are fixed. Green must not mean "profitable" in one view and "feed healthy" in another without an accompanying label. Red is reserved for real risk, failure, invalidity, or veto. + +All foreground/background combinations must pass WCAG AA contrast before the palette is frozen in code. + +### Typography + +- **Manrope:** navigation, scores, teams, probabilities, headings, controls, and explanatory text. +- **IBM Plex Mono:** timestamps, case IDs, prices, basis points, latency, ledger sequence, and hashes. + +Fonts should be self-hosted or packaged with the deployment. Numeric data uses tabular figures. Large score and probability typography may be expressive; evidence text remains restrained. + +### Geometry and density + +- 12-column desktop grid with a fluid content width up to approximately 1600 px; +- 20-24 px desktop gutters, 12-16 px compact gutters; +- 4 px base spacing rhythm; +- 14-18 px default panel radius; +- quiet one-pixel blue-gray borders with shallow panel shadows; +- one dominant analytical panel per screen; +- limited use of pills, reserved for filters and compact states; +- no glassmorphism, floating orbs, excessive glow, decorative 3D assets, or editorial poster composition. + +### Iconography + +Use a consistent outlined icon family for navigation and state. Do not use team or FIFA trademarks without clear rights. Flags, generic team initials, and plain text remain acceptable fallbacks. + +## 9. Data visualization rules + +1. Probability is the universal display currency and is shown as a percentage at the edge of the UI. +2. The y-axis never truncates in a way that exaggerates small probability moves without an explicit visual break. +3. Best bid and ask are visually distinct from midpoint and fair consensus. +4. Executable CLV is primary; midpoint CLV is secondary and labeled. +5. Feed outages are gaps or shaded unavailable intervals, never interpolated as unchanged prices. +6. Entry, close, and settlement markers are tied to ledger timestamps. +7. Charts expose exact values through keyboard-accessible focus or a data table alternative. +8. Series use both color and line treatment, marker, or label so color vision is not required. +9. Confidence intervals show the estimate, lower and upper bound, bootstrap unit, and sample size. +10. No decorative chart is permitted. Every visual must answer a named product question. + +## 10. Motion + +Motion communicates state transitions rather than decoration. + +Allowed motion: + +- probability lines updating with restrained interpolation; +- a new signal entering the Signal Tape; +- a case advancing along the Decision Rail; +- replay cursor movement and event markers; +- a short page-load reveal that establishes hierarchy. + +Avoid constant pulsing, animated gradients, card hover theatrics, confetti, fake terminal typing, and ambient chart motion. Respect `prefers-reduced-motion`; replay must remain usable with motion reduced. + +## 11. Content design + +Copy is concise, factual, and evidence-led. + +Preferred: + +- `No executable book was available after decision latency.` +- `Risk veto: aggregate fixture exposure would exceed $15.` +- `Long-run endpoints remain sealed until the stopping rule is met.` +- `Captured replay from July 10, 2026. Not live.` + +Avoid: + +- `AI found a guaranteed winner.` +- `High-confidence profit opportunity.` +- `The model knows Spain will win.` +- `Connect wallet to continue.` + +Claude should be named only where its bounded role matters: `Haiku triage` and `Opus analysis`. Product copy emphasizes the system and evidence, not model personality. + +## 12. Responsive behavior + +### Desktop + +Desktop is the full analytical environment. Matchroom keeps the Probability Field and Decision Rail visible together. Wide tables may use sticky identity columns and horizontal scrolling inside their own region. + +### Tablet + +The chart remains first. The Decision Rail becomes a fixed-width adjacent panel in landscape and a full-width section below the chart in portrait. Global system health collapses into a labeled summary that expands on demand. + +### Mobile + +Mobile order: + +1. system mode and gate; +2. match masthead; +3. active signal summary; +4. compact probability chart; +5. Decision Rail; +6. study progress; +7. proof link. + +The complete signal table and dense study tables may switch to grouped rows, but no evidence field may disappear solely because of viewport size. + +## 13. Accessibility + +- Target WCAG 2.2 AA. +- Every interactive element is keyboard reachable with visible focus. +- Status never depends on color alone. +- Live updates use restrained, appropriately scoped announcements rather than reading every tick. +- Replay controls expose play/pause, speed, step backward, step forward, current time, and event list. +- Charts provide summaries and tabular alternatives. +- Touch targets are at least 44 by 44 CSS pixels on compact layouts. +- User-selected zoom to 200% must preserve core workflows. + +## 14. Public data and technical boundary + +The browser consumes **derived, read-only projections**, never raw ledger files, raw TXLine captures, server credentials, wallet material, or execution adapters. + +The server-side projection layer should expose five conceptual view models: + +- `SystemProjection`: mode, health, gate, freshness, latency, and spend status; +- `MatchProjection`: fixture identity, derived match state, selected market, and probability series; +- `DecisionCaseProjection`: ordered lifecycle stages and public evidence; +- `StudyProjection`: stopping rule, sealed status, metrics, guardrails, and per-match rows; +- `ProofProjection`: public hashes, verification results, and explorer links. + +These names describe frontend contracts; they do not freeze route names or storage schemas. + +Rules: + +1. Projection generation is deterministic from append-only artifacts. +2. Secrets remain server-side. +3. Raw TXLine payloads and reconstructable raw series are never serialized to public clients. +4. Live updates carry derived projection deltas over a read-only stream. +5. Replay emits the same projection shapes and lifecycle semantics as live. +6. The client cannot mutate execution, risk, mapping, study, ledger, or anchor state. +7. The UI displays server event time and projection-generation time separately when freshness matters. +8. Unknown enum values fail visibly as `UNKNOWN STATE`; they are not silently mapped to success. + +## 15. Replay and demo mode + +The replay player is a core product capability because judges may review Samaritan after a match has ended. + +Required controls: + +- play and pause; +- 0.5x, 1x, 2x, 5x, and 10x speed where the event density permits; +- step to previous or next material event; +- restart; +- timestamp and original match date; +- visible `CAPTURED REPLAY` label at all times. + +The demo sequence must use real captured or ledger-derived artifacts. A case may demonstrate a no-trade or veto when that is what the evidence produced. Spain-Belgium may honestly demonstrate synchronized capture, feed outages, and rejection of the stale-quote hypothesis; it must not be edited into a winning trade. + +Golden demo flow: + +1. Command establishes mode, feed health, and closed real-money gate. +2. Matchroom shows the match and selected exact market. +3. A detector emits a real signal. +4. Haiku triage and Opus thesis appear on the Decision Rail. +5. Deterministic risk approves, shrinks, or vetoes the case. +6. Paper execution records fill, partial fill, no fill, or expiry. +7. Study and Proof show the measured result and integrity chain. + +## 16. Design and implementation sequence + +The v1 comparison baseline remains at [`design/matchroom-high-fidelity/`](../design/matchroom-high-fidelity/). It must not be overwritten while v2 is under review. + +The approved second checkpoint is [`design/matchroom-v2/`](../design/matchroom-v2/), with a [dark desktop](../design/matchroom-v2/matchroom-v2-desktop.jpg), [dark mobile](../design/matchroom-v2/matchroom-v2-mobile.jpg), [mobile Decision Rail](../design/matchroom-v2/matchroom-v2-mobile-evidence.jpg), [375 px QA](../design/matchroom-v2/matchroom-v2-mobile-375.jpg), [light style frame](../design/matchroom-v2/matchroom-v2-light.jpg), [v1/v2 comparison](../design/matchroom-v2/matchroom-v1-v2-comparison.jpg), and [design decision report](../design/matchroom-v2/DESIGN-DECISION.md). + +The dark hybrid is the launch direction. Light mode is a deferred, non-blocking enhancement; the light style frame is its visual reference and must retain information-architecture and semantic-state parity when implemented. + +Both checkpoints use the verified Spain-Belgium capture and deliberately end in `NO TRADE`: the live-lane study found that Polymarket moved before TXLine and produced no clean stale-quote window. They are design prototypes, not production frontend code. + +### Production implementation status + +The first four production vertical slices are implemented in [`apps/dashboard/`](../apps/dashboard/) and served by the read-only projection layer in [`src/dash/`](../src/dash/). Matchroom renders the verified Spain-Belgium replay, Command derives the operating posture and capture schedule, Casebook reconstructs the complete verified refusal record, and Study presents the suspended v1 audit without prototype fallback data. + +Implemented in this slice: + +- fail-closed fixture and replay identity validation; +- derived-only public API with no raw TXLine payloads, asset IDs, source paths, credentials, or wallet controls; +- responsive Match Masthead, Probability Field, Decision Rail, evidence sequence, and replay proof; +- manual replay states and deterministic autoplay across `T-5 seconds`, `Goal first seen`, and `T+30 seconds`; +- explicit captured-replay, paper, no-trade, and real-money-gate-closed states; +- root-level Command surface with current system state, exact confirmed capture fixtures, the strongest verified case, and direct Matchroom entry; +- fail-closed Command fixture admission against exact TXLine fixture and Polymarket event evidence; +- searchable Casebook index with fixture, market, detector, disposition, execution, lane, source, and date filters; +- complete Casebook detail with lifecycle, executable evidence, analyst/execution boundaries, observation sequence, and replay hashes; +- honest no-result behavior and verified-capture admission that prevents scheduled fixtures or empty ledgers from appearing as cases; +- sealed long-run study progress and compact integrity proof without exposing preregistered profitability endpoints; +- standalone Study with dual-lane separation, frozen candidate, sample clock, CLV decision rule, guardrails, risk caps, evidence admission, and ledger proof; +- fail-closed Study result opening that requires the stopping rule, per-match rows, and reconciled guardrails before any long-run endpoint can enter the public contract; +- deterministic `ACCEPT` validation against the registered positive-CI, baseline, settlement, and guardrail requirements while the real-money gate remains closed; +- packaged Manrope and IBM Plex Mono fonts, reduced-motion behavior, keyboard focus, and mobile evidence cards; +- honest loading and fail-closed error surfaces. + +Expanded Proof remains the subsequent major observer route. Until it exists, the shared shell routes Proof to the authoritative integrity section of Command rather than a fabricated placeholder screen. + +Frontend work follows this order: + +1. **Annotated reference board:** capture the exact pattern taken and rejected from each source below. +2. **Low-fidelity wireframes:** Command, Matchroom, and Casebook on desktop; Matchroom on mobile. +3. **High-fidelity checkpoint:** one Matchroom with realistic Samaritan data and all major lifecycle states. V2 dark hybrid is approved and unlocks production dashboard implementation. Full paper-signal states follow after eligible ledgers contain evidence; light mode follows later without blocking launch. +4. **State inventory:** loading, empty, sealed, no-signal, no-trade, veto, partial fill, degraded feed, stale projection, offline artifact, and not-yet-anchored. +5. **Clickable replay prototype:** one honest captured sequence using the final information hierarchy. +6. **Comprehension test:** a new viewer explains what moved, what Samaritan decided, and whether money moved within 30 seconds. +7. **Implementation:** shared shell, derived projection layer, replay, responsive states, and accessibility. +8. **Visual QA:** desktop and mobile screenshots, reduced motion, keyboard flow, contrast, long labels, and real empty states. + +No implementation should invent placeholder profits, winning percentages, fabricated Claude explanations, or fake live matches for visual completeness. + +## 17. Acceptance criteria + +The v1 public UI is complete only when: + +- a new viewer can state what Samaritan does within 30 seconds; +- the viewer can trace one case from signal to proof within three minutes; +- live, captured replay, and offline artifact modes cannot be confused; +- paper status and real-money gate state are persistent and correct; +- no account or wallet is required; +- no raw TXLine data or secret reaches the browser; +- every case disposition, including no-trade and veto, is inspectable; +- the Study screen preserves sealing and stopping-rule behavior; +- the Proof screen verifies ledger/replay identity from generated artifacts; +- the desktop and mobile golden paths are keyboard and touch usable; +- the interface passes WCAG AA contrast checks; +- the bounty demo can run from a verified replay if no match is live; +- public copy makes no unsupported prediction or profitability claim. + +## 18. Reference board + +| Reference | Take | Reject | +|---|---|---| +| [Polymarket Sports](https://polymarket.com/sports/live) | Match scanning, league grouping, compact probabilities, score/time prominence | Order-entry emphasis and exchange identity | +| [Nomos Discover](https://pro.nomos.trade/discover) | Persistent search, multi-source discovery, briefing rail, dense navigation | Generic black/blue treatment and excessive filter pills | +| [Hudl StatsBomb IQ Live](https://statsbomb.com/what-we-do/iq-live/) | Match timeline, race chart, analytical drill-down, meaningful widgets | Professional complexity on the first screen | +| [Hue & Machine sports dashboard](https://dribbble.com/shots/23905032-Sports-Analytics-Dashboard) | Hard-edged editorial grid and numerical hierarchy | Purple accent and tiny labels | +| [Dynamic Sports Analytics](https://dribbble.com/shots/25514823-Dynamic-Sports-Analytics-Dashboard) | Controlled acid energy and strong contrast | Bubble cards, pie-chart prominence, and gamified casino tone | +| [Live Matches & Stats](https://dribbble.com/shots/26818920-Sports-dashboard-Live-Matches-Stats) | **30% sporting-energy reference:** deep-navy character, luminous lime selection, score-first composition, and compact match tension | Betting economics, widget density, and decorative athlete tile | +| [SportsTensor](https://dribbble.com/shots/26056873-Sportstensor-Sports-Analytics-Dashboard-Design-Betting-Liv) | **70% spatial reference:** analytical hierarchy, clean chart proportions, table organization, modular calm, and breathing room | Washed lavender as the primary skin, low-contrast labels, and generic prediction leaderboard | + +Dribbble references are visual inspiration only. Product architecture, error handling, evidence states, and accessibility come from Samaritan's real workflow and artifacts. diff --git a/docs/proof/decision-receipt.md b/docs/proof/decision-receipt.md new file mode 100644 index 0000000..0f16305 --- /dev/null +++ b/docs/proof/decision-receipt.md @@ -0,0 +1,37 @@ +# Samaritan Decision Receipt v1 + +The Decision Receipt is Samaritan's portable, offline-verifiable record of one completed paper-decision lifecycle. It is generated only after the local decision ledger passes a full v2 hash-chain verification. The generator rejects mixed v1/v2 ledgers, incomplete or reordered lifecycles, and filled cases without a ledgered position. + +## What the receipt commits + +- the versioned code and frozen-config hashes; +- SHA-256 references to private TXLine and public venue source records; +- licence-safe signal evidence: 25-bps movement buckets, aggregate z/CUSUM state, categorical relative-value direction, and hashes—never an exact TXLine fair level or reconstructive cross-market gap; +- triage and analyst model, prompt version, prompt/response commitments, token usage, and nano-USD cost evidence; +- a SHA-256 commitment to the complete private submitted thesis plus its non-price identity/status fields, and the deterministic risk verdict with its fair-value limit kept private; +- the paper intent, post-analysis/post-venue-delay book evidence, and fill or no-fill result; exact public Polymarket book/fill prices remain visible because they do not disclose a TXLine level; +- the position, closing mark, and settlement when those lifecycle stages exist; +- ordered v2 ledger-entry commitments and the full ledger head at generation time; +- optional Solana memo metadata that must commit the same ledger head. + +The receipt's domain-separated canonical SHA-256 detects changes to every disclosed field. The offline verifier also recursively rejects exact fair/consensus probabilities, private deterministic limit probabilities, raw TXLine keys, and exact/reconstructive gap fields. It checks 25-bps bucketing, cross-field identity, lifecycle order, cost arithmetic, timing, fill/position state, source-reference roles, settlement order, and optional anchor consistency. + +## Proof boundaries + +`receipt:verify` is intentionally offline. It does not fetch private source payloads, reopen the local SQLite ledger, query Polymarket, or call Solana RPC. Its success means the strict schema, canonical receipt hash, disclosed lifecycle relationships, and committed ledger head are internally consistent. It does not independently prove that undisclosed source payloads existed, that the local ledger was honest when generated, that an anchor transaction is on-chain, or that a strategy is profitable. + +An unanchored receipt hash is tamper-evident, not an external timestamp or identity signature. Even when anchor metadata is present, the v1 verifier always reports `solanaNetworkVerificationPerformed: false`. Network verification belongs in a separate RPC-backed step. + +That separate offline-prepare / human-submit / read-only-verify workflow is documented in [Solana devnet Decision Receipt anchoring](solana-devnet-anchor.md). It leaves the source receipt immutable and reports network verification separately, so offline verification never overclaims an on-chain proof. + +## Synthetic proving fixture + +[`proof/fixtures/decision-receipt.synthetic.v1.json`](../../proof/fixtures/decision-receipt.synthetic.v1.json) is deterministic and prominently classified as synthetic. It represents no real match, feed observation, Claude invocation, order, fill, settlement, or performance. Its `performanceUse` is permanently `excluded_synthetic`. + +Verify it locally: + +```bash +pnpm receipt:verify -- proof/fixtures/decision-receipt.synthetic.v1.json +``` + +The generator lives in `src/proof/decision-receipt.ts`; the strict schema and independent verifier live in `src/proof/decision-receipt-schema.ts`. diff --git a/docs/proof/solana-devnet-anchor.md b/docs/proof/solana-devnet-anchor.md new file mode 100644 index 0000000..44d4601 --- /dev/null +++ b/docs/proof/solana-devnet-anchor.md @@ -0,0 +1,125 @@ +# Solana devnet Decision Receipt anchoring + +Status: tooling implemented and unit-tested; **no transaction has been submitted by this implementation work**. A prepared intent is not an on-chain proof. Only a signature that passes the read-only verifier is an externally timestamped devnet anchor. + +This workflow anchors one immutable Samaritan Decision Receipt without exposing private TXLine payloads. The Solana Memo commits: + +- Decision Receipt schema version; +- the receipt's canonical SHA-256 hash; +- the verified decision-ledger head sequence and SHA-256 hash; +- the commitment type and the literal `devnet` network. + +The canonical Memo is domain-separated as `samaritan.decision-receipt.anchor/v1` and encoded with Samaritan stable JSON. The transaction uses the official Solana Memo program, `MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr`. + +## Safety boundary + +The workflow deliberately separates three operations: + +1. `anchor:prepare` is offline. It reads a receipt, runs the receipt verifier, and emits a deterministic unsigned instruction/transaction intent. It has no fee payer, blockhash, signature, signer-loading path, or RPC call. +2. `anchor:submit` is the only write-capable command. It is devnet-only and fails unless all human gates below are explicit. +3. `anchor:verify` is read-only. It checks the RPC genesis hash, fetches one supplied signature at confirmed commitment, and compares the canonical Memo with the exact receipt and ledger head. It writes and signs nothing. + +The source receipt is never edited by these commands. Network verification is a separate JSON sidecar/output. `receipt:verify` remains an offline verifier and correctly continues to report `solanaNetworkVerificationPerformed: false`. + +## 1. Verify and prepare offline + +First verify the receipt: + +```bash +pnpm receipt:verify -- path/to/decision-receipt.json +``` + +Then create the unsigned intent. The default network is devnet; passing mainnet is refused. + +```bash +pnpm anchor:prepare -- path/to/decision-receipt.json > path/to/anchor-intent.json +``` + +Preparation output explicitly says: + +```json +{ + "transaction": { + "feePayer": "supplied_only_at_submit", + "recentBlockhash": "fetched_only_at_submit", + "signatures": "none" + }, + "preparation": { + "offline": true, + "signerAccessed": false, + "networkAccessed": false + } +} +``` + +The same receipt produces the same Memo and intent hash. Inspect and record these intent fields before continuing: + +- `.network` must be `devnet`; +- `.commitment.receipt.hash` must equal the receipt's `.integrity.receiptHash`; +- `.commitment.ledgerHead.hash` and `.sequence` must equal the receipt ledger head; +- `.integrity.intentHash` identifies the exact unsigned intent reviewed. + +## 2. HUMAN APPROVAL REQUIRED before submission + +> **STOP — DEBORAH MUST APPROVE THIS DEVNET WRITE.** Do not run `anchor:submit` as an unattended agent step. Deborah must inspect the receipt hash, ledger head, unsigned intent, signer path, and RPC network, then deliberately provide both the exact hash and `--approve-devnet-write`. + +Place the devnet keypair under the repository's ignored `.wallet/` directory, or provide another path that `git check-ignore` confirms is ignored. Never paste a secret key into a command, environment variable, document, log, receipt, or intent. The environment variable contains a **path only**: + +```bash +export SAMARITAN_SOLANA_ANCHOR_KEYPAIR_PATH="$PWD/.wallet/devnet-anchor.json" +``` + +After human approval, the explicit write command is: + +```bash +pnpm anchor:submit -- \ + --receipt path/to/decision-receipt.json \ + --intent path/to/anchor-intent.json \ + --network devnet \ + --confirm-receipt-hash \ + --approve-devnet-write +``` + +An optional devnet RPC URL may be supplied with `--rpc-url` or `SAMARITAN_SOLANA_DEVNET_RPC_URL`. Before reading the keypair, the command: + +- refuses every network string except `devnet`; +- re-verifies the receipt and unsigned intent; +- compares the exact confirmation hash; +- checks that the signer path is gitignored; +- queries and verifies the devnet genesis hash. + +It then creates a one-instruction legacy transaction with `@solana/web3.js`, signs locally, sends with preflight, confirms it, and immediately runs the same read-only Memo verifier. Mainnet submission is unconditionally refused in code. + +## 3. Independently verify the anchor + +Anyone can verify the resulting signature without a wallet: + +```bash +pnpm anchor:verify -- \ + --receipt path/to/decision-receipt.json \ + --signature \ + --network devnet +``` + +The verifier requires all of the following: + +- RPC genesis hash is the canonical Solana devnet genesis hash; +- the fetched transaction exists at `confirmed` commitment and succeeded; +- the requested signature belongs to that transaction; +- exactly one Memo instruction exists under the official Memo program; +- the Memo is domain-separated, strict-schema-valid, and canonically encoded; +- network, receipt schema, receipt hash, ledger sequence, ledger hash, and commitment type all match the supplied receipt. + +It only calls `getGenesisHash` and `getParsedTransaction`. It does not access a signer, send a transaction, request an airdrop, change the receipt, or persist a verification result. + +## Honest proof claims + +- Offline receipt verification proves disclosed schema/hash/lifecycle consistency, not network inclusion. +- An unsigned anchor intent proves only what would be submitted. +- A transaction signature by itself is not enough; it must pass `anchor:verify` against the exact public receipt. +- A successful devnet verification externally timestamps the receipt hash and its committed local ledger head. It does not prove source-payload truth or trading profitability. +- Judges require no wallet, tokens, or paid service. They only need the public receipt, signature, and read-only verifier. + +## Tests + +`test/solana-decision-anchor.test.ts` covers deterministic Memo canonicalization, non-canonical encoding, wrong RPC network, wrong receipt hash, wrong ledger head, missing signer, missing exact confirmation, mainnet refusal, and the rule that RPC genesis is checked before signer loading or sending. diff --git a/docs/research/capture-readiness.md b/docs/research/capture-readiness.md new file mode 100644 index 0000000..90bef68 --- /dev/null +++ b/docs/research/capture-readiness.md @@ -0,0 +1,10 @@ +# Paired Capture Readiness + +Generated 2026-07-12T10:40:36.521Z. Public Polymarket discovery plus the local TXLine fixture snapshot; no authenticated refresh was performed. + +| Match | Kickoff UTC | TXLine fixture | Status | Recommended capture start | Latest registered signal time | +|---|---:|---:|---|---:|---:| +| France vs Spain | 2026-07-14T19:00:00.000Z | 18237038 | ready_for_human_confirmation | 2026-07-14T16:00:00.000Z | 2026-07-14T18:45:00.000Z | +| England vs Argentina | 2026-07-15T19:00:00.000Z | 18241006 | ready_for_human_confirmation | 2026-07-15T16:00:00.000Z | 2026-07-15T18:45:00.000Z | + +A capture remains blocked until TXLine fixture ID, teams, and kickoff match an exact Polymarket event family and Deborah confirms the mapping. Recommended start is three hours before kickoff so the frozen feature windows warm up before the 15-minute signal cutoff. diff --git a/docs/research/historical-gate-study-causal-economic-v3.md b/docs/research/historical-gate-study-causal-economic-v3.md new file mode 100644 index 0000000..d054959 --- /dev/null +++ b/docs/research/historical-gate-study-causal-economic-v3.md @@ -0,0 +1,96 @@ +# Historical Detector Gate Study + +Generated: 2026-07-14T10:30:34.346Z +Protocol: `historical-gate-causal-economic-v3-2026-07-14` +Configuration SHA-256: `6c2b5cb517611628b2af7e6f93b18c9bcc7cb28315745d4cb9335cdacd32e968` + +> Research evidence only. Historical Polymarket points are sampled prices without bid/ask or depth. Results below are signal research, not executable-fill proof. The real-money gate remains closed. +> Audit status: the causal selector and binary Total Goals economic-case normalization are repaired. The result still requires Deborah's human gate review and must not be described as executable alpha. +> Source SHA-256: archive `eb6b09d339cca91e64a99730be352e4b1860db52af40b0d3d749372c1e09dc40`; mappings `39e2c8e3b4eccd463d5c4994db27054c0ac9285ce4480e813cc7c79c3b51e673`; causal selector evidence `228105f1f857b11ed0c7685652fd40483ae311f0f6191e5f574127fa2651064a`. + +## Design + +- Chronological train/test split: 68 / 30 fixtures +- Training range: 2026-06-11T19:05:00.000Z to 2026-06-27T21:00:00.000Z +- Held-out range: 2026-06-27T23:31:00.000Z to 2026-07-12T01:00:00.000Z +- Split cutoff: 2026-06-27T21:00:00.000Z; identical kickoff timestamps never cross partitions +- Replay window: final 3.0 hours through kickoff; in-running TXLine rows excluded +- Forward label: 15 minutes with 25.0 bps minimum gap closure +- Historical cost proxy: 100.0 probability bps per signal +- Training selection: labeled snapshot F1, guarded by at least 30 normalized economic cases; raw emissions cannot satisfy the minimum-n gate +- Uncertainty: 10,000 fixture-clustered bootstrap iterations, seed 20260714 +- Model operating cost: $0.00 (0 model calls; deterministic historical replay only) + +## Coverage + +- Training groups with both sources: 136/136; 164,542 events; 227,142 snapshots +- Held-out groups with both sources: 52/60; 60,092 events; 77,445 snapshots +- Causal diagnostics across 590 selector rows: 0 future probabilities; 0 future coverage rows; 0 late selector cutoffs; 0 nonzero untimestamped liquidity/volume rows +- Held-out missing groups: `18209181:match_result:full_time:none`, `18209181:total_goals:full_time:2500`, `18218149:match_result:full_time:none`, `18218149:total_goals:full_time:2500`, `18213979:match_result:full_time:none`, `18213979:total_goals:full_time:2500`, `18222446:match_result:full_time:none`, `18222446:total_goals:full_time:2500` + +## Dynamic Total Candidate + +The candidate rule freezes each exact mapped full-time total 3.0 hours before TXLine kickoff—before the first detector snapshot—then selects the line closest to 50/50. Both probability and coverage are bounded by that same as-of timestamp. It requires at least 1,000 as-of history points and fails closed beyond 1500.0 bps from even. It selected 98 fixtures with 0 failures. Distribution: O/U 2.5: 75, O/U 4.5: 1, O/U 1.5: 5, O/U 3.5: 17. + +Status: `candidate_pending_human_gate_review`. Volume and liquidity are zeroed and forbidden as selector inputs because no timestamped historical as-of evidence exists for them. + +## Held-Out Results + +| Detector | Train-selected parameters | Train raw emissions | Train normalized cases | Train snapshot precision | Train snapshot recall | Test raw emissions | Test normalized cases | Test CLV n | Fixtures | Test snapshot precision | Test snapshot recall | Mean signal CLV bps | Mean after-cost bps | Fixture-clustered 95% CI bps | Match Result net bps | Total net bps | Necessary evidence status | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---| +| XMARKET_DIVERGENCE | gap=100.0bps, persist=0s, stableZ=1 | 1762 | 934 | 65.9% | 2.2% | 372 | 331 | 331 | 25 | 50.0% | 1.9% | 92.4 | -7.6 | [-44.1, 27.8] | -15.2 | 45.0 | nonpositive_net_signal_clv | +| CONSENSUS_MOVE | z=1, cusum=10.0bps, gap=100.0bps, updates=5 | 702 | 301 | 62.8% | 0.7% | 107 | 72 | 72 | 23 | 50.5% | 0.6% | 138.4 | 38.4 | [-67.1, 120.8] | -67.0 | 132.7 | clustered_interval_not_positive | +| FADER_CANDIDATE | pmZ=1, gap=100.0bps, persist=0s, stableZ=1 | 412 | 268 | 6.1% | 0.1% | 72 | 56 | 56 | 16 | 5.6% | 0.1% | 54.5 | -45.5 | [-109.1, 35.9] | -50.3 | -32.2 | nonpositive_net_signal_clv | + +`human_review_required` means only that the predeclared sample, after-cost point estimate, and fixture-clustered interval checks passed. `clustered_interval_not_positive` means the point estimate was not strong enough to exclude zero after fixture clustering. Neither status is permission to trade or a substitute for Deborah's gate decision, settlement verification, risk caps, or executable-book evidence. + +For binary totals, `buy Over + sell Under` is one Over exposure and `buy Under + sell Over` is one Under exposure. An actual BUY is retained; duplicates and complementary sell expressions collapse into it. Sell-only totals are excluded because detector inputs do not prove an executable complementary-token ask. Three-way Match Result signals are not economically normalized. + +### Held-Out Normalization Audit + +| Detector | Raw | Normalized | Executable total buys | Match Result pass-through | Complementary sells collapsed | Duplicate buys collapsed | Sell-only totals dropped | Invalid total outcomes dropped | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| XMARKET_DIVERGENCE | 372 | 331 | 42 | 289 | 35 | 0 | 6 | 0 | +| CONSENSUS_MOVE | 107 | 72 | 38 | 34 | 29 | 0 | 6 | 0 | +| FADER_CANDIDATE | 72 | 56 | 15 | 41 | 9 | 0 | 7 | 0 | + +## Cost Sensitivity + +| Detector | 50 bps proxy | 100 bps proxy | 150 bps proxy | 200 bps proxy | +|---|---:|---:|---:|---:| +| XMARKET_DIVERGENCE | 42.4 | -7.6 | -57.6 | -107.6 | +| CONSENSUS_MOVE | 88.4 | 38.4 | -11.6 | -61.6 | +| FADER_CANDIDATE | 4.5 | -45.5 | -95.5 | -145.5 | + +Values are mean directional signal CLV after subtracting each proxy. This is a sensitivity check, not a claim about actual historical execution costs. + +## Training Leaderboard + +| Detector | Rank | Parameters | Raw emissions | Normalized cases | Snapshot precision | Snapshot recall | F1 | Minimum n met | Cost floor met | +|---|---:|---|---:|---:|---:|---:|---:|---|---| +| XMARKET_DIVERGENCE | 1 | gap=100.0bps, persist=0s, stableZ=1 | 1762 | 934 | 65.9% | 2.2% | 0.042 | yes | yes | +| XMARKET_DIVERGENCE | 2 | gap=100.0bps, persist=0s, stableZ=0.5 | 1522 | 674 | 65.9% | 1.5% | 0.030 | yes | yes | +| XMARKET_DIVERGENCE | 3 | gap=100.0bps, persist=60s, stableZ=1 | 434 | 263 | 60.2% | 0.5% | 0.010 | yes | yes | +| XMARKET_DIVERGENCE | 4 | gap=150.0bps, persist=0s, stableZ=1 | 355 | 132 | 85.5% | 0.5% | 0.009 | yes | yes | +| XMARKET_DIVERGENCE | 5 | gap=150.0bps, persist=0s, stableZ=0.5 | 285 | 116 | 84.0% | 0.4% | 0.007 | yes | yes | +| CONSENSUS_MOVE | 1 | z=1, cusum=10.0bps, gap=100.0bps, updates=5 | 702 | 301 | 62.8% | 0.7% | 0.013 | yes | yes | +| CONSENSUS_MOVE | 2 | z=1, cusum=10.0bps, gap=100.0bps, updates=3 | 702 | 301 | 62.8% | 0.7% | 0.013 | yes | yes | +| CONSENSUS_MOVE | 3 | z=1.5, cusum=10.0bps, gap=100.0bps, updates=3 | 596 | 259 | 63.2% | 0.6% | 0.012 | yes | yes | +| CONSENSUS_MOVE | 4 | z=1.5, cusum=10.0bps, gap=100.0bps, updates=5 | 596 | 259 | 63.2% | 0.6% | 0.012 | yes | yes | +| CONSENSUS_MOVE | 5 | z=2, cusum=10.0bps, gap=100.0bps, updates=5 | 478 | 228 | 65.5% | 0.5% | 0.011 | yes | yes | +| FADER_CANDIDATE | 1 | pmZ=1, gap=100.0bps, persist=0s, stableZ=1 | 412 | 268 | 6.1% | 0.1% | 0.003 | yes | yes | +| FADER_CANDIDATE | 2 | pmZ=1, gap=100.0bps, persist=0s, stableZ=0.5 | 319 | 163 | 8.9% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 3 | pmZ=1.5, gap=100.0bps, persist=0s, stableZ=1 | 359 | 238 | 5.4% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 4 | pmZ=1.5, gap=100.0bps, persist=0s, stableZ=0.5 | 281 | 142 | 6.6% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 5 | pmZ=1.5, gap=150.0bps, persist=0s, stableZ=0.5 | 65 | 30 | 18.6% | 0.1% | 0.001 | yes | yes | + +## Guardrails + +- No held-out result influenced configuration selection. +- Total-line selection uses only prices and coverage observable no later than the first detector snapshot. +- Training and held-out minimum-sample checks use normalized economic cases, never raw detector emissions. +- Sell-only Total Goals cases fail closed; sampled history cannot prove a complementary token was buyable at that moment. +- Sampled Polymarket history cannot establish executable spread, slippage, or fill probability; the cost proxy is deliberately conservative but remains a proxy. +- Candidate mappings remain non-tradeable and no settlement review is inferred from research alignment. +- Thresholds in this report are exploratory until the human gate review explicitly freezes or rejects them. +- STALE_QUOTE remains disabled based on the synchronized live-lane result; this historical study does not evaluate it. diff --git a/docs/research/historical-gate-study-causal-economic-v4.md b/docs/research/historical-gate-study-causal-economic-v4.md new file mode 100644 index 0000000..7205845 --- /dev/null +++ b/docs/research/historical-gate-study-causal-economic-v4.md @@ -0,0 +1,104 @@ +# Historical Detector Gate Study + +Generated: 2026-07-14T10:34:05.545Z +Protocol: `historical-gate-causal-economic-v4-2026-07-14` +Configuration SHA-256: `9a4eeff928f697fc55ab5147a4dc07f611c40bb749501fc3bd92b211f24b2e54` + +> Research evidence only. Historical Polymarket points are sampled prices without bid/ask or depth. Results below are signal research, not executable-fill proof. The real-money gate remains closed. +> Audit status: the causal selector and binary Total Goals economic-case normalization are repaired. The result still requires Deborah's human gate review and must not be described as executable alpha. +> Source SHA-256: archive `eb6b09d339cca91e64a99730be352e4b1860db52af40b0d3d749372c1e09dc40`; mappings `39e2c8e3b4eccd463d5c4994db27054c0ac9285ce4480e813cc7c79c3b51e673`; causal selector evidence `228105f1f857b11ed0c7685652fd40483ae311f0f6191e5f574127fa2651064a`. + +## Design + +- Chronological train/test split: 68 / 30 fixtures +- Training range: 2026-06-11T19:05:00.000Z to 2026-06-27T21:00:00.000Z +- Held-out range: 2026-06-27T23:31:00.000Z to 2026-07-12T01:00:00.000Z +- Split cutoff: 2026-06-27T21:00:00.000Z; identical kickoff timestamps never cross partitions +- Replay window: final 3.0 hours through kickoff; in-running TXLine rows excluded +- Forward label: 15 minutes with 25.0 bps minimum gap closure +- Historical cost proxy: 100.0 probability bps per signal +- Training selection: labeled snapshot F1, guarded by at least 30 normalized economic cases; raw emissions cannot satisfy the minimum-n gate +- Uncertainty: 10,000 fixture-clustered bootstrap iterations, seed 20260714 +- Model operating cost: $0.00 (0 model calls; deterministic historical replay only) + +## Coverage + +- Training groups with both sources: 136/136; 164,542 events; 227,142 snapshots +- Held-out groups with both sources: 52/60; 60,092 events; 77,445 snapshots +- Causal diagnostics across 590 selector rows: 0 future probabilities; 0 future coverage rows; 0 late selector cutoffs; 0 nonzero untimestamped liquidity/volume rows +- Held-out missing groups: `18209181:match_result:full_time:none`, `18209181:total_goals:full_time:2500`, `18218149:match_result:full_time:none`, `18218149:total_goals:full_time:2500`, `18213979:match_result:full_time:none`, `18213979:total_goals:full_time:2500`, `18222446:match_result:full_time:none`, `18222446:total_goals:full_time:2500` + +## Dynamic Total Candidate + +The candidate rule freezes each exact mapped full-time total 3.0 hours before TXLine kickoff—before the first detector snapshot—then selects the line closest to 50/50. Both probability and coverage are bounded by that same as-of timestamp. It requires at least 1,000 as-of history points and fails closed beyond 1500.0 bps from even. It selected 98 fixtures with 0 failures. Distribution: O/U 2.5: 75, O/U 4.5: 1, O/U 1.5: 5, O/U 3.5: 17. + +Status: `candidate_pending_human_gate_review`. Volume and liquidity are zeroed and forbidden as selector inputs because no timestamped historical as-of evidence exists for them. + +## Forward Paper Candidate Assessment + +The prespecified paper family is **Total Goals only**. Its frozen `CONSENSUS_MOVE` configuration was chosen from training data before held-out Total Goals results were inspected. A family-specific training audit found 135 normalized Total Goals cases, so the training minimum was not borrowed from Match Result. + +Held-out Total Goals: 38 normalized buy cases across 18 fixtures; mean after the 100.0 bps cost proxy 132.7 bps; fixture-clustered 95% CI [14.3, 243.9] bps. + +Status: `historical_signal_candidate_for_forward_paper_review`. This is a **historical signal candidate for a fresh forward paper review**, not alpha, profitability, or executable-fill evidence. Inputs are sampled Polymarket prices without bid/ask depth; the v2 paper protocol remains `engineering_candidate_unregistered` and cannot admit observations or spend Claude tokens before Deborah registers it. + +## Held-Out Results + +| Detector | Train-selected parameters | Train raw emissions | Train normalized cases | Train snapshot precision | Train snapshot recall | Test raw emissions | Test normalized cases | Test CLV n | Fixtures | Test snapshot precision | Test snapshot recall | Mean signal CLV bps | Mean after-cost bps | Fixture-clustered 95% CI bps | Match Result net bps | Total net bps | Necessary evidence status | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---| +| XMARKET_DIVERGENCE | gap=100.0bps, persist=0s, stableZ=1 | 1762 | 934 | 65.9% | 2.2% | 372 | 331 | 331 | 25 | 50.0% | 1.9% | 92.4 | -7.6 | [-44.1, 27.8] | -15.2 | 45.0 | nonpositive_net_signal_clv | +| CONSENSUS_MOVE | z=1, cusum=10.0bps, gap=100.0bps, updates=5 | 702 | 301 | 62.8% | 0.7% | 107 | 72 | 72 | 23 | 50.5% | 0.6% | 138.4 | 38.4 | [-67.1, 120.8] | -67.0 | 132.7 | clustered_interval_not_positive | +| FADER_CANDIDATE | pmZ=1, gap=100.0bps, persist=0s, stableZ=1 | 412 | 268 | 6.1% | 0.1% | 72 | 56 | 56 | 16 | 5.6% | 0.1% | 54.5 | -45.5 | [-109.1, 35.9] | -50.3 | -32.2 | nonpositive_net_signal_clv | + +`human_review_required` means only that the predeclared sample, after-cost point estimate, and fixture-clustered interval checks passed. `clustered_interval_not_positive` means the point estimate was not strong enough to exclude zero after fixture clustering. Neither status is permission to trade or a substitute for Deborah's gate decision, settlement verification, risk caps, or executable-book evidence. + +For binary totals, `buy Over + sell Under` is one Over exposure and `buy Under + sell Over` is one Under exposure. An actual BUY is retained; duplicates and complementary sell expressions collapse into it. Sell-only totals are excluded because detector inputs do not prove an executable complementary-token ask. Three-way Match Result signals are not economically normalized. + +### Held-Out Normalization Audit + +| Detector | Raw | Normalized | Executable total buys | Match Result pass-through | Complementary sells collapsed | Duplicate buys collapsed | Sell-only totals dropped | Invalid total outcomes dropped | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| XMARKET_DIVERGENCE | 372 | 331 | 42 | 289 | 35 | 0 | 6 | 0 | +| CONSENSUS_MOVE | 107 | 72 | 38 | 34 | 29 | 0 | 6 | 0 | +| FADER_CANDIDATE | 72 | 56 | 15 | 41 | 9 | 0 | 7 | 0 | + +## Cost Sensitivity + +| Detector | 50 bps proxy | 100 bps proxy | 150 bps proxy | 200 bps proxy | +|---|---:|---:|---:|---:| +| XMARKET_DIVERGENCE | 42.4 | -7.6 | -57.6 | -107.6 | +| CONSENSUS_MOVE | 88.4 | 38.4 | -11.6 | -61.6 | +| FADER_CANDIDATE | 4.5 | -45.5 | -95.5 | -145.5 | + +Values are mean directional signal CLV after subtracting each proxy. This is a sensitivity check, not a claim about actual historical execution costs. + +## Training Leaderboard + +| Detector | Rank | Parameters | Raw emissions | Normalized cases | Snapshot precision | Snapshot recall | F1 | Minimum n met | Cost floor met | +|---|---:|---|---:|---:|---:|---:|---:|---|---| +| XMARKET_DIVERGENCE | 1 | gap=100.0bps, persist=0s, stableZ=1 | 1762 | 934 | 65.9% | 2.2% | 0.042 | yes | yes | +| XMARKET_DIVERGENCE | 2 | gap=100.0bps, persist=0s, stableZ=0.5 | 1522 | 674 | 65.9% | 1.5% | 0.030 | yes | yes | +| XMARKET_DIVERGENCE | 3 | gap=100.0bps, persist=60s, stableZ=1 | 434 | 263 | 60.2% | 0.5% | 0.010 | yes | yes | +| XMARKET_DIVERGENCE | 4 | gap=150.0bps, persist=0s, stableZ=1 | 355 | 132 | 85.5% | 0.5% | 0.009 | yes | yes | +| XMARKET_DIVERGENCE | 5 | gap=150.0bps, persist=0s, stableZ=0.5 | 285 | 116 | 84.0% | 0.4% | 0.007 | yes | yes | +| CONSENSUS_MOVE | 1 | z=1, cusum=10.0bps, gap=100.0bps, updates=5 | 702 | 301 | 62.8% | 0.7% | 0.013 | yes | yes | +| CONSENSUS_MOVE | 2 | z=1, cusum=10.0bps, gap=100.0bps, updates=3 | 702 | 301 | 62.8% | 0.7% | 0.013 | yes | yes | +| CONSENSUS_MOVE | 3 | z=1.5, cusum=10.0bps, gap=100.0bps, updates=3 | 596 | 259 | 63.2% | 0.6% | 0.012 | yes | yes | +| CONSENSUS_MOVE | 4 | z=1.5, cusum=10.0bps, gap=100.0bps, updates=5 | 596 | 259 | 63.2% | 0.6% | 0.012 | yes | yes | +| CONSENSUS_MOVE | 5 | z=2, cusum=10.0bps, gap=100.0bps, updates=5 | 478 | 228 | 65.5% | 0.5% | 0.011 | yes | yes | +| FADER_CANDIDATE | 1 | pmZ=1, gap=100.0bps, persist=0s, stableZ=1 | 412 | 268 | 6.1% | 0.1% | 0.003 | yes | yes | +| FADER_CANDIDATE | 2 | pmZ=1, gap=100.0bps, persist=0s, stableZ=0.5 | 319 | 163 | 8.9% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 3 | pmZ=1.5, gap=100.0bps, persist=0s, stableZ=1 | 359 | 238 | 5.4% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 4 | pmZ=1.5, gap=100.0bps, persist=0s, stableZ=0.5 | 281 | 142 | 6.6% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 5 | pmZ=1.5, gap=150.0bps, persist=0s, stableZ=0.5 | 65 | 30 | 18.6% | 0.1% | 0.001 | yes | yes | + +## Guardrails + +- No held-out result influenced configuration selection. +- Total-line selection uses only prices and coverage observable no later than the first detector snapshot. +- Training and held-out minimum-sample checks use normalized economic cases, never raw detector emissions. +- Sell-only Total Goals cases fail closed; sampled history cannot prove a complementary token was buyable at that moment. +- Sampled Polymarket history cannot establish executable spread, slippage, or fill probability; the cost proxy is deliberately conservative but remains a proxy. +- Candidate mappings remain non-tradeable and no settlement review is inferred from research alignment. +- Thresholds in this report are exploratory until the human gate review explicitly freezes or rejects them. +- STALE_QUOTE remains disabled based on the synchronized live-lane result; this historical study does not evaluate it. diff --git a/docs/research/historical-gate-study-v1-invalidation.md b/docs/research/historical-gate-study-v1-invalidation.md new file mode 100644 index 0000000..53cf600 --- /dev/null +++ b/docs/research/historical-gate-study-v1-invalidation.md @@ -0,0 +1,63 @@ +# Historical Gate Study V1 — Invalidation Record + +**Declared:** July 14, 2026
+**Affected protocol:** historical dynamic-total selector and paper-study v1 upstream evidence
+**Decision:** Invalidated for strategy promotion, profitability claims, and paper admission
+**Real-money gate:** **CLOSED** + +## Summary + +An independent causal audit found that the v1 historical study chose each fixture's “main” full-time total using Polymarket information that was not available during the eligible signal window. + +The selector used the latest price up to kickoff minus five minutes. Historical signals were eligible only through kickoff minus fifteen minutes. In the frozen v1 artifact: + +- 95 of 98 selected lines used a selector observation later than the last eligible signal; +- 81 of 98 selected lines used an observation within approximately six minutes of kickoff; +- `coverage_points` counted history without applying the selector cutoff; +- the totals CLV sample contained 38 buys and 35 sells, while the current paper executor cannot open sell positions without inventory; +- 58 emissions were 29 same-timestamp complementary Over/Under expressions of the same economic move. + +The defect does not prove that `CONSENSUS_MOVE` has no edge. It means the reported positive v1 result cannot establish one. + +## Why this is invalid + +A live system cannot ask a future market observation which line it should have monitored earlier. Choosing the line at T−5 minutes and evaluating decisions that ended at T−15 minutes leaks future market state into the test universe. + +Chronological train/test separation does not cure this defect because the look-ahead occurs independently inside every fixture in both partitions. + +The unbounded coverage count creates a second admission leak: observations after the selector time can make a line appear sufficiently covered when it was not sufficiently covered at decision time. + +Finally, raw detector emissions are not necessarily executable or independent economic decisions. Unsupported sells and complementary token expressions must not inflate the primary sample. + +## Affected claims + +The following v1 claims are withdrawn: + +- that the dynamic-total result is valid held-out edge evidence; +- that its positive probability-bps estimate supports paper promotion; +- that 73 detector emissions represent 73 executable opportunities; +- that paper-study v1 may admit new cases under the old selector. + +The original machine-readable and Markdown artifacts remain preserved for audit traceability. Their numbers must be labelled invalidated wherever displayed. + +## Unaffected evidence + +The synchronized Spain-Belgium stale-quote feasibility study does not use the historical dynamic-total selector. Its narrow conclusion remains: that one captured match produced no clean post-TXLine stale window in the measured cases. It does not establish a universal absence of stale quotes. + +The system's architectural boundaries, normalization tests, replay/live canonical contracts, strict Claude schemas, and real-money gate are also unaffected by this methodological correction. + +## Required v2 repair + +Before any detector can be promoted again: + +1. Freeze one deterministic selector time no later than detector evaluation start. +2. Apply that same as-of cutoff to probability, coverage, volume, liquidity, and market availability. +3. Fail closed when no line was eligible at that time. +4. Prove that changing all post-cutoff data cannot change selection. +5. Define unique economic cases rather than counting raw complementary emissions. +6. Convert sells into verified complementary-token buys or exclude them from executable results. +7. Rerun training and held-out evaluation under a new protocol/config hash. +8. Report model cost separately from probability CLV. +9. Create and sign a new paper preregistration only if the corrected result merits it. + +No repair may rewrite or retroactively validate paper-study v1. diff --git a/docs/research/historical-gate-study-v3-to-v4-methodology-note.md b/docs/research/historical-gate-study-v3-to-v4-methodology-note.md new file mode 100644 index 0000000..532b642 --- /dev/null +++ b/docs/research/historical-gate-study-v3-to-v4-methodology-note.md @@ -0,0 +1,17 @@ +# Historical Gate V3 → V4 Methodology Note + +**Dated:** July 14, 2026
+**Real-money gate:** closed + +The causal/economic v3 rerun repaired the future-informed total-line selector, normalized binary Total Goals emissions into executor-expressible buy cases, and added fixture-clustered uncertainty. It is preserved at `historical-gate-study-causal-economic-v3.md`. + +Review of that generated report exposed one remaining reporting gap: Samaritan's proposed forward paper family is **Total Goals only**, while v3 showed the training minimum for the selected detector configuration only in aggregate across Total Goals and Match Result. The held-out report already separated both market families, but it did not prove that the prespecified Total Goals family independently met the training sample floor. + +V4 therefore adds one audit field without changing any detector threshold, selector rule, train/test boundary, cost proxy, label, or held-out row: + +- count normalized Total Goals cases for the already training-selected configuration using training fixtures only; +- assess the prespecified held-out Total Goals slice against the same minimum sample, positive after-cost point estimate, and fixture-clustered 95% interval rule; +- keep the aggregate detector result visible; +- label any passing Total Goals result only as a historical signal candidate for a fresh forward paper review. + +No v3 artifact was overwritten. V4 uses a new protocol ID, configuration hash, JSON path, and Markdown path. This refinement cannot activate the paper study: the corrected paper protocol remains `engineering_candidate_unregistered`, operational Claude readiness remains disabled, and Deborah must review and sign a fresh protocol before any qualifying observation. diff --git a/docs/research/historical-gate-study.md b/docs/research/historical-gate-study.md new file mode 100644 index 0000000..95bbf81 --- /dev/null +++ b/docs/research/historical-gate-study.md @@ -0,0 +1,76 @@ +# Historical Detector Gate Study — Invalidated V1 Audit Record + +Generated: 2026-07-11T06:57:14.119Z + +> **Invalidated July 14, 2026:** this generated v1 artifact selected the dynamic total with information later than the eligible signal window for 95 of 98 fixtures, counted coverage without the same as-of cutoff, and mixed unsupported sells with complementary expressions of the same economic move. Preserve these values for traceability only. They are not valid evidence of held-out edge, alpha, or executable opportunities. The real-money gate remains closed. See `historical-gate-study-v1-invalidation.md`. + +## Design + +- Chronological train/test split: 68 / 30 fixtures +- Training range: 2026-06-11T19:05:00.000Z to 2026-06-27T21:00:00.000Z +- Held-out range: 2026-06-27T23:31:00.000Z to 2026-07-12T01:00:00.000Z +- Split cutoff: 2026-06-27T21:00:00.000Z; identical kickoff timestamps never cross partitions +- Replay window: final 3.0 hours through kickoff; in-running TXLine rows excluded +- Forward label: 15 minutes with 25.0 bps minimum gap closure +- Historical cost proxy: 100.0 probability bps per signal +- Training selection: labeled F1, guarded by at least 30 predicted-positive cases + +## Coverage + +- Training groups with both sources: 136/136; 164,655 events; 227,368 snapshots +- Held-out groups with both sources: 52/60; 60,092 events; 77,445 snapshots +- Held-out missing groups: `18209181:match_result:full_time:none`, `18209181:total_goals:full_time:2500`, `18218149:match_result:full_time:none`, `18218149:total_goals:full_time:2500`, `18213979:match_result:full_time:none`, `18213979:total_goals:full_time:2500`, `18222446:match_result:full_time:none`, `18222446:total_goals:full_time:2500` + +## Dynamic Total Candidate + +The candidate rule selects the exact mapped full-time total closest to 50/50 at least five minutes before kickoff, requires at least 1,000 history points, and fails closed beyond 1500.0 bps from even. It selected 98 fixtures with 0 failures. Distribution: O/U 2.5: 77, O/U 4.5: 1, O/U 1.5: 5, O/U 3.5: 15. + +Status: `candidate_pending_human_gate_review`. Volume and liquidity are not weighted because captured Gamma liquidity is post-close and zero for most lines. + +## Held-Out Results + +| Detector | Train-selected parameters | Train signals | Train precision | Train recall | Test CLV n | Fixtures | Test precision | Test recall | Mean signal CLV bps | Mean after-cost bps | Match Result net bps | Total net bps | Necessary evidence status | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| +| XMARKET_DIVERGENCE | gap=100.0bps, persist=0s, stableZ=1 | 1226 | 65.4% | 2.2% | 372 | 25 | 50.0% | 1.9% | 97.9 | -2.1 | -15.2 | 43.6 | nonpositive_net_signal_clv | +| CONSENSUS_MOVE | z=1, cusum=10.0bps, gap=100.0bps, updates=5 | 450 | 62.2% | 0.7% | 107 | 23 | 50.5% | 0.6% | 159.9 | 59.9 | -67.0 | 119.0 | human_review_required | +| FADER_CANDIDATE | pmZ=1, gap=100.0bps, persist=0s, stableZ=1 | 314 | 6.1% | 0.1% | 72 | 16 | 5.6% | 0.1% | 54.0 | -46.0 | -50.3 | -40.2 | nonpositive_net_signal_clv | + +`human_review_required` means only that the predeclared necessary sample/after-cost checks passed. It is not permission to trade and does not substitute for Deborah's gate decision, settlement verification, risk caps, or executable-book evidence. + +## Cost Sensitivity + +| Detector | 50 bps proxy | 100 bps proxy | 150 bps proxy | 200 bps proxy | +|---|---:|---:|---:|---:| +| XMARKET_DIVERGENCE | 47.9 | -2.1 | -52.1 | -102.1 | +| CONSENSUS_MOVE | 109.9 | 59.9 | 9.9 | -40.1 | +| FADER_CANDIDATE | 4.0 | -46.0 | -96.0 | -146.0 | + +Values are mean directional signal CLV after subtracting each proxy. This is a sensitivity check, not a claim about actual historical execution costs. + +## Training Leaderboard + +| Detector | Rank | Parameters | Signals | Precision | Recall | F1 | Minimum n met | Cost floor met | +|---|---:|---|---:|---:|---:|---:|---|---| +| XMARKET_DIVERGENCE | 1 | gap=100.0bps, persist=0s, stableZ=1 | 1226 | 65.4% | 2.2% | 0.042 | yes | yes | +| XMARKET_DIVERGENCE | 2 | gap=100.0bps, persist=0s, stableZ=0.5 | 858 | 64.5% | 1.5% | 0.029 | yes | yes | +| XMARKET_DIVERGENCE | 3 | gap=100.0bps, persist=60s, stableZ=1 | 322 | 59.0% | 0.5% | 0.010 | yes | yes | +| XMARKET_DIVERGENCE | 4 | gap=150.0bps, persist=0s, stableZ=1 | 195 | 85.1% | 0.4% | 0.009 | yes | yes | +| XMARKET_DIVERGENCE | 5 | gap=150.0bps, persist=0s, stableZ=0.5 | 158 | 83.5% | 0.4% | 0.007 | yes | yes | +| CONSENSUS_MOVE | 1 | z=1, cusum=10.0bps, gap=100.0bps, updates=5 | 450 | 62.2% | 0.7% | 0.014 | yes | yes | +| CONSENSUS_MOVE | 2 | z=1, cusum=10.0bps, gap=100.0bps, updates=3 | 450 | 62.2% | 0.7% | 0.014 | yes | yes | +| CONSENSUS_MOVE | 3 | z=1.5, cusum=10.0bps, gap=100.0bps, updates=3 | 398 | 62.3% | 0.6% | 0.012 | yes | yes | +| CONSENSUS_MOVE | 4 | z=1.5, cusum=10.0bps, gap=100.0bps, updates=5 | 398 | 62.3% | 0.6% | 0.012 | yes | yes | +| CONSENSUS_MOVE | 5 | z=2, cusum=10.0bps, gap=100.0bps, updates=5 | 348 | 64.4% | 0.5% | 0.011 | yes | yes | +| FADER_CANDIDATE | 1 | pmZ=1, gap=100.0bps, persist=0s, stableZ=1 | 314 | 6.1% | 0.1% | 0.003 | yes | yes | +| FADER_CANDIDATE | 2 | pmZ=1, gap=100.0bps, persist=0s, stableZ=0.5 | 196 | 8.7% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 3 | pmZ=1.5, gap=100.0bps, persist=0s, stableZ=1 | 276 | 5.4% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 4 | pmZ=1.5, gap=100.0bps, persist=0s, stableZ=0.5 | 168 | 6.5% | 0.1% | 0.002 | yes | yes | +| FADER_CANDIDATE | 5 | pmZ=2, gap=150.0bps, persist=0s, stableZ=0.5 | 36 | 22.2% | 0.1% | 0.001 | yes | yes | + +## Guardrails + +- No held-out result influenced configuration selection. +- Sampled Polymarket history cannot establish executable spread, slippage, or fill probability; the cost proxy is deliberately conservative but remains a proxy. +- Candidate mappings remain non-tradeable and no settlement review is inferred from research alignment. +- Thresholds in this report are exploratory until the human gate review explicitly freezes or rejects them. +- STALE_QUOTE remains disabled based on the synchronized live-lane result; this historical study does not evaluate it. diff --git a/docs/research/paired-spain-belgium-2026-07-10-live-lane.md b/docs/research/paired-spain-belgium-2026-07-10-live-lane.md new file mode 100644 index 0000000..f8ef910 --- /dev/null +++ b/docs/research/paired-spain-belgium-2026-07-10-live-lane.md @@ -0,0 +1,91 @@ +# Spain-Belgium Paired Live-Lane Evidence + +Generated: 2026-07-11T06:35:38.499Z + +> Research evidence only. The mapping is capture-confirmed but not settlement-verified, every in-play result remains paper-only, and the material-move threshold is exploratory rather than an approved detector threshold. + +## Scope + +- Fixture: `18218149` +- Canonical target assets: 13 across 6 exact market groups +- Material midpoint move: 50 probability bps +- Polymarket messages scanned through the last response window: 1,986,443 +- Canonical Polymarket events normalized: 2,262,950 + +## Feed Health + +- TXLine odds receive latency p50/p90/p99: 142 / 721 / 8875 ms +- TXLine score receive latency p50/p90/p99: 39 / 362 / 50772 ms +- Polymarket non-book venue-timestamp age outside reconnect recovery p50/p90/p99: 119 / 9399 / 158965 ms +- Polymarket all-event venue-timestamp age p50/p90/p99: 121 / 9066 / 158965 ms +- Polymarket payload timestamps show event age, not a pure network-latency clock; stale/repeated venue timestamps remain visible outside reconnect windows and must be treated as feed-health evidence rather than transport timing alone. +- Public WebSocket outages: 8; total 91171 ms; max 25375 ms + +| Outage start | Reconnected | Duration ms | Code | +|---|---|---:|---:| +| 2026-07-10T19:42:56.902Z | 2026-07-10T19:42:58.713Z | 1811 | 1006 | +| 2026-07-10T20:27:07.908Z | 2026-07-10T20:27:10.460Z | 2552 | 1006 | +| 2026-07-10T20:32:22.564Z | 2026-07-10T20:32:27.317Z | 4753 | 1006 | +| 2026-07-10T20:36:04.809Z | 2026-07-10T20:36:14.354Z | 9545 | 1006 | +| 2026-07-10T20:41:09.121Z | 2026-07-10T20:41:24.612Z | 15491 | 1006 | +| 2026-07-10T20:50:17.844Z | 2026-07-10T20:50:43.219Z | 25375 | 1006 | +| 2026-07-10T20:52:35.358Z | 2026-07-10T20:52:51.491Z | 16133 | 1006 | +| 2026-07-10T23:32:55.720Z | 2026-07-10T23:33:11.231Z | 15511 | 1006 | + +| Polymarket observation | Events | Age p50 ms | Age p90 ms | Age p99 ms | +|---|---:|---:|---:|---:| +| best_bid_ask | 56,942 | 102 | 11852 | 158792 | +| book | 37,723 | 123 | 13024 | 158812 | +| last_trade | 25,793 | 122 | 14657 | 158848 | +| price_change | 2,142,492 | 121 | 8857 | 158965 | + +## Goal Delivery + +| Goal | First seen | Participant | Match clock sec | TXLine receive latency ms | Confirmation delay ms | +|---:|---|---:|---:|---:|---:| +| 1 | 2026-07-10T19:30:00.389Z | 1 | 1761 | 11 | 74416 | +| 2 | 2026-07-10T19:40:50.195Z | 2 | 2411 | -8 | 162426 | +| 3 | 2026-07-10T20:49:37.081Z | 1 | 5244 | 49 | 92191 | + +## Repricing Evidence + +A pre-trigger move compares the book at T-5s with the last state before TXLine delivered the goal. Material movement before T0 means the venue had already reacted before Samaritan received the score event; that is evidence against a post-TXLine stale-quote window for that market instance. + +| Goal | Clock sec | Market | First quote update ms | First material move ms | Pre-trigger move pp | Classification | Outage overlap | +|---:|---:|---|---:|---:|---:|---|---| +| 1 | 1761 | match_result | 24 | 228 | 7.750 | polymarket_moved_before_txline | no | +| 1 | 1761 | total_goals 0.5 | 24 | 243 | 1.625 | polymarket_moved_before_txline | no | +| 1 | 1761 | total_goals 1.5 | 24 | 546 | 5.375 | polymarket_moved_before_txline | no | +| 1 | 1761 | total_goals 2.5 | 24 | 243 | 8.375 | polymarket_moved_before_txline | no | +| 1 | 1761 | total_goals 3.5 | 24 | 3257 | 6.000 | polymarket_moved_before_txline | no | +| 1 | 1761 | total_goals 4.5 | 24 | 1671 | 8.000 | polymarket_moved_before_txline | no | +| 2 | 2411 | match_result | 0 | 1284 | 5.750 | polymarket_moved_before_txline | no | +| 2 | 2411 | total_goals 0.5 | 1900 | n/a | 0.000 | no_material_reprice_in_window | no | +| 2 | 2411 | total_goals 1.5 | 0 | 3042 | 5.375 | polymarket_moved_before_txline | no | +| 2 | 2411 | total_goals 2.5 | 0 | 1781 | 5.750 | polymarket_moved_before_txline | no | +| 2 | 2411 | total_goals 3.5 | 0 | 162 | 6.375 | polymarket_moved_before_txline | no | +| 2 | 2411 | total_goals 4.5 | 31 | 1611 | 8.250 | polymarket_moved_before_txline | no | +| 3 | 5244 | match_result | 1 | 1368 | 0.500 | polymarket_moved_before_txline | no | +| 3 | 5244 | total_goals 0.5 | n/a | n/a | 0.000 | no_material_reprice_in_window | no | +| 3 | 5244 | total_goals 1.5 | 5656 | n/a | 0.000 | no_material_reprice_in_window | no | +| 3 | 5244 | total_goals 2.5 | 1 | n/a | 0.000 | no_material_reprice_in_window | no | +| 3 | 5244 | total_goals 3.5 | 1 | n/a | 0.125 | no_material_reprice_in_window | no | +| 3 | 5244 | total_goals 4.5 | 6781 | n/a | 0.000 | no_material_reprice_in_window | no | + +## Gate Readout + +- Market-event cases measured: 18 +- Polymarket moved at least the exploratory threshold before TXLine first delivery: 12 +- Material post-TXLine repricing observed without a prior move: 0 +- No material move inside the 30-second response window: 6 +- Result for STALE_QUOTE: `not_supported_by_this_match` + +For this match, Polymarket was already repricing before the first TXLine goal event arrived whenever a material move was visible. The capture therefore does not support a post-TXLine stale-order edge. STALE_QUOTE remains disabled and paper-only; more synchronized matches may add evidence but cannot reverse this result by assumption. + +## Interpretation Guardrails + +- These are three event instances from one match, not a fitted latency distribution. +- A code-1006 reconnect interval is unavailable data, never evidence that a quote stayed unchanged. +- Match Result is grouped from the three canonical Yes tokens; complement No tokens are not double-counted. +- Every mapped totals line is reported. No O/U line is hard-coded as the production main total. +- No trading, fill simulation, or real-money claim is authorized by this report. diff --git a/docs/research/paper-fixture-universe.md b/docs/research/paper-fixture-universe.md new file mode 100644 index 0000000..a269e4e --- /dev/null +++ b/docs/research/paper-fixture-universe.md @@ -0,0 +1,13 @@ +# Paper Fixture Evidence Universe + +Generated 2026-07-12T09:50:09.161Z. This is a derived metadata inventory; it does not redistribute raw TXLine data. + +| Fixture | Match | Kickoff UTC | Frozen total | Evidence grade | Bounty lane | Long-run lane | +|---|---|---:|---:|---|---|---| +| 18213979 | Norway vs England | 2026-07-11T21:00:00.000Z | O/U 2.5 | sampled_price_history | signal_research_only | no (predates_long_run_lane_start) | +| 18218149 | Spain vs Belgium | 2026-07-10T19:00:00.000Z | O/U 2.5 | paired_order_books | book_lifecycle_replay | no (predates_long_run_lane_start) | +| 18222446 | Argentina vs Switzerland | 2026-07-12T01:00:00.000Z | O/U 2.5 | sampled_price_history | signal_research_only | no (predates_long_run_lane_start) | + +Summary: 1 paired-book replay (0 strategy-executable; 1 lifecycle-only), 2 sampled-history research fixtures, 0 unavailable. + +Sampled-price fixtures may demonstrate signal generation but cannot produce registered paper fills, executable CLV, or profitability evidence. A paired capture that starts inside the 15-minute signal cutoff may prove book/close/resolution lifecycle behavior but cannot create a registered strategy fill. Every listed fixture predates the fresh long-run lane and is excluded from its stopping count. diff --git a/docs/research/paper-study-current.md b/docs/research/paper-study-current.md new file mode 100644 index 0000000..9a06d0a --- /dev/null +++ b/docs/research/paper-study-current.md @@ -0,0 +1,43 @@ +# Samaritan Paper Study Evidence + +> **Invalidated/suspended July 14, 2026:** this is a preserved zero-observation v1 ledger report, not an active preregistered study. It cannot admit cases. The corrected v2 engineering candidate awaits Deborah's registration. + +Generated: 2026-07-12T16:48:13.459Z
+Protocol: `paper-study-v1-2026-07-12`
+Frozen config SHA-256: `7dcb4c20ac05195bdb4a6c8803432079eeefb8cc747c186f576084dc772fd2a2`
+Fixture universe generated: 2026-07-12T09:50:09.161Z
+Real-money gate: **CLOSED** + +This report is reconstructed from append-only decision ledgers. Bounty results are exploratory. Long-run rows and endpoints remain sealed until the registered stopping rule is met. + +| Lane | Chain rows | Chain head | +|---|---:|---| +| Bounty | 1 | `1ff24c63635535548d17200c478ad895df929a38e43e662325f6a26aad949487` | +| Long-run | 1 | `488ed79a089e35ab2d70800352c49d3587e0284ad64bb0eda32d848874194c92` | + +## Bounty Demonstration Lane + +Status: **EXPLORATORY**
+Reason: Complete close and settlement evidence is required before endpoints are computed
+Counts: 0 matches / 0 signals / 0 filled matches / 0 fills / 0 settled fills. + +| Fixture | Kickoff UTC | Total | Signals | Fills | Fill rate | Half-spread bps | Slippage bps | Gross CLV bps | Net CLV bps | Settlement P&L ($) | Net return bps | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| + +Endpoints are unavailable until close and settlement evidence is complete. + +### Guardrails + +| Fill rate | Slippage | Drawdown | Depth complete | Close complete | Settlement complete | +|---:|---:|---:|---|---|---| +| 0.0% (fail) | - bps (fail) | $0.00 (pass) | no | no | no | + +## Long-Run Profitability Lane + +Status: **SEALED**
+Reason: Long-run endpoints sealed until 20 filled matches and 40 fills
+Counts: 0 matches / 0 signals / 0 filled matches / 0 fills / 0 settled fills. + +Primary endpoints and per-match rows are sealed until both registered stopping thresholds are met. + +No result in this artifact authorizes real-money execution. diff --git a/docs/research/paper-study-ledger-initialization.md b/docs/research/paper-study-ledger-initialization.md new file mode 100644 index 0000000..2ef2c7d --- /dev/null +++ b/docs/research/paper-study-ledger-initialization.md @@ -0,0 +1,14 @@ +# Paper Study Ledger Initialization + +> **Preserved v1 audit record — invalidated and suspended July 14, 2026.** These zero-observation ledgers must not be reopened or relabelled as v2. + +Protocol: `paper-study-v1-2026-07-12`
+Frozen config SHA-256: `7dcb4c20ac05195bdb4a6c8803432079eeefb8cc747c186f576084dc772fd2a2`
+Real-money gate: **closed** + +- Bounty ledger start: 2026-07-12T09:45:17.212Z; chain rows: 1; status: exploratory. +- Long-run ledger start: 2026-07-12T09:45:17.212Z; chain rows: 1; report status: sealed. +- Long-run stopping counts at initialization: 0 filled matches / 0 fills. +- Fixtures before the long-run start timestamp are excluded from its stopping count. + +No wallet, venue authentication, token, deposit, approval, order, or money movement is part of either ledger initialization. diff --git a/docs/research/semifinal-capture-schedule.md b/docs/research/semifinal-capture-schedule.md new file mode 100644 index 0000000..304fb8c --- /dev/null +++ b/docs/research/semifinal-capture-schedule.md @@ -0,0 +1,12 @@ +# Semifinal Paired Capture Schedule + +Confirmed by Deborah on July 12, 2026 for public-data capture only. Both mappings remain `tradeable: false`; confirmation does not authorize orders or money movement. + +| Match | TXLine fixture | Polymarket events | Launch | Duration | Verification | Automation IDs | +|---|---:|---|---:|---:|---:|---| +| France vs Spain | `18237038` | `691040`, `691131` | 2026-07-14 16:00 UTC / 17:00 Lagos | 360 min | 2026-07-14 23:15 UTC / 2026-07-15 00:15 Lagos | `start-france-spain-paired-capture`, `verify-france-spain-paired-capture` | +| England vs Argentina | `18241006` | `694581`, `694786` | 2026-07-15 16:00 UTC / 17:00 Lagos | 360 min | 2026-07-15 23:15 UTC / 2026-07-16 00:15 Lagos | `start-england-argentina-paired-capture`, `verify-england-argentina-paired-capture` | + +Each launch task reruns strict config validation, refuses duplicate processes, loads Node 22 through nvm, enables Corepack, records logs/PID, and verifies file growth. Each verification task checks capture completeness, canonical replay parity, source-timestamp diagnostics, kickoff close/resolution evidence, and sealed long-run handling. After verification it regenerates the fixture universe, runs persistent Claude readiness, and rewrites the ledger-derived paper report. Candidate mappings are never promoted automatically; a missing paper-verification gate produces zero Claude calls and unchanged decision ledgers. + +The tasks may use existing TXLine data access and the public Polymarket market channel. If and only if a fixture is already paper-verified and admitted, the verification task may use the bounded Claude runtime through its capped spend ledger. It may not initiate Polymarket authentication, refresh or activate market tokens, access a wallet, approve, deposit, trade, place orders, or move money. diff --git a/docs/submission/demo-script-v1.md b/docs/submission/demo-script-v1.md new file mode 100644 index 0000000..d93e929 --- /dev/null +++ b/docs/submission/demo-script-v1.md @@ -0,0 +1,78 @@ +# Samaritan Demo Script v1 — Target 4:45 + +> **Narrator and owner:** Deborah. She must rehearse, personally review every visible artifact, rewrite the narration in her natural voice, and submit the final video herself. Do not present Claude as the participant or builder of record. + +## Recording setup + +- Record one continuous 1080p browser/terminal walkthrough; target **4:45** to retain 15 seconds of screening margin below the bounty's hard 5:00 limit. +- Use the final public commit and public URL. Hide notifications, bookmarks, usernames, local private paths, credentials, wallet software, and raw capture files. +- Pre-open: Command, Matchroom, Study, a terminal at the public repo, and the frozen receipt. +- Use only derived dashboard output. Do not expose exact TXLine probability levels or raw TXLine payloads. +- Run all commands once before recording. Do not fake output or splice separate evidence into one purported live trade. + +## Exact shot list and narration + +| Time | Screen and action | Deborah narration | +|---|---|---| +| **0:00–0:20** | **Command** hero: “The trade Samaritan refused.” Keep the Paper and Real-money gate closed badges visible. | “I’m Deborah, and this is Samaritan: my auditable sports-market agent built on live TXLine data. It does not promise winning bets. It proves why it acted—or, just as importantly, why it refused. This bounty build is paper-only and the real-money gate is closed.” | +| **0:20–0:50** | Scroll through **System posture** and the integrity panel, then briefly show the architecture diagram from the technical overview. | “TXLine odds and scores become canonical probability events. Deterministic code detects a case; Claude can return only a strict thesis; deterministic risk decides; the paper adapter simulates against executable depth; and every stage is hash-chained before the next action. Live and replay publish the same event types, with no mode flag for the strategy.” | +| **0:50–1:40** | Open **Matchroom** for the Spain–Belgium replay. Play or scrub across the first-goal marker, book chart, decision rail, and authoritative `NO TRADE` verdict. | “This is not synthetic. It is a synchronized, read-only Spain–Belgium capture pairing TXLine mainnet SL12 delivery with public Polymarket books. Here, the public book had already repriced before the goal reached Samaritan. Across all 18 measured market-event cases in this capture, zero showed a clean post-TXLine stale window. Samaritan kept STALE_QUOTE disabled and made no order. The dashboard exposes only a 25-basis-point TXLine movement bucket; the exact licensed level stays private.” | +| **1:40–2:15** | Open **Study**. Show the invalidated v1 audit, corrected v4 Total Goals panel, and the visually separate **Synthetic full-lifecycle proving fixture** panel. Keep `engineering_candidate_unregistered`, `Performance use: Excluded`, and `Solana anchor: Not submitted` visible. | “We also caught our own bad result. The first total-line selector used future information, so I preserved and invalidated v1 instead of hiding it. After repairing causality and economic-case counting, the held-out Total Goals candidate had 38 normalized buy cases across 18 fixtures, plus 132.7 probability basis points after a 100-basis-point proxy, with a fixture-clustered 95 percent interval from plus 14.3 to plus 243.9. These are sampled-price signal results—not alpha, fills, or profitability. V2 is not registered. The green panel below is a different evidence class: synthetic wiring proof, permanently excluded from those results.” | +| **2:15–3:20** | Terminal: run `pnpm demo`. Pause on the top boundary label, then the runtime and receipt sections. Highlight `20` canonical events, `12` ledger rows, `filled_settled`, and all external-call counters at zero. | “Now I’ll prove the whole lifecycle without pretending it is performance evidence. This synthetic fixture runs the production paper components with deterministic stubs at the two model boundaries: signal, triage, thesis, deterministic risk, execution intent, paper fill, kickoff close, settlement, and receipt. It produced 20 canonical events and 12 version-two ledger records. It made zero TXLine, Polymarket, Anthropic, wallet, Solana RPC, or real-order calls, and it is permanently excluded from performance results.” | +| **3:20–4:00** | Terminal: run `pnpm receipt:verify -- public/artifacts/dashboard/synthetic-decision-receipt.json`. Hold on `valid: true`, `filled_settled`, `synthetic: true`, and both Solana fields. | “A judge can verify the frozen receipt offline. This checks its strict schema, canonical hash, disclosed lifecycle relationships, and committed ledger head. It does not replay private source payloads and does not prove profitability. Notice the proof boundary: Solana anchor metadata is false and network verification is false.” | +| **4:00–4:25** | Terminal: run `pnpm public:audit`, then return to **Command → Proof**. | “The judge bundle also passes a public-artifact audit. It contains six small derived files, no credentials or wallet controls, no raw TXLine response, and no exact TXLine probability series. Judges can open the dashboard and run the proof without a subscription, API key, or wallet.” | +| **4:25–4:45** | Keep **Proof** and the closed-gate badge visible. If useful, briefly show the devnet anchor documentation status line—do not show a fake explorer link. | “The devnet prepare, human-gated submit, and read-only verify tools are implemented, but no anchor transaction has been submitted, so this receipt is honestly unanchored. Samaritan’s edge is disciplined autonomy: fast deterministic detection, bounded judgment, code-owned risk, and evidence you can challenge. I’m Deborah, the owner and submitter. Thank you.” | + +## Commands to have ready + +```bash +pnpm demo +pnpm receipt:verify -- public/artifacts/dashboard/synthetic-decision-receipt.json +pnpm public:audit +``` + +Expected proof markers on the final commit: + +- `synthetic: true` +- `performanceUse: "excluded_synthetic"` +- `protocolStatus: "engineering_candidate_unregistered"` +- `realMoneyGate: "closed"` +- `canonicalEventsPublished: 20` +- `ledgerRows: 12` +- `finalLifecycleStatus: "filled_settled"` +- `valid: true` +- `solanaAnchorMetadataPresent: false` +- `solanaNetworkVerificationPerformed: false` + +Re-record if any expected marker differs; do not narrate a stale number. + +## Non-negotiable wording guardrails + +Never say: + +- “profitable,” “proof of alpha,” “winning strategy,” or “guaranteed edge”; +- “real trade,” “real fill,” or “live Polymarket execution”; +- “v2 is registered,” “the study is active,” or “preregistered”; +- “the receipt is on-chain,” “anchored,” or “externally timestamped” without a verified transaction; +- “Claude placed/approved/sized the trade”; +- “this synthetic lifecycle used live TXLine, Claude, or Polymarket”; or +- “raw TXLine data is in the public replay.” + +Use instead: + +- “sampled-price historical signal evidence”; +- “synthetic full-lifecycle proving fixture, excluded from performance evidence”; +- “deterministic paper execution”; +- “Claude submits a bounded thesis; code owns risk and execution”; +- “v2 engineering candidate is unregistered”; and +- “offline receipt verified; Solana not submitted or network-verified.” + +## Final recording gate + +- [ ] Total runtime is 4:45–4:55 and never exceeds 5:00 after platform processing. +- [ ] Every screen comes from the final public commit or deployed public artifact. +- [ ] The captured refusal and synthetic lifecycle are visually and verbally separated. +- [ ] The v4 number is immediately limited to sampled-price signal evidence. +- [ ] The receipt visibly reports no Solana anchor/network verification. +- [ ] No secret, private path, raw TXLine record, wallet UI, or unverified link appears. +- [ ] Deborah has rewritten and delivered the narration in her own voice. diff --git a/docs/submission/public-release-scope.md b/docs/submission/public-release-scope.md new file mode 100644 index 0000000..b50cb8c --- /dev/null +++ b/docs/submission/public-release-scope.md @@ -0,0 +1,73 @@ +# Samaritan Public Release Scope + +**Status:** release-candidate allowlist, July 14, 2026
+**Owner and final approver:** Deborah
+**Purpose:** prevent private licensed data, credentials, local creative exports, and unreviewed utilities from entering the bounty repository or hosted judge surface. + +This is an inclusion allowlist, not a claim that the current dirty working tree is already a public release. The final public commit must be assembled deliberately and rerun through every gate below. + +## Include in the public submission + +- Root product metadata and instructions: `AGENTS.md`, `README.md`, `plan.md`, `.nvmrc`, `package.json`, `pnpm-lock.yaml`, and `pnpm-workspace.yaml`. +- CI: `.github/workflows/ci.yml`. +- Product code: `src/`, including canonical ingestion contracts, replay, features, detectors, bounded agent harness, deterministic paper risk/execution, ledger, receipt, dashboard server, public audit, and devnet anchor tooling. +- Judge UI: `apps/dashboard/` source and build configuration, excluding generated `dist/`. +- Tests: `test/` and the Phase 0 deadline test under `phase0/test/`. +- Licence-safe frozen proof: `public/artifacts/dashboard/` and the synthetic receipt fixture under `proof/fixtures/`. +- Documentation: architecture, research methodology and limitations, invalidation record, submission package, proof contracts, and API feedback under `docs/`. +- Capture-only configuration: reviewed JSON files under `config/captures/`; every public copy must remain `tradeable: false`. +- Phase 0 source and examples needed to explain official TXLine integration, but never its local secrets, tokens, wallets, captured output, or logs. + +## Exclude from the public submission + +The following are enforced by `.gitignore` and must remain absent from both Git history and the deployed image: + +- `.env`, `.env.*`, wallet/keypair directories, session tokens, JWTs, and credentials; +- `data/`, SQLite/DuckDB files, `samples/`, raw SSE frames, captured replay archives, logs, and PID files; +- `content/` social-video projects and renders, which are not part of the judge runtime or final bounty video; +- `design/` explorations and screenshots, some of which predate the derived-only public data contract; +- one-off `.spain-belgium-verify*.ts` local verification helpers; +- `node_modules/`, `dist/`, coverage output, OS metadata, and local collaboration notes. + +The public artifact auditor is the second line of defence. It rejects raw/reconstructive TXLine field names, likely secrets, private filesystem paths, unsafe links, and oversized artifacts inside the frozen bundle. + +## Dependency isolation and open advisory + +The judge/runtime workspace and the Phase 0 subscription utility are intentionally separate install graphs: + +- Root `pnpm install --frozen-lockfile` does not install `phase0` dependencies. +- `pnpm security:audit` currently passes the high/critical threshold for the root judge/runtime graph; one moderate transitive `uuid` advisory remains through the latest `@solana/web3.js` → `jayson` dependency chain. +- Phase 0 still carries `@solana/spl-token` → `bigint-buffer@1.1.5`, which has the high-severity availability advisory [GHSA-3gc7-fjrx-p6mg](https://github.com/advisories/GHSA-3gc7-fjrx-p6mg) and no patched package release. + +The affected Phase 0 module is a local, human-invoked TXLine token-account/subscription helper. It is not imported by the dashboard, frozen demo, receipt verifier, capture process, or hosted read-only API. Do not expose its auth/subscription commands in the hosted image. Replacing its associated-token helpers changes a wallet/token path and therefore requires Deborah's explicit review and authorization under `AGENTS.md`; until then, this is an isolated and disclosed release exception, not a silently accepted production claim. + +## Final public-commit gate + +Run from a clean clone of the intended public commit: + +```bash +corepack enable +pnpm install --frozen-lockfile +pnpm security:audit +pnpm judge:check +pnpm --dir phase0 install --frozen-lockfile --ignore-workspace +pnpm --dir phase0 exec tsc -p tsconfig.json --noEmit +pnpm --dir phase0 test +``` + +Then verify all of the following before pushing or deploying: + +- `git status --short` is empty after the intentional commit. +- `git ls-files` contains no path excluded above. +- The public bundle hash and receipt hash match the video and submission text. +- The dashboard works signed out on desktop and mobile without a wallet, credential, subscription, or private archive. +- No exact TXLine probability series or raw response is visible. +- The receipt says unanchored unless Deborah has authorized a devnet submission and the final network verifier passes. +- Deborah has reviewed every public claim and submits the entry herself. + +## Authority-gated actions still open + +1. Deborah provides or approves the public Git remote and hosted deployment target. +2. Deborah decides whether the frozen receipt commitment may be submitted to Solana devnet. +3. Deborah sends and retains the sponsor clarification request. +4. Deborah rewrites/rehearses the narration, records the final video, verifies every public URL, and submits through Superteam Earn. diff --git a/docs/submission/sponsor-clarification-request.md b/docs/submission/sponsor-clarification-request.md new file mode 100644 index 0000000..6899a6f --- /dev/null +++ b/docs/submission/sponsor-clarification-request.md @@ -0,0 +1,40 @@ +# Sponsor Clarification Request + +**Status:** Ready for Deborah to send
+**Sender:** Deborah, the human participant and project owner
+**Suggested recipient:** TxODDS hackathon support / TxLINEChat
+**Subject:** Eligibility and public derived-data clarification for Samaritan + +Hello TxODDS team, + +I am Deborah, the human participant, project owner, and submitter of Samaritan, an entry for the Trading Tools and Agents bounty. I remain responsible for the product decisions, submission, demo, and any judge interview. Samaritan uses Claude as a constrained runtime component: it can produce a structured research thesis, but deterministic code owns eligibility, risk, sizing, and execution. Claude has no wallet or order-placement access. + +Before submission, could you please confirm two points in writing? + +1. **Human participation and agent products.** The bounty invites trading-agent products, while the hackathon terms say entries must be created, developed, and submitted by human participants and the listing metadata identifies the opportunity as `HUMAN_ONLY`. Is Samaritan eligible under the structure above, with me as the human participant and submitter and Claude disclosed only as a constrained component of the product? Is there any additional disclosure wording you would like us to use? + +2. **Public derived TXLine output.** We will not publish raw TXLine captures, raw API responses, or a reconstructable copy of the feed. May our public, no-login judge demo display the following outputs derived from TXLine? + + - detector labels and decisions; + - changes, velocities, z-scores, CUSUM values, cross-market gaps, and latency aggregates; + - aggregated or rounded probability movements rather than raw probability series; + - fixture and market identifiers needed to explain a decision; + - hashes or proof references that let a judge verify provenance without recovering the source feed; + - a small frozen replay bundle containing only those derived values and decisions. + +Please also confirm whether showing an exact normalized `Pct / 100` value or time series would still count as publishing TXLine Data. Unless you approve that expressly, we will omit exact normalized series and expose only non-reconstructive transformations and aggregates. + +If the public judge demo must be removed or changed when the hackathon data licence ends, please tell us what availability window you expect for judging. + +Thank you. We want the submission to demonstrate TXLine deeply while respecting both the human-participation rule and the data licence. + +Deborah
+Human participant and project owner, Samaritan + +## Internal handling notes + +- Deborah must send this request herself and retain the complete written response. +- Do not paraphrase silence as approval. +- Until an affirmative response is received, keep exact normalized TXLine probability series and reconstructive replay data out of public artifacts. +- If the answer narrows permissible use, update the dashboard, repository bundle, video, and submission copy before publication. +- This draft is an operational clarification request, not legal advice. diff --git a/docs/submission/submission-claims-matrix.md b/docs/submission/submission-claims-matrix.md new file mode 100644 index 0000000..68525a5 --- /dev/null +++ b/docs/submission/submission-claims-matrix.md @@ -0,0 +1,109 @@ +# Samaritan Submission Claims Matrix + +**Purpose:** Single source of truth for README, dashboard, video, bounty form, and judge-interview claims
+**Owner:** Deborah, the human participant and submitter
+**Rule:** A claim may move to “implemented and verified” only after its evidence gate passes on the exact public submission commit. + +## Status vocabulary + +| Status | Meaning | Public treatment | +|---|---|---| +| **Implemented and verified** | Present in the current system and supported by a named test, artifact, or captured observation | May be stated narrowly, with its limitation | +| **Planned / conditional** | Designed, in progress, or dependent on an unmet gate | Roadmap only; never use present tense | +| **Prohibited / withdrawn** | False, legally unsafe, invalidated, unsupported, or outside the bounty build | Must not appear as a positive claim | + +This matrix records the evidence available in the Phase status documents. It is not permission to publish a dirty or unverified workspace. Re-run the release checks and update the matrix before the final submission is frozen. + +## Implemented and verified claims + +| Claim area | Safe public formulation | Evidence and boundary | +|---|---|---| +| Human ownership | “Samaritan is Deborah's submission. Claude is a constrained component of the product.” | Deborah remains participant, project owner, submitter, narrator, and interview representative. Do not call Claude the participant or submitter. Sponsor clarification remains required. | +| TXLine integration | “Samaritan consumes official TXLine fixture, odds, and score surfaces and normalizes captured field shapes into canonical events.” | Phase 1 reports mainnet live and historical integration. `Pct` is divided by 100; `Prices` remains integer ×1000; missing values are not coerced to zero. See [Phase 1](../PHASE-1-STATUS.md). | +| Mainnet real-time evidence | “The integration has consumed TXLine mainnet SL12 live data.” | A read-only live smoke and synchronized capture are documented. This does not mean the public deployment currently has live credentials or that every public case is live. | +| Replay/live event contract | “Replay and live ingestion publish the same canonical event types; no replay/live mode field is exposed to strategies.” | Covered by Phase 1 equality tests and Phase 2 archive replay. Claim contract parity, not yet a fully deployed 24/7 autonomous service. | +| SSE resilience | “TXLine ingestion supports gzip, `Last-Event-ID`, reconnect, snapshot backfill, and event-ID deduplication.” | Implemented and tested in Phase 1. Do not claim zero data loss under every outage unless a release test proves it. | +| Storage integrity | “Raw ingress, canonical events, and decision lifecycle records are append-only in SQLite and locally hash-chained.” | Update/delete triggers and chain verification are documented in Phases 1 and 4. Do not call the current local chain externally timestamped, fully canonical, or Solana-anchored. | +| Strict LLM boundary | “Claude can submit only a strict research thesis. It cannot size a trade, access a wallet, authenticate to a venue, or place an order.” | Strict Zod/tool contracts and negative-path tests are documented in Phase 4. The analyst output is bound to the source signal. | +| Deterministic paper risk | “Code—not the model—rechecks eligibility, evidence, freshness, fee metadata, limits, exposure, drawdown, and inventory before paper execution.” | Implemented for the Phase 4 paper slice. Current sizing is a fixed `$3` paper stake under a `$50` paper bankroll, not full Kelly/correlation production risk. | +| Paper execution | “The paper adapter walks captured executable depth, supports partial/no fills, applies market metadata, and never calls a trading venue.” | Phase 4 documents depth walking, tick/minimum checks, fee handling, slippage, close, and settlement logic. The public proving case is synthetic and cannot support a fill-rate or profitability claim. | +| Fail-closed behavior | “Schema failures, mismatches, stale evidence, malformed fee metadata, ineligible research signals, and execution errors terminate without a paper order.” | Covered by the Phase 4 focused tests. Avoid the absolute “all failures” formulation. | +| Pre-action lifecycle recording | “The current orchestrator records the risk verdict and execution intent before invoking the paper adapter.” | Verified by Phase 4 tests. This is a local ordering property, not yet externally timestamped proof. | +| Model-spend control | “Samaritan reserves model spend before each API call and enforces a code-owned `$300` project ceiling.” | Phase 4 documents an append-only spend ledger and pre-network budget check. Do not present the ceiling as trading bankroll or economic profitability. | +| Actual Claude smoke | “A bounded Haiku triage and Opus thesis path have both completed successfully under the strict schemas.” | Phase 4 records a synthetic research-only smoke. It is not a live trade, performance sample, or end-to-end paper fill. | +| Synthetic lifecycle proof | “A deterministic, closed-world proving fixture traverses canonical event publication, detection, triage and analyst stubs, deterministic risk, pre-action intent, paper depth execution, close, settlement, ledger verification, and receipt generation.” | `pnpm demo` completes at `filled_settled` with 12 v2 ledger rows and zero Anthropic, TXLine API, Polymarket API, wallet, Solana RPC, or real-order calls. It is permanently `excluded_synthetic` and proves wiring—not autonomy quality, alpha, fill rate, or profitability. | +| Restart reconstruction | “The paper runtime can rebuild dedupe state, pending/terminal cases, positions, exposure, realized P&L, peak equity, and drawdown from the append-only ledger before resuming.” | Restart parity and inconsistent-history rejection are covered by the state-rehydrator and ledger tests. This does not make the overall system a deployed 24/7 service. | +| Canonical decision hash | “V2 decision-ledger hashes commit to event kind, case identity, event and insertion timestamps, canonical payload, sequence, and prior hash.” | Tamper tests cover changed kinds, IDs, timestamps, payloads, ordering, omissions, and prior hashes. V1 records remain preserved and separately identifiable. | +| Offline Decision Receipt | “A judge can verify the frozen synthetic receipt's strict schema, canonical hash, disclosed lifecycle relationships, and committed ledger head without private data.” | `pnpm receipt:verify -- public/artifacts/dashboard/synthetic-decision-receipt.json` passes. It does not replay private source payloads and does not perform Solana network verification. | +| Licence-safe public bundle | “The dashboard reads a strict, hash-verified frozen bundle containing derived evidence only.” | `pnpm public:audit` rejects raw/reconstructive TXLine shapes, secret patterns, private paths, unsafe links, and oversized artifacts. The clean-clone-shaped API test runs without ignored `data/` or `samples/`; sponsor clarification is still required before final publication. | +| Research-only sampled history | “Sampled Polymarket price history is research-only and cannot authorize execution.” | Enforced at detector eligibility, scheduler admission, and deterministic risk according to Phases 2 and 4. | +| Honest strategy rejection | “Current evidence rejects XMARKET_DIVERGENCE, FADER_CANDIDATE, and Match Result CONSENSUS_MOVE for v1.” | Phase 2 explicitly records these no-go decisions. A rejected strategy is not evidence that it can never work. | +| Stale-quote feasibility case | “In one synchronized Spain–Belgium study, no clean post-TXLine stale window appeared in the measured cases.” | Narrow result only: 0/18 measured market-event cases met that exploratory condition. It is not proof that stale quotes never occur. | +| Historical study invalidation | “Samaritan's causal audit invalidated its v1 dynamic-total result and withdrew it from strategy promotion.” | The defect and affected claims are preserved in [the invalidation record](../research/historical-gate-study-v1-invalidation.md). This is evidence of falsification discipline, not alpha. | +| Corrected historical Total Goals signal | “Under the unchanged chronological boundary, the corrected Total Goals candidate produced 38 normalized held-out buy cases across 18 fixtures, `+132.7` probability bps after the 100 bps proxy, with a fixture-clustered 95% interval of `+14.3` to `+243.9` bps.” | [Corrected v4 report](../research/historical-gate-study-causal-economic-v4.md). Must immediately add: sampled prices lack executable depth; this supports only a fresh forward paper review, not alpha, fills, profitability, or an active study. | +| Real-money boundary | “The bounty system is paper-only and the real-money gate is closed.” | Explicit in Phase 4 and the generated paper report. Repeat this in every judge-facing description of execution. | +| Preserved v1 ledger state | “The invalidated v1 paper ledgers are retained for audit history and remain sealed at zero filled matches and zero fills.” | These are suspended historical artifacts, not active preregistered evidence. See [current v1 paper evidence](../research/paper-study-current.md) and the invalidation record. Do not imply that an active long-run study or performance endpoint exists. | +| Candidate mappings | “Market mappings fail closed and remain non-tradeable until explicit verification.” | Phase 1 records candidate mapping coverage but all research-registry rows remain `tradeable: false`. Do not turn candidate counts into execution coverage. | +| Testing | “The release-candidate judge gate passes.” | On July 14 the full private workspace passed 37 test files / 214 tests. A separate 223-file, private-data-free release tree installed from lock and passed 36 files / 207 tests with the 7 private-projection tests intentionally skipped, plus deterministic demo, receipt verification, artifact audit, dashboard build, root high-severity dependency gate, and isolated Phase 0 typecheck/test. Re-run on the exact public commit before stating counts in the submission. | + +## Planned or conditional claims + +| Feature or claim | Required gate before promotion | Allowed public treatment now | +|---|---|---| +| Active forward dynamic-total study | Deborah reviews the completed causal/economic v4 report, explicitly registers the exact v2 candidate config before any qualifying observation, and fresh ledgers are initialized | “Corrected historical signal evidence exists; paper v2 remains `engineering_candidate_unregistered` and awaits Deborah's registration.” No active-study claim or qualifying admission. | +| Live-model complete lifecycle | One eligible captured case completes event → signal → real Haiku → real Opus thesis → risk → paper result → close → settlement under a registered protocol | The current complete proving case uses deterministic model stubs. Do not describe it as a live Claude trading case or performance sample. | +| Solana decision anchor | Corrected chain head anchored on Solana devnet with explorer transaction and exact public receipt | “Solana anchoring is planned.” No “on-chain decisions” or “every decision anchored.” | +| Network-backed anchor verification | A submitted devnet transaction, explorer URL, and receipt metadata all commit to the same receipt and ledger head | Offline receipt verification and read-only anchor-verifier tooling are implemented; no network-backed anchor claim is allowed until Deborah authorizes submission and the exact transaction verifies. | +| Hosted judge MVP | Public no-login URL works incognito/mobile without wallet, subscription, API key, or local archives | Local dashboard may be shown as development work, not a deployed MVP. | +| Final public release of derived replay | Sponsor approves the displayed derived fields and the exact public commit passes the artifact audit | The frozen derived-only bundle is technically ready; do not publish private archives or exact normalized TXLine series. | +| Public live-connectivity proof | Release artifact demonstrates current TXLine connectivity without exposing server credentials or raw feed data | “The local integration has consumed live SL12” is safe; “the hosted app is live” is not. | +| Economic model gate | Case-level inference cost and expected decision value are recorded; expensive analysis fails closed when uneconomic | State measured model cost where relevant, but do not claim net-positive economics. | +| Analyst evidence tools | Implement bounded, read-only tools; test authority, latency, cost, prompt-injection, and failure handling | The current implemented analyst exit is `submit_thesis`; do not advertise unimplemented search/query tools. | +| Full deterministic risk suite | Implement and exhaustively test quarter-Kelly, correlation/global exposure, kill switch, and durable drawdown state | Current fixed-stake paper controls may be described; the complete planned suite may not. Any money-touching judgment requires Deborah's explicit decision. | +| Strategy tournament/personas | Four isolated paper bankrolls, scoring, and deterministic reallocation exist and are demonstrated | Architecture/roadmap only. Not part of the winning critical path. | +| MODELER / Data Doctor | Fitted/calibrated model certificate or implemented feed-health agent and halt boundary | Roadmap only. Never imply these agents currently run. | +| Real Polymarket execution | Phase 3 gate passes, Deborah explicitly authorizes money-touching work, deterministic controls complete, and V2 integration is independently verified | Post-bounty roadmap at most. It is intentionally outside the submission's proof. | +| Post-hackathon operation | Appropriate commercial TXLine licence and venue/legal requirements are in place | State that continued operation requires the appropriate licence. | + +## Prohibited or withdrawn claims + +| Prohibited wording or implication | Why it is prohibited | Required replacement | +|---|---|---| +| “Proof of alpha” / “profitable strategy” | No valid corrected edge study or completed paper sample exists | “Falsification-first governed paper system; corrected research pending.” | +| “+119 bps held-out edge,” “+219 bps raw edge,” or any v1 totals profitability number | V1 used future-informed market selection and unbounded coverage | “V1 invalidated by causal audit; no totals candidate is approved.” | +| “73 executable trades” | Emissions included unsupported sells and complementary duplicates | Report unique executable economic cases only after v2. | +| “Every decision is anchored on-chain” | Current decision chain is local and Solana anchoring is unfinished | “Lifecycle records are locally append-only and hash-chained; external anchoring is planned.” | +| “Production real-money trader” / “Samaritan executes real Polymarket orders” | Real-money gate is closed and no production adapter is demonstrated | “Paper execution only.” | +| “Production-ready autonomous daemon” | Complete lifecycle, restart reconstruction, deployment, and public proof remain open | Describe the implemented slice and exact missing gates. | +| “AI-built submission,” “Claude's entry,” or “autonomously submitted” | Deborah is the human participant and submitter; terms require human participation | “Deborah built and submits Samaritan; Claude is a constrained product component.” | +| Raw TXLine API responses, captures, replay files, or a reconstructable feed | Hackathon data terms prohibit redistribution/publishing of Data | Publish only sponsor-approved, non-reconstructive derived output. | +| Exact normalized TXLine probability time series as automatically licence-safe | Dividing `Pct` by 100 may be too close to source data to constitute non-reconstructive output | Omit unless TxODDS approves it in writing; prefer rounded aggregates, movements, features, decisions, and hashes. | +| “TXLine gives us individual bookmaker lead/lag in the free World Cup feed” | Captures exposed StablePrice consensus rows rather than a bookmaker panel | “TXLine provides a de-vigged consensus price in the captured tier.” | +| “STALE_QUOTE is validated” | One live feasibility study found no supporting clean post-TXLine window | “STALE_QUOTE remains disabled and paper-only.” | +| “Historical sampled prices prove executable fills” | Sampled histories lack bid, ask, size, spread, and executable depth | Use paired canonical books and post-readiness execution evidence. | +| “All five detectors / all four personas are running” | Only three detector types are implemented in the documented Phase 2 slice; tournament/personas are unfinished | Name only the exact implemented detectors and their current evidence status. | +| “Claude performs autonomous web/book/series research” | Current bounded production path exposes strict submissions, not the planned tool suite | “Claude evaluates a code-assembled evidence bundle and returns a strict thesis.” | +| “Risk uses quarter-Kelly and full portfolio correlation controls” | Current Phase 4 paper slice uses fixed stake and bounded exposure/drawdown checks | State the exact current paper risk configuration. | +| “The proof is provably un-cherry-picked” | No corrected protocol has been registered or externally timestamped | “The v2 engineering configuration is locally versioned but unregistered”; strengthen only after Deborah registers it before observations and the external proof exists. | +| “The v2 study is preregistered,” “the long-run study is active,” or any equivalent present-tense claim | V1 is invalidated and suspended; v2 is an `engineering_candidate_unregistered` until Deborah explicitly registers it | “V1 is preserved as invalidated audit history; v2 awaits Deborah registration.” | +| “SL1 always means a 60-second delay” | The observed devnet on-chain row reported a conflicting sampling interval | Name the network/tier and current on-chain metadata explicitly. | +| “World Cup data licence continues automatically after the hackathon” | The hackathon licence terminates; future operation requires the appropriate normal licence | Disclose the post-hackathon licensing dependency. | +| FIFA branding, logos, or implied affiliation | Hackathon terms prohibit the implication and trademark use is unsafe | Use neutral “World Cup data via TxODDS” copy. | +| Any guarantee of winnings, safety, correctness, or zero loss | Neither research nor deterministic controls eliminate market or implementation risk | Describe bounded controls and measured evidence, never guarantees. | + +## Final claim-release checklist + +Before freezing any README, video, dashboard, or submission copy: + +- [ ] Deborah has received and retained sponsor clarification on human participation. +- [ ] Deborah has received and retained sponsor clarification on publicly displayed derived TXLine fields. +- [ ] Every implemented claim points to evidence produced by the exact public commit. +- [x] Every planned feature uses future tense and is visibly separated from the judge path. +- [x] The v1 edge numbers and 73-emission framing are absent except inside the labelled invalidation history. +- [x] V1 ledgers are described only as suspended audit history, and v2 is never called registered or active before Deborah's explicit registration. +- [x] Paper-only and real-money-gate-closed language appears wherever execution is described. +- [x] No public artifact contains raw TXLine Data, secrets, private paths, or reconstructive series. +- [x] No public surface requires a judge to pay, subscribe, authenticate, or create a wallet. +- [ ] The Solana claim matches the exact explorer transaction and receipt that can be independently verified. +- [ ] The final test/typecheck/build counts were rerun and recorded rather than copied from an earlier phase report. +- [ ] Deborah can explain every claim, limitation, and rejected strategy in her own words. diff --git a/docs/submission/submission-form-draft.md b/docs/submission/submission-form-draft.md new file mode 100644 index 0000000..64c926a --- /dev/null +++ b/docs/submission/submission-form-draft.md @@ -0,0 +1,88 @@ +# Samaritan — Superteam Submission Form Draft + +> **Not ready for copy/paste without human review.** Deborah is the participant, owner, narrator, and submitter. She must personally verify the final build and links, rewrite this draft in her own voice, remove any claim that is not true on the final public commit, and submit it herself. Claude is a constrained component of Samaritan; it is not the participant or submitter. + +Listing checked: [TxODDS — Trading Tools and Agents](https://superteam.fun/earn/listing/trading-tools-and-agents/) on July 14, 2026. The listing requires a public repository, public working MVP, public demo video of at most five minutes, technical documentation with the exact TXLine endpoints, and API feedback. + +## Project Title + +**Samaritan — The Auditable, Risk-Gated Sports Trading Agent** + +## One-liner + +Samaritan turns live TXLine odds and score events into deterministic signals, bounded Claude theses, fail-closed paper decisions, and independently verifiable decision receipts. + +## Briefly explain your Project + +Sports-trading agents are easy to demo badly: send odds to a model, accept confident prose, and hide the timing, risk, or failed hypotheses. Samaritan is built around the opposite idea—**detect deterministically, reason within a strict boundary, authorize in code, and prove the entire decision lifecycle**. + +Samaritan consumes official TXLine fixture, odds, and score APIs, including live mainnet SL12 SSE. Live and historical records become the same canonical event types on one bus, so the feature, detector, risk, and paper-execution path cannot switch to easier logic during replay. Deterministic detectors escalate only selected cases. Claude can return a strict thesis but cannot size a position, access a wallet, or place an order. Code-owned checks revalidate eligibility, timing, evidence, mapping, fees, exposure, and drawdown before a depth-aware paper simulator acts. Every stage is appended to a v2 hash-chained ledger before the next action and can be exported as a licence-safe Decision Receipt. + +The demo proves two different things honestly. A synchronized Spain–Belgium capture shows a real no-trade: across 18 measured market-event cases, zero produced a clean post-TXLine stale window, so the detector stays disabled. A separate, prominently labelled synthetic fixture runs the full paper lifecycle—signal, triage, thesis, deterministic risk, intent, fill, close, settlement, and receipt—without any external model, venue, wallet, or network call and is excluded from performance evidence. + +The corrected historical Total Goals candidate produced 38 normalized held-out buy cases across 18 fixtures, `+132.7` probability bps after a 100-bps proxy, with a fixture-clustered 95% interval of `+14.3` to `+243.9` bps. This is sampled-price **signal evidence only**: it is not alpha, executable-fill evidence, or profitability. The v2 paper protocol is unregistered, the bounty build is paper-only, and the real-money gate is closed. + +## Problem + +An autonomous market tool must answer more than “what would the model bet?” It must prove which evidence was available at decision time, whether the venue had already moved, whether the position was allowed, and whether the displayed result came from the same logic used live. Without those controls, agent autonomy becomes an unreviewable risk surface. + +## Solution + +Samaritan provides one end-to-end control plane: + +- official TXLine live and historical ingestion with reconnect/backfill; +- probability normalization and deterministic signal detection; +- bounded Claude triage/thesis schemas; +- code-owned paper-risk and execution rules; +- identical live/replay event contracts; +- restart-safe append-only lifecycle state; +- a derived-only public observer dashboard; and +- offline-verifiable Decision Receipts. + +## Innovation and novelty + +The novelty is not “AI predicts football.” It is the separation of market-speed detection, model judgment, deterministic authorization, and verifiable evidence. Samaritan treats a refusal as a first-class output, makes invalidated research visible instead of deleting it, and exposes proof boundaries precisely: a local receipt is not called an on-chain anchor, and sampled prices are not called executable fills. + +## Business and technical fit + +Samaritan is designed as an auditable decision layer for professional trading teams, market operators, and B2B intermediaries. The reusable product is the harness—feed normalization, replay parity, agent boundaries, risk authorization, execution adapters, and receipts—rather than one tournament-specific prediction. It can expand to other competitions and venues under the appropriate TXLine commercial licence and applicable legal/venue approvals. + +## Current status and limits + +- **Execution:** paper only; no real Polymarket orders. +- **Gate:** real-money gate closed. +- **Study:** v1 invalidated and preserved; corrected v2 is `engineering_candidate_unregistered`. +- **Performance:** no profitability or alpha claim. +- **Solana:** devnet prepare/submit/verify tooling exists, but no transaction has been submitted; the current receipt is unanchored. +- **Public data:** derived, non-reconstructive output only; no raw TXLine feed or exact TXLine probability series is redistributed. +- **Ownership:** Deborah is the human participant and submitter; Claude is a constrained runtime component. + +## Required links + +Replace every placeholder only after testing it in a signed-out/incognito browser. + +- **Live and working MVP:** `[PUBLIC_MVP_URL]` +- **Live demo video (≤5:00, publicly viewable):** `[YOUTUBE_OR_LOOM_URL]` +- **Public repository:** `[PUBLIC_GITHUB_URL]` +- **Technical documentation:** `[PUBLIC_TECHNICAL_OVERVIEW_URL]` +- **Optional X profile/post:** `[PUBLIC_X_URL_OR_REMOVE]` + +Do not add a Solana explorer link unless an actual devnet transaction has been submitted and the public `anchor:verify` command passes against the exact receipt. At the time of this draft, no such link exists. + +## TxLINE API experience + +What worked best was the combination of normalized de-vigged `Pct`, live odds and score SSE, historical interval access, rich score/action envelopes, stable fixture/market identities, and validation surfaces. Together they made a single live/replay event contract possible instead of forcing a demo-only architecture. + +The main friction was contract precision rather than product scope: timestamp units and snapshot omission behavior, quote-array/no-quote semantics, service-level capability metadata, score/VAR revision lifecycle, retention/empty-result semantics, auth expiry/reactivation, and end-to-end proof-verification examples. Our highest-value requests are explicit numeric units in OpenAPI, a versioned score/action state machine, authoritative per-network service-level metadata, a documented auth/SSE resume lifecycle, and fixed validation vectors with a first-party TypeScript verifier. + +Full draft feedback: `[PUBLIC_TXLINE_FEEDBACK_URL]` or [repository source](txline-api-feedback.md). + +## Final human release checklist + +- [ ] Deborah has rewritten the submission in her own voice and can explain every sentence. +- [ ] The MVP, video, repository, and documentation links work while signed out. +- [ ] The video is at most five minutes and shows the working product, TXLine's backend role, and the exact proof commands. +- [ ] The final public commit passes install, checks, build, demo, receipt verification, and public-artifact audit. +- [ ] Paper-only, closed-gate, unregistered-v2, unanchored-receipt, and sampled-price limitations remain visible. +- [ ] No raw TXLine data, secret, private path, FIFA branding, or unsupported result is public. +- [ ] Deborah—not an agent—submits the entry through Superteam Earn. diff --git a/docs/submission/technical-overview.md b/docs/submission/technical-overview.md new file mode 100644 index 0000000..77930dc --- /dev/null +++ b/docs/submission/technical-overview.md @@ -0,0 +1,108 @@ +# Samaritan — Technical Overview + +> **Submission-owner note:** Samaritan is Deborah's project and submission. Claude is a constrained component of the product, not the participant or submitter. Deborah must personally review this document, rewrite it in her own voice where needed, verify every link against the final public commit, and submit it herself. + +## What Samaritan is + +Samaritan is an auditable, risk-gated sports-market decision system built around live TXLine World Cup odds and scores. Deterministic TypeScript code watches the feed, computes probability-space features, and raises typed market signals. Claude is invoked only at a judgment boundary to return a strict thesis; it cannot size a position, access a wallet, or place an order. Code-owned risk checks make the final decision, a depth-aware simulator executes **paper orders only**, and an append-only hash chain records the lifecycle before each action. + +This directly targets the bounty's five published criteria: working TXLine ingestion, autonomous operation once started, defensible deterministic logic, a novel agent/risk separation, and a deployable evidence surface. The current bounty build is deliberately **paper-only**: the real-money gate is closed, the corrected v2 paper protocol is `engineering_candidate_unregistered`, and no profitability claim is made. + +## Architecture + +```text +TXLine odds + scores SSE Public Polymarket books + \ / + normalized canonical events + | + one serialized event bus + | + rolling features + deterministic detectors + | + Haiku triage -> Opus strict thesis + | + deterministic eligibility + risk rules + | + paper execution adapter + | + append-only v2 decision ledger -> public receipt +``` + +The high-frequency path is deterministic. The model sees only escalated cases and its only analyst exit is `submit_thesis`, validated by a strict schema. Risk and paper execution recheck identity, evidence class, timing, market mapping, book freshness, venue metadata, exposure, and drawdown without trusting model prose. The same canonical event types feed live and replay operation; strategies receive no replay/live mode flag. + +### Specific TXLine endpoints used + +Mainnet origin: `https://txline.txodds.com`; devnet was also used for integration tests. + +| Method and endpoint | Use in Samaritan | +|---|---| +| `POST /auth/guest/start` | Start the documented guest-authentication flow. | +| `POST /api/token/activate` | Activate a wallet-backed TXLine subscription and obtain the API token used server-side. | +| `GET /api/fixtures/snapshot` | Discover fixtures and exact fixture/kickoff identities. | +| `GET /api/odds/stream` | Consume live odds SSE, optionally scoped by `fixtureId`; gzip and `Last-Event-ID` are supported. | +| `GET /api/scores/stream` | Consume live score/action SSE on the same canonical bus. | +| `GET /api/odds/snapshot/{fixtureId}?asOf=` | Reconnect/backfill the current odds state before resuming a fixture stream. | +| `GET /api/scores/snapshot/{fixtureId}` | Reconnect/backfill the current score state. | +| `GET /api/odds/updates/{epochDay}/{hourOfDay}/{interval}` | Build the five-minute historical replay archive used for detector research. | +| `GET /api/scores/updates/{epochDay}/{hourOfDay}/{interval}` | Build the aligned historical score archive. | +| `GET /api/scores/historical/{fixtureId}` | Recover the score/action sequence for a fixture where retained. | + +Captured TXLine `Pct` is parsed as a de-vigged percentage and divided by 100; `Prices` is retained as integer odds multiplied by 1000. Empty quote transitions are not converted to zero. Public artifacts do not contain raw TXLine responses, exact TXLine probability levels, or reconstructive time series. + +## Live and replay are one path + +Both live SSE envelopes and captured historical records pass through the same strict normalizers into the same `CanonicalEvent` union and serialized bus. Features, detectors, schedulers, risk, and the decision ledger consume those events without a mode switch. Replay therefore exercises the product path rather than a separate backtest implementation. + +The live evidence case in the public dashboard is a synchronized, read-only Spain–Belgium capture pairing TXLine mainnet SL12 delivery with public Polymarket books. Across 18 measured market-event cases, **zero showed a clean post-TXLine stale window**. Samaritan keeps `STALE_QUOTE` disabled and presents the refusal as the correct result; it does not manufacture a trade from an unsupported hypothesis. + +## Risk boundary + +- Claude can classify or submit a research thesis only. Its schemas contain no stake, bankroll, order, wallet, credential, or venue-authentication field. +- Deterministic code can veto; model output cannot override a cap, enlarge a position, create an order, or reopen the kill switch. +- Research-only sampled prices cannot authorize execution. Only correctly mapped, post-readiness executable-book evidence can reach the paper adapter. +- The paper adapter walks displayed depth, handles partial/no fills, applies validated fee/tick/minimum metadata, and never calls an order endpoint. +- Every signal, triage result, thesis, risk verdict, execution intent, paper result, close, and settlement is appended before its downstream action. Restart reconstruction verifies the chain before restoring pending cases, positions, exposure, P&L, and drawdown. +- The bounty build has no real-money execution path enabled. The real-money gate is closed and requires a separate human decision outside this submission. + +## Evidence and proof + +1. **Captured refusal:** the Spain–Belgium case demonstrates real synchronized input and a defensible no-trade result. Only derived, non-reconstructive TXLine movement buckets are public. +2. **Corrected historical signal evidence:** under a fixed chronological split, the Total Goals `CONSENSUS_MOVE` candidate produced 38 normalized held-out buy cases across 18 fixtures, with mean `+132.7` probability bps after a 100-bps proxy and a fixture-clustered 95% interval of `+14.3` to `+243.9` bps. These are sampled-price signal results without executable bid/ask depth—not alpha, fills, or profitability—and support only a fresh forward paper review. See the [v4 report](../research/historical-gate-study-causal-economic-v4.md). +3. **Falsification record:** Samaritan preserved and withdrew its earlier result after discovering a future-informed selector, repaired the selector and economic-case semantics, and reran the unchanged boundary. See the [v1 invalidation record](../research/historical-gate-study-v1-invalidation.md). +4. **Synthetic full lifecycle:** `pnpm demo` runs 20 invented canonical events through the production paper components, creating one signal, paper fill, kickoff close, settlement, and 12 v2 ledger records. The two model boundaries use deterministic stubs; there are zero external API, model, wallet, RPC, or real-order calls. The fixture is permanently marked synthetic and excluded from performance evidence. +5. **Decision Receipt:** the public receipt commits to build/config hashes, source-evidence hashes, bucketed derived signals, model-run metadata, lifecycle ordering, and the final ledger head. `receipt:verify` checks the strict schema, canonical receipt hash, disclosed lifecycle consistency, and committed ledger head offline. It does not replay private source data or prove profitability. See the [receipt contract](../proof/decision-receipt.md). +6. **Current Solana status:** offline prepare, human-gated devnet submit, and read-only verify tooling is implemented and tested, but **no anchor transaction has been submitted**. The public receipt is unanchored: `solanaAnchorMetadataPresent: false` and `solanaNetworkVerificationPerformed: false`. See the [devnet anchor boundary](../proof/solana-devnet-anchor.md). + +## Run the judge path locally + +Requirements: Node 22, pnpm 10, and a clean clone of the final public repository. + +```bash +corepack enable +pnpm install --frozen-lockfile +pnpm judge +``` + +Open `http://127.0.0.1:4173`. The command runs the clean-clone verification gate before starting the server. The frozen observer bundle requires no TXLine credential, Anthropic key, wallet, RPC write, paid subscription, or private archive. + +In another terminal, run the reproducible proof path: + +```bash +pnpm demo +pnpm receipt:verify -- public/artifacts/dashboard/synthetic-decision-receipt.json +pnpm public:audit +``` + +For engineering verification: + +```bash +pnpm check +``` + +The public audit rejects raw/reconstructive TXLine fields, common secret patterns, unsafe links, private paths, and oversized artifacts. Maintainers regenerate the frozen bundle only from the private licensed workspace; judges do not need those inputs. + +## Business and production fit + +Samaritan is a decision-control and evidence layer for a professional trading team, market operator, or B2B intermediary—not a tip generator. Its reusable value is the harness: normalized live evidence, identical replay behavior, bounded judgment, deterministic authorization, failure-safe paper execution, and portable receipts. New competitions or venues can sit behind adapters while the risk and audit boundaries remain unchanged. Continued operation after the hackathon requires the appropriate TXLine commercial licence and all applicable legal/venue approvals. + +Further detail: [architecture](../02-architecture.md), [strategy playbook](../03-strategy-playbook.md), [API feedback](txline-api-feedback.md), and the [submission claims matrix](submission-claims-matrix.md). diff --git a/docs/submission/third-party-notices.md b/docs/submission/third-party-notices.md new file mode 100644 index 0000000..19a96f3 --- /dev/null +++ b/docs/submission/third-party-notices.md @@ -0,0 +1,20 @@ +# Samaritan Third-Party Notices + +This file covers the judge-facing bounty repository and dashboard. Local ignored design/video experiments are outside the public release scope. + +## Data and services + +- **TXLine / TxODDS:** official fixture, odds, score, and validation APIs are used under the World Cup Hackathon terms. The public bundle contains only Samaritan-derived, non-reconstructive signals, decisions, aggregates, and hashes; it does not redistribute raw TXLine data. Continued post-hackathon operation requires the appropriate TxODDS licence. +- **Polymarket:** public market metadata, sampled prices, and public order-book data are used for research and paper simulation. Samaritan places no real order in the bounty build and does not require a judge wallet or account. +- **Anthropic Claude:** Haiku and Opus are constrained runtime components. The zero-network public proving fixture uses deterministic stubs at those two boundaries and does not call Anthropic. +- **Solana:** `@solana/web3.js` and the public Memo program are used for offline devnet anchor preparation and human-gated submission/verification tooling. No anchor transaction has been submitted for the current receipt. + +## Fonts and visual assets + +- **Manrope**, distributed through `@fontsource/manrope`, is licensed under SIL Open Font License 1.1. +- **IBM Plex Mono**, distributed through `@fontsource/ibm-plex-mono`, is licensed under SIL Open Font License 1.1. +- The Samaritan wordmark/logo and dashboard interface artwork in the public repository are project-owned assets. No FIFA logo, team crest, tournament emblem, or endorsement mark is used. + +## Software + +Runtime and development dependencies are declared in `package.json`, with exact resolved versions and integrity hashes in `pnpm-lock.yaml`. Their respective licences remain with their authors. The public judge graph is checked with `pnpm security:audit`; the separately installed Phase 0 graph and its known upstream advisory are documented in `public-release-scope.md`. diff --git a/docs/submission/txline-api-feedback.md b/docs/submission/txline-api-feedback.md new file mode 100644 index 0000000..4fa5083 --- /dev/null +++ b/docs/submission/txline-api-feedback.md @@ -0,0 +1,246 @@ +# TxLINE API Feedback Draft + +**Status:** Submission draft; verify links and current service metadata immediately before sending
+**Project:** Samaritan
+**Participant:** Deborah
+**Basis:** Phase 0 through Phase 4 integration work using the OpenAPI specification, mainnet SL12, devnet subscription metadata, historical endpoints, snapshots, SSE, score data, and validation surfaces + +## Executive summary + +TxLINE gave Samaritan an unusually strong foundation for building a reproducible sports-market system. Its most valuable combination is not merely low-latency odds: it is normalized de-vigged probabilities, historical access, live SSE, rich score events, and on-chain validation surfaces under one data model. That combination makes it possible to test the same deterministic decision logic in replay and live operation and to retain evidence for why a system acted or refused to act. + +The main integration cost came from places where the documented contract did not fully describe observed behavior: timestamp requirements, service-level metadata, empty quote transitions, array alignment, retention windows, score-state semantics, and authentication renewal. None is a request for more product scope. A tighter executable contract, explicit lifecycle semantics, and a small official SDK/conformance suite would remove most of the defensive reverse engineering required by a production consumer. + +## What worked especially well + +### 1. The de-vigged probability representation is excellent + +`Pct` is a high-value primitive for quantitative products. It lets a consumer work in probability space without independently rebuilding bookmaker-margin removal for every quote. The captured values behaved as percentages on a 0–100 scale and summed near 100, so Samaritan normalizes them into 0–1 probabilities at ingestion. + +This substantially reduced ambiguity in feature engineering. It also made movements across Match Result, Over/Under, and Handicap markets comparable under one internal convention. + +### 2. Live and historical surfaces support reproducible research + +The combination of: + +- live odds and scores via SSE; +- latest snapshots; +- interval-based historical odds and scores; +- full score history by fixture; and +- stable fixture, market, period, parameter, and message identities + +allowed Samaritan to build canonical events that can be consumed by the same downstream feature and detector code in replay and live operation. Historical access was large enough to support serious research rather than a hand-built demo. + +### 3. SSE has the right production primitives + +Standard `id`, `event`, `data`, and `retry` frames, heartbeats, `Last-Event-ID`, and gzip support are the right building blocks. Gzip is particularly valuable for high-volume odds streams. Resumability also makes it possible for a consumer to distinguish reconnect delivery from new market evidence and to deduplicate safely. + +### 4. The score feed is richer than a scoreboard + +The observed score envelope included clock, possession, score, possible-event state, kickoff state, player statistics, lineups, and actions such as shots, VAR, penalties, goals, corners, and cards. This is useful for building event-aware market diagnostics and for testing whether a venue repriced before or after a material match event. + +### 5. Validation endpoints are a meaningful differentiator + +Fixture, odds, score/stat, and batch-validation endpoints create the foundation for externally verifiable decision evidence. For an autonomous system, proving the provenance of the input is almost as important as recording the output. This is a stronger product surface than an ordinary centralized odds API. + +### 6. The free World Cup access enabled a real integration + +Mainnet SL12 real-time access and historical availability allowed Samaritan to test real event flow, reconnect behavior, replay, data normalization, and synchronized market observation. The product was able to exercise the actual service rather than rely on mocks alone. + +## Friction encountered and suggested fixes + +### 1. Make timestamp units and omission behavior explicit + +**Observed friction:** `GET /api/odds/snapshot/{fixtureId}` required `asOf` to be supplied as an epoch-millisecond value in practice. Omitting it returned only a partial snapshot during a July 10 mainnet smoke. The contract did not make that operational consequence sufficiently clear. + +**Suggestion:** Specify the unit and required/optional behavior in both OpenAPI and prose: + +- `asOf`: Unix epoch milliseconds; +- what “omitted” means; +- whether the result is latest-known, partial, or bounded by a server default; +- inclusive/exclusive cutoff semantics; and +- an example response for each case. + +An OpenAPI `format`, minimum/maximum example, and a conformance test would prevent seconds-versus-milliseconds failures. + +### 2. Publish one authoritative service-level matrix per network + +**Observed friction:** Mainnet SL1 was described as 60-second delayed and mainnet SL12 as real-time. The current devnet SL1 on-chain pricing row reported `samplingIntervalSec = 0`, which contradicted the earlier blanket association between SL1 and a 60-second delay. + +**Suggestion:** Make the on-chain subscription row the explicitly documented source of truth and publish a generated table containing, for every network and service level: + +- sampling interval; +- permitted competitions; +- historical availability; +- program ID and mint identity; +- activation requirements; and +- the metadata version/effective time. + +Clients should be told to inspect the subscribed row instead of inferring latency from a service-level name. + +### 3. Define quote-array invariants and no-quote transitions + +**Observed friction:** Observed market rows sometimes retained `PriceNames` while both `Prices` and `Pct` were empty. Samaritan interpreted a row with both value arrays empty as an explicit no-quote transition and treated partially populated or misaligned arrays as invalid. That distinction was necessary for safety but was not fully specified. + +**Suggestion:** Document the legal combinations of: + +- `PriceNames`; +- `Prices`; +- `Pct`; and +- market availability/status. + +Provide an explicit `quoteStatus` or `isAvailable` field if possible. At minimum, specify that empty `Prices` and `Pct` with retained names means “no current quote,” and define whether unequal non-empty array lengths are always malformed. Include examples for open, suspended, removed, and resumed markets. + +### 4. Document units beside every numeric field + +**Observed friction:** `Prices` were observed as integer odds multiplied by 1000, while `Pct` contained three-decimal strings on a 0–100 percentage scale. Both are usable, but the conventions are easy to misread and have materially different failure modes. + +**Suggestion:** Put units directly into OpenAPI field descriptions and examples: + +- `Prices`: decimal odds ×1000, integer; +- `Pct`: de-vigged probability percentage on 0–100, decimal string; +- timestamps: seconds or milliseconds, named per field; +- line parameters: representation and precision rules. + +Consider adding machine-readable extension metadata such as `x-unit` and `x-scale` so generated clients can preserve the contract. + +### 5. Clarify consensus coverage versus constituent bookmaker coverage + +**Observed friction:** The free World Cup captures exposed `TXLineStablePriceDemargined` consensus rows rather than a multi-bookmaker panel, even though the object model includes `BookmakerId` and `Bookmaker`. That is a valid and useful product, but consumers can otherwise design a bookmaker-lead/lag strategy that the subscribed feed cannot support. + +**Suggestion:** For every tier, state whether odds rows are: + +- StablePrice consensus only; +- individual bookmaker observations; or +- both. + +Expose a capability descriptor in subscription metadata so software can fail closed before assuming constituent-book availability. + +### 6. Specify score/action lifecycle and revisions + +**Observed friction:** Goal-related score messages included initial deliveries and later confirmation revisions. The feed also exposes `PossibleEvent`, `Action`, score counters, and match-state fields, but the semantic relationship between provisional, confirmed, corrected, and reversed events needs a stronger contract for VAR-sensitive systems. + +**Suggestion:** Document a state machine for material events: + +```text +possible → provisional → confirmed + ↘ corrected / cancelled +``` + +Define which identity remains stable across revisions, which timestamp denotes source occurrence versus publication, and how a consumer should recognize a correction or reversal. Include worked VAR and penalty examples. + +### 7. Keep the score parser contract open and publish the full taxonomy + +**Observed friction:** Captured `Stats` keys used period prefixes 0 through 7, while the earlier documentation summary did not describe the full observed range. Observed match-state IDs included 1–13 and 100, while the documentation listed a wider 1–19 family. Odds rows also frequently had blank `GameState` values. + +**Suggestion:** Publish versioned enumerations for: + +- period prefixes and their meanings; +- stat base keys; +- match-state IDs; +- action names; +- which fields may be blank on odds versus score events; and +- forward-compatibility behavior for unknown values. + +An `unknown` rule is important: consumers should retain new actions and states instead of rejecting an entire event when the taxonomy expands. + +### 8. State retention and empty-result semantics per endpoint + +**Observed friction:** Interval endpoints returned data over a longer observed range than a consumer might infer from the prose documentation. Score interval coverage differed by network and date. `GET /api/scores/historical/{fixtureId}` returned successful empty bodies for some older fixtures rather than a not-found/expired distinction. + +**Suggestion:** Publish a per-endpoint retention table with: + +- guaranteed versus best-effort retention; +- network differences; +- current earliest available time; +- whether an empty 200 means no events, expired data, unavailable competition, or an unknown fixture; and +- whether retention can change by subscription tier. + +Response metadata such as `coverageStart`, `coverageEnd`, `isComplete`, and `emptyReason` would make research admission deterministic. + +### 9. Document authentication expiry and reactivation as a complete lifecycle + +**Observed friction:** Authentication uses both a guest JWT and activated API token. No proven refresh endpoint was available to the integration, so Samaritan detects expiry and invokes a separately supplied reactivation path rather than inventing renewal behavior. + +**Suggestion:** Provide an official lifecycle sequence covering: + +- JWT expiry and renewal; +- API-token expiry and renewal; +- subscription expiration; +- whether a wallet signature may be reused; +- error codes for each expired component; +- retry safety and idempotency; and +- how an open SSE connection behaves when either credential expires. + +A tiny official TypeScript session helper would eliminate substantial repeated work across submissions. + +### 10. Define event ordering and resume guarantees + +**Observed friction:** A safe consumer must distinguish source time, server publication time, local receipt time, replay order, and reconnect redelivery. `Last-Event-ID` and message IDs help, but the ordering guarantees across odds, scores, snapshots, and reconnect backfill need to be explicit. + +**Suggestion:** State: + +- whether IDs are unique per fixture, stream, endpoint, or globally; +- whether IDs are monotonic; +- whether reconnect may redeliver or reorder frames; +- the retention window for resume IDs; +- how gaps are signalled; and +- the supported snapshot-plus-stream handoff procedure. + +An official “lossless reconnect” example should demonstrate snapshot backfill, `Last-Event-ID`, deduplication, and gap detection together. + +### 11. Provide a first-party validation verifier and canonical proof example + +**Observed friction:** The validation endpoints are strategically important, but consumers still need to understand canonical leaf serialization, proof ordering, on-chain account selection, network identity, and how to verify a returned proof independently. + +**Suggestion:** Publish: + +- one fixed fixture, odds, and score validation vector; +- the exact canonical bytes and resulting leaf hash; +- a TypeScript verifier; +- the expected root/account and transaction reference; and +- clear failure examples for the wrong network, stale root, malformed path, or mismatched payload. + +This would turn validation from an advanced integration surface into a feature every hackathon entry could demonstrate correctly. + +### 12. Add an executable conformance pack and changelog + +**Observed friction:** The OpenAPI specification is a strong starting point, but several important behaviors were discovered only through captured traffic. In a money-adjacent system, documentation drift forces every client to build its own interpretation layer. + +**Suggestion:** Ship a small versioned conformance pack containing sanitized examples and assertions for: + +- authentication and activation; +- snapshots with and without `asOf`; +- live quote, no-quote, and resumed-quote rows; +- score revisions and VAR cancellation; +- SSE reconnect and deduplication; +- retention-empty responses; and +- validation proof verification. + +Tie it to a public schema changelog with effective dates. A generated strict TypeScript client would be helpful, but the conformance vectors are the higher-value first step. + +## Prioritized recommendations + +If only five improvements can be made, Samaritan recommends: + +1. Specify quote-array/no-quote invariants and numeric units in OpenAPI. +2. Publish the score/action revision state machine, especially for VAR. +3. Make service-level capability metadata authoritative and machine-readable per network. +4. Document the complete auth-expiry and SSE-resume lifecycle. +5. Release fixed validation vectors with a first-party TypeScript verifier. + +## Closing feedback + +TxLINE's strongest differentiator is the combination of normalized real-time evidence and independent validation, not any one endpoint. Samaritan was able to build a strict canonical event layer, replay research, score-aware diagnostics, and provenance-bearing decision records because those surfaces exist together. + +The requested improvements are primarily contract clarity. Making the implicit lifecycle rules executable would shorten integration time, reduce unsafe assumptions, and make it much easier for developers to demonstrate TXLine's on-chain verification advantage accurately. + +## Internal verification checklist + +Before this feedback is submitted: + +- Recheck current endpoint names and service-level rows against the live official documentation. +- Keep all examples aggregate or schematic; attach no raw response or private capture. +- Remove any observation that is no longer reproducible under the current API version. +- Submit the feedback in Deborah's name as the human participant. +- Keep Polymarket-specific feedback out of the TxLINE section. diff --git a/package.json b/package.json index 9233e2e..76ce998 100644 --- a/package.json +++ b/package.json @@ -11,20 +11,50 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", + "dash:dev": "vite --config apps/dashboard/vite.config.ts", + "dash:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit", + "dash:build": "pnpm dash:typecheck && vite build --config apps/dashboard/vite.config.ts", + "dash:serve": "tsx src/dash/server.ts", + "dash:export-public": "tsx src/dash/export-public-bundle.ts", + "public:audit": "tsx src/public/audit-public-artifacts.ts --bundle public/artifacts/dashboard", + "security:audit": "pnpm audit --prod --audit-level high", + "demo": "tsx src/demo/run-synthetic-judge-case.ts", + "judge:check": "pnpm check && pnpm demo && pnpm receipt:verify -- public/artifacts/dashboard/synthetic-decision-receipt.json && pnpm public:audit && pnpm dash:build", + "judge": "pnpm judge:check && pnpm dash:serve", "test": "vitest run", "test:watch": "vitest", - "check": "pnpm typecheck && pnpm test", + "check": "pnpm typecheck && pnpm dash:typecheck && pnpm test", "smoke:replay": "tsx src/replay/smoke.ts", "smoke:live": "tsx src/replay/live-smoke.ts", "smoke:discovery": "tsx src/replay/discovery-smoke.ts", + "smoke:claude": "tsx src/agents/haiku-smoke.ts", + "smoke:claude:analyst": "tsx src/agents/opus-smoke.ts", "import:mappings": "tsx src/import/build-mapping-registry.ts", "import:archives": "tsx src/import/import-archives.ts", "import:txline-metadata": "tsx src/import/backfill-txline-metadata.ts", - "research:total-evidence": "tsx src/research/build-total-line-evidence.ts" + "research:total-evidence": "tsx src/research/build-total-line-evidence.ts", + "research:paired-live": "tsx src/research/analyze-paired-capture.ts", + "research:historical-gate": "tsx src/research/run-historical-gate.ts", + "paper:fixture-universe": "tsx src/harness/build-paper-fixture-universe.ts", + "paper:init-ledgers": "tsx src/harness/initialize-paper-study-ledgers.ts", + "paper:claude-readiness": "tsx src/harness/verify-claude-paper-runtime.ts", + "paper:report": "tsx src/harness/write-paper-study-report.ts", + "capture:readiness": "tsx src/harness/build-capture-readiness.ts", + "capture:validate": "tsx src/harness/validate-capture-configs.ts", + "receipt:verify": "tsx src/proof/verify-decision-receipt.ts", + "anchor:prepare": "tsx src/chain/prepare-decision-anchor.ts", + "anchor:submit": "tsx src/chain/submit-decision-anchor.ts", + "anchor:verify": "tsx src/chain/verify-decision-anchor.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.111.0", "@duckdb/node-api": "1.5.4-r.1", + "@fontsource/ibm-plex-mono": "^5.2.6", + "@fontsource/manrope": "^5.2.6", + "@solana/web3.js": "^1.98.4", "better-sqlite3": "^12.11.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", "stream-json": "^3.5.0", "ws": "^8.21.0", "zod": "^4.4.3" @@ -32,10 +62,14 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.20.1", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", "@types/stream-json": "^1.7.8", "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^5.0.4", "tsx": "^4.20.6", "typescript": "^5.9.3", + "vite": "^7.1.7", "vitest": "^4.1.10" }, "pnpm": { diff --git a/phase0/package.json b/phase0/package.json index 3ba7f5e..9f9d12d 100644 --- a/phase0/package.json +++ b/phase0/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "test": "tsx --test test/*.test.ts", "airdrop:devnet": "tsx src/devnet-airdrop.ts", "auth:devnet": "tsx src/txline-auth.ts --network devnet", "auth:mainnet": "tsx src/txline-auth.ts --network mainnet", diff --git a/phase0/src/capture-sse.ts b/phase0/src/capture-sse.ts index d6120ff..deebfff 100644 --- a/phase0/src/capture-sse.ts +++ b/phase0/src/capture-sse.ts @@ -1,5 +1,7 @@ +import { once } from "node:events"; import { createWriteStream } from "node:fs"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { appendJsonl, authHeaders, @@ -42,93 +44,117 @@ function parseSseBlock(block: string): ParsedSse | null { return message.data || message.event || message.id ? message : null; } -async function captureStream(options: { +export type CaptureStreamOptions = { network: string; streamName: "odds" | "scores"; url: string; headers: Record; outDir: string; deadline: number; -}): Promise { + fetchImpl?: typeof fetch; + reconnectDelayMs?: number; +}; + +export async function captureStream(options: CaptureStreamOptions): Promise { let lastEventId = ""; let reconnect = 0; + const fetchImpl = options.fetchImpl ?? fetch; + const reconnectDelayMs = options.reconnectDelayMs ?? 2_000; const raw = createWriteStream(join(options.outDir, `${options.streamName}.raw.sse`), { flags: "a" }); const framesPath = join(options.outDir, `${options.streamName}.frames.ndjson`); const reconnectPath = join(options.outDir, "reconnects.ndjson"); - while (Date.now() < options.deadline) { - const headers: Record = { - ...options.headers, - Accept: "text/event-stream", - "Cache-Control": "no-cache", - "Accept-Encoding": "gzip" - }; - if (lastEventId) headers["Last-Event-ID"] = lastEventId; - - await appendJsonl(reconnectPath, { - at: new Date().toISOString(), - stream: options.streamName, - reconnect, - lastEventId: lastEventId || null, - action: "connect" - }); + try { + while (Date.now() < options.deadline) { + const headers: Record = { + ...options.headers, + Accept: "text/event-stream", + "Cache-Control": "no-cache", + "Accept-Encoding": "gzip" + }; + if (lastEventId) headers["Last-Event-ID"] = lastEventId; - try { - const response = await fetch(options.url, { headers }); - if (!response.ok || !response.body) { - const text = await response.text(); - throw new Error(`${options.streamName} stream failed ${response.status}: ${text.slice(0, 300)}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (Date.now() < options.deadline) { - const { value, done } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); - raw.write(chunk); - buffer += chunk; - let separator = buffer.match(/\r?\n\r?\n/); - while (separator?.index !== undefined) { - const receivedAt = new Date(); - const receivedAtUnixNs = `${BigInt(receivedAt.getTime()) * 1_000_000n}`; - const receivedAtMonotonicNs = nowNs(); - const rawFrame = buffer.slice(0, separator.index); - buffer = buffer.slice(separator.index + separator[0].length); - const parsed = parseSseBlock(rawFrame); - if (parsed?.id) lastEventId = parsed.id; - await appendJsonl(framesPath, { - receivedAt: receivedAt.toISOString(), - receivedAtUnixNs, - receivedAtMonotonicNs, - stream: options.streamName, - lastEventId: parsed?.id ?? null, - event: parsed?.event ?? null, - rawFrame - }); - separator = buffer.match(/\r?\n\r?\n/); - } - } - reader.releaseLock(); - } catch (error) { await appendJsonl(reconnectPath, { at: new Date().toISOString(), stream: options.streamName, reconnect, lastEventId: lastEventId || null, - action: "disconnect", - error: error instanceof Error ? error.message : String(error) + action: "connect" }); - await new Promise((resolve) => setTimeout(resolve, 2_000)); + + const controller = new AbortController(); + const deadlineTimer = setTimeout( + () => controller.abort(new Error("capture deadline reached")), + Math.max(1, options.deadline - Date.now()) + ); + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await fetchImpl(options.url, { headers, signal: controller.signal }); + if (!response.ok || !response.body) { + const text = await response.text(); + throw new Error(`${options.streamName} stream failed ${response.status}: ${text.slice(0, 300)}`); + } + + reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (Date.now() < options.deadline) { + const { value, done } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + raw.write(chunk); + buffer += chunk; + let separator = buffer.match(/\r?\n\r?\n/); + while (separator?.index !== undefined) { + const receivedAt = new Date(); + const receivedAtUnixNs = `${BigInt(receivedAt.getTime()) * 1_000_000n}`; + const receivedAtMonotonicNs = nowNs(); + const rawFrame = buffer.slice(0, separator.index); + buffer = buffer.slice(separator.index + separator[0].length); + const parsed = parseSseBlock(rawFrame); + if (parsed?.id) lastEventId = parsed.id; + await appendJsonl(framesPath, { + receivedAt: receivedAt.toISOString(), + receivedAtUnixNs, + receivedAtMonotonicNs, + stream: options.streamName, + lastEventId: parsed?.id ?? null, + event: parsed?.event ?? null, + rawFrame + }); + separator = buffer.match(/\r?\n\r?\n/); + } + } + } catch (error) { + const expectedDeadline = controller.signal.aborted && Date.now() >= options.deadline; + if (!expectedDeadline) { + await appendJsonl(reconnectPath, { + at: new Date().toISOString(), + stream: options.streamName, + reconnect, + lastEventId: lastEventId || null, + action: "disconnect", + error: error instanceof Error ? error.message : String(error) + }); + const remainingMs = Math.max(0, options.deadline - Date.now()); + await new Promise((resolve) => setTimeout(resolve, Math.min(reconnectDelayMs, remainingMs))); + } + } finally { + clearTimeout(deadlineTimer); + if (reader) { + await reader.cancel("capture ended").catch(() => undefined); + reader.releaseLock(); + } + } + reconnect += 1; } - reconnect += 1; + } finally { + raw.end(); + await once(raw, "finish"); } - - raw.end(); } -async function main(): Promise { +export async function main(): Promise { const args = parseArgs(); const network = getNetwork(args); const token = await loadToken(network); @@ -181,7 +207,10 @@ async function main(): Promise { console.log(`SSE capture complete: ${outDir}`); } -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); -}); +const entryPoint = process.argv[1]; +if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/phase0/test/capture-sse.test.ts b/phase0/test/capture-sse.test.ts new file mode 100644 index 0000000..c06d731 --- /dev/null +++ b/phase0/test/capture-sse.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { captureStream } from "../src/capture-sse.js"; + +test("captureStream aborts an open SSE response at the deadline", async () => { + const directory = await mkdtemp(join(tmpdir(), "samaritan-sse-deadline-")); + const server = createServer((_request, response) => { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache" + }); + response.write('id: event-1\ndata: {"Ts":1}\n\n'); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP test server"); + + const startedAt = Date.now(); + try { + await captureStream({ + network: "mainnet", + streamName: "odds", + url: `http://127.0.0.1:${address.port}`, + headers: {}, + outDir: directory, + deadline: startedAt + 100, + reconnectDelayMs: 1 + }); + + assert.ok(Date.now() - startedAt < 1_000, "capture should not wait for the server to close"); + const frames = await readFile(join(directory, "odds.frames.ndjson"), "utf8"); + assert.match(frames, /event-1/); + const reconnects = await readFile(join(directory, "reconnects.ndjson"), "utf8"); + assert.doesNotMatch(reconnects, /"action":"disconnect"/); + } finally { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/plan.md b/plan.md index 4f7b6fa..50eed67 100644 --- a/plan.md +++ b/plan.md @@ -63,7 +63,7 @@ The docs only tell us so much; now we touch the real thing. - [ ] Detector bank v1: `CONSENSUS_MOVE`, `XMARKET_DIVERGENCE`, `FADER_CANDIDATE`, `MODEL_MARKET_GAP`, `STALE_QUOTE` (paper/live measurement only) - [x] Replay engine: timestamp-merge and re-emit historical sources onto the bus at configurable speed - [x] Dynamic main-total research harness: build per-fixture balance/volume/liquidity/coverage evidence and rank exact full-time lines under injected weights; production rule remains unfrozen until Phase 3 -- [ ] Detector metrics harness: streaming threshold grids and forward convergence/reversal labels are implemented; score-event labels and STALE latency labels await the synchronized capture +- [x] Detector metrics harness: streaming threshold grids, forward convergence/reversal labels, and outage-aware score-event/book latency labels are implemented; Spain-Belgium produced no supporting STALE_QUOTE case, so the detector remains disabled **Exit criteria:** detectors firing sensibly on replayed group-stage matches, with dashboards-in-terminal output. @@ -71,25 +71,38 @@ The docs only tell us so much; now we touch the real thing. The mandatory study before any real-money trading. Deliverable is a written report (`docs/06-gate-study.md`): -- [ ] Historical lane: align 5-minute TXLine StablePrice with one-minute Polymarket sampled prices; measure divergence, convergence, signal CLV, continuation/reversal, and FADER behavior with train/test separation -- [ ] Freeze a dynamic main-full-time-total selection rule from captured evidence; never hard-code O/U 2.5 -- [ ] Apply conservative historical fee/spread/slippage proxies and label all historical results as signal research, not executable-fill proof -- [ ] Live lane: use synchronized TXLine SSE + Polymarket books to measure feed availability, event→repricing lag, spread/depth, and STALE_QUOTE feasibility; in-play stays paper-only -- [ ] Tune `CONSENSUS_MOVE`, XMARKET, and FADER thresholds on replay; report precision/recall and sample sizes +- [x] Historical lane: timestamp-align TXLine StablePrice with Polymarket sampled prices; measure divergence, convergence, signal CLV, continuation/reversal, and FADER behavior with chronological train/test separation +- [x] Preserve and suspend the invalidated v1 paper protocol; corrected causal selector and execution timing now exist only in an unregistered v2 engineering candidate pending Deborah sign-off +- [x] Apply conservative historical fee/spread/slippage proxies and label all historical results as signal research, not executable-fill proof +- [x] Live lane: synchronized Spain-Belgium TXLine SSE + Polymarket books measured feed availability, event→repricing lag, spread/depth, and STALE_QUOTE feasibility; 0/18 cases showed a clean post-TXLine stale window, so in-play remains paper-only and STALE_QUOTE stays disabled +- [x] Tune `CONSENSUS_MOVE`, XMARKET, and FADER threshold candidates on training replay; report sealed heldout precision/recall, CLV, market-family attribution, cost sensitivity, and sample sizes without freezing them - [ ] MODELER calibration baseline: fit the in-play goal model on group stage, report Brier vs market - [ ] **Go/No-Go review with Deborah:** which pre-match edges are real; set per-trade and drawdown caps within the locked $50 bankroll / $15 initial aggregate-exposure ceiling +- [ ] Run the signed two-lane paper validation in `docs/07-paper-study-preregistration.md` (`$3` stake, `$15` exposure, `$20` rejection stop; executable CLV, match-clustered CI, per-match P&L; real-money gate stays closed) **Exit criteria:** signed-off study; thresholds frozen for v1; real-money numbers agreed and written into config. ### Phase 4 — Claude reasoning layer (Jul 14–15) -- [ ] Trade-thesis schema (strict) — the only bridge from AI to money -- [ ] Triage agent (Haiku 4.5): dedupe/classify/drop, one-line rationale -- [ ] Analyst agent (Opus 4.8, adaptive thinking, prompt-cached context): tools = `query_series`, `get_match_state`, `web_search`, `get_polymarket_book`, `submit_thesis` -- [ ] Risk manager: deterministic rule layer (caps, quarter-Kelly, correlation buckets, drawdown breakers, kill switch) + agent review pass; veto logged with reasons -- [ ] Decision ledger: append-only record of every signal → triage → thesis → veto/approval → fill -- [ ] Paper execution adapter (slippage-modeled fills) -- [ ] Spend controls: $200 operating target, $300 hard project ceiling; fail to detectors-only mode rather than exceed it +- [x] Trade-thesis schema (strict) — the only bridge from AI to deterministic risk; unknown sizing/order fields are rejected +- [x] Triage agent (Haiku 4.5): bounded strict-tool classify/drop/escalate with one-line rationale +- [x] Analyst agent bounty slice: Opus 4.8 adaptive thinking over a bounded code-assembled signal bundle; strict `submit_thesis` is the only tool/exit +- [ ] Analyst pull-tools extension: `query_series`, `get_match_state`, `web_search`, and `get_polymarket_book` remain unimplemented and are not submission claims +- [ ] Risk manager: signed paper caps/drawdown/market/evidence rules and settled portfolio state are implemented; quarter-Kelly, correlation buckets, global kill switch, and agent judgment pass remain +- [x] Decision ledger: append-only hash-chained record of every signal → triage → thesis → veto/approval → execution intent → paper result +- [x] Paper execution adapter: deterministic limit, post-latency canonical book, depth walking, partial/no fills, per-market fees, micro-unit accounting +- [x] Paper case scheduler/runtime: pre-model lane-specific ledgering, measured wall-clock decision latency mapped to event time, frozen detector/selector config, 15-minute kickoff cutoff, first eligible post-decision canonical book, duplicate suppression, terminal expiry +- [x] Public CLOB execution metadata: cached V2 fee curve, taker-only flag, minimum shares, tick size, condition/token identity; malformed or legacy-only metadata fails closed +- [x] Paper portfolio evidence: actual entry cost, midpoint/executable CLV, settlement P&L/Brier, exposure, equity peak, and drawdown state +- [x] Kickoff/settlement lifecycle: latest canonical pre-kickoff close, public winning-asset resolution, and pre-mutation append-only accounting +- [x] Registered evidence evaluator: ledger reconstruction, sealed long-run endpoints, per-match rows, 10,000-iteration match-block bootstrap, random-direction control, and guardrails +- [x] Preserve the v1 two-lane zero-observation ledgers as audit history; do not append under the unregistered v2 candidate +- [x] Fixture evidence/readiness gates: paired-book vs sampled-history classification, physical scheduler exclusion, observed-time captured replay merge, and three-hour pre-kickoff capture target +- [x] Semifinal capture candidates: authenticated read-only TXLine refresh, exact France-Spain / England-Argentina cross-source identity, full-time totals rules check, and launch withheld pending human capture-only confirmation +- [x] Semifinal capture schedule: Deborah capture-only confirmation, fail-closed starts three hours pre-kickoff, detached process checks, and post-run canonical replay verification +- [x] Rolling fixture admission: atomic two-lane refresh, verified mapping + paired executable evidence, immutable admitted identity, and pre-Claude research-only rejection +- [x] Evidence artifact writer: ledger-derived JSON + preregistered per-match Markdown table with strict long-run sealing +- [x] Spend controls: shared append-only reservations, $200 operating target, $300 pre-request hard project ceiling, and fail-closed API accounting **Exit criteria:** end-to-end on replay — signal fires, Haiku triages, Opus writes a thesis, risk manager rules, paper fill lands, ledger records all of it. @@ -107,7 +120,7 @@ The mandatory study before any real-money trading. Deliverable is a written repo - [ ] Ledger anchoring: hash ledger segments → Solana memo tx per match/hour; explorer links surfaced - [ ] Merkle spot-validation of TXLine data we acted on (validation endpoints) -- [ ] Dashboard (the judge surface): live board, signal tape, agent reasoning feed (triage→thesis→veto visible), tournament leaderboard, CLV/calibration curves, verification panel (message IDs, proofs, anchor txs) +- [ ] Dashboard (the judge surface), implemented against the approved Matchroom v2 dark-hybrid direction in [`docs/UI.md`](docs/UI.md): Command, Matchroom, Decision Rail, Casebook, Study, and Proof; live/replay state, triage -> thesis -> veto/approval, CLV/calibration, and verification remain visibly auditable. Light mode is deferred and non-blocking. - [ ] Hosted deployment: credentials server-side, judges get a URL, read-only **Exit criteria:** a stranger with a link can watch Samaritan think during a live match. @@ -137,5 +150,6 @@ The mandatory study before any real-money trading. Deliverable is a written repo ## Locked and pending from Deborah - **Locked:** Match Result + dynamic main full-time O/U only; Claude $200 target/$300 hard ceiling; $50 total bankroll; $15 maximum initial aggregate live exposure; daily lesson review; concurrency 3; nightly batch post-mortems. -- **Pending at Phase 3:** per-trade cap and drawdown halts; approval of any real-money edge. +- **Suspended July 14:** the v1 totals-only paper protocol is preserved but cannot admit new cases after its upstream selector was found to use future information. The `$3` stake, `$15` aggregate exposure, `$20` drawdown rejection stop, and separate ledgers remain historical v1 configuration only until a corrected protocol is signed. +- **Pending after paper ACCEPT:** separate approval of any real-money edge and its real-money caps; paper approval does not carry over. - **Pending per live mapping:** human confirmation of teams, kickoff, market period, and full Polymarket rules text. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a785e9a..fc4b792 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,30 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.111.0 + version: 0.111.0(zod@4.4.3) '@duckdb/node-api': specifier: 1.5.4-r.1 version: 1.5.4-r.1 + '@fontsource/ibm-plex-mono': + specifier: ^5.2.6 + version: 5.2.7 + '@fontsource/manrope': + specifier: ^5.2.6 + version: 5.2.8 + '@solana/web3.js': + specifier: ^1.98.4 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) better-sqlite3: specifier: ^12.11.1 version: 12.11.1 + react: + specifier: ^19.1.1 + version: 19.2.7 + react-dom: + specifier: ^19.1.1 + version: 19.2.7(react@19.2.7) stream-json: specifier: ^3.5.0 version: 3.5.0 @@ -30,59 +48,132 @@ importers: '@types/node': specifier: ^22.20.1 version: 22.20.1 + '@types/react': + specifier: ^19.1.10 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.1.7 + version: 19.2.3(@types/react@19.2.17) '@types/stream-json': specifier: ^1.7.8 version: 1.7.8 '@types/ws': specifier: ^8.18.1 version: 8.18.1 + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.2.0(vite@7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0)) tsx: specifier: ^4.20.6 version: 4.23.0 typescript: specifier: ^5.9.3 version: 5.9.3 + vite: + specifier: ^7.1.7 + version: 7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.0)) - - phase0: - dependencies: - '@solana/spl-token': - specifier: ^0.4.13 - version: 0.4.15(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) - '@solana/web3.js': - specifier: ^1.98.4 - version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - dotenv: - specifier: ^17.2.3 - version: 17.4.2 - tweetnacl: - specifier: ^1.0.3 - version: 1.0.3 - ws: - specifier: ^8.21.0 - version: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - devDependencies: - '@types/node': - specifier: ^24.10.0 - version: 24.13.3 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 - tsx: - specifier: ^4.20.6 - version: 4.23.0 - typescript: - specifier: ^5.9.3 - version: 5.9.3 + version: 4.1.10(@types/node@22.20.1)(vite@7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0)) packages: + '@anthropic-ai/sdk@0.111.0': + resolution: {integrity: sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@duckdb/node-api@1.5.4-r.1': resolution: {integrity: sha512-3PYk/4//svuYmb9RYVM71cCoNa6ngoYWwrMhJaqbm010txzIMWbJCbxrFeqDl+krdIug+Hc/MNCeS0gHsTXSIw==} @@ -133,15 +224,6 @@ packages: '@duckdb/node-bindings@1.5.4-r.1': resolution: {integrity: sha512-v834OZZKQ59yAvh8GOEmM+R1foPNgdMGL0VTBgHywgblgQNyq11HvWgT4QGJIUFfo/cQ9WFyf1MFhK0+hV1aiA==} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -298,14 +380,27 @@ packages: cpu: [x64] os: [win32] + '@fontsource/ibm-plex-mono@5.2.7': + resolution: {integrity: sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==} + + '@fontsource/manrope@5.2.8': + resolution: {integrity: sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} @@ -315,159 +410,163 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] + libc: [musl] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] os: [win32] - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] - '@solana/buffer-layout-utils@0.3.0': - resolution: {integrity: sha512-MuQOCC1j0np1xH9yAv0ZWWfwvr7Bt7Sz4LId11Wi4wDdAmJ+lobE+vHg/mZmGcihF0BIkqVBNxGmlv8QE5DrtA==} - engines: {node: '>= 10'} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] '@solana/buffer-layout@4.0.1': resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} engines: {node: '>=5.10'} - '@solana/codecs-core@2.0.0-rc.1': - resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==} - peerDependencies: - typescript: '>=5' - '@solana/codecs-core@2.3.0': resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} engines: {node: '>=20.18.0'} peerDependencies: typescript: '>=5.3.3' - '@solana/codecs-data-structures@2.0.0-rc.1': - resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==} - peerDependencies: - typescript: '>=5' - - '@solana/codecs-numbers@2.0.0-rc.1': - resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==} - peerDependencies: - typescript: '>=5' - '@solana/codecs-numbers@2.3.0': resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} engines: {node: '>=20.18.0'} peerDependencies: typescript: '>=5.3.3' - '@solana/codecs-strings@2.0.0-rc.1': - resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==} - peerDependencies: - fastestsmallesttextencoderdecoder: ^1.0.22 - typescript: '>=5' - - '@solana/codecs@2.0.0-rc.1': - resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==} - peerDependencies: - typescript: '>=5' - - '@solana/errors@2.0.0-rc.1': - resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==} - hasBin: true - peerDependencies: - typescript: '>=5' - '@solana/errors@2.3.0': resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} engines: {node: '>=20.18.0'} @@ -475,40 +574,29 @@ packages: peerDependencies: typescript: '>=5.3.3' - '@solana/options@2.0.0-rc.1': - resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==} - peerDependencies: - typescript: '>=5' - - '@solana/spl-token-group@0.0.7': - resolution: {integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==} - engines: {node: '>=16'} - peerDependencies: - '@solana/web3.js': ^1.95.3 - - '@solana/spl-token-metadata@0.1.6': - resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==} - engines: {node: '>=16'} - peerDependencies: - '@solana/web3.js': ^1.95.3 - - '@solana/spl-token@0.4.15': - resolution: {integrity: sha512-3Lof3mNov8NVQ3PalIWb1Jgr/TZ6lYM+/sexv2TLqdhNFVth2OfWmH3d7QucgMjSbokkjNiNlRr6I8Fd269uaw==} - engines: {node: '>=16'} - peerDependencies: - '@solana/web3.js': ^1.95.5 - '@solana/web3.js@1.98.4': resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -534,6 +622,14 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/stream-chain@2.1.0': resolution: {integrity: sha512-guDyAl6s/CAzXUOWpGK2bHvdiopLIwpGu8v10+lb9hnQOyo4oj/ZUQFOvqFjKGsE3wJP1fpIesCcMvbXuWsqOg==} @@ -549,6 +645,12 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -592,17 +694,15 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + better-sqlite3@12.11.1: resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} - bigint-buffer@1.1.5: - resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==} - engines: {node: '>= 10.0.0'} - - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -615,6 +715,11 @@ packages: borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bs58@4.0.1: resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} @@ -628,6 +733,9 @@ packages: resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} + caniuse-lite@1.0.30001805: + resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -639,10 +747,6 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -653,6 +757,18 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -669,9 +785,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} + electron-to-chromium@1.5.389: + resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -690,6 +805,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -708,12 +827,12 @@ packages: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-stable-stringify@1.0.0: resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} - fastestsmallesttextencoderdecoder@1.0.22: - resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -734,6 +853,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -759,9 +882,26 @@ packages: engines: {node: '>=8'} hasBin: true + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -836,6 +976,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -877,6 +1020,10 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -911,13 +1058,26 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} - engines: {node: ^20.19.0 || >=22.12.0} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true rpc-websockets@9.3.9: @@ -926,6 +1086,13 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -947,6 +1114,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -1003,6 +1173,9 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1014,9 +1187,6 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1028,6 +1198,12 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + utf-8-validate@6.0.6: resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} engines: {node: '>=6.14.2'} @@ -1044,16 +1220,15 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true - vite@8.1.4: - resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 - esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 + lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -1064,14 +1239,12 @@ packages: peerDependenciesMeta: '@types/node': optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true jiti: optional: true less: optional: true + lightningcss: + optional: true sass: optional: true sass-embedded: @@ -1166,13 +1339,135 @@ packages: utf-8-validate: optional: true + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: + '@anthropic-ai/sdk@0.111.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@duckdb/node-api@1.5.4-r.1': dependencies: '@duckdb/node-bindings': 1.5.4-r.1 @@ -1214,22 +1509,6 @@ snapshots: '@duckdb/node-bindings-win32-arm64': 1.5.4-r.1 '@duckdb/node-bindings-win32-x64': 1.5.4-r.1 - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true @@ -1308,14 +1587,28 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@fontsource/ibm-plex-mono@5.2.7': {} + + '@fontsource/manrope@5.2.8': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@jridgewell/trace-mapping@0.3.31': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 '@noble/curves@1.9.7': dependencies: @@ -1323,177 +1616,104 @@ snapshots: '@noble/hashes@1.8.0': {} - '@oxc-project/types@0.139.0': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rolldown/binding-android-arm64@1.1.5': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true - '@solana/buffer-layout-utils@0.3.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': - dependencies: - '@solana/buffer-layout': 4.0.1 - '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - bigint-buffer: 1.1.5 - bignumber.js: 9.3.1 - transitivePeerDependencies: - - bufferutil - - encoding - - typescript - - utf-8-validate + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true '@solana/buffer-layout@4.0.1': dependencies: buffer: 6.0.3 - '@solana/codecs-core@2.0.0-rc.1(typescript@5.9.3)': - dependencies: - '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) - typescript: 5.9.3 - '@solana/codecs-core@2.3.0(typescript@5.9.3)': dependencies: '@solana/errors': 2.3.0(typescript@5.9.3) typescript: 5.9.3 - '@solana/codecs-data-structures@2.0.0-rc.1(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) - '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) - typescript: 5.9.3 - - '@solana/codecs-numbers@2.0.0-rc.1(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) - '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) - typescript: 5.9.3 - '@solana/codecs-numbers@2.3.0(typescript@5.9.3)': dependencies: '@solana/codecs-core': 2.3.0(typescript@5.9.3) '@solana/errors': 2.3.0(typescript@5.9.3) typescript: 5.9.3 - '@solana/codecs-strings@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) - '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) - fastestsmallesttextencoderdecoder: 1.0.22 - typescript: 5.9.3 - - '@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - '@solana/errors@2.0.0-rc.1(typescript@5.9.3)': - dependencies: - chalk: 5.6.2 - commander: 12.1.0 - typescript: 5.9.3 - '@solana/errors@2.3.0(typescript@5.9.3)': dependencies: chalk: 5.6.2 commander: 14.0.3 typescript: 5.9.3 - '@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': - dependencies: - '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) - '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - '@solana/spl-token-group@0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': - dependencies: - '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - typescript - - '@solana/spl-token-metadata@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': - dependencies: - '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - transitivePeerDependencies: - - fastestsmallesttextencoderdecoder - - typescript - - '@solana/spl-token@0.4.15(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)': - dependencies: - '@solana/buffer-layout': 4.0.1 - '@solana/buffer-layout-utils': 0.3.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - '@solana/spl-token-group': 0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/spl-token-metadata': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - buffer: 6.0.3 - transitivePeerDependencies: - - bufferutil - - encoding - - fastestsmallesttextencoderdecoder - - typescript - - utf-8-validate - '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': dependencies: '@babel/runtime': 7.29.7 @@ -1517,16 +1737,34 @@ snapshots: - typescript - utf-8-validate + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 - '@tybys/wasm-util@0.10.3': + '@types/babel__core@7.20.5': dependencies: - tslib: 2.8.1 - optional: true + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 '@types/better-sqlite3@7.6.13': dependencies: @@ -1555,6 +1793,14 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/stream-chain@2.1.0': dependencies: '@types/node': 24.13.3 @@ -1574,6 +1820,18 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -1583,13 +1841,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.0))': + '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.0) + vite: 7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -1627,17 +1885,13 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.43: {} + better-sqlite3@12.11.1: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 - bigint-buffer@1.1.5: - dependencies: - bindings: 1.5.0 - - bignumber.js@9.3.1: {} - bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -1656,6 +1910,14 @@ snapshots: bs58: 4.0.1 text-encoding-utf-8: 1.0.2 + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001805 + electron-to-chromium: 1.5.389 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + bs58@4.0.1: dependencies: base-x: 3.0.11 @@ -1675,20 +1937,26 @@ snapshots: node-gyp-build: 4.8.4 optional: true + caniuse-lite@1.0.30001805: {} + chai@6.2.2: {} chalk@5.6.2: {} chownr@1.1.4: {} - commander@12.1.0: {} - commander@14.0.3: {} commander@2.20.3: {} convert-source-map@2.0.0: {} + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -1699,7 +1967,7 @@ snapshots: detect-libc@2.1.2: {} - dotenv@17.4.2: {} + electron-to-chromium@1.5.389: {} end-of-stream@1.4.5: dependencies: @@ -1742,6 +2010,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1754,9 +2024,9 @@ snapshots: eyes@0.1.8: {} - fast-stable-stringify@1.0.0: {} + fast-sha256@1.3.0: {} - fastestsmallesttextencoderdecoder@1.0.22: {} + fast-stable-stringify@1.0.0: {} fdir@6.5.0(picomatch@4.0.5): optionalDependencies: @@ -1769,6 +2039,8 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + github-from-package@0.0.0: {} humanize-ms@1.2.1: @@ -1803,8 +2075,19 @@ snapshots: - bufferutil - utf-8-validate + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1853,6 +2136,11 @@ snapshots: lightningcss-linux-x64-musl: 1.32.0 lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + optional: true + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 magic-string@0.30.21: dependencies: @@ -1881,6 +2169,8 @@ snapshots: node-gyp-build@4.8.4: optional: true + node-releases@2.0.51: {} + obug@2.1.3: {} once@1.4.0: @@ -1926,32 +2216,51 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react@19.2.7: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - rolldown@1.1.5: + rollup@4.62.2: dependencies: - '@oxc-project/types': 0.139.0 - '@rolldown/pluginutils': 1.0.1 + '@types/estree': 1.0.9 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 rpc-websockets@9.3.9: dependencies: @@ -1968,6 +2277,10 @@ snapshots: safe-buffer@5.2.1: {} + scheduler@0.27.0: {} + + semver@6.3.1: {} + semver@7.8.5: {} siginfo@2.0.0: {} @@ -1984,6 +2297,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@4.2.0: {} stream-chain@2.2.5: {} @@ -2036,6 +2354,8 @@ snapshots: tr46@0.0.3: {} + ts-algebra@2.0.0: {} + tslib@2.8.1: {} tsx@4.23.0: @@ -2048,14 +2368,18 @@ snapshots: dependencies: safe-buffer: 5.2.1 - tweetnacl@1.0.3: {} - typescript@5.9.3: {} undici-types@6.21.0: {} undici-types@7.18.2: {} + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + utf-8-validate@6.0.6: dependencies: node-gyp-build: 4.8.4 @@ -2067,23 +2391,24 @@ snapshots: uuid@8.3.2: {} - vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.0): + vite@7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0): dependencies: - lightningcss: 1.32.0 + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 postcss: 8.5.16 - rolldown: 1.1.5 + rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 - esbuild: 0.28.1 fsevents: 2.3.3 + lightningcss: 1.32.0 tsx: 4.23.0 - vitest@4.1.10(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.0)): + vitest@4.1.10(@types/node@22.20.1)(vite@7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.0)) + '@vitest/mocker': 4.1.10(vite@7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2100,7 +2425,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.0) + vite: 7.3.6(@types/node@22.20.1)(lightningcss@1.32.0)(tsx@4.23.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 @@ -2131,4 +2456,6 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 6.0.6 + yallist@3.1.1: {} + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ed70373..d05a7e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,2 @@ packages: - . - - phase0 diff --git a/proof/fixtures/decision-receipt.synthetic.v1.json b/proof/fixtures/decision-receipt.synthetic.v1.json new file mode 100644 index 0000000..73e28a3 --- /dev/null +++ b/proof/fixtures/decision-receipt.synthetic.v1.json @@ -0,0 +1,435 @@ +{ + "schemaVersion": 1, + "receiptType": "samaritan_decision_receipt", + "receiptId": "6a5268ce1e18a14b8b07228b74635c4e2337ee40ffa36aefd7a113cf6d174e66", + "generatedAtTsMs": 1700000011000, + "provenance": { + "evidenceClass": "synthetic_proving_fixture", + "synthetic": true, + "performanceUse": "excluded_synthetic", + "label": "SYNTHETIC PROVING FIXTURE — no real match, feed, model call, order, fill, or performance evidence" + }, + "disclosure": { + "policy": "hashes_and_derived_signals_only", + "rawTxlineFieldsIncluded": false + }, + "build": { + "codeVersion": "decision-receipt-v1-synthetic-fixture", + "codeSha256": "b3378a2df0ceefd4d0d65a4dbf7cb86ca259be10be737449956502cc38dd64e9", + "configSha256": "2a862746dc1f07064367445667a530eea645c42d7ea7f0ffaa432ba72d2d674c" + }, + "sourceEvidence": [ + { + "evidenceRefSha256": "3d3b41a2227873d8b21248202f7b4f883272d57ecef8d0bf6a8098da4cf52b94", + "source": "txline", + "role": "signal", + "sourceTsMs": 1699999999936, + "observedAtTsMs": 1700000000000, + "payloadSha256": "078399ecb1eedf610fda560c31c8268d71bd8aa0967847f8d46a15397de973df", + "disclosure": "hash_only" + }, + { + "evidenceRefSha256": "ce70188de2a3ec2c4856b9e7d453b43588ffba88d4f0cf1a54fb15264bc7f400", + "source": "polymarket", + "role": "signal", + "sourceTsMs": 1699999999975, + "observedAtTsMs": 1700000000000, + "payloadSha256": "c0cbf1028f7d99e2c800c770a9f19edd2630fa7b45cd70d8c2c70137a2f87198", + "disclosure": "hash_only" + }, + { + "evidenceRefSha256": "0970a82a4d74742708a5ea39e895d7641ce52f92248629afa9280af952808fb0", + "source": "polymarket", + "role": "execution", + "sourceTsMs": 1700000002436, + "observedAtTsMs": 1700000002500, + "payloadSha256": "dcabb052a305c6f33bf6d019952f4ee8d3e0e57444750ecec411e37b7235fac8", + "disclosure": "hash_only" + }, + { + "evidenceRefSha256": "cd3a2bb43870e3e037901d91818d22e90271a05248aae7871e851c3b9e10419e", + "source": "polymarket", + "role": "close", + "sourceTsMs": 1700000004930, + "observedAtTsMs": 1700000005000, + "payloadSha256": "0c45d0ab522c33c8faa6557bcadbd66f69170e1761d6ec550eb60123553754e4", + "disclosure": "hash_only" + }, + { + "evidenceRefSha256": "807120b1be8e13a10652816d734233b44ea436d2fa190069dc2f72dbb90d2098", + "source": "polymarket", + "role": "settlement", + "sourceTsMs": 1700000009950, + "observedAtTsMs": 1700000010000, + "payloadSha256": "ce14207457515b49d6df821e29d77c2f1820e1b40c359cd5f831d9922ad86ab3", + "disclosure": "hash_only" + } + ], + "agents": { + "runs": [ + { + "stage": "triage", + "invocationClass": "synthetic_stub", + "model": "claude-haiku-4-5", + "promptVersion": "triage-v1", + "promptSha256": "5165e7052e4f78f3584a2bf8deb109fea2aa2298e05618c3ae62c8759ae8f4d1", + "responseSha256": "e306c8b5f4023e537c970fda834c17fe50623f07205c786326e0bea18fc91c20", + "billingEvidenceRefSha256": "44d47f382a3be238d8620750a147cff95aff18ff484e944c974ce5987e0e495f", + "status": "success", + "usage": { + "inputTokens": 0, + "outputTokens": 0, + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0 + }, + "pricing": { + "pricingVersion": "anthropic-public-2026-07-12", + "currency": "nano_usd", + "inputNanoUsdPerToken": 1000, + "outputNanoUsdPerToken": 5000, + "cacheWriteNanoUsdPerToken": 1250, + "cacheReadNanoUsdPerToken": 100 + }, + "actualCostNanoUsd": 0 + }, + { + "stage": "analyst", + "invocationClass": "synthetic_stub", + "model": "claude-opus-4-8", + "promptVersion": "analyst-v1", + "promptSha256": "a19775689dfb6bf5032466a6716c0c0a5e4d8358d931bd7869bd10f4b9682523", + "responseSha256": "1e3d645c1e9e6c1b8f748b6f2a209ddd22699a0623029355830ea1f3638688a9", + "billingEvidenceRefSha256": "47744ac9aab2b40c8deb13f910efc36f61d3204f2157bc06d39a0b502ec6d27e", + "status": "success", + "usage": { + "inputTokens": 0, + "outputTokens": 0, + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0 + }, + "pricing": { + "pricingVersion": "anthropic-public-2026-07-12", + "currency": "nano_usd", + "inputNanoUsdPerToken": 5000, + "outputNanoUsdPerToken": 25000, + "cacheWriteNanoUsdPerToken": 6250, + "cacheReadNanoUsdPerToken": 500 + }, + "actualCostNanoUsd": 0 + } + ], + "totalActualCostNanoUsd": 0 + }, + "lifecycle": { + "finalStatus": "filled_settled", + "orderedEventKinds": [ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "execution_intent", + "paper_execution", + "case_terminal", + "position_opened", + "position_closed", + "position_settled" + ], + "signal": { + "signalRefSha256": "fc61ba747574e3c953a6dd799c50f899300dcf50003fd617639f278d843d882e", + "fixtureRefSha256": "1f57de0cedca3e1851b4c05140408d70769031423aa5aa6a93b22e75b036e762", + "marketRefSha256": "80b77e4246270ab67aae53c0f61c60545f8b738c7dccf88d7969575bfeae7acb", + "lineRefSha256": "fe82e6baa7be838b672749b06987ad38a401fc72651a530e732066bc0daffc86", + "detector": "CONSENSUS_MOVE", + "sourceTsMs": 1700000000000, + "observedAtTsMs": 1700000000000, + "marketFamily": "total_goals", + "marketPeriod": "full_time", + "outcome": "over", + "direction": "buy", + "eligibility": "pretrade_review_required", + "derivedEvidence": { + "txlineMovementBucketBps": 25, + "consensusVelocityBucketBps": 100, + "consensusZScore": 1.5, + "polymarketVelocityBucketBps": 0, + "polymarketZScore": 0, + "cusumUpScore": 0.002, + "cusumDownScore": 0, + "relativeValueDirection": "consensus_above_venue", + "gapBasis": "live_book", + "persistenceMs": 60000, + "mappingStatus": "verified", + "scoreContextActionCount": 0 + }, + "sourceEvidenceRefs": [ + "3d3b41a2227873d8b21248202f7b4f883272d57ecef8d0bf6a8098da4cf52b94", + "ce70188de2a3ec2c4856b9e7d453b43588ffba88d4f0cf1a54fb15264bc7f400" + ], + "ledgerEntryHash": "3a64f4905aea5c1c02f7757cbaf2f7368d0d31e1a176cd66f1f66cc2ccdf7368" + }, + "triage": { + "decision": "escalate", + "priority": "normal", + "rationale": "Synthetic evidence satisfies the proving-fixture escalation path.", + "ledgerEntryHash": "6f3d93978471a7af150ee5c97beafb19ae0924c195ae5ee1c62451943f8d51d8" + }, + "thesis": { + "schemaVersion": 1, + "signalRefSha256": "fc61ba747574e3c953a6dd799c50f899300dcf50003fd617639f278d843d882e", + "fixtureRefSha256": "1f57de0cedca3e1851b4c05140408d70769031423aa5aa6a93b22e75b036e762", + "marketRefSha256": "80b77e4246270ab67aae53c0f61c60545f8b738c7dccf88d7969575bfeae7acb", + "outcome": "over", + "direction": "buy", + "recommendation": "paper_trade", + "disclosure": "private_payload_sha256_commitment", + "thesisPayloadSha256": "ab033dcc7a1f947427915746765c84e69ff0e972fd860e79907e7a7a3a6dc51b", + "submittedAtTsMs": 1700000000300, + "expiresAtTsMs": 1700000900300, + "analystModel": "claude-opus-4-8", + "ledgerEntryHash": "7b7aacab586ae9586697612d6ee755121cd66d432df402e49b4896e0fc4976c4" + }, + "analysis": { + "signalSourceTsMs": 1700000000000, + "signalObservedTsMs": 1700000000000, + "decisionLatencyMs": 300, + "readyAtTsMs": 1700000000300, + "venuePlacementDelayMs": 1000, + "orderEligibleAtTsMs": 1700000001300, + "recommendation": "paper_trade", + "ledgerEntryHash": "893f6021f0a397dea91f50e98911d9526213759c57905aecb2257f6332446d00" + }, + "risk": { + "decision": "approve", + "reasons": [], + "stakeMicroUsd": 3000000, + "limitPolicy": "deterministic_private_fair_value_boundary", + "realMoneyGate": "closed", + "ledgerEntryHash": "8cd81eb389ee4a83289158e814fc0ede93b6e0805a09c1dbd159ddeea586c7c4" + }, + "intent": { + "lane": "bounty", + "signalRefSha256": "fc61ba747574e3c953a6dd799c50f899300dcf50003fd617639f278d843d882e", + "fixtureRefSha256": "1f57de0cedca3e1851b4c05140408d70769031423aa5aa6a93b22e75b036e762", + "marketRefSha256": "80b77e4246270ab67aae53c0f61c60545f8b738c7dccf88d7969575bfeae7acb", + "outcome": "over", + "direction": "buy", + "stakeMicroUsd": 3000000, + "limitPolicy": "deterministic_private_fair_value_boundary", + "availableShares": 0, + "ledgerEntryHash": "212be5d2c3bdd268bf14b7f9545e7b41224baa2fc61199bd7aae36a36f053c35" + }, + "execution": { + "adapter": "paper", + "status": "filled", + "reason": null, + "assetRefSha256": "165750a05c5b4d223975cbfc4c1e6181b052db4bdacd5f93edf445422575e396", + "conditionRefSha256": "2e9568f28fc6d4d1f026500f835047b5e096483bd97d428b78cf187e0bc537a2", + "direction": "buy", + "requestedStakeMicroUsd": 3000000, + "grossMicroUsd": 2930000, + "feeMicroUsd": 70000, + "netConsiderationMicroUsd": 3000000, + "filledShares": 5.5, + "averagePrice": 0.5327272727272727, + "bestPrice": 0.52, + "halfSpreadBps": 100, + "executableDepthUsd": 10, + "slippageProbabilityBps": 127.27272727272698, + "bookSourceTsMs": 1700000002436, + "bookObservedTsMs": 1700000002500, + "bookEvidenceRefSha256": "0970a82a4d74742708a5ea39e895d7641ce52f92248629afa9280af952808fb0", + "feeMetadataRefSha256": "494da64419219a3b8d4694bc01b1f8861f596d2fe62335799c15c5ec7c0fc575", + "ledgerEntryHash": "facca01a6fcafd86b04cd1dccd12167842f12be43ab53e2b7c083d4aafbe43df" + }, + "position": { + "lane": "bounty", + "signalRefSha256": "fc61ba747574e3c953a6dd799c50f899300dcf50003fd617639f278d843d882e", + "fixtureRefSha256": "1f57de0cedca3e1851b4c05140408d70769031423aa5aa6a93b22e75b036e762", + "marketRefSha256": "80b77e4246270ab67aae53c0f61c60545f8b738c7dccf88d7969575bfeae7acb", + "conditionRefSha256": "2e9568f28fc6d4d1f026500f835047b5e096483bd97d428b78cf187e0bc537a2", + "assetRefSha256": "165750a05c5b4d223975cbfc4c1e6181b052db4bdacd5f93edf445422575e396", + "outcome": "over", + "lineRefSha256": "fe82e6baa7be838b672749b06987ad38a401fc72651a530e732066bc0daffc86", + "openedAtTsMs": 1700000002500, + "filledShares": 5.5, + "averageEntryPrice": 0.5327272727272727, + "entryGrossMicroUsd": 2930000, + "entryFeeMicroUsd": 70000, + "entryCostMicroUsd": 3000000, + "fillStatus": "filled", + "entryHalfSpreadBps": 100, + "entrySlippageBps": 127.27272727272698, + "selectedDepthUsd": 10, + "ledgerEntryHash": "6297be56975fc74ea180ee15b23bad116b54236d5f3b3d4c507881dc8edeb084" + }, + "close": { + "cutoffTsMs": 1700000005000, + "markedAtTsMs": 1700000005000, + "bookSourceTsMs": 1700000004930, + "bookObservedTsMs": 1700000005000, + "closeBid": 0.57, + "closeAsk": 0.59, + "closeMidpoint": 0.58, + "grossMidpointClvBps": 472.72727272727246, + "netMidpointClvBps": 345.45454545454544, + "executableLiquidationClvBps": 245.45454545454538, + "bookEvidenceRefSha256": "cd3a2bb43870e3e037901d91818d22e90271a05248aae7871e851c3b9e10419e", + "ledgerEntryHash": "3945a8cf179a117908d99d424d6a1286449cb5571050e810285422c98f3b4259" + }, + "settlement": { + "settledAtTsMs": 1700000010000, + "won": true, + "payoutMicroUsd": 5500000, + "pnlMicroUsd": 2500000, + "returnBps": 8333.333333333334, + "entryBrier": 0.21834380165289258, + "resolutionEvidenceRefSha256": "807120b1be8e13a10652816d734233b44ea436d2fa190069dc2f72dbb90d2098", + "ledgerEntryHash": "ebadd4b57d1e88a24df104f03918fb8b20e86f51fe17ab913b802f7f13bd31a7" + }, + "terminal": { + "status": "filled", + "reason": "filled", + "decisionLatencyMs": null, + "ledgerEntryHash": "ec2db14205262aaa9f4b4c95128db37a389cbe7c33cafd5ba63d0600cb684a62" + } + }, + "ledger": { + "verificationAtGeneration": "full_v2_chain_valid", + "payloadDisclosure": "sha256_commitments_only", + "caseIdSha256": "becca5114c0773541b8cc0f18ffd18c67a140a2e863e5a598f5d68d2fbb9036c", + "rowsAtGeneration": 12, + "finalHeadSequence": 12, + "finalHeadHash": "ebadd4b57d1e88a24df104f03918fb8b20e86f51fe17ab913b802f7f13bd31a7", + "caseEntries": [ + { + "sequence": 2, + "kind": "signal_received", + "atTsMs": 1700000000000, + "insertedAtMs": 1700000000010, + "entryIdSha256": "6c9d8192370ebb7c2a718b0711688e2af9e008d2b4d76cc51fa045e28440da97", + "payloadSha256": "f895ad3acba6a4444dfefc1c704d43aa90eb4d45d24d93d89984a8f12e9b54c3", + "previousHash": "6449eb53a31b87ec0d7b9f49a59801b5dc677be5b48740006139003c6c309c5a", + "entryHash": "3a64f4905aea5c1c02f7757cbaf2f7368d0d31e1a176cd66f1f66cc2ccdf7368", + "hashSchemaVersion": 2 + }, + { + "sequence": 3, + "kind": "triage_decision", + "atTsMs": 1700000000100, + "insertedAtMs": 1700000000110, + "entryIdSha256": "87d0950aa0f9c4604d4a4a9b6c2e05e5745ba6db8a33ab5c702989070ca74b42", + "payloadSha256": "300be11c438b40f10336d7701903465e0f0d684916445996aa599cb45bcb279d", + "previousHash": "3a64f4905aea5c1c02f7757cbaf2f7368d0d31e1a176cd66f1f66cc2ccdf7368", + "entryHash": "6f3d93978471a7af150ee5c97beafb19ae0924c195ae5ee1c62451943f8d51d8", + "hashSchemaVersion": 2 + }, + { + "sequence": 4, + "kind": "thesis_submitted", + "atTsMs": 1700000000300, + "insertedAtMs": 1700000000310, + "entryIdSha256": "58a72d3a0bdedb06f37877fe17db18b92aef7201c7f1243a49676274e5121c27", + "payloadSha256": "ab033dcc7a1f947427915746765c84e69ff0e972fd860e79907e7a7a3a6dc51b", + "previousHash": "6f3d93978471a7af150ee5c97beafb19ae0924c195ae5ee1c62451943f8d51d8", + "entryHash": "7b7aacab586ae9586697612d6ee755121cd66d432df402e49b4896e0fc4976c4", + "hashSchemaVersion": 2 + }, + { + "sequence": 5, + "kind": "analysis_completed", + "atTsMs": 1700000000300, + "insertedAtMs": 1700000000310, + "entryIdSha256": "3b1dc21e93430e1246a0e8ffeeb94825829c85afa99c62ff54bb290a6bd1e4b2", + "payloadSha256": "3189b0911b5e14ed47290521b1eb68fcd2dc03d992ac05b4dfdc3ea14e089854", + "previousHash": "7b7aacab586ae9586697612d6ee755121cd66d432df402e49b4896e0fc4976c4", + "entryHash": "893f6021f0a397dea91f50e98911d9526213759c57905aecb2257f6332446d00", + "hashSchemaVersion": 2 + }, + { + "sequence": 6, + "kind": "risk_verdict", + "atTsMs": 1700000002500, + "insertedAtMs": 1700000002510, + "entryIdSha256": "1941c96d23ce7aa3395a1da9922bbbb29637b71253a764d4d690debaf3194cd6", + "payloadSha256": "72951bf422061213048536dc8b3c85d4109f0f1b6bfb218f1667324c53933a46", + "previousHash": "893f6021f0a397dea91f50e98911d9526213759c57905aecb2257f6332446d00", + "entryHash": "8cd81eb389ee4a83289158e814fc0ede93b6e0805a09c1dbd159ddeea586c7c4", + "hashSchemaVersion": 2 + }, + { + "sequence": 7, + "kind": "execution_intent", + "atTsMs": 1700000002500, + "insertedAtMs": 1700000002510, + "entryIdSha256": "966bf1628d1cb4a27d8d108aa8a51d45a16cd1407b0484a7e9d301c1ea637999", + "payloadSha256": "095dab4ca494ffe658df7a40270d7dbb2b75ac477b29690fdcf3c36e9caade70", + "previousHash": "8cd81eb389ee4a83289158e814fc0ede93b6e0805a09c1dbd159ddeea586c7c4", + "entryHash": "212be5d2c3bdd268bf14b7f9545e7b41224baa2fc61199bd7aae36a36f053c35", + "hashSchemaVersion": 2 + }, + { + "sequence": 8, + "kind": "paper_execution", + "atTsMs": 1700000002500, + "insertedAtMs": 1700000002510, + "entryIdSha256": "37a8d3105fc423efed982b005439833253f1b93a66804cec9dcf3dc9f7d1db08", + "payloadSha256": "324f7b7da1190467c8f5fbe4ec5ed5e381d85c7bd216464fcc778da794a89367", + "previousHash": "212be5d2c3bdd268bf14b7f9545e7b41224baa2fc61199bd7aae36a36f053c35", + "entryHash": "facca01a6fcafd86b04cd1dccd12167842f12be43ab53e2b7c083d4aafbe43df", + "hashSchemaVersion": 2 + }, + { + "sequence": 9, + "kind": "case_terminal", + "atTsMs": 1700000002500, + "insertedAtMs": 1700000002510, + "entryIdSha256": "ed60575ca0a5c9123fd8ef9cb59c314f051ed89d4bc5157addc71bf2c652bfa6", + "payloadSha256": "a279a2a5d4bef14c5f523f7145bcdbd762a9e4ff6bee856ccf4d3214bacc4ead", + "previousHash": "facca01a6fcafd86b04cd1dccd12167842f12be43ab53e2b7c083d4aafbe43df", + "entryHash": "ec2db14205262aaa9f4b4c95128db37a389cbe7c33cafd5ba63d0600cb684a62", + "hashSchemaVersion": 2 + }, + { + "sequence": 10, + "kind": "position_opened", + "atTsMs": 1700000002500, + "insertedAtMs": 1700000002510, + "entryIdSha256": "68b0696dffcec92707ae1ea69bb2637f847c2783f8537fc35f78b62733d7be3d", + "payloadSha256": "424243b8b05516adb7e51afaced01b0cde62687dd39fb073a66f2974778dbf10", + "previousHash": "ec2db14205262aaa9f4b4c95128db37a389cbe7c33cafd5ba63d0600cb684a62", + "entryHash": "6297be56975fc74ea180ee15b23bad116b54236d5f3b3d4c507881dc8edeb084", + "hashSchemaVersion": 2 + }, + { + "sequence": 11, + "kind": "position_closed", + "atTsMs": 1700000005000, + "insertedAtMs": 1700000005010, + "entryIdSha256": "6c93e6fc6e7ca5da3c581fff3cd76667a94c998d7a74383bea0521143fbf7fd5", + "payloadSha256": "265c994a18a9a07af46b05896b67aea15953a7e608cf192bfe4023fa087536c8", + "previousHash": "6297be56975fc74ea180ee15b23bad116b54236d5f3b3d4c507881dc8edeb084", + "entryHash": "3945a8cf179a117908d99d424d6a1286449cb5571050e810285422c98f3b4259", + "hashSchemaVersion": 2 + }, + { + "sequence": 12, + "kind": "position_settled", + "atTsMs": 1700000010000, + "insertedAtMs": 1700000010010, + "entryIdSha256": "6e4fc49c621a5b8ec1f25ec148177769ba555797f8f79c235ded44212aafd87a", + "payloadSha256": "7bd702c62fe1074025ac0f969eeeff947e700bb66b997221546c183b16179fcb", + "previousHash": "3945a8cf179a117908d99d424d6a1286449cb5571050e810285422c98f3b4259", + "entryHash": "ebadd4b57d1e88a24df104f03918fb8b20e86f51fe17ab913b802f7f13bd31a7", + "hashSchemaVersion": 2 + } + ] + }, + "solanaAnchor": null, + "integrity": { + "algorithm": "sha256", + "canonicalization": "samaritan-stable-json-v1", + "domain": "samaritan.decision-receipt/v1", + "receiptHash": "5ecddb4707e2d95a231ac8039ea27c318f9f4159da77d4e33a88c4897bd21207" + } +} diff --git a/public/artifacts/dashboard/casebook.json b/public/artifacts/dashboard/casebook.json new file mode 100644 index 0000000..7511091 --- /dev/null +++ b/public/artifacts/dashboard/casebook.json @@ -0,0 +1 @@ +{"data":{"cases":[{"awayCode":"BEL","caseId":"ESP-BEL-01","detector":"STALE_QUOTE_FEASIBILITY","disposition":"No trade","evidenceLane":"Research only","executionOutcome":"Not executed","fixtureId":"18218149","fixtureLabel":"Spain vs Belgium","homeCode":"ESP","marketFamily":"Match result","marketLabel":"Match result · Draw","matchroomId":"paired-spain-belgium-2026-07-10","occurredAt":"2026-07-10T19:30:00.389Z","preTriggerMarketMoveBps":775,"reason":"Market moved before signal","source":"Captured replay","verificationStatus":"Verified"}],"executionMode":"paper","filterOptions":{"detectors":["STALE_QUOTE_FEASIBILITY"],"dispositions":["No trade"],"evidenceLanes":["Research only"],"executionOutcomes":["Not executed"],"fixtures":["Spain vs Belgium"],"marketFamilies":["Match result"],"sources":["Captured replay"]},"generatedAt":"2026-07-11T06:35:38.499Z","mode":"offline_artifact","nextEvidence":{"detail":"New cases appear only after a paired capture passes identity, completeness, and replay verification.","label":"One verified case, without survivorship filtering","status":"waiting_for_verified_capture"},"publicDataPolicy":{"credentialsRequired":false,"derivedOnly":true,"txlineMovementBucketBps":25,"txlineProbabilityDisplay":"bucketed_movement_only","walletControlsExposed":false},"realMoneyGate":"closed","schemaVersion":2,"selectedCase":{"analysis":{"costMicros":0,"costReason":"No order was constructed, so spread, slippage, and fee costs were not incurred.","costStatus":"not_applicable","invalidation":"The apparent gap is invalid if the executable market repriced before the signal reached Samaritan.","thesisReason":"The deterministic evidence gate rejected the opportunity before analyst escalation.","thesisStatus":"not_requested"},"decision":{"capitalMovedMicros":0,"disposition":"no_trade","explanation":"The candidate arrived after the market had already moved. Samaritan preserved capital.","label":"No trade","ordersPlaced":0,"primaryReason":"Market moved before signal","semanticStatus":"disciplined_pass","stages":[{"detail":"STALE_QUOTE feasibility · research only","id":"signal","label":"Goal observed","status":"complete","timingLabel":"+0ms"},{"detail":"First material update","id":"evidence","label":"Evidence checked","status":"complete","timingLabel":"+228ms"},{"detail":"No clean stale window","id":"pass","label":"Opportunity passed","status":"passed","timingLabel":"Final"},{"detail":"Gate remained closed","id":"execution","label":"Execution not reached","status":"locked","timingLabel":"$0.00"}],"walletAccessed":false},"evidence":[{"assessment":"Moved first","bestAsk":0.26,"observation":"Draw ask begins repricing","observedAt":"2026-07-10T19:29:55.389Z","offsetLabel":"T−5.000s","replayStateId":"pre","source":"Polymarket"},{"assessment":"Pass","bestAsk":0.18,"observation":"Spain goal delivered at 29:21","observedAt":"2026-07-10T19:30:00.389Z","offsetLabel":"First seen","replayStateId":"goal","source":"TXLine"},{"assessment":"No trade","bestAsk":0.1625,"observation":"Spread tightens after repricing","observedAt":"2026-07-10T19:30:30.389Z","offsetLabel":"T+30.000s","replayStateId":"post","source":"Samaritan"}],"evidenceReadout":{"bestAsk":0.18,"bestBid":0.17,"consensusMoveFromBaselineBps":25,"movementConclusion":"Polymarket moved before TXLine","preTriggerMarketMoveBps":775,"spread":0.009999999999999981},"lifecycle":[{"detail":"STALE_QUOTE feasibility · research only","id":"signal","label":"Goal observed","status":"complete","timingLabel":"+0ms"},{"detail":"First material update","id":"evidence","label":"Evidence checked","status":"complete","timingLabel":"+228ms"},{"detail":"No clean stale window","id":"pass","label":"Opportunity passed","status":"passed","timingLabel":"Final"},{"detail":"Gate remained closed","id":"execution","label":"Execution not reached","status":"locked","timingLabel":"$0.00"}],"match":{"away":{"code":"BEL","name":"Belgium"},"clockLabel":"29:21","clockSeconds":1761,"competition":"World Cup","eventSlug":"fifwc-esp-bel-2026-07-10","fixtureId":"18218149","home":{"code":"ESP","name":"Spain"},"kickoffUtc":"2026-07-10T19:00:00.000Z","originalMatchDate":"2026-07-10","scoreAtCursor":{"away":0,"home":1},"stage":"Round of 16"},"proof":{"canonicalEvents":2470342,"captureStatus":"verified","cleanStaleWindows":0,"feedDowntimeMs":91171,"feedOutageCount":8,"gateCases":18,"headHash":"55b74d2f769bb40b822c02332bf94cc9af645185952dbbe7d115e6dac20ae05b","identityHash":"d7958c89929f2742e42b77ef2a4fb7f8df744f40dc3240e80bf724695eef4260","identityParity":true,"maximumFeedDowntimeMs":25375,"movedBeforeTxlineCases":12,"noMaterialRepriceCases":6,"replayMode":"capture-order-per-source"},"summary":{"awayCode":"BEL","caseId":"ESP-BEL-01","detector":"STALE_QUOTE_FEASIBILITY","disposition":"No trade","evidenceLane":"Research only","executionOutcome":"Not executed","fixtureId":"18218149","fixtureLabel":"Spain vs Belgium","homeCode":"ESP","marketFamily":"Match result","marketLabel":"Match result · Draw","matchroomId":"paired-spain-belgium-2026-07-10","occurredAt":"2026-07-10T19:30:00.389Z","preTriggerMarketMoveBps":775,"reason":"Market moved before signal","source":"Captured replay","verificationStatus":"Verified"}},"snapshotId":"casebook-2026-world-cup","statistics":{"capitalMovedMicros":0,"executedCases":0,"noTradeCases":1,"totalCases":1,"verifiedCases":1},"tradeable":false}} diff --git a/public/artifacts/dashboard/command.json b/public/artifacts/dashboard/command.json new file mode 100644 index 0000000..53121d5 --- /dev/null +++ b/public/artifacts/dashboard/command.json @@ -0,0 +1 @@ +{"data":{"additionalCaseState":{"detail":"Capture verification may add evidence, but study admission waits for Deborah to register corrected v2.","label":"No active study can admit cases","status":"waiting_for_eligible_capture"},"executionMode":"paper","featuredCase":{"bestAsk":0.18,"candidateLabel":"Live-lane gate readout","canonicalEvents":2470342,"caseId":"ESP-BEL-01","chart":[{"available":true,"bestAsk":0.26,"bestBid":0.245,"offsetMs":-5000,"spread":0.015000000000000013},{"available":true,"bestAsk":0.205,"bestBid":0.17,"offsetMs":-1000,"spread":0.034999999999999976},{"available":true,"bestAsk":0.18,"bestBid":0.17,"offsetMs":0,"spread":0.009999999999999981},{"available":true,"bestAsk":0.18,"bestBid":0.17,"offsetMs":250,"spread":0.009999999999999981},{"available":true,"bestAsk":0.18,"bestBid":0.17,"offsetMs":500,"spread":0.009999999999999981},{"available":true,"bestAsk":0.1775,"bestBid":0.1525,"offsetMs":1000,"spread":0.024999999999999994},{"available":true,"bestAsk":0.1775,"bestBid":0.16,"offsetMs":2000,"spread":0.017499999999999988},{"available":true,"bestAsk":0.1675,"bestBid":0.16,"offsetMs":5000,"spread":0.007500000000000007},{"available":true,"bestAsk":0.1725,"bestBid":0.17,"offsetMs":10000,"spread":0.0024999999999999745},{"available":true,"bestAsk":0.1625,"bestBid":0.16,"offsetMs":30000,"spread":0.0025000000000000022}],"clockLabel":"29:21","conclusion":"The candidate arrived after the market had already moved. Samaritan preserved capital.","consensusMoveFromBaselineBps":25,"disposition":"no_trade","dispositionLabel":"No trade","evidenceStatus":"verified_replay","fixtureId":"18218149","fixtureLabel":"Spain vs Belgium","identityParity":true,"marketLabel":"Match result · Draw","matchroomId":"paired-spain-belgium-2026-07-10","occurredAt":"2026-07-10T19:30:00.389Z","preTriggerMarketMoveBps":775,"reason":"Market moved before signal","scoreLabel":"1–0"},"fixtureSchedule":[{"away":{"code":"ESP","name":"Spain"},"captureOnly":true,"captureStartUtc":"2026-07-14T16:00:00.000Z","eventSlug":"fifwc-fra-esp-2026-07-14","fixtureId":"18237038","home":{"code":"FRA","name":"France"},"identityStatus":"exact_match_confirmed","kickoffUtc":"2026-07-14T19:00:00.000Z","phase":"scheduled","signalCutoffUtc":"2026-07-14T18:45:00.000Z","statusLabel":"Capture confirmed","tradeable":false},{"away":{"code":"ARG","name":"Argentina"},"captureOnly":true,"captureStartUtc":"2026-07-15T16:00:00.000Z","eventSlug":"fifwc-eng-arg-2026-07-15","fixtureId":"18241006","home":{"code":"ENG","name":"England"},"identityStatus":"exact_match_confirmed","kickoffUtc":"2026-07-15T19:00:00.000Z","phase":"scheduled","signalCutoffUtc":"2026-07-15T18:45:00.000Z","statusLabel":"Capture confirmed","tradeable":false}],"generatedAt":"2026-07-14T10:34:05.545Z","mode":"offline_artifact","proof":{"bountyLedgerRows":1,"bountyLedgerValid":true,"canonicalEvents":2470342,"evidenceFixtures":3,"longRunLedgerRows":1,"longRunLedgerValid":true,"pairedBookReplays":1,"replayIdentityHash":"d7958c89929f2742e42b77ef2a4fb7f8df744f40dc3240e80bf724695eef4260","replayIdentityParity":true,"signalResearchOnly":2},"publicDataPolicy":{"credentialsRequired":false,"derivedOnly":true,"txlineMovementBucketBps":25,"txlineProbabilityDisplay":"bucketed_movement_only","walletControlsExposed":false},"realMoneyGate":"closed","recentCases":[{"bestAsk":0.18,"candidateLabel":"Live-lane gate readout","caseId":"ESP-BEL-01","consensusMoveFromBaselineBps":25,"disposition":"no_trade","dispositionLabel":"No trade","evidenceStatus":"verified_replay","fixtureId":"18218149","fixtureLabel":"Spain vs Belgium","marketLabel":"Match result · Draw","matchroomId":"paired-spain-belgium-2026-07-10","occurredAt":"2026-07-10T19:30:00.389Z","preTriggerMarketMoveBps":775,"reason":"Market moved before signal"}],"schemaVersion":2,"snapshotId":"command-2026-world-cup","sourceFreshness":{"fixtureUniverseGeneratedAt":"2026-07-12T09:50:09.161Z","paperReportGeneratedAt":"2026-07-12T16:48:13.459Z","replayGeneratedAt":"2026-07-11T06:35:38.499Z"},"study":{"bountyStatus":"exploratory","configHash":"7dcb4c20ac05195bdb4a6c8803432079eeefb8cc747c186f576084dc772fd2a2","filledMatches":0,"fills":0,"protocolStatus":"invalidated_suspended","protocolVersion":"paper-study-v1-2026-07-12","reason":"Long-run endpoints sealed until 20 filled matches and 40 fills","requiredFilledMatches":20,"requiredFills":40,"startedAt":"2026-07-12T09:45:17.212Z","status":"suspended","statusLabel":"V1 suspended","stoppingRuleMet":false},"system":{"detail":"France vs Spain is confirmed for public-data capture only.","feeds":[{"detail":"Exact fixture 18237038 confirmed","id":"txline","label":"TXLine","status":"scheduled","statusLabel":"Scheduled"},{"detail":"Exact event family confirmed","id":"polymarket","label":"Polymarket","status":"scheduled","statusLabel":"Scheduled"},{"detail":"Invalidated v1 zero-observation chains remain valid","id":"decision_ledger","label":"Paper ledgers","status":"initialized","statusLabel":"Preserved"},{"detail":"2,470,342 canonical events","id":"replay_proof","label":"Replay proof","status":"verified","statusLabel":"Verified"}],"label":"Standing by for paired capture","posture":"standing_by"},"tradeable":false}} diff --git a/public/artifacts/dashboard/manifest.json b/public/artifacts/dashboard/manifest.json new file mode 100644 index 0000000..48141ac --- /dev/null +++ b/public/artifacts/dashboard/manifest.json @@ -0,0 +1 @@ +{"bundleId":"samaritan-dashboard-public-v1","bundleSha256":"4950d9a9cfd5ca326a9c32a53c5a477bfe192cd0c4d9afaed7efa44e85366276","canonicalization":"samaritan-stable-json-v1","downloads":[{"bytes":15362,"file":"synthetic-decision-receipt.json","id":"synthetic-decision-receipt","mediaType":"application/json","performanceUse":"excluded_synthetic","sha256":"275fa6d110e4b6e58f5508360f576d8b6a6fbbdf077548f34c964aabce310a82","synthetic":true}],"files":[{"apiPath":"/api/v1/command","bytes":5186,"file":"command.json","sha256":"d26b1e4bd487cc58840821ca7a41e690ea4dcd7e5f057a788f42b25828182fa1","snapshotId":"command-2026-world-cup"},{"apiPath":"/api/v1/casebook","bytes":5366,"file":"casebook.json","sha256":"4294c508a738012d713ff997f1ddbb7a9d42864b7c7e78b27e2ffd173b6ddf50","snapshotId":"casebook-2026-world-cup"},{"apiPath":"/api/v1/study","bytes":4599,"file":"study.json","sha256":"9fc33219e940b2c0319e761ea64e51a1fe32b1436ba37f2a57a254aa12c5b78b","snapshotId":"paper-study-2026-world-cup"},{"apiPath":"/api/v1/matchroom/paired-spain-belgium-2026-07-10","bytes":5217,"file":"matchroom-spain-belgium.json","sha256":"1c5b45b1f8e2616ba5b3b60d52c091118ea2d2d299de20ab18fe8d7235f3ba0f","snapshotId":"paired-spain-belgium-2026-07-10"}],"generatedAt":"2026-07-14T10:34:05.545Z","publicDataPolicy":{"credentialsRequired":false,"derivedOnly":true,"txlineMovementBucketBps":25,"txlineProbabilityDisplay":"bucketed_movement_only","walletControlsExposed":false},"schemaVersion":1} diff --git a/public/artifacts/dashboard/matchroom-spain-belgium.json b/public/artifacts/dashboard/matchroom-spain-belgium.json new file mode 100644 index 0000000..2d9f0a9 --- /dev/null +++ b/public/artifacts/dashboard/matchroom-spain-belgium.json @@ -0,0 +1 @@ +{"data":{"decision":{"capitalMovedMicros":0,"disposition":"no_trade","explanation":"The candidate arrived after the market had already moved. Samaritan preserved capital.","label":"No trade","ordersPlaced":0,"primaryReason":"Market moved before signal","semanticStatus":"disciplined_pass","stages":[{"detail":"STALE_QUOTE feasibility · research only","id":"signal","label":"Goal observed","status":"complete","timingLabel":"+0ms"},{"detail":"First material update","id":"evidence","label":"Evidence checked","status":"complete","timingLabel":"+228ms"},{"detail":"No clean stale window","id":"pass","label":"Opportunity passed","status":"passed","timingLabel":"Final"},{"detail":"Gate remained closed","id":"execution","label":"Execution not reached","status":"locked","timingLabel":"$0.00"}],"walletAccessed":false},"evidence":[{"assessment":"Moved first","bestAsk":0.26,"observation":"Draw ask begins repricing","observedAt":"2026-07-10T19:29:55.389Z","offsetLabel":"T−5.000s","replayStateId":"pre","source":"Polymarket"},{"assessment":"Pass","bestAsk":0.18,"observation":"Spain goal delivered at 29:21","observedAt":"2026-07-10T19:30:00.389Z","offsetLabel":"First seen","replayStateId":"goal","source":"TXLine"},{"assessment":"No trade","bestAsk":0.1625,"observation":"Spread tightens after repricing","observedAt":"2026-07-10T19:30:30.389Z","offsetLabel":"T+30.000s","replayStateId":"post","source":"Samaritan"}],"executionMode":"paper","generatedAt":"2026-07-11T06:35:38.499Z","market":{"family":"match_result","label":"Match result · Draw","mappingStatus":"research_only","outcome":"draw","period":"90 minutes plus stoppage time"},"match":{"away":{"code":"BEL","name":"Belgium"},"clockLabel":"29:21","clockSeconds":1761,"competition":"World Cup","eventSlug":"fifwc-esp-bel-2026-07-10","fixtureId":"18218149","home":{"code":"ESP","name":"Spain"},"kickoffUtc":"2026-07-10T19:00:00.000Z","originalMatchDate":"2026-07-10","scoreAtCursor":{"away":0,"home":1},"stage":"Round of 16"},"mode":"captured_replay","proof":{"canonicalEvents":2470342,"captureStatus":"verified","cleanStaleWindows":0,"feedDowntimeMs":91171,"feedOutageCount":8,"gateCases":18,"headHash":"55b74d2f769bb40b822c02332bf94cc9af645185952dbbe7d115e6dac20ae05b","identityHash":"d7958c89929f2742e42b77ef2a4fb7f8df744f40dc3240e80bf724695eef4260","identityParity":true,"maximumFeedDowntimeMs":25375,"movedBeforeTxlineCases":12,"noMaterialRepriceCases":6,"replayMode":"capture-order-per-source"},"publicDataPolicy":{"credentialsRequired":false,"derivedOnly":true,"txlineMovementBucketBps":25,"txlineProbabilityDisplay":"bucketed_movement_only","walletControlsExposed":false},"realMoneyGate":"closed","replay":{"activeStateId":"goal","availabilityGaps":[],"chart":[{"available":true,"bestAsk":0.26,"bestBid":0.245,"offsetMs":-5000,"spread":0.015000000000000013},{"available":true,"bestAsk":0.205,"bestBid":0.17,"offsetMs":-1000,"spread":0.034999999999999976},{"available":true,"bestAsk":0.18,"bestBid":0.17,"offsetMs":0,"spread":0.009999999999999981},{"available":true,"bestAsk":0.18,"bestBid":0.17,"offsetMs":250,"spread":0.009999999999999981},{"available":true,"bestAsk":0.18,"bestBid":0.17,"offsetMs":500,"spread":0.009999999999999981},{"available":true,"bestAsk":0.1775,"bestBid":0.1525,"offsetMs":1000,"spread":0.024999999999999994},{"available":true,"bestAsk":0.1775,"bestBid":0.16,"offsetMs":2000,"spread":0.017499999999999988},{"available":true,"bestAsk":0.1675,"bestBid":0.16,"offsetMs":5000,"spread":0.007500000000000007},{"available":true,"bestAsk":0.1725,"bestBid":0.17,"offsetMs":10000,"spread":0.0024999999999999745},{"available":true,"bestAsk":0.1625,"bestBid":0.16,"offsetMs":30000,"spread":0.0025000000000000022}],"firstMaterialMoveLatencyMs":228,"firstSeenAt":"2026-07-10T19:30:00.389Z","firstSeenLatencyMs":11,"preTriggerMarketMoveBps":775,"states":[{"bestAsk":0.26,"bestBid":0.245,"conclusionBody":"Polymarket was already moving before TXLine delivered the goal.","conclusionTitle":"Repricing had begun.","consensusMoveFromBaselineBps":0,"decisionExplanation":"The market was moving before the trigger entered Samaritan's event bus.","id":"pre","label":"T−5 seconds","observedAt":"2026-07-10T19:29:55.389Z","offsetMs":-5000,"spread":0.015000000000000013},{"bestAsk":0.18,"bestBid":0.17,"conclusionBody":"It repriced before TXLine delivered the goal. No stale quote remained.","conclusionTitle":"Polymarket moved first.","consensusMoveFromBaselineBps":25,"decisionExplanation":"The candidate arrived after the market had already moved. Samaritan preserved capital.","id":"goal","label":"Goal first seen","observedAt":"2026-07-10T19:30:00.389Z","offsetMs":0,"spread":0.009999999999999981},{"bestAsk":0.1625,"bestBid":0.16,"conclusionBody":"A tighter spread did not restore an opportunity that disappeared before the trigger.","conclusionTitle":"The window stayed invalid.","consensusMoveFromBaselineBps":25,"decisionExplanation":"The spread tightened after repricing, but the candidate remained ineligible for execution.","id":"post","label":"T+30 seconds","observedAt":"2026-07-10T19:30:30.389Z","offsetMs":30000,"spread":0.0025000000000000022}]},"schemaVersion":2,"snapshotId":"paired-spain-belgium-2026-07-10","tradeable":false}} diff --git a/public/artifacts/dashboard/study.json b/public/artifacts/dashboard/study.json new file mode 100644 index 0000000..8e14144 --- /dev/null +++ b/public/artifacts/dashboard/study.json @@ -0,0 +1 @@ +{"data":{"correctedHistoricalCandidate":{"activeStudy":false,"claimBoundary":"Forward paper review candidate only; not alpha, profitability, fill proof, or permission to trade.","configurationHash":"9a4eeff928f697fc55ab5147a4dc07f611c40bb749501fc3bd92b211f24b2e54","costProxyBps":100,"detector":"CONSENSUS_MOVE","evidenceClass":"historical_sampled_price_signal_research","executable":false,"executionEvidence":"not_established_no_historical_bid_ask_or_depth","generatedAt":"2026-07-14T10:34:05.545Z","heldoutFixtures":18,"heldoutNormalizedCases":38,"marketFamily":"Full-time totals","matchClustered95Bps":{"cluster":"fixture","high":243.9,"iterations":10000,"low":14.3},"meanNetAfterCostProxyBps":132.7,"protocolId":"historical-gate-causal-economic-v4-2026-07-14","registration":"engineering_candidate_unregistered","schemaVersion":4,"status":"historical_signal_candidate_for_forward_paper_review","trainingNormalizedCases":135},"decisionRules":{"accept":["Inactive v1 rule retained for audit only; it cannot produce an acceptance decision.","Mean net executable CLV is positive and its match-clustered 95% CI lower bound is above zero.","Mean settlement P&L is positive and the strategy beats no-trade and random-direction baselines.","Every registered fill, slippage, drawdown, and depth guardrail holds."],"inconclusive":["No active study exists; corrected v2 remains an engineering candidate until Deborah registers it.","Capture-only evidence cannot authorize model spend, paper admission, or real money."],"reject":["V1 was invalidated before observations because upstream selection used future information.","The executable CLV interval touches or falls below zero.","Any registered guardrail fails materially."]},"executionMode":"paper","fixtureUniverse":{"evidenceFixtures":3,"executableBookReplays":0,"generatedAt":"2026-07-12T09:50:09.161Z","longRunEligible":0,"pairedBookReplays":1,"signalResearchOnly":2},"generatedAt":"2026-07-12T16:48:13.459Z","lanes":{"bounty":{"canSatisfyGate":false,"chain":{"headHash":"1ff24c63635535548d17200c478ad895df929a38e43e662325f6a26aad949487","rows":1,"valid":true},"counts":{"filledMatches":0,"fills":0,"matches":0,"settledFills":0,"signals":0},"label":"Preserved v1 bounty ledger","reason":"Complete close and settlement evidence is required before endpoints are computed","status":"exploratory","statusLabel":"Exploratory"},"longRun":{"canSatisfyGate":false,"chain":{"headHash":"488ed79a089e35ab2d70800352c49d3587e0284ad64bb0eda32d848874194c92","rows":1,"valid":true},"counts":{"filledMatches":0,"fills":0,"matches":0,"settledFills":0,"signals":0},"label":"Preserved v1 long-run ledger","reason":"Long-run endpoints sealed until 20 filled matches and 40 fills","status":"sealed","statusLabel":"V1 suspended","stoppingRuleMet":false}},"mode":"offline_artifact","protocol":{"active":false,"candidate":{"cusumThresholdBps":10,"detector":"CONSENSUS_MOVE","marketFamily":"Full-time totals only","maximumDistanceFromEven":0.15,"minimumCoveragePoints":1000,"minimumGapBps":100,"minimumUpdates":5,"moveAbsZ":1,"selector":"Closest to even"},"configHash":"7dcb4c20ac05195bdb4a6c8803432079eeefb8cc747c186f576084dc772fd2a2","evaluation":{"bootstrapIterations":10000,"bootstrapSeed":20260712,"minimumFilledMatches":20,"minimumFills":40,"primaryEndpoint":"Executable CLV net of measured costs","randomDirectionControl":"Seeded matched-cost sign flip","targetMatches":30,"unitOfAnalysis":"match"},"guardrailThresholds":{"maximumDrawdownMicroUsd":20000000,"maximumMeanSlippageBps":100,"minimumFillRate":0.6,"selectedDepthRequired":true},"risk":{"aggregateExposureMicroUsd":15000000,"bankrollMicroUsd":50000000,"drawdownStopMicroUsd":20000000,"perTradeStakeMicroUsd":3000000},"startedAt":"2026-07-12T09:45:17.212Z","status":"invalidated_suspended","version":"paper-study-v1-2026-07-12"},"publicDataPolicy":{"credentialsRequired":false,"derivedOnly":true,"txlineMovementBucketBps":25,"txlineProbabilityDisplay":"bucketed_movement_only","walletControlsExposed":false},"realMoneyGate":"closed","results":{"endpoints":null,"guardrails":null,"rows":null,"visibility":"sealed"},"schemaVersion":2,"snapshotId":"paper-study-2026-world-cup","syntheticProof":{"explanation":"Closed-world production-component demo; separate from historical evidence and excluded from every performance claim.","externalCalls":0,"label":"Synthetic full-lifecycle proving fixture","lifecycleStatus":"filled_settled","offlineVerified":true,"path":"/artifacts/dashboard/synthetic-decision-receipt.json","performanceUse":"excluded_synthetic","solanaAnchorStatus":"not_submitted"},"tradeable":false}} diff --git a/public/artifacts/dashboard/synthetic-decision-receipt.json b/public/artifacts/dashboard/synthetic-decision-receipt.json new file mode 100644 index 0000000..4a2388c --- /dev/null +++ b/public/artifacts/dashboard/synthetic-decision-receipt.json @@ -0,0 +1 @@ +{"agents":{"runs":[{"actualCostNanoUsd":0,"billingEvidenceRefSha256":"440766253819749e1399e4fbba33e009bd89a1228636fe4e882ac67d9fc9046b","invocationClass":"synthetic_stub","model":"claude-haiku-4-5","pricing":{"cacheReadNanoUsdPerToken":100,"cacheWriteNanoUsdPerToken":1250,"currency":"nano_usd","inputNanoUsdPerToken":1000,"outputNanoUsdPerToken":5000,"pricingVersion":"anthropic-public-2026-07-12"},"promptSha256":"6f72831fb8edc6e952d43407a43b715e02b5a4914e9fd20e0821bde8f2d153a5","promptVersion":"synthetic-judge-triage-v1","responseSha256":"8838a6686bb5729f0ac24969e371f9f26ea9097835fc60a5b9cab50b3ceddd28","stage":"triage","status":"success","usage":{"cacheCreationInputTokens":0,"cacheReadInputTokens":0,"inputTokens":0,"outputTokens":0}},{"actualCostNanoUsd":0,"billingEvidenceRefSha256":"5020037e4a8a8abbc61ba717a3d1a21e88bcf5cab68bcd62b85181d7701e4fdf","invocationClass":"synthetic_stub","model":"claude-opus-4-8","pricing":{"cacheReadNanoUsdPerToken":500,"cacheWriteNanoUsdPerToken":6250,"currency":"nano_usd","inputNanoUsdPerToken":5000,"outputNanoUsdPerToken":25000,"pricingVersion":"anthropic-public-2026-07-12"},"promptSha256":"8d22add73e03b60a0c2d71c5cdba5a5a81809f310ea95243a8b385d662f72721","promptVersion":"synthetic-judge-analyst-v1","responseSha256":"e9e98a419270c8012f32b89fc4af972675fdb8bbb7e69668f74701e8c9250b30","stage":"analyst","status":"success","usage":{"cacheCreationInputTokens":0,"cacheReadInputTokens":0,"inputTokens":0,"outputTokens":0}}],"totalActualCostNanoUsd":0},"build":{"codeSha256":"a150b4741ce2c0f18137bd05ed3d717ab5a5db42aa6ae856eeffb2206075a524","codeVersion":"synthetic-judge-case-v1","configSha256":"93e61c1903d0a13bbeb1dbbd3ad9b11af0335b96c82bd2ca7aa9ddedeeabf3ce"},"disclosure":{"policy":"hashes_and_derived_signals_only","rawTxlineFieldsIncluded":false},"generatedAtTsMs":1784023260000,"integrity":{"algorithm":"sha256","canonicalization":"samaritan-stable-json-v1","domain":"samaritan.decision-receipt/v1","receiptHash":"130f1ee7764456fd56f842f6e347a2efa1beafaba2eec0d5c831c87d7883f265"},"ledger":{"caseEntries":[{"atTsMs":1782864480010,"entryHash":"70a1b767c10c7a6e553f3575b7854ffd707b8d291bf7a1e997cf610d100e897f","entryIdSha256":"3dcd660d6fa9270574b0b572db7b827c2e55a460631b03341542bcb3ab41b6b6","hashSchemaVersion":2,"insertedAtMs":1784023200001,"kind":"signal_received","payloadSha256":"a36c0e620e75544f631b8109cac3d535f68040644cb17fdb614bf351ffaf112a","previousHash":"797eca246e4fa8beaf01480da0571b39d495c97aec6b12fa0d7187567e2e5965","sequence":2},{"atTsMs":1782864480310,"entryHash":"93be922c2d431e0b8a3a73165cb7b5dcf21f4e6479b3df9e7380e2f3679a4ca0","entryIdSha256":"398be6658d6f9bd82e0fca149cdd5c29e78bdbcd363405a56f8c5bf6398964e6","hashSchemaVersion":2,"insertedAtMs":1784023200002,"kind":"triage_decision","payloadSha256":"8838a6686bb5729f0ac24969e371f9f26ea9097835fc60a5b9cab50b3ceddd28","previousHash":"70a1b767c10c7a6e553f3575b7854ffd707b8d291bf7a1e997cf610d100e897f","sequence":3},{"atTsMs":1782864480310,"entryHash":"19688afaac0d8e8e192a911b6fa27a0942e9317f4047444315138395ac865e6b","entryIdSha256":"8b6d0de178d39ecdd413970e9e0fd0a9418143f1f6fa07fa05249d9fb81007e1","hashSchemaVersion":2,"insertedAtMs":1784023200003,"kind":"thesis_submitted","payloadSha256":"e9e98a419270c8012f32b89fc4af972675fdb8bbb7e69668f74701e8c9250b30","previousHash":"93be922c2d431e0b8a3a73165cb7b5dcf21f4e6479b3df9e7380e2f3679a4ca0","sequence":4},{"atTsMs":1782864480310,"entryHash":"c00facc89a1896c5af47c56b1432473240fdc251fad80077b74537fc96252c89","entryIdSha256":"2340d857f566672fcd48822095fe2af49c907d604af1cd886a4f46d4ebd220e8","hashSchemaVersion":2,"insertedAtMs":1784023200004,"kind":"analysis_completed","payloadSha256":"1efe0b3e622fef222193845f92c04dd5952838a31366e8cc868fab362d5cb010","previousHash":"19688afaac0d8e8e192a911b6fa27a0942e9317f4047444315138395ac865e6b","sequence":5},{"atTsMs":1782864482010,"entryHash":"909399624afbe9a5ba58742273676a292df5da6d1a7454c61cc6efbf0aadedac","entryIdSha256":"7dc2178e9cbdb2db22883429b326a12264f386c1665eaa6aedfae2e4cad99848","hashSchemaVersion":2,"insertedAtMs":1784023200005,"kind":"risk_verdict","payloadSha256":"72951bf422061213048536dc8b3c85d4109f0f1b6bfb218f1667324c53933a46","previousHash":"c00facc89a1896c5af47c56b1432473240fdc251fad80077b74537fc96252c89","sequence":6},{"atTsMs":1782864482010,"entryHash":"4bcd7ac0f96f1c07ff3dd33ed73976740a9a74c63b8b14787dd09378389f27c5","entryIdSha256":"a7a36c20564db161d6f35cb6b153ab46e34e209678dd285a11b1bf734f0ddc33","hashSchemaVersion":2,"insertedAtMs":1784023200006,"kind":"execution_intent","payloadSha256":"9964f04c80111f09fa699e672784df4d86a9d7a716c0debbd2598ff605e5ea9b","previousHash":"909399624afbe9a5ba58742273676a292df5da6d1a7454c61cc6efbf0aadedac","sequence":7},{"atTsMs":1782864482010,"entryHash":"1e1e35cb5e5b833b2ca7c8a9579acbfdcf7fd1c0052545ae2f564318b36b7a7b","entryIdSha256":"3873c7c600f63a041897f5121bda8f4c3ba94a7423437b454be0e58e6cefed1c","hashSchemaVersion":2,"insertedAtMs":1784023200007,"kind":"paper_execution","payloadSha256":"fa29d76c427d10f8bbb057dcb4482dae892ce6cffbadb9efe5110a80e18457ed","previousHash":"4bcd7ac0f96f1c07ff3dd33ed73976740a9a74c63b8b14787dd09378389f27c5","sequence":8},{"atTsMs":1782864482010,"entryHash":"1fb0f3298eb7b91b4bf7501f103d6cea1a57ae73e451484594439c5bd9295c8a","entryIdSha256":"7bc71dd68871cbdfb8b3c495f73035fb825fd51870619fdd6936490981e0cb85","hashSchemaVersion":2,"insertedAtMs":1784023200008,"kind":"case_terminal","payloadSha256":"a279a2a5d4bef14c5f523f7145bcdbd762a9e4ff6bee856ccf4d3214bacc4ead","previousHash":"1e1e35cb5e5b833b2ca7c8a9579acbfdcf7fd1c0052545ae2f564318b36b7a7b","sequence":9},{"atTsMs":1782864482010,"entryHash":"4fe7cadaec7b4723fc40ec2a8164596832b0680ab4ee688fc8374ebe0f44c1b3","entryIdSha256":"2e3ec19009e0e6d35b3ed7b12204c705256b3751d0f6525afe2504e6fcfc0a81","hashSchemaVersion":2,"insertedAtMs":1784023200009,"kind":"position_opened","payloadSha256":"f9025e7f8c92a72ddf2349f59c75c60f08504e20b04a6ad16bbfffbf3b2ca4db","previousHash":"1fb0f3298eb7b91b4bf7501f103d6cea1a57ae73e451484594439c5bd9295c8a","sequence":10},{"atTsMs":1782885600010,"entryHash":"bfd59fd4aee0aab0fdd8dbc3ee8d1f8f0010b1fa0071f3c7c011c52328d2008d","entryIdSha256":"244be1dd7b19e38b94a5e0860f59f1ca0c455d88e15ef7d2046ea956a8f259cc","hashSchemaVersion":2,"insertedAtMs":1784023200010,"kind":"position_closed","payloadSha256":"0b120d55bfe9ed1432df88f66c6b0f2f944ee99e19fe41fb3d0cf7018d640f19","previousHash":"4fe7cadaec7b4723fc40ec2a8164596832b0680ab4ee688fc8374ebe0f44c1b3","sequence":11},{"atTsMs":1782885600010,"entryHash":"a034cd8688e68f6524ef64d2c3d6025134387e77e0ec5b57100a0791b1575dc4","entryIdSha256":"af2d168df3bd94bd15808bff67e979f3e796680f097b3abc713efd7d08ce33a4","hashSchemaVersion":2,"insertedAtMs":1784023200011,"kind":"position_settled","payloadSha256":"14fe6386ef7eb490c9eeb28161875d2b3018e660d4befecf33c963117492ec70","previousHash":"bfd59fd4aee0aab0fdd8dbc3ee8d1f8f0010b1fa0071f3c7c011c52328d2008d","sequence":12}],"caseIdSha256":"8ed0eed287a115e1471adf8c518a871c2e87cff1a626a0709e399aab3b25d001","finalHeadHash":"a034cd8688e68f6524ef64d2c3d6025134387e77e0ec5b57100a0791b1575dc4","finalHeadSequence":12,"payloadDisclosure":"sha256_commitments_only","rowsAtGeneration":12,"verificationAtGeneration":"full_v2_chain_valid"},"lifecycle":{"analysis":{"decisionLatencyMs":300,"ledgerEntryHash":"c00facc89a1896c5af47c56b1432473240fdc251fad80077b74537fc96252c89","orderEligibleAtTsMs":1782864481310,"readyAtTsMs":1782864480310,"recommendation":"paper_trade","signalObservedTsMs":1782864480010,"signalSourceTsMs":1782864480000,"venuePlacementDelayMs":1000},"close":{"bookEvidenceRefSha256":"81ad53197017674b67f6e67f428d641eceff9cb067d13ba6f4b7ac8be968d0e2","bookObservedTsMs":1782878399010,"bookSourceTsMs":1782878399000,"closeAsk":0.59,"closeBid":0.57,"closeMidpoint":0.58,"cutoffTsMs":1782878400000,"executableLiquidationClvBps":311.28268245494195,"grossMidpointClvBps":535.9241294812489,"ledgerEntryHash":"bfd59fd4aee0aab0fdd8dbc3ee8d1f8f0010b1fa0071f3c7c011c52328d2008d","markedAtTsMs":1782885600010,"netMidpointClvBps":411.28268245494206},"execution":{"adapter":"paper","assetRefSha256":"458b48b13d636ffa4da35d22785caaf845edf323c0c0fafc4bd16423a63b998d","averagePrice":0.5264075870518751,"bestPrice":0.52,"bookEvidenceRefSha256":"bfb339ee3586af140a9c78770d6f601d604d58665391a987e84e067ce5923a38","bookObservedTsMs":1782864482010,"bookSourceTsMs":1782864482000,"conditionRefSha256":"fbd0f84b94d0f25d65eb3f848da00ac3233d4a54ac8a35e12388c5c3dd149a7b","direction":"buy","executableDepthUsd":54.04,"feeMetadataRefSha256":"491445a7a852574f966a4bb1acc64ff3915e0ff71021cd1488f3577aedd8c9d7","feeMicroUsd":69390,"filledShares":5.567169,"grossMicroUsd":2930600,"halfSpreadBps":100.00000000000009,"ledgerEntryHash":"1e1e35cb5e5b833b2ca7c8a9579acbfdcf7fd1c0052545ae2f564318b36b7a7b","netConsiderationMicroUsd":2999990,"reason":null,"requestedStakeMicroUsd":3000000,"slippageProbabilityBps":64.07587051875052,"status":"filled"},"finalStatus":"filled_settled","intent":{"availableShares":0,"direction":"buy","fixtureRefSha256":"5d9ba078a6b11bbe03d2103ac2366e334c3d1ded3ba5db278aa61a546d263ac8","lane":"bounty","ledgerEntryHash":"4bcd7ac0f96f1c07ff3dd33ed73976740a9a74c63b8b14787dd09378389f27c5","limitPolicy":"deterministic_private_fair_value_boundary","marketRefSha256":"e5b4d3bb0e830b25f37e1e4ce0ab0e21b994c4c8ce0c45878012539b9ba94550","outcome":"over","signalRefSha256":"c46cea7c0f66b95303668b592d03cf98c171319a2b71c723a3f95918527b4162","stakeMicroUsd":3000000},"orderedEventKinds":["signal_received","triage_decision","thesis_submitted","analysis_completed","risk_verdict","execution_intent","paper_execution","case_terminal","position_opened","position_closed","position_settled"],"position":{"assetRefSha256":"458b48b13d636ffa4da35d22785caaf845edf323c0c0fafc4bd16423a63b998d","averageEntryPrice":0.5264075870518751,"conditionRefSha256":"fbd0f84b94d0f25d65eb3f848da00ac3233d4a54ac8a35e12388c5c3dd149a7b","entryCostMicroUsd":2999990,"entryFeeMicroUsd":69390,"entryGrossMicroUsd":2930600,"entryHalfSpreadBps":100.00000000000009,"entrySlippageBps":64.07587051875052,"filledShares":5.567169,"fillStatus":"filled","fixtureRefSha256":"5d9ba078a6b11bbe03d2103ac2366e334c3d1ded3ba5db278aa61a546d263ac8","lane":"bounty","ledgerEntryHash":"4fe7cadaec7b4723fc40ec2a8164596832b0680ab4ee688fc8374ebe0f44c1b3","lineRefSha256":"fe82e6baa7be838b672749b06987ad38a401fc72651a530e732066bc0daffc86","marketRefSha256":"e5b4d3bb0e830b25f37e1e4ce0ab0e21b994c4c8ce0c45878012539b9ba94550","openedAtTsMs":1782864482010,"outcome":"over","selectedDepthUsd":54.04,"signalRefSha256":"c46cea7c0f66b95303668b592d03cf98c171319a2b71c723a3f95918527b4162"},"risk":{"decision":"approve","ledgerEntryHash":"909399624afbe9a5ba58742273676a292df5da6d1a7454c61cc6efbf0aadedac","limitPolicy":"deterministic_private_fair_value_boundary","realMoneyGate":"closed","reasons":[],"stakeMicroUsd":3000000},"settlement":{"entryBrier":0.2242897736020273,"ledgerEntryHash":"a034cd8688e68f6524ef64d2c3d6025134387e77e0ec5b57100a0791b1575dc4","payoutMicroUsd":5567169,"pnlMicroUsd":2567179,"resolutionEvidenceRefSha256":"bbadbb5f3030b79e58e44f567abe03021b3299623fb0fbee9adb14aa981545ca","returnBps":8557.291857639526,"settledAtTsMs":1782885600010,"won":true},"signal":{"derivedEvidence":{"consensusVelocityBucketBps":500,"consensusZScore":489.5655813290226,"cusumDownScore":0,"cusumUpScore":0.049400000000000055,"gapBasis":"live_book","mappingStatus":"verified","persistenceMs":0,"polymarketVelocityBucketBps":0,"polymarketZScore":null,"relativeValueDirection":"consensus_above_venue","scoreContextActionCount":0,"txlineMovementBucketBps":25},"detector":"CONSENSUS_MOVE","direction":"buy","eligibility":"pretrade_review_required","fixtureRefSha256":"5d9ba078a6b11bbe03d2103ac2366e334c3d1ded3ba5db278aa61a546d263ac8","ledgerEntryHash":"70a1b767c10c7a6e553f3575b7854ffd707b8d291bf7a1e997cf610d100e897f","lineRefSha256":"fe82e6baa7be838b672749b06987ad38a401fc72651a530e732066bc0daffc86","marketFamily":"total_goals","marketPeriod":"full_time","marketRefSha256":"e5b4d3bb0e830b25f37e1e4ce0ab0e21b994c4c8ce0c45878012539b9ba94550","observedAtTsMs":1782864480010,"outcome":"over","signalRefSha256":"c46cea7c0f66b95303668b592d03cf98c171319a2b71c723a3f95918527b4162","sourceEvidenceRefs":["239e38c9735ba146d49ca4b839b07c4bd6d07ebd1a95c156b321cc2adcb9690f","60b04d2031dc50c645dda3a276c3a19f4558091d6674a65ade43c37ad95aece1"],"sourceTsMs":1782864480000},"terminal":{"decisionLatencyMs":null,"ledgerEntryHash":"1fb0f3298eb7b91b4bf7501f103d6cea1a57ae73e451484594439c5bd9295c8a","reason":"filled","status":"filled"},"thesis":{"analystModel":"claude-opus-4-8","direction":"buy","disclosure":"private_payload_sha256_commitment","expiresAtTsMs":1782865380310,"fixtureRefSha256":"5d9ba078a6b11bbe03d2103ac2366e334c3d1ded3ba5db278aa61a546d263ac8","ledgerEntryHash":"19688afaac0d8e8e192a911b6fa27a0942e9317f4047444315138395ac865e6b","marketRefSha256":"e5b4d3bb0e830b25f37e1e4ce0ab0e21b994c4c8ce0c45878012539b9ba94550","outcome":"over","recommendation":"paper_trade","schemaVersion":1,"signalRefSha256":"c46cea7c0f66b95303668b592d03cf98c171319a2b71c723a3f95918527b4162","submittedAtTsMs":1782864480310,"thesisPayloadSha256":"e9e98a419270c8012f32b89fc4af972675fdb8bbb7e69668f74701e8c9250b30"},"triage":{"decision":"escalate","ledgerEntryHash":"93be922c2d431e0b8a3a73165cb7b5dcf21f4e6479b3df9e7380e2f3679a4ca0","priority":"normal","rationale":"Synthetic proving signal meets the fixed escalation path; performance use is forbidden."}},"provenance":{"evidenceClass":"synthetic_proving_fixture","label":"SYNTHETIC JUDGE CASE — no real match, licensed feed record, external model call, venue order, wallet, network request, or performance evidence","performanceUse":"excluded_synthetic","synthetic":true},"receiptId":"69674d36cd4b01654bfdc83586e2b6c0424594f479758b64c40ca099f72755b7","receiptType":"samaritan_decision_receipt","schemaVersion":1,"solanaAnchor":null,"sourceEvidence":[{"disclosure":"hash_only","evidenceRefSha256":"239e38c9735ba146d49ca4b839b07c4bd6d07ebd1a95c156b321cc2adcb9690f","observedAtTsMs":1782864480010,"payloadSha256":"122e0cd6fc48001601ec3648b7bcedeeffd3aa50ee87947357182b98264761a4","role":"signal","source":"txline","sourceTsMs":1782864480000},{"disclosure":"hash_only","evidenceRefSha256":"60b04d2031dc50c645dda3a276c3a19f4558091d6674a65ade43c37ad95aece1","observedAtTsMs":1782864420110,"payloadSha256":"9b60c11c93d611a6c0758bdeaff83eb440b34137251ca3f479329aa13795b21e","role":"signal","source":"polymarket","sourceTsMs":1782864420100},{"disclosure":"hash_only","evidenceRefSha256":"bfb339ee3586af140a9c78770d6f601d604d58665391a987e84e067ce5923a38","observedAtTsMs":1782864482010,"payloadSha256":"e72c19ca5e545eca991b95db3ca950af4da24f2c4a6abd33422bd195bc3fab4d","role":"execution","source":"polymarket","sourceTsMs":1782864482000},{"disclosure":"hash_only","evidenceRefSha256":"81ad53197017674b67f6e67f428d641eceff9cb067d13ba6f4b7ac8be968d0e2","observedAtTsMs":1782878399010,"payloadSha256":"1568e8ca81c3539b324b82f02a35e38863ecdb2c92bcb52ee89862b4c30e22c9","role":"close","source":"polymarket","sourceTsMs":1782878399000},{"disclosure":"hash_only","evidenceRefSha256":"bbadbb5f3030b79e58e44f567abe03021b3299623fb0fbee9adb14aa981545ca","observedAtTsMs":1782885600010,"payloadSha256":"e18d003e5e6b77dc27ba39e1ec986b264ea953b26953282b0c0606bd6e4131bc","role":"settlement","source":"polymarket","sourceTsMs":1782885600000}]} diff --git a/src/agents/claude-pricing.ts b/src/agents/claude-pricing.ts new file mode 100644 index 0000000..159c0c9 --- /dev/null +++ b/src/agents/claude-pricing.ts @@ -0,0 +1,64 @@ +export const CLAUDE_MODEL = { + triage: "claude-haiku-4-5", + analyst: "claude-opus-4-8" +} as const; + +export type ClaudeModel = (typeof CLAUDE_MODEL)[keyof typeof CLAUDE_MODEL]; + +export type ClaudePricing = { + inputNanoUsdPerToken: number; + outputNanoUsdPerToken: number; + cacheWriteNanoUsdPerToken: number; + cacheReadNanoUsdPerToken: number; +}; + +// Anthropic API pricing verified 2026-07-12. Nano-USD keeps fractional +// micro-dollar cache rates exact while all spend arithmetic remains integer. +export const CLAUDE_PRICING: Record = { + "claude-haiku-4-5": { + inputNanoUsdPerToken: 1_000, + outputNanoUsdPerToken: 5_000, + cacheWriteNanoUsdPerToken: 1_250, + cacheReadNanoUsdPerToken: 100 + }, + "claude-opus-4-8": { + inputNanoUsdPerToken: 5_000, + outputNanoUsdPerToken: 25_000, + cacheWriteNanoUsdPerToken: 6_250, + cacheReadNanoUsdPerToken: 500 + } +}; + +export const CLAUDE_OPERATING_TARGET_NANO_USD = 200_000_000_000; +export const CLAUDE_HARD_CEILING_NANO_USD = 300_000_000_000; + +export type ClaudeUsage = { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +}; + +export function claudeUsageCostNanoUsd(model: ClaudeModel, usage: ClaudeUsage): number { + const pricing = CLAUDE_PRICING[model]; + return ( + usage.inputTokens * pricing.inputNanoUsdPerToken + + usage.outputTokens * pricing.outputNanoUsdPerToken + + usage.cacheCreationInputTokens * pricing.cacheWriteNanoUsdPerToken + + usage.cacheReadInputTokens * pricing.cacheReadNanoUsdPerToken + ); +} + +export function claudeWorstCaseCostNanoUsd(input: { + model: ClaudeModel; + maximumInputTokens: number; + maximumOutputTokens: number; +}): number { + const pricing = CLAUDE_PRICING[input.model]; + return ( + input.maximumInputTokens * Math.max( + pricing.inputNanoUsdPerToken, + pricing.cacheWriteNanoUsdPerToken + ) + input.maximumOutputTokens * pricing.outputNanoUsdPerToken + ); +} diff --git a/src/agents/claude-spend-ledger.ts b/src/agents/claude-spend-ledger.ts new file mode 100644 index 0000000..85e4035 --- /dev/null +++ b/src/agents/claude-spend-ledger.ts @@ -0,0 +1,272 @@ +import { createHash } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import Database from "better-sqlite3"; +import { stableJson } from "../domain/json.js"; +import { + CLAUDE_HARD_CEILING_NANO_USD, + claudeUsageCostNanoUsd, + type ClaudeModel, + type ClaudeUsage +} from "./claude-pricing.js"; + +const GENESIS_HASH = "0".repeat(64); + +type ReservationRow = { + request_id: string; + case_id: string; + stage: ClaudeStage; + model: ClaudeModel; + maximum_cost_nano_usd: number; +}; + +export type ClaudeStage = "triage" | "analyst" | "smoke"; +export type ClaudeSettlementStatus = "success" | "response_invalid" | "request_rejected" | "billing_unknown"; + +export type ClaudeSpendSummary = { + actualCostNanoUsd: number; + outstandingReservedNanoUsd: number; + hardCeilingNanoUsd: number; + remainingNanoUsd: number; +}; + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function assertNonnegativeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a nonnegative safe integer`); +} + +export class ClaudeSpendLedger { + readonly #db: Database.Database; + readonly hardCeilingNanoUsd: number; + + constructor(path: string, hardCeilingNanoUsd = CLAUDE_HARD_CEILING_NANO_USD) { + assertNonnegativeInteger(hardCeilingNanoUsd, "hardCeilingNanoUsd"); + if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); + this.hardCeilingNanoUsd = hardCeilingNanoUsd; + this.#db = new Database(path); + this.#db.pragma("journal_mode = WAL"); + this.#db.pragma("synchronous = FULL"); + this.#db.exec(` + CREATE TABLE IF NOT EXISTS claude_spend_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id TEXT NOT NULL UNIQUE, + request_id TEXT NOT NULL, + case_id TEXT NOT NULL, + stage TEXT NOT NULL, + model TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('reservation', 'settlement')), + maximum_cost_nano_usd INTEGER NOT NULL, + actual_cost_nano_usd INTEGER, + status TEXT, + usage_json TEXT, + previous_hash TEXT NOT NULL, + entry_hash TEXT NOT NULL UNIQUE, + inserted_at_ms INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS claude_spend_one_reservation + ON claude_spend_events(request_id) WHERE kind = 'reservation'; + CREATE UNIQUE INDEX IF NOT EXISTS claude_spend_one_settlement + ON claude_spend_events(request_id) WHERE kind = 'settlement'; + CREATE TRIGGER IF NOT EXISTS claude_spend_no_update + BEFORE UPDATE ON claude_spend_events BEGIN SELECT RAISE(ABORT, 'Claude spend ledger is append-only'); END; + CREATE TRIGGER IF NOT EXISTS claude_spend_no_delete + BEFORE DELETE ON claude_spend_events BEGIN SELECT RAISE(ABORT, 'Claude spend ledger is append-only'); END; + `); + } + + reserve(input: { + requestId: string; + caseId: string; + stage: ClaudeStage; + model: ClaudeModel; + maximumCostNanoUsd: number; + insertedAtMs?: number; + }): void { + assertNonnegativeInteger(input.maximumCostNanoUsd, "maximumCostNanoUsd"); + this.#db.transaction(() => { + const existing = this.#reservation(input.requestId); + if (existing) { + if ( + existing.case_id !== input.caseId || + existing.stage !== input.stage || + existing.model !== input.model || + existing.maximum_cost_nano_usd !== input.maximumCostNanoUsd + ) { + throw new Error(`Claude request ID collision: ${input.requestId}`); + } + return; + } + const summary = this.#summary(); + const projected = summary.actualCostNanoUsd + summary.outstandingReservedNanoUsd + input.maximumCostNanoUsd; + if (projected > this.hardCeilingNanoUsd) { + throw new Error(`Claude hard spend ceiling would be exceeded by request ${input.requestId}`); + } + this.#append({ + entryId: `${input.requestId}:reservation`, + requestId: input.requestId, + caseId: input.caseId, + stage: input.stage, + model: input.model, + kind: "reservation", + maximumCostNanoUsd: input.maximumCostNanoUsd, + actualCostNanoUsd: null, + status: null, + usageJson: null, + insertedAtMs: input.insertedAtMs ?? Date.now() + }); + })(); + } + + settle(input: { + requestId: string; + status: ClaudeSettlementStatus; + usage?: ClaudeUsage; + insertedAtMs?: number; + }): void { + this.#db.transaction(() => { + const reservation = this.#reservation(input.requestId); + if (!reservation) throw new Error(`No Claude spend reservation for ${input.requestId}`); + const existing = this.#db.prepare( + "SELECT entry_id FROM claude_spend_events WHERE request_id = ? AND kind = 'settlement'" + ).get(input.requestId) as { entry_id: string } | undefined; + if (existing) throw new Error(`Claude request already settled: ${input.requestId}`); + + const actualCostNanoUsd = input.status === "billing_unknown" + ? reservation.maximum_cost_nano_usd + : claudeUsageCostNanoUsd(reservation.model, input.usage ?? { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + }); + if (actualCostNanoUsd > reservation.maximum_cost_nano_usd) { + throw new Error(`Claude actual cost exceeded reservation for ${input.requestId}`); + } + this.#append({ + entryId: `${input.requestId}:settlement`, + requestId: input.requestId, + caseId: reservation.case_id, + stage: reservation.stage, + model: reservation.model, + kind: "settlement", + maximumCostNanoUsd: reservation.maximum_cost_nano_usd, + actualCostNanoUsd, + status: input.status, + usageJson: input.usage === undefined ? null : stableJson(input.usage), + insertedAtMs: input.insertedAtMs ?? Date.now() + }); + })(); + } + + summary(): ClaudeSpendSummary { + return this.#summary(); + } + + verifyChain(): { valid: true; rows: number; headHash: string } { + let previousHash = GENESIS_HASH; + let rows = 0; + for (const row of this.#db.prepare( + "SELECT entry_id, request_id, case_id, stage, model, kind, maximum_cost_nano_usd, actual_cost_nano_usd, status, usage_json, previous_hash, entry_hash FROM claude_spend_events ORDER BY sequence" + ).iterate() as Iterable>) { + if (row.previous_hash !== previousHash) throw new Error(`Broken Claude spend link at ${String(row.entry_id)}`); + const payload = stableJson({ + requestId: row.request_id, + caseId: row.case_id, + stage: row.stage, + model: row.model, + kind: row.kind, + maximumCostNanoUsd: row.maximum_cost_nano_usd, + actualCostNanoUsd: row.actual_cost_nano_usd, + status: row.status, + usageJson: row.usage_json + }); + const expected = sha256(`${previousHash}\n${String(row.entry_id)}\n${payload}`); + if (row.entry_hash !== expected) throw new Error(`Broken Claude spend hash at ${String(row.entry_id)}`); + previousHash = String(row.entry_hash); + rows += 1; + } + return { valid: true, rows, headHash: previousHash }; + } + + close(): void { + this.#db.close(); + } + + #summary(): ClaudeSpendSummary { + const row = this.#db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN kind = 'settlement' THEN actual_cost_nano_usd ELSE 0 END), 0) AS actual, + COALESCE(SUM(CASE WHEN kind = 'reservation' AND request_id NOT IN ( + SELECT request_id FROM claude_spend_events WHERE kind = 'settlement' + ) THEN maximum_cost_nano_usd ELSE 0 END), 0) AS outstanding + FROM claude_spend_events + `).get() as { actual: number; outstanding: number }; + return { + actualCostNanoUsd: row.actual, + outstandingReservedNanoUsd: row.outstanding, + hardCeilingNanoUsd: this.hardCeilingNanoUsd, + remainingNanoUsd: this.hardCeilingNanoUsd - row.actual - row.outstanding + }; + } + + #reservation(requestId: string): ReservationRow | undefined { + return this.#db.prepare(` + SELECT request_id, case_id, stage, model, maximum_cost_nano_usd + FROM claude_spend_events WHERE request_id = ? AND kind = 'reservation' + `).get(requestId) as ReservationRow | undefined; + } + + #append(input: { + entryId: string; + requestId: string; + caseId: string; + stage: ClaudeStage; + model: ClaudeModel; + kind: "reservation" | "settlement"; + maximumCostNanoUsd: number; + actualCostNanoUsd: number | null; + status: ClaudeSettlementStatus | null; + usageJson: string | null; + insertedAtMs: number; + }): void { + const previous = this.#db.prepare( + "SELECT entry_hash FROM claude_spend_events ORDER BY sequence DESC LIMIT 1" + ).get() as { entry_hash: string } | undefined; + const previousHash = previous?.entry_hash ?? GENESIS_HASH; + const payload = stableJson({ + requestId: input.requestId, + caseId: input.caseId, + stage: input.stage, + model: input.model, + kind: input.kind, + maximumCostNanoUsd: input.maximumCostNanoUsd, + actualCostNanoUsd: input.actualCostNanoUsd, + status: input.status, + usageJson: input.usageJson + }); + const entryHash = sha256(`${previousHash}\n${input.entryId}\n${payload}`); + this.#db.prepare(` + INSERT INTO claude_spend_events + (entry_id, request_id, case_id, stage, model, kind, maximum_cost_nano_usd, + actual_cost_nano_usd, status, usage_json, previous_hash, entry_hash, inserted_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.entryId, + input.requestId, + input.caseId, + input.stage, + input.model, + input.kind, + input.maximumCostNanoUsd, + input.actualCostNanoUsd, + input.status, + input.usageJson, + previousHash, + entryHash, + input.insertedAtMs + ); + } +} diff --git a/src/agents/claude.ts b/src/agents/claude.ts new file mode 100644 index 0000000..889a313 --- /dev/null +++ b/src/agents/claude.ts @@ -0,0 +1,299 @@ +import { randomUUID } from "node:crypto"; +import Anthropic from "@anthropic-ai/sdk"; +import type { APIError } from "@anthropic-ai/sdk/core/error"; +import type { + Message, + MessageCreateParamsNonStreaming, + Tool +} from "@anthropic-ai/sdk/resources/messages/messages"; +import { transformJSONSchema } from "@anthropic-ai/sdk/lib/transform-json-schema"; +import { z, type ZodType } from "zod"; +import { stableJson } from "../domain/json.js"; +import { + tradeThesisSchema, + triageDecisionSchema, + type AnalystAgent, + type TriageAgent +} from "./contracts.js"; +import { + CLAUDE_MODEL, + claudeWorstCaseCostNanoUsd, + type ClaudeModel, + type ClaudeUsage +} from "./claude-pricing.js"; +import { ClaudeSpendLedger, type ClaudeStage } from "./claude-spend-ledger.js"; + +export const CLAUDE_PROMPT_VERSION = { + triage: "triage-v1", + analyst: "analyst-v1" +} as const; + +const TRIAGE_SYSTEM = `You are Samaritan's bounded signal triage component. +Classify only the supplied deterministic detector signal. Escalate only when the evidence is coherent enough for analyst review. +You cannot trade, size a position, construct an order, change detector eligibility, or override risk rules. +Your entire response must be one submit_triage tool call.`; + +const ANALYST_SYSTEM = `You are Samaritan's bounded sports-market analyst. +Judge only the supplied detector signal and evidence. Steelman the opposing case and state concrete invalidation conditions. +You cannot size a position, construct or place an order, access a wallet, authenticate to a venue, or override deterministic risk rules. +The recommendation is paper-only. Your only valid exit is one submit_thesis tool call. Never answer in prose.`; + +type RequestOptions = { + timeout?: number; + maxRetries?: number; + signal?: AbortSignal; + idempotencyKey?: string; +}; + +export type ClaudeMessagesClient = { + create(params: MessageCreateParamsNonStreaming, options?: RequestOptions): Promise; +}; + +export function createAnthropicMessagesClient(apiKey: string): ClaudeMessagesClient { + if (apiKey.length === 0) throw new Error("ANTHROPIC_API_KEY is required"); + const client = new Anthropic({ apiKey }); + return { + create: (params, options) => client.messages.create(params, options) + }; +} + +type AdapterConfig = { + client: ClaudeMessagesClient; + spendLedger: ClaudeSpendLedger; + requestId?: () => string; +}; + +type Invocation = { + caseId: string; + stage: ClaudeStage; + model: ClaudeModel; + toolName: string; + schema: ZodType; + maximumInputTokens: number; + maximumOutputTokens: number; + timeoutMs: number; + params: MessageCreateParamsNonStreaming; +}; + +function tool(name: string, description: string, schema: ZodType): Tool { + const jsonSchema = transformJSONSchema(z.toJSONSchema(schema)); + if (jsonSchema.type !== "object") throw new Error(`Tool schema ${name} must be an object`); + return { + name, + description, + input_schema: jsonSchema as Tool.InputSchema, + strict: true + }; +} + +function usage(message: Message): ClaudeUsage { + return { + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + cacheCreationInputTokens: message.usage.cache_creation_input_tokens ?? 0, + cacheReadInputTokens: message.usage.cache_read_input_tokens ?? 0 + }; +} + +function responseToolInput(message: Message, expectedToolName: string): unknown { + if (message.stop_reason !== "tool_use") { + throw new Error(`Claude response stopped with ${message.stop_reason ?? "no stop reason"}`); + } + const toolUses = message.content.filter((block) => block.type === "tool_use"); + if (toolUses.length !== 1 || toolUses[0]?.name !== expectedToolName) { + throw new Error(`Claude response must contain exactly one ${expectedToolName} call`); + } + const unexpected = message.content.filter((block) => + block.type !== "tool_use" && block.type !== "thinking" && block.type !== "redacted_thinking" + ); + if (unexpected.length > 0) throw new Error("Claude response contained content outside the submission tool"); + return toolUses[0].input; +} + +function safeApiError(error: APIError): string { + const detail = error.message + .replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]") + .replace(/\s+/g, " ") + .slice(0, 500); + return `${error.status ?? "unknown"} ${error.type ?? error.name}: ${detail}`; +} + +export class BoundedClaudeInvoker { + readonly #client: ClaudeMessagesClient; + readonly #spendLedger: ClaudeSpendLedger; + readonly #requestId: () => string; + + constructor(config: AdapterConfig) { + this.#client = config.client; + this.#spendLedger = config.spendLedger; + this.#requestId = config.requestId ?? randomUUID; + } + + async invoke(input: Invocation): Promise { + const requestId = this.#requestId(); + // UTF-8 bytes are a deliberately loose token upper bound. The fixed allowance + // covers Anthropic's hidden tool-use prompt and request framing. + const maximumInputTokens = Buffer.byteLength(stableJson(input.params), "utf8") + 4_096; + if (maximumInputTokens > input.maximumInputTokens) { + throw new Error(`${input.stage} Claude request exceeds its input bound`); + } + const maximumCostNanoUsd = claudeWorstCaseCostNanoUsd({ + model: input.model, + maximumInputTokens, + maximumOutputTokens: input.maximumOutputTokens + }); + this.#spendLedger.reserve({ + requestId, + caseId: input.caseId, + stage: input.stage, + model: input.model, + maximumCostNanoUsd + }); + + let message: Message; + try { + message = await this.#client.create(input.params, { + timeout: input.timeoutMs, + maxRetries: 1, + idempotencyKey: requestId + }); + } catch (error) { + if (error instanceof Anthropic.APIError && error.status !== undefined && error.status < 500) { + this.#spendLedger.settle({ requestId, status: "request_rejected" }); + throw new Error(`${input.stage} Claude request rejected (${safeApiError(error)})`); + } + this.#spendLedger.settle({ requestId, status: "billing_unknown" }); + throw new Error(`${input.stage} Claude request failed`); + } + + const measuredUsage = usage(message); + try { + const toolInput = responseToolInput(message, input.toolName); + const parsed = input.schema.parse(toolInput); + this.#spendLedger.settle({ requestId, status: "success", usage: measuredUsage }); + return parsed; + } catch (error) { + this.#spendLedger.settle({ requestId, status: "response_invalid", usage: measuredUsage }); + throw error; + } + } +} + +export class ClaudeTriageAgent implements TriageAgent { + readonly #invoker: BoundedClaudeInvoker; + + constructor(config: AdapterConfig) { + this.#invoker = new BoundedClaudeInvoker(config); + } + + triage(input: Parameters[0]): Promise { + const submissionTool = tool( + "submit_triage", + "Submit the bounded drop-or-escalate classification. This does not execute or authorize a trade.", + triageDecisionSchema + ); + const params: MessageCreateParamsNonStreaming = { + model: CLAUDE_MODEL.triage, + max_tokens: 512, + cache_control: { type: "ephemeral" }, + system: TRIAGE_SYSTEM, + messages: [{ + role: "user", + content: stableJson({ + promptVersion: CLAUDE_PROMPT_VERSION.triage, + caseId: input.caseId, + signal: input.signal + }) + }], + tools: [submissionTool], + tool_choice: { + type: "tool", + name: submissionTool.name, + disable_parallel_tool_use: true + } + }; + return this.#invoker.invoke({ + caseId: input.caseId, + stage: "triage", + model: CLAUDE_MODEL.triage, + toolName: submissionTool.name, + schema: triageDecisionSchema, + maximumInputTokens: 16_000, + maximumOutputTokens: params.max_tokens, + timeoutMs: 60_000, + params + }); + } +} + +export class ClaudeAnalystAgent implements AnalystAgent { + readonly #invoker: BoundedClaudeInvoker; + + constructor(config: AdapterConfig) { + this.#invoker = new BoundedClaudeInvoker(config); + } + + async investigate(input: Parameters[0]): Promise { + const nowTsMs = input.asOfTsMs; + if (!Number.isSafeInteger(nowTsMs) || nowTsMs < input.signal.observedAtTsMs) { + throw new Error("Claude analyst as-of timestamp is invalid"); + } + const boundedThesisSchema = tradeThesisSchema.superRefine((thesis, context) => { + if (thesis.analystModel !== CLAUDE_MODEL.analyst) { + context.addIssue({ + code: "custom", + message: "Unexpected analyst model identity", + path: ["analystModel"] + }); + } + if (thesis.submittedAtTsMs < nowTsMs || thesis.expiresAtTsMs > nowTsMs + 15 * 60_000) { + context.addIssue({ + code: "custom", + message: "Thesis timestamps are outside the bounded analysis window", + path: ["submittedAtTsMs"] + }); + } + }); + const submissionTool = tool( + "submit_thesis", + "Submit the final paper-only thesis. Never include stake, order, wallet, authentication, or execution fields.", + boundedThesisSchema + ); + const params: MessageCreateParamsNonStreaming = { + model: CLAUDE_MODEL.analyst, + max_tokens: 8_192, + cache_control: { type: "ephemeral" }, + thinking: { type: "adaptive", display: "omitted" }, + output_config: { effort: "medium" }, + system: ANALYST_SYSTEM, + messages: [{ + role: "user", + content: stableJson({ + promptVersion: CLAUDE_PROMPT_VERSION.analyst, + caseId: input.caseId, + nowTsMs, + maximumExpiryTsMs: nowTsMs + 15 * 60_000, + requiredAnalystModel: CLAUDE_MODEL.analyst, + triage: input.triage, + signal: input.signal + }) + }], + tools: [submissionTool], + tool_choice: { + type: "auto", + disable_parallel_tool_use: true + } + }; + return this.#invoker.invoke({ + caseId: input.caseId, + stage: "analyst", + model: CLAUDE_MODEL.analyst, + toolName: submissionTool.name, + schema: boundedThesisSchema, + maximumInputTokens: 64_000, + maximumOutputTokens: params.max_tokens, + timeoutMs: 180_000, + params + }); + } +} diff --git a/src/agents/contracts.ts b/src/agents/contracts.ts new file mode 100644 index 0000000..9cbb9fc --- /dev/null +++ b/src/agents/contracts.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import type { DetectorSignal } from "../detectors/types.js"; + +export const triageDecisionSchema = z.object({ + decision: z.enum(["drop", "escalate"]), + priority: z.enum(["low", "normal", "high"]), + rationale: z.string().min(1).max(500) +}).strict(); + +export type TriageDecision = z.infer; + +export const tradeThesisSchema = z.object({ + schemaVersion: z.literal(1), + signalId: z.string().min(1), + fixtureId: z.string().min(1), + marketKey: z.string().min(1), + outcome: z.enum(["home", "draw", "away", "over", "under"]), + direction: z.enum(["buy", "sell"]), + recommendation: z.enum(["paper_trade", "no_trade"]), + fairProbability: z.number().finite().min(0).max(1), + thesisSummary: z.string().min(1).max(2_000), + evidenceFor: z.array(z.string().min(1).max(500)).min(1).max(12), + steelmanAgainst: z.string().min(1).max(2_000), + invalidationConditions: z.array(z.string().min(1).max(500)).min(1).max(12), + submittedAtTsMs: z.number().int().nonnegative(), + expiresAtTsMs: z.number().int().nonnegative(), + analystModel: z.string().min(1).max(100) +}).strict().superRefine((thesis, context) => { + if (thesis.expiresAtTsMs <= thesis.submittedAtTsMs) { + context.addIssue({ + code: "custom", + message: "Thesis expiry must be after submission", + path: ["expiresAtTsMs"] + }); + } +}); + +export type TradeThesis = z.infer; + +export type TriageAgent = { + triage(input: { caseId: string; signal: DetectorSignal }): Promise; +}; + +export type AnalystAgent = { + investigate(input: { + caseId: string; + signal: DetectorSignal; + triage: TriageDecision; + asOfTsMs: number; + }): Promise; +}; + +export function assertThesisMatchesSignal(thesis: TradeThesis, signal: DetectorSignal): void { + if ( + thesis.signalId !== signal.signalId || + thesis.fixtureId !== signal.fixtureId || + thesis.marketKey !== signal.market.key || + thesis.outcome !== signal.outcome || + thesis.direction !== signal.direction + ) { + throw new Error("Trade thesis identity or direction does not match the detector signal"); + } +} diff --git a/src/agents/haiku-smoke.ts b/src/agents/haiku-smoke.ts new file mode 100644 index 0000000..a97372f --- /dev/null +++ b/src/agents/haiku-smoke.ts @@ -0,0 +1,67 @@ +import { loadEnvFile } from "node:process"; +import { resolve } from "node:path"; +import type { DetectorSignal } from "../detectors/types.js"; +import { ClaudeTriageAgent, createAnthropicMessagesClient } from "./claude.js"; +import { CLAUDE_MODEL } from "./claude-pricing.js"; +import { ClaudeSpendLedger } from "./claude-spend-ledger.js"; + +loadEnvFile(resolve(process.cwd(), ".env")); +const apiKey = process.env.ANTHROPIC_API_KEY; +if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not configured"); + +const nowTsMs = Date.now(); +const smokeSignal: DetectorSignal = { + signalId: `claude-smoke-${nowTsMs}`, + kind: "CONSENSUS_MOVE", + detectedAtTsMs: nowTsMs, + observedAtTsMs: nowTsMs, + fixtureId: "synthetic-smoke-fixture", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "synthetic-smoke-fixture:total_goals:full_time:2500" + }, + outcome: "over", + direction: "buy", + eligibility: "research_only", + reason: "Synthetic adapter smoke; no executable market and no trade authorization.", + evidence: { + consensusProbability: 0.51, + polymarketProbability: 0.51, + consensusVelocity: 0, + consensusZScore: 0, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0, + cusumDown: 0, + rawGap: 0, + gapBasis: "sampled_history_proxy", + persistenceMs: 0, + mappingStatus: null, + scoreContextActions: [] + } +}; + +const ledger = new ClaudeSpendLedger(resolve("data/agents/claude-spend.sqlite")); +try { + const agent = new ClaudeTriageAgent({ + client: createAnthropicMessagesClient(apiKey), + spendLedger: ledger + }); + const decision = await agent.triage({ + caseId: `case-${smokeSignal.signalId}`, + signal: smokeSignal + }); + const summary = ledger.summary(); + process.stdout.write(`${JSON.stringify({ + ok: true, + model: CLAUDE_MODEL.triage, + decision, + spendNanoUsd: summary.actualCostNanoUsd, + outstandingReservedNanoUsd: summary.outstandingReservedNanoUsd, + spendChain: ledger.verifyChain() + }, null, 2)}\n`); +} finally { + ledger.close(); +} diff --git a/src/agents/opus-smoke.ts b/src/agents/opus-smoke.ts new file mode 100644 index 0000000..ded03cb --- /dev/null +++ b/src/agents/opus-smoke.ts @@ -0,0 +1,73 @@ +import { resolve } from "node:path"; +import { loadEnvFile } from "node:process"; +import type { DetectorSignal } from "../detectors/types.js"; +import { ClaudeAnalystAgent, createAnthropicMessagesClient } from "./claude.js"; +import { CLAUDE_MODEL } from "./claude-pricing.js"; +import { ClaudeSpendLedger } from "./claude-spend-ledger.js"; + +loadEnvFile(resolve(process.cwd(), ".env")); +const apiKey = process.env.ANTHROPIC_API_KEY; +if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not configured"); + +const nowTsMs = Date.now(); +const smokeSignal: DetectorSignal = { + signalId: `claude-opus-smoke-${nowTsMs}`, + kind: "CONSENSUS_MOVE", + detectedAtTsMs: nowTsMs, + observedAtTsMs: nowTsMs, + fixtureId: "synthetic-opus-smoke-fixture", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "synthetic-opus-smoke-fixture:total_goals:full_time:2500" + }, + outcome: "over", + direction: "buy", + eligibility: "research_only", + reason: "Synthetic analyst adapter smoke with no executable fixture.", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.51, + consensusVelocity: 0.02, + consensusZScore: 1.2, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.001, + cusumDown: 0, + rawGap: 0.04, + gapBasis: "sampled_history_proxy", + persistenceMs: 5_000, + mappingStatus: null, + scoreContextActions: [] + } +}; + +const ledger = new ClaudeSpendLedger(resolve("data/agents/claude-spend.sqlite")); +try { + const agent = new ClaudeAnalystAgent({ + client: createAnthropicMessagesClient(apiKey), + spendLedger: ledger + }); + const thesis = await agent.investigate({ + caseId: `case-${smokeSignal.signalId}`, + signal: smokeSignal, + asOfTsMs: nowTsMs, + triage: { + decision: "escalate", + priority: "low", + rationale: "Synthetic adapter contract verification only." + } + }); + const summary = ledger.summary(); + process.stdout.write(`${JSON.stringify({ + ok: true, + model: CLAUDE_MODEL.analyst, + thesis, + totalSpendNanoUsd: summary.actualCostNanoUsd, + outstandingReservedNanoUsd: summary.outstandingReservedNanoUsd, + spendChain: ledger.verifyChain() + }, null, 2)}\n`); +} finally { + ledger.close(); +} diff --git a/src/bus/events.ts b/src/bus/events.ts index 234faae..3f72075 100644 --- a/src/bus/events.ts +++ b/src/bus/events.ts @@ -91,6 +91,18 @@ export type PolymarketBookEvent = CanonicalEventBase & { tickSize: string | null; }; +export type PolymarketResolutionEvent = CanonicalEventBase & { + kind: "polymarket.resolution"; + source: "polymarket"; + fixtureId: string; + market: CanonicalMarket; + mappingStatus: MappingStatus; + conditionId: string; + assetIds: string[]; + winningAssetId: string; + winningOutcomeLabel: string; +}; + export type FeedEvent = CanonicalEventBase & { kind: "feed.heartbeat" | "feed.status"; fixtureId: null; @@ -104,6 +116,7 @@ export type CanonicalEvent = | ScoreEvent | PolymarketPriceEvent | PolymarketBookEvent + | PolymarketResolutionEvent | FeedEvent; export function marketKey( diff --git a/src/chain/decision-anchor-submit.ts b/src/chain/decision-anchor-submit.ts new file mode 100644 index 0000000..9986675 --- /dev/null +++ b/src/chain/decision-anchor-submit.ts @@ -0,0 +1,230 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + Connection, + Keypair, + Transaction, + clusterApiUrl, + type ParsedTransactionWithMeta, + type Signer +} from "@solana/web3.js"; +import { decisionReceiptSchema, verifyDecisionReceipt } from "../proof/decision-receipt-schema.js"; +import { + SOLANA_DEVNET_GENESIS_HASH, + assertDevnetOnly, + buildDecisionAnchorInstruction, + verifyDecisionAnchorIntent, + verifyDecisionAnchorOnChain, + type DecisionAnchorIntent, + type DecisionAnchorNetworkVerification, + type DecisionAnchorReadRpc +} from "./decision-anchor.js"; + +export const SOLANA_ANCHOR_SIGNER_PATH_ENV = "SAMARITAN_SOLANA_ANCHOR_KEYPAIR_PATH" as const; +export const SOLANA_ANCHOR_RPC_URL_ENV = "SAMARITAN_SOLANA_DEVNET_RPC_URL" as const; + +export type DecisionAnchorSubmitRpc = DecisionAnchorReadRpc & { + getLatestBlockhash(): Promise<{ blockhash: string; lastValidBlockHeight: number }>; + sendRawTransaction(rawTransaction: Buffer): Promise; + confirmTransaction(input: { + signature: string; + blockhash: string; + lastValidBlockHeight: number; + }): Promise<{ value: { err: unknown | null } }>; +}; + +export type DecisionAnchorSubmitDependencies = { + assertSignerPathIgnored(path: string): void | Promise; + connect(rpcUrl: string): DecisionAnchorSubmitRpc; + loadSigner(path: string): Signer | Promise; + signAndSend(input: { + rpc: DecisionAnchorSubmitRpc; + signer: Signer; + intent: DecisionAnchorIntent; + }): Promise; +}; + +export type DecisionAnchorSubmitRequest = { + receipt: unknown; + intent: unknown; + network: string; + confirmedReceiptHash: string; + signerPath?: string; + humanApproved: boolean; + rpcUrl?: string; +}; + +function checkedRpcUrl(value: string | undefined): string { + const rpcUrl = value ?? clusterApiUrl("devnet"); + let parsed: URL; + try { + parsed = new URL(rpcUrl); + } catch { + throw new Error("Solana RPC URL is invalid"); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Solana RPC URL must use http or https"); + } + return rpcUrl; +} + +export function authorizeDecisionAnchorSubmission(input: DecisionAnchorSubmitRequest): { + receiptHash: string; + signerPath: string; + rpcUrl: string; + intent: DecisionAnchorIntent; +} { + assertDevnetOnly(input.network); + if (!input.humanApproved) { + throw new Error("Human approval is required: pass the explicit devnet write approval flag"); + } + if (!input.signerPath?.trim()) { + throw new Error( + `A signer path is required via --signer or ${SOLANA_ANCHOR_SIGNER_PATH_ENV}` + ); + } + verifyDecisionReceipt(input.receipt); + const receipt = decisionReceiptSchema.parse(input.receipt); + if (input.confirmedReceiptHash !== receipt.integrity.receiptHash) { + throw new Error("Exact receipt-hash confirmation does not match the supplied receipt"); + } + const intent = verifyDecisionAnchorIntent(input.intent, receipt); + return { + receiptHash: receipt.integrity.receiptHash, + signerPath: resolve(input.signerPath), + rpcUrl: checkedRpcUrl(input.rpcUrl), + intent + }; +} + +/** + * The only write-capable entry point. Every fail-closed authorization check, + * ignored-path check, and RPC genesis check runs before the signer is read. + */ +export async function submitDecisionAnchor( + input: DecisionAnchorSubmitRequest, + dependencies: DecisionAnchorSubmitDependencies +): Promise { + const authorized = authorizeDecisionAnchorSubmission(input); + await dependencies.assertSignerPathIgnored(authorized.signerPath); + const rpc = dependencies.connect(authorized.rpcUrl); + const genesisHash = await rpc.getGenesisHash(); + if (genesisHash !== SOLANA_DEVNET_GENESIS_HASH) { + throw new Error( + `Solana RPC network mismatch: anchor submission refuses genesis ${genesisHash}` + ); + } + const signer = await dependencies.loadSigner(authorized.signerPath); + const signature = await dependencies.signAndSend({ + rpc, + signer, + intent: authorized.intent + }); + try { + return await verifyDecisionAnchorOnChain({ + receipt: input.receipt, + signature, + network: "devnet", + rpc + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Anchor transaction ${signature} was submitted; post-submit verification failed: ${message}. ` + + "Verify this signature before any retry." + ); + } +} + +function assertSignerPathIsGitIgnored(path: string): void { + const result = spawnSync("git", ["check-ignore", "-q", "--", path], { + cwd: process.cwd(), + stdio: "ignore" + }); + if (result.status !== 0) { + throw new Error( + "Signer path is not covered by this repository's .gitignore; place it under .wallet/" + ); + } +} + +function loadSolanaKeypair(path: string): Keypair { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if ( + !Array.isArray(parsed) || + parsed.length !== 64 || + parsed.some((value) => !Number.isInteger(value) || value < 0 || value > 255) + ) { + throw new Error("Signer file must contain a Solana 64-byte secret-key JSON array"); + } + return Keypair.fromSecretKey(Uint8Array.from(parsed)); +} + +function createDecisionAnchorSubmitRpc(rpcUrl: string): DecisionAnchorSubmitRpc { + const connection = new Connection(rpcUrl, { commitment: "confirmed" }); + return { + getGenesisHash: () => connection.getGenesisHash(), + getParsedTransaction: (signature: string): Promise => + connection.getParsedTransaction(signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0 + }), + getLatestBlockhash: () => connection.getLatestBlockhash("confirmed"), + sendRawTransaction: (rawTransaction: Buffer) => connection.sendRawTransaction(rawTransaction, { + skipPreflight: false, + preflightCommitment: "confirmed", + maxRetries: 3 + }), + confirmTransaction: async (confirmation) => { + const result = await connection.confirmTransaction(confirmation, "confirmed"); + return { value: { err: result.value.err } }; + } + }; +} + +async function signAndSendDecisionAnchor(input: { + rpc: DecisionAnchorSubmitRpc; + signer: Signer; + intent: DecisionAnchorIntent; +}): Promise { + const latestBlockhash = await input.rpc.getLatestBlockhash(); + const transaction = new Transaction({ + feePayer: input.signer.publicKey, + recentBlockhash: latestBlockhash.blockhash + }).add(buildDecisionAnchorInstruction(input.intent)); + transaction.sign(input.signer); + const signature = await input.rpc.sendRawTransaction(transaction.serialize()); + let confirmation: { value: { err: unknown | null } }; + try { + confirmation = await input.rpc.confirmTransaction({ + signature, + blockhash: latestBlockhash.blockhash, + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Solana returned anchor signature ${signature}, but confirmation failed: ${message}. ` + + "Verify this signature before any retry." + ); + } + if (confirmation.value.err !== null) { + throw new Error(`Solana anchor transaction ${signature} failed during confirmation`); + } + return signature; +} + +const defaultDecisionAnchorSubmitDependencies: DecisionAnchorSubmitDependencies = { + assertSignerPathIgnored: assertSignerPathIsGitIgnored, + connect: createDecisionAnchorSubmitRpc, + loadSigner: loadSolanaKeypair, + signAndSend: signAndSendDecisionAnchor +}; + +/** Production wrapper: callers cannot reach the signer/send backend without the guards. */ +export function submitDecisionAnchorWithDefaultDependencies( + input: DecisionAnchorSubmitRequest +): Promise { + return submitDecisionAnchor(input, defaultDecisionAnchorSubmitDependencies); +} diff --git a/src/chain/decision-anchor.ts b/src/chain/decision-anchor.ts new file mode 100644 index 0000000..1d93783 --- /dev/null +++ b/src/chain/decision-anchor.ts @@ -0,0 +1,350 @@ +import { + PublicKey, + TransactionInstruction, + type ParsedInstruction, + type ParsedTransactionWithMeta, + type PartiallyDecodedInstruction +} from "@solana/web3.js"; +import { z } from "zod"; +import { stableJson } from "../domain/json.js"; +import { + DECISION_RECEIPT_SCHEMA_VERSION, + decisionReceiptSchema, + sha256, + verifyDecisionReceipt, + type DecisionReceipt +} from "../proof/decision-receipt-schema.js"; + +export const DECISION_ANCHOR_SCHEMA_VERSION = 1 as const; +export const DECISION_ANCHOR_MEMO_DOMAIN = "samaritan.decision-receipt.anchor/v1" as const; +export const DECISION_ANCHOR_INTENT_HASH_DOMAIN = + "samaritan.decision-receipt.anchor-intent/v1" as const; +export const DECISION_ANCHOR_COMMITMENT_TYPE = + "decision_receipt_and_ledger_head_sha256" as const; +export const SOLANA_MEMO_PROGRAM_ID = + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" as const; +export const SOLANA_DEVNET_GENESIS_HASH = + "EtWTRABZaYq6iMfeYKouRu166VU2xqa1" as const; +export const SOLANA_MAINNET_BETA_GENESIS_HASH = + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" as const; +export const SOLANA_MEMO_MAX_BYTES = 566 as const; + +const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/); +const signatureSchema = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{64,88}$/); + +export const decisionAnchorMemoPayloadSchema = z.object({ + schemaVersion: z.literal(DECISION_ANCHOR_SCHEMA_VERSION), + anchorType: z.literal("samaritan_decision_receipt_anchor"), + network: z.literal("devnet"), + commitmentType: z.literal(DECISION_ANCHOR_COMMITMENT_TYPE), + receipt: z.object({ + schemaVersion: z.literal(DECISION_RECEIPT_SCHEMA_VERSION), + hash: sha256Schema + }).strict(), + ledgerHead: z.object({ + hashSchemaVersion: z.literal(2), + sequence: z.number().int().positive().safe(), + hash: sha256Schema + }).strict() +}).strict(); + +export type DecisionAnchorMemoPayload = z.infer; + +const decisionAnchorIntentBodySchema = z.object({ + schemaVersion: z.literal(DECISION_ANCHOR_SCHEMA_VERSION), + intentType: z.literal("samaritan_unsigned_solana_memo_transaction_intent"), + network: z.literal("devnet"), + transaction: z.object({ + format: z.literal("legacy"), + feePayer: z.literal("supplied_only_at_submit"), + recentBlockhash: z.literal("fetched_only_at_submit"), + signatures: z.literal("none"), + instructionCount: z.literal(1) + }).strict(), + instruction: z.object({ + programId: z.literal(SOLANA_MEMO_PROGRAM_ID), + keys: z.array(z.string()).max(0), + dataEncoding: z.literal("utf8"), + data: z.string().min(1) + }).strict(), + commitment: decisionAnchorMemoPayloadSchema, + preparation: z.object({ + offline: z.literal(true), + signerAccessed: z.literal(false), + networkAccessed: z.literal(false) + }).strict() +}).strict(); + +export const decisionAnchorIntentSchema = decisionAnchorIntentBodySchema.extend({ + integrity: z.object({ + algorithm: z.literal("sha256"), + canonicalization: z.literal("samaritan-stable-json-v1"), + domain: z.literal(DECISION_ANCHOR_INTENT_HASH_DOMAIN), + intentHash: sha256Schema + }).strict() +}).strict(); + +export type DecisionAnchorIntent = z.infer; + +export function assertDevnetOnly(network: string): asserts network is "devnet" { + if (network !== "devnet") { + throw new Error( + `Solana decision anchoring is devnet-only; ${network || "an empty network"} is forbidden` + ); + } +} + +export function buildDecisionAnchorMemoPayload( + receiptValue: unknown, + network = "devnet" +): DecisionAnchorMemoPayload { + assertDevnetOnly(network); + verifyDecisionReceipt(receiptValue); + const receipt = decisionReceiptSchema.parse(receiptValue); + return decisionAnchorMemoPayloadSchema.parse({ + schemaVersion: DECISION_ANCHOR_SCHEMA_VERSION, + anchorType: "samaritan_decision_receipt_anchor", + network, + commitmentType: DECISION_ANCHOR_COMMITMENT_TYPE, + receipt: { + schemaVersion: receipt.schemaVersion, + hash: receipt.integrity.receiptHash + }, + ledgerHead: { + hashSchemaVersion: 2, + sequence: receipt.ledger.finalHeadSequence, + hash: receipt.ledger.finalHeadHash + } + }); +} + +export function canonicalDecisionAnchorMemo(payloadValue: unknown): string { + const payload = decisionAnchorMemoPayloadSchema.parse(payloadValue); + const memo = `${DECISION_ANCHOR_MEMO_DOMAIN}\n${stableJson(payload)}`; + const bytes = Buffer.byteLength(memo, "utf8"); + if (bytes > SOLANA_MEMO_MAX_BYTES) { + throw new Error(`Decision anchor memo is ${bytes} bytes; maximum is ${SOLANA_MEMO_MAX_BYTES}`); + } + return memo; +} + +export function parseCanonicalDecisionAnchorMemo(memo: string): DecisionAnchorMemoPayload { + const prefix = `${DECISION_ANCHOR_MEMO_DOMAIN}\n`; + if (!memo.startsWith(prefix)) throw new Error("Solana memo has the wrong Samaritan domain"); + let parsed: unknown; + try { + parsed = JSON.parse(memo.slice(prefix.length)) as unknown; + } catch { + throw new Error("Solana memo payload is not valid JSON"); + } + const payload = decisionAnchorMemoPayloadSchema.parse(parsed); + if (canonicalDecisionAnchorMemo(payload) !== memo) { + throw new Error("Solana memo payload is valid but not canonically encoded"); + } + return payload; +} + +function hashIntentBody(body: z.infer): string { + return sha256(`${DECISION_ANCHOR_INTENT_HASH_DOMAIN}\n${stableJson(body)}`); +} + +/** + * Builds a deterministic unsigned transaction intent. This function does not + * construct or read a signer, request a blockhash, or make an RPC call. + */ +export function buildDecisionAnchorIntent( + receiptValue: unknown, + network = "devnet" +): DecisionAnchorIntent { + const commitment = buildDecisionAnchorMemoPayload(receiptValue, network); + const memo = canonicalDecisionAnchorMemo(commitment); + const body = decisionAnchorIntentBodySchema.parse({ + schemaVersion: DECISION_ANCHOR_SCHEMA_VERSION, + intentType: "samaritan_unsigned_solana_memo_transaction_intent", + network: commitment.network, + transaction: { + format: "legacy", + feePayer: "supplied_only_at_submit", + recentBlockhash: "fetched_only_at_submit", + signatures: "none", + instructionCount: 1 + }, + instruction: { + programId: SOLANA_MEMO_PROGRAM_ID, + keys: [], + dataEncoding: "utf8", + data: memo + }, + commitment, + preparation: { + offline: true, + signerAccessed: false, + networkAccessed: false + } + }); + return decisionAnchorIntentSchema.parse({ + ...body, + integrity: { + algorithm: "sha256", + canonicalization: "samaritan-stable-json-v1", + domain: DECISION_ANCHOR_INTENT_HASH_DOMAIN, + intentHash: hashIntentBody(body) + } + }); +} + +export function verifyDecisionAnchorIntent( + intentValue: unknown, + receiptValue: unknown +): DecisionAnchorIntent { + assertDevnetOnly( + typeof intentValue === "object" && intentValue !== null && "network" in intentValue + ? String(intentValue.network) + : "" + ); + const intent = decisionAnchorIntentSchema.parse(intentValue); + const { integrity: _integrity, ...bodyValue } = intent; + const body = decisionAnchorIntentBodySchema.parse(bodyValue); + if (intent.integrity.intentHash !== hashIntentBody(body)) { + throw new Error("Decision anchor intent canonical hash mismatch"); + } + const expected = buildDecisionAnchorIntent(receiptValue, "devnet"); + if (stableJson(intent) !== stableJson(expected)) { + throw new Error("Decision anchor intent does not match the supplied decision receipt"); + } + const memoPayload = parseCanonicalDecisionAnchorMemo(intent.instruction.data); + if (stableJson(memoPayload) !== stableJson(intent.commitment)) { + throw new Error("Decision anchor instruction and commitment payload differ"); + } + return intent; +} + +export function buildDecisionAnchorInstruction(intentValue: unknown): TransactionInstruction { + const intent = decisionAnchorIntentSchema.parse(intentValue); + assertDevnetOnly(intent.network); + parseCanonicalDecisionAnchorMemo(intent.instruction.data); + return new TransactionInstruction({ + keys: [], + programId: new PublicKey(SOLANA_MEMO_PROGRAM_ID), + data: Buffer.from(intent.instruction.data, "utf8") + }); +} + +export type DecisionAnchorReadRpc = { + getGenesisHash(): Promise; + getParsedTransaction(signature: string): Promise; +}; + +function memoText( + instruction: ParsedInstruction | PartiallyDecodedInstruction +): string | null { + if (!instruction.programId.equals(new PublicKey(SOLANA_MEMO_PROGRAM_ID))) return null; + if (!("parsed" in instruction)) { + throw new Error("Solana RPC returned an undecoded Memo instruction"); + } + if (typeof instruction.parsed === "string") return instruction.parsed; + if ( + typeof instruction.parsed === "object" && + instruction.parsed !== null && + "memo" in instruction.parsed && + typeof instruction.parsed.memo === "string" + ) return instruction.parsed.memo; + throw new Error("Solana RPC returned an unsupported parsed Memo instruction"); +} + +export type DecisionAnchorNetworkVerification = { + schemaVersion: typeof DECISION_ANCHOR_SCHEMA_VERSION; + verificationType: "samaritan_solana_decision_anchor_verification"; + network: "devnet"; + genesisHash: typeof SOLANA_DEVNET_GENESIS_HASH; + transactionSignature: string; + slot: number; + blockTimeTsMs: number | null; + rpcCommitment: "confirmed"; + memoProgramId: typeof SOLANA_MEMO_PROGRAM_ID; + commitmentType: typeof DECISION_ANCHOR_COMMITMENT_TYPE; + receiptSchemaVersion: typeof DECISION_RECEIPT_SCHEMA_VERSION; + receiptHash: string; + ledgerHeadSequence: number; + ledgerHeadHash: string; + assurance: [ + "devnet_genesis_hash_verified", + "transaction_succeeded_at_confirmed_commitment", + "canonical_memo_matches_receipt_and_ledger_head" + ]; +}; + +/** + * Performs only Solana read calls. It does not sign, send, persist, or mutate + * either the receipt or an anchor sidecar. + */ +export async function verifyDecisionAnchorOnChain(input: { + receipt: unknown; + signature: string; + network: string; + rpc: DecisionAnchorReadRpc; +}): Promise { + assertDevnetOnly(input.network); + const signature = signatureSchema.parse(input.signature); + verifyDecisionReceipt(input.receipt); + const receipt: DecisionReceipt = decisionReceiptSchema.parse(input.receipt); + const genesisHash = await input.rpc.getGenesisHash(); + if (genesisHash !== SOLANA_DEVNET_GENESIS_HASH) { + throw new Error( + `Solana RPC network mismatch: expected devnet genesis ${SOLANA_DEVNET_GENESIS_HASH}, received ${genesisHash}` + ); + } + const transaction = await input.rpc.getParsedTransaction(signature); + if (!transaction) throw new Error(`Solana transaction ${signature} was not found at confirmed commitment`); + if (!transaction.transaction.signatures.includes(signature)) { + throw new Error("Solana RPC transaction does not contain the requested signature"); + } + if (!transaction.meta) throw new Error("Solana transaction metadata is unavailable"); + if (transaction.meta.err !== null) throw new Error("Solana anchor transaction failed on-chain"); + + const memos = transaction.transaction.message.instructions + .map(memoText) + .filter((value): value is string => value !== null); + if (memos.length !== 1) { + throw new Error(`Expected exactly one Solana Memo instruction, found ${memos.length}`); + } + const payload = parseCanonicalDecisionAnchorMemo(memos[0]!); + if (payload.network !== input.network) throw new Error("Solana memo commits the wrong network"); + if (payload.receipt.schemaVersion !== receipt.schemaVersion) { + throw new Error("Solana memo commits the wrong decision receipt schema version"); + } + if (payload.receipt.hash !== receipt.integrity.receiptHash) { + throw new Error("Solana memo receipt hash does not match the supplied receipt"); + } + if ( + payload.ledgerHead.sequence !== receipt.ledger.finalHeadSequence || + payload.ledgerHead.hash !== receipt.ledger.finalHeadHash + ) { + throw new Error("Solana memo ledger head does not match the supplied receipt"); + } + + const blockTimeTsMs = transaction.blockTime === null || transaction.blockTime === undefined + ? null + : transaction.blockTime * 1_000; + return { + schemaVersion: DECISION_ANCHOR_SCHEMA_VERSION, + verificationType: "samaritan_solana_decision_anchor_verification", + network: "devnet", + genesisHash: SOLANA_DEVNET_GENESIS_HASH, + transactionSignature: signature, + slot: transaction.slot, + blockTimeTsMs, + rpcCommitment: "confirmed", + memoProgramId: SOLANA_MEMO_PROGRAM_ID, + commitmentType: DECISION_ANCHOR_COMMITMENT_TYPE, + receiptSchemaVersion: receipt.schemaVersion, + receiptHash: receipt.integrity.receiptHash, + ledgerHeadSequence: receipt.ledger.finalHeadSequence, + ledgerHeadHash: receipt.ledger.finalHeadHash, + assurance: [ + "devnet_genesis_hash_verified", + "transaction_succeeded_at_confirmed_commitment", + "canonical_memo_matches_receipt_and_ledger_head" + ] + }; +} diff --git a/src/chain/prepare-decision-anchor.ts b/src/chain/prepare-decision-anchor.ts new file mode 100644 index 0000000..fba0971 --- /dev/null +++ b/src/chain/prepare-decision-anchor.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildDecisionAnchorIntent } from "./decision-anchor.js"; + +function usage(): never { + throw new Error( + "Usage: pnpm anchor:prepare -- [--network devnet]" + ); +} + +function main(): void { + const args = process.argv.slice(2).filter((argument) => argument !== "--"); + const receiptArgument = args[0]; + if (!receiptArgument || receiptArgument.startsWith("--")) usage(); + let network = "devnet"; + for (let index = 1; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--network") { + const value = args[index + 1]; + if (!value) usage(); + network = value; + index += 1; + continue; + } + usage(); + } + const receiptPath = resolve(receiptArgument); + const receipt = JSON.parse(readFileSync(receiptPath, "utf8")) as unknown; + const intent = buildDecisionAnchorIntent(receipt, network); + process.stdout.write(`${JSON.stringify(intent, null, 2)}\n`); +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Decision anchor preparation failed: ${message}\n`); + process.exitCode = 1; +} diff --git a/src/chain/submit-decision-anchor.ts b/src/chain/submit-decision-anchor.ts new file mode 100644 index 0000000..9b6611a --- /dev/null +++ b/src/chain/submit-decision-anchor.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + SOLANA_ANCHOR_RPC_URL_ENV, + SOLANA_ANCHOR_SIGNER_PATH_ENV, + submitDecisionAnchorWithDefaultDependencies +} from "./decision-anchor-submit.js"; + +type SubmitCliArguments = { + receiptPath: string; + intentPath: string; + network: string; + confirmedReceiptHash: string; + signerPath?: string; + rpcUrl?: string; + humanApproved: boolean; +}; + +function usage(): never { + throw new Error( + "Usage: pnpm anchor:submit -- --receipt --intent " + + "--network devnet --confirm-receipt-hash <64-hex> --approve-devnet-write " + + `[--signer ] [--rpc-url ] (signer env: ${SOLANA_ANCHOR_SIGNER_PATH_ENV})` + ); +} + +function parseArguments(): SubmitCliArguments { + const args = process.argv.slice(2).filter((argument) => argument !== "--"); + const values = new Map(); + let humanApproved = false; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--approve-devnet-write") { + humanApproved = true; + continue; + } + if (!argument?.startsWith("--")) usage(); + const value = args[index + 1]; + if (!value || value.startsWith("--")) usage(); + values.set(argument, value); + index += 1; + } + const receiptPath = values.get("--receipt"); + const intentPath = values.get("--intent"); + const network = values.get("--network"); + const confirmedReceiptHash = values.get("--confirm-receipt-hash"); + if (!receiptPath || !intentPath || !network || !confirmedReceiptHash) usage(); + const known = new Set([ + "--receipt", + "--intent", + "--network", + "--confirm-receipt-hash", + "--signer", + "--rpc-url" + ]); + if ([...values.keys()].some((key) => !known.has(key))) usage(); + const signerPath = values.get("--signer") ?? process.env[SOLANA_ANCHOR_SIGNER_PATH_ENV]; + const rpcUrl = values.get("--rpc-url") ?? process.env[SOLANA_ANCHOR_RPC_URL_ENV]; + return { + receiptPath: resolve(receiptPath), + intentPath: resolve(intentPath), + network, + confirmedReceiptHash, + ...(signerPath === undefined ? {} : { signerPath }), + ...(rpcUrl === undefined ? {} : { rpcUrl }), + humanApproved + }; +} + +async function main(): Promise { + const args = parseArguments(); + const receipt = JSON.parse(readFileSync(args.receiptPath, "utf8")) as unknown; + const intent = JSON.parse(readFileSync(args.intentPath, "utf8")) as unknown; + const result = await submitDecisionAnchorWithDefaultDependencies({ + receipt, + intent, + network: args.network, + confirmedReceiptHash: args.confirmedReceiptHash, + humanApproved: args.humanApproved, + ...(args.signerPath === undefined ? {} : { signerPath: args.signerPath }), + ...(args.rpcUrl === undefined ? {} : { rpcUrl: args.rpcUrl }) + }); + process.stdout.write(`${JSON.stringify({ + ...result, + note: "Devnet write completed after explicit human approval; the source receipt was not modified." + }, null, 2)}\n`); +} + +try { + await main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Decision anchor submission failed: ${message}\n`); + process.exitCode = 1; +} diff --git a/src/chain/verify-decision-anchor.ts b/src/chain/verify-decision-anchor.ts new file mode 100644 index 0000000..4e56de4 --- /dev/null +++ b/src/chain/verify-decision-anchor.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { Connection, clusterApiUrl } from "@solana/web3.js"; +import { verifyDecisionAnchorOnChain, type DecisionAnchorReadRpc } from "./decision-anchor.js"; + +function usage(): never { + throw new Error( + "Usage: pnpm anchor:verify -- --receipt --signature " + + "--network devnet [--rpc-url ]" + ); +} + +function argumentsMap(): Map { + const args = process.argv.slice(2).filter((argument) => argument !== "--"); + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || !value || value.startsWith("--")) usage(); + values.set(key, value); + } + const known = new Set(["--receipt", "--signature", "--network", "--rpc-url"]); + if ([...values.keys()].some((key) => !known.has(key))) usage(); + return values; +} + +async function main(): Promise { + const values = argumentsMap(); + const receiptArgument = values.get("--receipt"); + const signature = values.get("--signature"); + const network = values.get("--network"); + if (!receiptArgument || !signature || !network) usage(); + const receiptPath = resolve(receiptArgument); + const receipt = JSON.parse(readFileSync(receiptPath, "utf8")) as unknown; + const connection = new Connection(values.get("--rpc-url") ?? clusterApiUrl("devnet"), { + commitment: "confirmed" + }); + const rpc: DecisionAnchorReadRpc = { + getGenesisHash: () => connection.getGenesisHash(), + getParsedTransaction: (transactionSignature) => connection.getParsedTransaction( + transactionSignature, + { commitment: "confirmed", maxSupportedTransactionVersion: 0 } + ) + }; + const result = await verifyDecisionAnchorOnChain({ receipt, signature, network, rpc }); + process.stdout.write(`${JSON.stringify({ + file: receiptPath, + ...result, + note: "Read-only Solana verification; no receipt, sidecar, wallet, or network state was modified." + }, null, 2)}\n`); +} + +try { + await main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Decision anchor verification failed: ${message}\n`); + process.exitCode = 1; +} diff --git a/src/config/paper-study.ts b/src/config/paper-study.ts new file mode 100644 index 0000000..747b41f --- /dev/null +++ b/src/config/paper-study.ts @@ -0,0 +1,57 @@ +import type { DetectorBankConfig } from "../detectors/bank.js"; +import type { FeatureEngineConfig } from "../features/engine.js"; +import type { TotalLineSelectorConfig } from "../research/main-total-selector.js"; + +export const PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS = 15 * 60_000; +export const PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS = 180 * 60_000; + +// The historical total line must be frozen before the first detector snapshot. +// Keeping this equal to the replay window makes the causal boundary explicit. +export const PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS = + PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS; + +export const PAPER_STUDY_ECONOMIC_CASE_CONFIG = Object.freeze({ + version: "binary_totals_executable_buy_v1" as const, + marketFamily: "total_goals" as const, + retain: "actual_buy_only" as const, + complementarySell: "collapse_only_when_actual_buy_present" as const, + sellOnly: "drop_unproven_complementary_ask" as const, + matchResult: "not_in_candidate" as const +}); + +export const PAPER_STUDY_HISTORICAL_EVIDENCE = Object.freeze({ + protocolId: "historical-gate-causal-economic-v4-2026-07-14" as const, + configurationHash: "9a4eeff928f697fc55ab5147a4dc07f611c40bb749501fc3bd92b211f24b2e54" as const, + status: "historical_signal_candidate_for_forward_paper_review" as const, + executionEvidence: "not_established_sampled_prices_only" as const +}); + +export const PAPER_STUDY_FEATURE_CONFIG: FeatureEngineConfig = Object.freeze({ + velocityWindowsMs: Object.freeze([60_000, 300_000, 900_000]), + velocityEwmaHalfLifeMs: 900_000, + cusumDriftProbability: 0.0005, + scoreContextWindowMs: 300_000, + freshnessMaxAgeMs: 360_000 +}); + +export const PAPER_STUDY_DETECTOR_CONFIG: DetectorBankConfig = Object.freeze({ + velocityWindowMs: 300_000, + consensusMoveAbsZ: 1, + consensusCusumThreshold: 0.001, + consensusMinimumUpdates: 5, + consensusMinimumRawGap: 0.01, + consensusStableAbsZ: 0, + xmarketMinimumRawGap: 2, + xmarketPersistenceMs: Number.MAX_SAFE_INTEGER, + faderPolymarketAbsZ: Number.MAX_SAFE_INTEGER, + faderMinimumRawGap: 2, + faderPersistenceMs: Number.MAX_SAFE_INTEGER +}); + +export const PAPER_STUDY_TOTAL_SELECTOR_CONFIG: TotalLineSelectorConfig = Object.freeze({ + minimumCoveragePoints: 1_000, + minimumVolume: 0, + minimumLiquidity: 0, + maximumDistanceFromEven: 0.15, + weights: Object.freeze({ balance: 1, volume: 0, liquidity: 0, coverage: 0 }) +}); diff --git a/src/dash/api.ts b/src/dash/api.ts new file mode 100644 index 0000000..aa4cf8c --- /dev/null +++ b/src/dash/api.ts @@ -0,0 +1,87 @@ +import type { ServerResponse } from "node:http"; +import { + CASEBOOK_API_PATH, + COMMAND_API_PATH, + readFrozenDashboardResponse, + SPAIN_BELGIUM_API_PATH, + STUDY_API_PATH, + type PublicDashboardApiPath +} from "./public-bundle.js"; + +export { CASEBOOK_API_PATH, COMMAND_API_PATH, SPAIN_BELGIUM_API_PATH, STUDY_API_PATH } from "./public-bundle.js"; + +export type DashboardApiResult = { + status: number; + headers: Record; + body: string; +}; + +function jsonResult(status: number, value: unknown): DashboardApiResult { + return { + status, + headers: { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff" + }, + body: JSON.stringify(value) + }; +} + +function frozenJsonResult(body: string, bundleSha256: string): DashboardApiResult { + return { + status: 200, + headers: { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff", + "x-samaritan-public-bundle": bundleSha256 + }, + body + }; +} + +export type DashboardApiSource = "frozen" | "private"; + +async function privateProjection(pathname: PublicDashboardApiPath, repoRoot: string): Promise { + if (pathname === COMMAND_API_PATH) { + const { buildCommandDashboardResponse } = await import("./command-projection.js"); + return buildCommandDashboardResponse(repoRoot); + } + if (pathname === CASEBOOK_API_PATH) { + const { buildCasebookDashboardResponse } = await import("./casebook-projection.js"); + return buildCasebookDashboardResponse(repoRoot); + } + if (pathname === STUDY_API_PATH) { + const { buildStudyDashboardResponse } = await import("./study-projection.js"); + return buildStudyDashboardResponse(repoRoot); + } + const { buildSpainBelgiumDashboardResponse } = await import("./projection.js"); + return buildSpainBelgiumDashboardResponse(repoRoot); +} + +export async function handleDashboardApi( + pathname: string, + repoRoot: string, + options: { source?: DashboardApiSource } = {} +): Promise { + if (pathname === "/api/v1/health") { + return jsonResult(200, { status: "ok", service: "samaritan-dashboard", readOnly: true }); + } + const definition = [COMMAND_API_PATH, CASEBOOK_API_PATH, STUDY_API_PATH, SPAIN_BELGIUM_API_PATH] + .find((candidate) => candidate === pathname) as PublicDashboardApiPath | undefined; + if (definition) { + if (options.source === "private") return jsonResult(200, await privateProjection(definition, repoRoot)); + const frozen = await readFrozenDashboardResponse(repoRoot, definition); + return frozenJsonResult(frozen.body, frozen.manifest.bundleSha256); + } + if (pathname.startsWith("/api/")) { + return jsonResult(404, { error: "not_found" }); + } + return null; +} + +export function writeApiResult(response: ServerResponse, result: DashboardApiResult, headOnly = false): void { + response.writeHead(result.status, result.headers); + response.end(headOnly ? undefined : result.body); +} diff --git a/src/dash/casebook-projection.ts b/src/dash/casebook-projection.ts new file mode 100644 index 0000000..a5660b0 --- /dev/null +++ b/src/dash/casebook-projection.ts @@ -0,0 +1,104 @@ +import { + CASEBOOK_SNAPSHOT_ID, + SPAIN_BELGIUM_MATCHROOM_ID, + type CasebookApiResponse, + type CasebookCaseSummary, + type CasebookSnapshot +} from "./public-contract.js"; +import { buildSpainBelgiumMatchroomSnapshot } from "./projection.js"; + +export async function buildCasebookSnapshot(repoRoot: string): Promise { + const matchroom = await buildSpainBelgiumMatchroomSnapshot(repoRoot); + if ( + matchroom.decision.disposition !== "no_trade" || + matchroom.decision.capitalMovedMicros !== 0 || + matchroom.decision.ordersPlaced !== 0 || + matchroom.decision.walletAccessed || + !matchroom.proof.identityParity + ) { + throw new Error("Casebook projection failed closed: verified refusal case changed"); + } + const goal = matchroom.replay.states.find((state) => state.id === "goal"); + if (!goal) throw new Error("Casebook projection failed closed: selected case has no goal state"); + + const summary: CasebookCaseSummary = { + caseId: "ESP-BEL-01", + matchroomId: SPAIN_BELGIUM_MATCHROOM_ID, + occurredAt: matchroom.replay.firstSeenAt, + fixtureId: matchroom.match.fixtureId, + fixtureLabel: "Spain vs Belgium", + homeCode: "ESP", + awayCode: "BEL", + marketFamily: "Match result", + marketLabel: "Match result · Draw", + detector: "STALE_QUOTE_FEASIBILITY", + disposition: "No trade", + executionOutcome: "Not executed", + evidenceLane: "Research only", + source: "Captured replay", + verificationStatus: "Verified", + reason: "Market moved before signal", + preTriggerMarketMoveBps: matchroom.replay.preTriggerMarketMoveBps + }; + + return { + schemaVersion: 2, + snapshotId: CASEBOOK_SNAPSHOT_ID, + generatedAt: matchroom.generatedAt, + mode: "offline_artifact", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + statistics: { + totalCases: 1, + noTradeCases: 1, + executedCases: 0, + verifiedCases: 1, + capitalMovedMicros: 0 + }, + filterOptions: { + fixtures: [summary.fixtureLabel], + marketFamilies: [summary.marketFamily], + detectors: [summary.detector], + dispositions: [summary.disposition], + executionOutcomes: [summary.executionOutcome], + evidenceLanes: [summary.evidenceLane], + sources: [summary.source] + }, + cases: [summary], + selectedCase: { + summary, + match: matchroom.match, + decision: matchroom.decision, + lifecycle: matchroom.decision.stages, + evidenceReadout: { + consensusMoveFromBaselineBps: goal.consensusMoveFromBaselineBps, + bestBid: goal.bestBid, + bestAsk: goal.bestAsk, + spread: goal.spread, + preTriggerMarketMoveBps: matchroom.replay.preTriggerMarketMoveBps, + movementConclusion: "Polymarket moved before TXLine" + }, + analysis: { + thesisStatus: "not_requested", + thesisReason: "The deterministic evidence gate rejected the opportunity before analyst escalation.", + invalidation: "The apparent gap is invalid if the executable market repriced before the signal reached Samaritan.", + costStatus: "not_applicable", + costMicros: 0, + costReason: "No order was constructed, so spread, slippage, and fee costs were not incurred." + }, + evidence: matchroom.evidence, + proof: matchroom.proof + }, + nextEvidence: { + status: "waiting_for_verified_capture", + label: "One verified case, without survivorship filtering", + detail: "New cases appear only after a paired capture passes identity, completeness, and replay verification." + }, + publicDataPolicy: matchroom.publicDataPolicy + }; +} + +export async function buildCasebookDashboardResponse(repoRoot: string): Promise { + return { data: await buildCasebookSnapshot(repoRoot) }; +} diff --git a/src/dash/command-projection.ts b/src/dash/command-projection.ts new file mode 100644 index 0000000..273d970 --- /dev/null +++ b/src/dash/command-projection.ts @@ -0,0 +1,247 @@ +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { validateCaptureConfig } from "../harness/capture-config.js"; +import { + COMMAND_SNAPSHOT_ID, + SPAIN_BELGIUM_MATCHROOM_ID, + type CommandApiResponse, + type CommandCase, + type CommandFixture, + type CommandSnapshot +} from "./public-contract.js"; +import { buildSpainBelgiumMatchroomSnapshot } from "./projection.js"; + +const paperCountsSchema = z.object({ + matches: z.number().int().nonnegative(), + signals: z.number().int().nonnegative(), + filledMatches: z.number().int().nonnegative(), + fills: z.number().int().nonnegative(), + settledFills: z.number().int().nonnegative() +}); + +const paperReportSchema = z.object({ + generatedAt: z.string().datetime(), + protocolVersion: z.string().min(1), + configHash: z.string().regex(/^[a-f0-9]{64}$/), + realMoneyGate: z.literal("closed"), + fixtureUniverseGeneratedAt: z.string().datetime(), + lanes: z.object({ + bounty: z.object({ + chain: z.object({ valid: z.literal(true), rows: z.number().int().positive(), headHash: z.string().regex(/^[a-f0-9]{64}$/) }), + report: z.object({ status: z.literal("exploratory"), counts: paperCountsSchema }) + }), + longRun: z.object({ + initialization: z.object({ + startedAt: z.string().datetime(), + frozenConfig: z.object({ + evaluation: z.object({ + minimumFilledMatches: z.number().int().positive(), + minimumFills: z.number().int().positive() + }) + }) + }), + chain: z.object({ valid: z.literal(true), rows: z.number().int().positive(), headHash: z.string().regex(/^[a-f0-9]{64}$/) }), + report: z.object({ + status: z.literal("sealed"), + reason: z.string().min(1), + counts: paperCountsSchema, + stoppingRuleMet: z.literal(false), + rows: z.null(), + endpoints: z.null(), + guardrails: z.null() + }) + }) + }) +}); + +const fixtureUniverseSchema = z.object({ + generatedAt: z.string().datetime(), + summary: z.object({ + fixtures: z.number().int().nonnegative(), + pairedBookReplays: z.number().int().nonnegative(), + signalResearchOnly: z.number().int().nonnegative(), + longRunEligible: z.number().int().nonnegative() + }) +}); + +async function json(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +function teamCode(name: string): string { + const code = ({ France: "FRA", Spain: "ESP", England: "ENG", Argentina: "ARG" } as Record)[name]; + if (!code) throw new Error(`Command projection has no reviewed team code for ${name}`); + return code; +} + +function fixturePhase(nowTsMs: number, captureStartTsMs: number, kickoffTsMs: number, durationMinutes: number): CommandFixture["phase"] { + if (nowTsMs < captureStartTsMs) return "scheduled"; + if (nowTsMs <= captureStartTsMs + durationMinutes * 60_000) return "capture_window"; + if (nowTsMs > kickoffTsMs) return "awaiting_verification"; + return "capture_window"; +} + +function phaseLabel(phase: CommandFixture["phase"]): string { + if (phase === "scheduled") return "Capture confirmed"; + if (phase === "capture_window") return "Capture window"; + return "Awaiting verification"; +} + +async function buildFixtureSchedule(repoRoot: string, nowTsMs: number): Promise { + const configDir = resolve(repoRoot, "config/captures"); + const [names, txlineFixtures, polymarketEvents] = await Promise.all([ + readdir(configDir), + json(resolve(repoRoot, "samples/fixtures/mainnet-world-cup-fixtures.json")), + json(resolve(repoRoot, "data/live/gamma-discovery/open-world-cup-events.json")) + ]); + if (!Array.isArray(txlineFixtures) || !Array.isArray(polymarketEvents)) { + throw new Error("Command projection failed closed: capture identity evidence is malformed"); + } + + const fixtures: CommandFixture[] = []; + for (const name of names.filter((value) => value.endsWith(".json")).sort()) { + const config = await json(resolve(configDir, name)); + if (config === null || typeof config !== "object" || !("capture" in config)) continue; + const validation = validateCaptureConfig({ repoRoot, config, txlineFixtures, polymarketEvents }); + if (!validation.readyToSchedule || validation.config.status !== "human_confirmed_for_capture_only") { + throw new Error(`Command projection failed closed: ${name} is not confirmed for capture`); + } + const kickoffTsMs = Date.parse(validation.config.txline.kickoffUtc); + const captureStartTsMs = Date.parse(validation.config.capture.scheduledStartUtc); + const phase = fixturePhase(nowTsMs, captureStartTsMs, kickoffTsMs, validation.config.capture.durationMinutes); + fixtures.push({ + fixtureId: validation.config.txline.fixtureId, + home: { name: validation.config.txline.home, code: teamCode(validation.config.txline.home) }, + away: { name: validation.config.txline.away, code: teamCode(validation.config.txline.away) }, + kickoffUtc: validation.config.txline.kickoffUtc, + captureStartUtc: validation.config.capture.scheduledStartUtc, + signalCutoffUtc: new Date(kickoffTsMs - 15 * 60_000).toISOString(), + eventSlug: validation.config.polymarket.eventSlug, + phase, + statusLabel: phaseLabel(phase), + identityStatus: "exact_match_confirmed", + captureOnly: true, + tradeable: false + }); + } + if (fixtures.length === 0) throw new Error("Command projection failed closed: no confirmed capture fixtures"); + return fixtures.sort((left, right) => Date.parse(left.kickoffUtc) - Date.parse(right.kickoffUtc)); +} + +export async function buildCommandSnapshot(repoRoot: string, nowTsMs = Date.now()): Promise { + if (!Number.isSafeInteger(nowTsMs)) throw new Error("Command projection requires a valid current timestamp"); + const [matchroom, reportValue, universeValue, fixtureSchedule] = await Promise.all([ + buildSpainBelgiumMatchroomSnapshot(repoRoot), + json(resolve(repoRoot, "data/paper/reports/current.json")), + json(resolve(repoRoot, "data/research/paper-fixture-universe.json")), + buildFixtureSchedule(repoRoot, nowTsMs) + ]); + const report = paperReportSchema.parse(reportValue); + const universe = fixtureUniverseSchema.parse(universeValue); + if (universe.summary.longRunEligible !== 0) { + throw new Error("Command projection failed closed: fixture admission changed without a refreshed public contract"); + } + + const goal = matchroom.replay.states.find((state) => state.id === "goal"); + if (!goal) throw new Error("Command projection failed closed: featured replay has no goal state"); + const recentCase: CommandCase = { + caseId: "ESP-BEL-01", + matchroomId: SPAIN_BELGIUM_MATCHROOM_ID, + fixtureId: matchroom.match.fixtureId, + fixtureLabel: "Spain vs Belgium", + occurredAt: matchroom.replay.firstSeenAt, + marketLabel: "Match result · Draw", + candidateLabel: "Live-lane gate readout", + disposition: "no_trade", + dispositionLabel: "No trade", + reason: "Market moved before signal", + evidenceStatus: "verified_replay", + preTriggerMarketMoveBps: matchroom.replay.preTriggerMarketMoveBps, + consensusMoveFromBaselineBps: goal.consensusMoveFromBaselineBps, + bestAsk: goal.bestAsk + }; + const activeFixture = fixtureSchedule.find((fixture) => fixture.phase === "capture_window") ?? + fixtureSchedule.find((fixture) => fixture.phase === "scheduled") ?? fixtureSchedule[0]!; + const posture = activeFixture.phase === "scheduled" ? "standing_by" : activeFixture.phase; + const postureLabel = posture === "standing_by" ? "Standing by for paired capture" : + posture === "capture_window" ? "Capture window is open" : "Awaiting capture verification"; + const postureDetail = activeFixture.phase === "scheduled" + ? `${activeFixture.home.name} vs ${activeFixture.away.name} is confirmed for public-data capture only.` + : `${activeFixture.home.name} vs ${activeFixture.away.name} remains non-tradeable until captured evidence is verified.`; + const longRun = report.lanes.longRun; + + return { + schemaVersion: 2, + snapshotId: COMMAND_SNAPSHOT_ID, + generatedAt: new Date(nowTsMs).toISOString(), + mode: "offline_artifact", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + system: { + posture, + label: postureLabel, + detail: postureDetail, + feeds: [ + { id: "txline", label: "TXLine", status: "scheduled", statusLabel: "Scheduled", detail: `Exact fixture ${activeFixture.fixtureId} confirmed` }, + { id: "polymarket", label: "Polymarket", status: "scheduled", statusLabel: "Scheduled", detail: "Exact event family confirmed" }, + { id: "decision_ledger", label: "Paper ledgers", status: "initialized", statusLabel: "Preserved", detail: "Invalidated v1 zero-observation chains remain valid" }, + { id: "replay_proof", label: "Replay proof", status: "verified", statusLabel: "Verified", detail: `${matchroom.proof.canonicalEvents.toLocaleString("en-US")} canonical events` } + ] + }, + featuredCase: { + ...recentCase, + scoreLabel: "1–0", + clockLabel: matchroom.match.clockLabel, + conclusion: goal.decisionExplanation, + canonicalEvents: matchroom.proof.canonicalEvents, + identityParity: true, + chart: matchroom.replay.chart + }, + fixtureSchedule, + recentCases: [recentCase], + additionalCaseState: { + status: "waiting_for_eligible_capture", + label: "No active study can admit cases", + detail: "Capture verification may add evidence, but study admission waits for Deborah to register corrected v2." + }, + study: { + protocolVersion: report.protocolVersion, + protocolStatus: "invalidated_suspended", + configHash: report.configHash, + startedAt: longRun.initialization.startedAt, + status: "suspended", + statusLabel: "V1 suspended", + filledMatches: longRun.report.counts.filledMatches, + requiredFilledMatches: longRun.initialization.frozenConfig.evaluation.minimumFilledMatches, + fills: longRun.report.counts.fills, + requiredFills: longRun.initialization.frozenConfig.evaluation.minimumFills, + bountyStatus: "exploratory", + stoppingRuleMet: false, + reason: longRun.report.reason + }, + proof: { + replayIdentityParity: true, + replayIdentityHash: matchroom.proof.identityHash, + canonicalEvents: matchroom.proof.canonicalEvents, + bountyLedgerValid: true, + bountyLedgerRows: report.lanes.bounty.chain.rows, + longRunLedgerValid: true, + longRunLedgerRows: longRun.chain.rows, + evidenceFixtures: universe.summary.fixtures, + pairedBookReplays: universe.summary.pairedBookReplays, + signalResearchOnly: universe.summary.signalResearchOnly + }, + sourceFreshness: { + paperReportGeneratedAt: report.generatedAt, + fixtureUniverseGeneratedAt: universe.generatedAt, + replayGeneratedAt: matchroom.generatedAt + }, + publicDataPolicy: matchroom.publicDataPolicy + }; +} + +export async function buildCommandDashboardResponse(repoRoot: string, nowTsMs = Date.now()): Promise { + return { data: await buildCommandSnapshot(repoRoot, nowTsMs) }; +} diff --git a/src/dash/export-public-bundle.ts b/src/dash/export-public-bundle.ts new file mode 100644 index 0000000..83302f7 --- /dev/null +++ b/src/dash/export-public-bundle.ts @@ -0,0 +1,213 @@ +#!/usr/bin/env node +import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { stableJson } from "../domain/json.js"; +import { runSyntheticJudgeCase } from "../demo/synthetic-judge-case.js"; +import { assertPublicArtifactsSafe, auditPublicArtifacts } from "../public/artifact-safety.js"; +import { verifyDecisionReceipt } from "../proof/decision-receipt-schema.js"; +import { buildCasebookDashboardResponse } from "./casebook-projection.js"; +import { buildCommandDashboardResponse } from "./command-projection.js"; +import { buildSpainBelgiumDashboardResponse } from "./projection.js"; +import { + PUBLIC_DASHBOARD_BUNDLE_ID, + PUBLIC_DASHBOARD_BUNDLE_RELATIVE_DIR, + PUBLIC_DASHBOARD_CANONICALIZATION, + PUBLIC_DASHBOARD_FILES, + PUBLIC_SYNTHETIC_RECEIPT_FILENAME, + parsePublicDashboardManifest, + parsePublicDashboardResponse, + publicDashboardBundleHash, + sha256, + type PublicDashboardApiPath, + type PublicDashboardFilename, + type PublicDashboardManifest, + type PublicDashboardManifestDownload, + type PublicDashboardManifestEntry +} from "./public-bundle.js"; +import { TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS } from "./public-contract.js"; +import { buildStudyDashboardResponse } from "./study-projection.js"; + +const canonicalRepoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const OUTPUT_FILENAMES = new Set([ + ...PUBLIC_DASHBOARD_FILES.map((definition) => definition.file), + PUBLIC_SYNTHETIC_RECEIPT_FILENAME, + "manifest.json" +]); + +export type ExportPublicDashboardBundleOptions = { + repoRoot: string; + outputDir?: string; +}; + +export type ExportPublicDashboardBundleResult = { + outputDir: string; + manifest: PublicDashboardManifest; +}; + +function canonicalJson(value: unknown): string { + return `${stableJson(value)}\n`; +} + +async function atomicWrite(path: string, body: string): Promise { + const temporaryPath = `${path}.tmp-${process.pid}`; + await writeFile(temporaryPath, body, { encoding: "utf8", mode: 0o644 }); + await rename(temporaryPath, path); +} + +async function refuseUnexpectedOutputFiles(outputDir: string): Promise { + let names: string[]; + try { + names = await readdir(outputDir); + } catch { + return; + } + const unexpected = names.filter((name) => !OUTPUT_FILENAMES.has(name)); + if (unexpected.length > 0) { + throw new Error(`Public dashboard export refuses output directory with unexpected files: ${unexpected.join(", ")}`); + } +} + +export async function exportPublicDashboardBundle( + options: ExportPublicDashboardBundleOptions +): Promise { + const repoRoot = resolve(options.repoRoot); + const outputDir = resolve(options.outputDir ?? resolve(repoRoot, PUBLIC_DASHBOARD_BUNDLE_RELATIVE_DIR)); + + // The corrected v4 artifact supplies a stable as-of time. Building Command + // with that timestamp makes identical private inputs produce identical bytes. + const study = await buildStudyDashboardResponse(repoRoot); + const generatedAt = study.data.correctedHistoricalCandidate.generatedAt; + const asOfTsMs = Date.parse(generatedAt); + if (!Number.isSafeInteger(asOfTsMs)) throw new Error("Corrected historical candidate has an invalid export timestamp"); + + const [command, casebook, matchroom] = await Promise.all([ + buildCommandDashboardResponse(repoRoot, asOfTsMs), + buildCasebookDashboardResponse(repoRoot), + buildSpainBelgiumDashboardResponse(repoRoot) + ]); + const responseByPath = new Map([ + [PUBLIC_DASHBOARD_FILES[0].apiPath, command], + [PUBLIC_DASHBOARD_FILES[1].apiPath, casebook], + [PUBLIC_DASHBOARD_FILES[2].apiPath, study], + [PUBLIC_DASHBOARD_FILES[3].apiPath, matchroom] + ]); + + const bodies = new Map(); + const entries: PublicDashboardManifestEntry[] = []; + for (const definition of PUBLIC_DASHBOARD_FILES) { + const response = responseByPath.get(definition.apiPath); + if (!response) throw new Error(`Public dashboard export lacks ${definition.apiPath}`); + const parsed = parsePublicDashboardResponse(definition.apiPath, response) as { data?: { snapshotId?: unknown } }; + if (parsed.data?.snapshotId !== definition.snapshotId) { + throw new Error(`Public dashboard export snapshot mismatch for ${definition.apiPath}`); + } + const body = canonicalJson(parsed); + bodies.set(definition.file, body); + entries.push({ + apiPath: definition.apiPath, + file: definition.file, + snapshotId: definition.snapshotId, + sha256: sha256(body), + bytes: Buffer.byteLength(body, "utf8") + }); + } + + // Export the receipt produced by the same production-component proving path + // exposed through `pnpm demo`, so the CLI and downloadable judge artifact + // commit to one case rather than two merely similar synthetic fixtures. + const syntheticReport = await runSyntheticJudgeCase(); + const syntheticReceipt = syntheticReport.receipt; + const receiptVerification = verifyDecisionReceipt(syntheticReceipt); + const syntheticProof = study.data.syntheticProof; + if ( + !receiptVerification.valid || + !receiptVerification.synthetic || + receiptVerification.lifecycleStatus !== "filled_settled" || + syntheticReceipt.solanaAnchor !== null || + syntheticReport.performanceUse !== "excluded_synthetic" || + syntheticReport.boundaries.anthropicCalls !== 0 || + syntheticReport.boundaries.txlineApiCalls !== 0 || + syntheticReport.boundaries.polymarketApiCalls !== 0 || + syntheticReport.boundaries.walletCalls !== 0 || + syntheticReport.boundaries.solanaRpcCalls !== 0 || + syntheticReport.boundaries.realOrders !== 0 || + !syntheticProof.offlineVerified || + syntheticProof.lifecycleStatus !== receiptVerification.lifecycleStatus || + syntheticProof.performanceUse !== syntheticReport.performanceUse || + syntheticProof.externalCalls !== 0 || + syntheticProof.solanaAnchorStatus !== "not_submitted" + ) { + throw new Error("Synthetic decision receipt does not satisfy the frozen proving-fixture contract"); + } + const syntheticReceiptBody = canonicalJson(syntheticReceipt); + const downloads: PublicDashboardManifestDownload[] = [{ + id: "synthetic-decision-receipt", + file: PUBLIC_SYNTHETIC_RECEIPT_FILENAME, + mediaType: "application/json", + sha256: sha256(syntheticReceiptBody), + bytes: Buffer.byteLength(syntheticReceiptBody, "utf8"), + synthetic: true, + performanceUse: "excluded_synthetic" + }]; + + const manifest = parsePublicDashboardManifest({ + schemaVersion: 1, + bundleId: PUBLIC_DASHBOARD_BUNDLE_ID, + generatedAt, + canonicalization: PUBLIC_DASHBOARD_CANONICALIZATION, + bundleSha256: publicDashboardBundleHash(generatedAt, entries, downloads), + files: entries, + downloads, + publicDataPolicy: { + derivedOnly: true, + txlineProbabilityDisplay: "bucketed_movement_only", + txlineMovementBucketBps: TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS, + credentialsRequired: false, + walletControlsExposed: false + } + }); + const manifestBody = canonicalJson(manifest); + + const stagingDir = await mkdtemp(join(tmpdir(), "samaritan-public-dashboard-")); + try { + for (const [filename, body] of bodies) await writeFile(resolve(stagingDir, filename), body, "utf8"); + await writeFile(resolve(stagingDir, PUBLIC_SYNTHETIC_RECEIPT_FILENAME), syntheticReceiptBody, "utf8"); + await writeFile(resolve(stagingDir, "manifest.json"), manifestBody, "utf8"); + const audit = await auditPublicArtifacts({ allowlistedPaths: [stagingDir], cwd: stagingDir }); + assertPublicArtifactsSafe(audit); + } finally { + await rm(stagingDir, { recursive: true, force: true }); + } + + await mkdir(outputDir, { recursive: true }); + await refuseUnexpectedOutputFiles(outputDir); + for (const [filename, body] of bodies) await atomicWrite(resolve(outputDir, filename), body); + await atomicWrite(resolve(outputDir, PUBLIC_SYNTHETIC_RECEIPT_FILENAME), syntheticReceiptBody); + await atomicWrite(resolve(outputDir, "manifest.json"), manifestBody); + return { outputDir, manifest }; +} + +function parseRepoRoot(args: readonly string[]): string { + if (args.length === 0) return canonicalRepoRoot; + if (args.length === 2 && args[0] === "--repo-root" && args[1]) return resolve(args[1]); + throw new Error("Usage: pnpm dash:export-public [-- --repo-root ]"); +} + +async function main(): Promise { + try { + const result = await exportPublicDashboardBundle({ repoRoot: parseRepoRoot(process.argv.slice(2)) }); + process.stdout.write( + `Exported ${result.manifest.files.length} deterministic dashboard responses to ${basename(result.outputDir)} ` + + `(bundle ${result.manifest.bundleSha256})\n` + ); + } catch (error) { + process.stderr.write(`Public dashboard export failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/src/dash/projection.ts b/src/dash/projection.ts new file mode 100644 index 0000000..d535d1e --- /dev/null +++ b/src/dash/projection.ts @@ -0,0 +1,373 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { + SPAIN_BELGIUM_MATCHROOM_ID, + TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS, + type DashboardApiResponse, + type EvidenceRow, + type MatchroomSnapshot, + type PublicBookPoint, + type ReplayState, + type ReplayStepId +} from "./public-contract.js"; + +const probabilitySchema = z.number().min(0).max(1); + +const outcomeSchema = z.object({ + outcome: z.string(), + consensusProbability: probabilitySchema.nullable(), + bestBid: probabilitySchema.nullable(), + bestAsk: probabilitySchema.nullable(), + spread: probabilitySchema.nullable() +}); + +const snapshotSchema = z.object({ + horizonMs: z.number().int(), + book: z.object({ + outcomes: z.array(outcomeSchema) + }) +}); + +const marketSchema = z.object({ + family: z.string(), + firstMaterialMoveLatencyMs: z.number().int().nullable(), + preTriggerFiveSecondMove: probabilitySchema.nullable(), + classification: z.string(), + snapshots: z.array(snapshotSchema) +}); + +const studySchema = z.object({ + generatedAt: z.string().datetime(), + status: z.literal("research_evidence_only"), + tradeable: z.literal(false), + fixtureId: z.string(), + feedHealth: z.object({ + polymarketOutages: z.object({ + outages: z.array(z.object({ + startedAt: z.string().datetime(), + endedAt: z.string().datetime().nullable(), + durationMs: z.number().int().nullable() + })), + totalDowntimeMs: z.number().int().nonnegative(), + maximumDowntimeMs: z.number().int().nonnegative() + }) + }), + gateReadout: z.object({ + marketEventCases: z.number().int().nonnegative(), + polymarketMovedBeforeTxline: z.number().int().nonnegative(), + postTxlineRepriceObserved: z.number().int().nonnegative(), + noMaterialRepriceInWindow: z.number().int().nonnegative(), + staleQuoteHypothesis: z.literal("not_supported_by_this_match") + }), + goals: z.array(z.object({ + participant: z.number().int().nullable(), + clockSeconds: z.number().int().nonnegative(), + firstSeenObservedTsMs: z.number().int(), + txlineFirstSeenLatencyMs: z.number().int(), + markets: z.array(marketSchema) + })).min(1) +}); + +const captureConfigSchema = z.object({ + captureId: z.literal(SPAIN_BELGIUM_MATCHROOM_ID), + tradeable: z.literal(false), + txline: z.object({ + fixtureId: z.string(), + home: z.string(), + away: z.string(), + kickoffUtc: z.string().datetime() + }), + polymarket: z.object({ + eventSlug: z.string(), + home: z.string(), + away: z.string(), + kickoffUtc: z.string().datetime(), + matchResultRulesPeriod: z.literal("first_90_minutes_plus_stoppage_time") + }) +}); + +const manifestSchema = z.object({ + runId: z.literal(SPAIN_BELGIUM_MATCHROOM_ID), + status: z.literal("verified"), + fixtureId: z.string(), + eventSlug: z.string(), + verification: z.object({ + replayMode: z.literal("capture-order-per-source"), + identityParity: z.literal(true), + identityHash: z.string().regex(/^[a-f0-9]{64}$/), + headHash: z.string().regex(/^[a-f0-9]{64}$/), + rows: z.number().int().positive() + }) +}); + +type ParsedSnapshot = z.infer; + +const replayStateConfiguration: Record = { + pre: { + horizonMs: -5000, + label: "T−5 seconds", + conclusionTitle: "Repricing had begun.", + conclusionBody: "Polymarket was already moving before TXLine delivered the goal.", + decisionExplanation: "The market was moving before the trigger entered Samaritan's event bus." + }, + goal: { + horizonMs: 0, + label: "Goal first seen", + conclusionTitle: "Polymarket moved first.", + conclusionBody: "It repriced before TXLine delivered the goal. No stale quote remained.", + decisionExplanation: "The candidate arrived after the market had already moved. Samaritan preserved capital." + }, + post: { + horizonMs: 30000, + label: "T+30 seconds", + conclusionTitle: "The window stayed invalid.", + conclusionBody: "A tighter spread did not restore an opportunity that disappeared before the trigger.", + decisionExplanation: "The spread tightened after repricing, but the candidate remained ineligible for execution." + } +}; + +function requiredNumber(value: number | null, field: string): number { + if (value === null) throw new Error(`Dashboard projection requires ${field}`); + return value; +} + +function bucketedConsensusMoveBps(value: number, baseline: number): number { + const exactMoveBps = (value - baseline) * 10_000; + const bucketed = Math.round(exactMoveBps / TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS) * TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS; + return Object.is(bucketed, -0) ? 0 : bucketed; +} + +function drawOutcome(snapshot: ParsedSnapshot): z.infer { + const draw = snapshot.book.outcomes.find((outcome) => outcome.outcome === "draw"); + if (!draw) throw new Error(`Dashboard projection is missing draw outcome at ${snapshot.horizonMs}ms`); + return draw; +} + +function findSnapshot(snapshots: readonly ParsedSnapshot[], horizonMs: number): ParsedSnapshot { + const snapshot = snapshots.find((candidate) => candidate.horizonMs === horizonMs); + if (!snapshot) throw new Error(`Dashboard projection is missing ${horizonMs}ms snapshot`); + return snapshot; +} + +function clockLabel(clockSeconds: number): string { + const minutes = Math.floor(clockSeconds / 60); + const seconds = clockSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +function createReplayState( + id: ReplayStepId, + snapshot: ParsedSnapshot, + firstSeenObservedTsMs: number, + consensusBaseline: number +): ReplayState { + const configuration = replayStateConfiguration[id]; + const draw = drawOutcome(snapshot); + const consensusProbability = requiredNumber(draw.consensusProbability, `${id} consensus probability`); + const bestBid = requiredNumber(draw.bestBid, `${id} best bid`); + const bestAsk = requiredNumber(draw.bestAsk, `${id} best ask`); + return { + id, + label: configuration.label, + offsetMs: configuration.horizonMs, + observedAt: new Date(firstSeenObservedTsMs + configuration.horizonMs).toISOString(), + consensusMoveFromBaselineBps: bucketedConsensusMoveBps(consensusProbability, consensusBaseline), + bestBid, + bestAsk, + spread: requiredNumber(draw.spread, `${id} spread`), + conclusionTitle: configuration.conclusionTitle, + conclusionBody: configuration.conclusionBody, + decisionExplanation: configuration.decisionExplanation + }; +} + +function createEvidence(states: Record): EvidenceRow[] { + return [ + { + replayStateId: "pre", + observedAt: states.pre.observedAt, + offsetLabel: "T−5.000s", + source: "Polymarket", + observation: "Draw ask begins repricing", + bestAsk: states.pre.bestAsk, + assessment: "Moved first" + }, + { + replayStateId: "goal", + observedAt: states.goal.observedAt, + offsetLabel: "First seen", + source: "TXLine", + observation: "Spain goal delivered at 29:21", + bestAsk: states.goal.bestAsk, + assessment: "Pass" + }, + { + replayStateId: "post", + observedAt: states.post.observedAt, + offsetLabel: "T+30.000s", + source: "Samaritan", + observation: "Spread tightens after repricing", + bestAsk: states.post.bestAsk, + assessment: "No trade" + } + ]; +} + +export async function buildSpainBelgiumMatchroomSnapshot(repoRoot: string): Promise { + const [studyRaw, configRaw, manifestRaw] = await Promise.all([ + readFile(resolve(repoRoot, "data/research/paired-spain-belgium-2026-07-10-live-lane.json"), "utf8"), + readFile(resolve(repoRoot, "config/captures/spain-belgium-2026-07-10.json"), "utf8"), + readFile(resolve(repoRoot, "data/live/paired-spain-belgium-2026-07-10/analysis-manifest.json"), "utf8") + ]); + const study = studySchema.parse(JSON.parse(studyRaw)); + const config = captureConfigSchema.parse(JSON.parse(configRaw)); + const manifest = manifestSchema.parse(JSON.parse(manifestRaw)); + + if ( + study.fixtureId !== config.txline.fixtureId || + study.fixtureId !== manifest.fixtureId || + config.polymarket.eventSlug !== manifest.eventSlug || + config.txline.home !== config.polymarket.home || + config.txline.away !== config.polymarket.away || + config.txline.kickoffUtc !== config.polymarket.kickoffUtc + ) { + throw new Error("Dashboard projection failed closed: capture identity mismatch"); + } + + const firstGoal = study.goals[0]!; + if (firstGoal.participant !== 1 || firstGoal.clockSeconds !== 1761) { + throw new Error("Dashboard projection failed closed: first goal identity changed"); + } + const matchResult = firstGoal.markets.find((market) => market.family === "match_result"); + if (!matchResult || matchResult.classification !== "polymarket_moved_before_txline") { + throw new Error("Dashboard projection requires verified pre-trigger match-result repricing"); + } + if (study.gateReadout.postTxlineRepriceObserved !== 0) { + throw new Error("Dashboard projection failed closed: clean stale-window result changed"); + } + + const consensusBaseline = requiredNumber( + drawOutcome(findSnapshot(matchResult.snapshots, -5000)).consensusProbability, + "consensus movement baseline" + ); + const states = { + pre: createReplayState("pre", findSnapshot(matchResult.snapshots, -5000), firstGoal.firstSeenObservedTsMs, consensusBaseline), + goal: createReplayState("goal", findSnapshot(matchResult.snapshots, 0), firstGoal.firstSeenObservedTsMs, consensusBaseline), + post: createReplayState("post", findSnapshot(matchResult.snapshots, 30000), firstGoal.firstSeenObservedTsMs, consensusBaseline) + } satisfies Record; + + const chart: PublicBookPoint[] = matchResult.snapshots.map((snapshot) => { + const draw = drawOutcome(snapshot); + return { + offsetMs: snapshot.horizonMs, + bestBid: requiredNumber(draw.bestBid, "chart best bid"), + bestAsk: requiredNumber(draw.bestAsk, "chart best ask"), + spread: requiredNumber(draw.spread, "chart spread"), + available: true + }; + }); + + const firstSeenAt = new Date(firstGoal.firstSeenObservedTsMs).toISOString(); + const windowStart = firstGoal.firstSeenObservedTsMs - 5000; + const windowEnd = firstGoal.firstSeenObservedTsMs + 30000; + const availabilityGaps = study.feedHealth.polymarketOutages.outages.flatMap((outage) => { + if (outage.endedAt === null || outage.durationMs === null) return []; + const start = Date.parse(outage.startedAt); + const end = Date.parse(outage.endedAt); + return end < windowStart || start > windowEnd ? [] : [{ + startedAt: outage.startedAt, + endedAt: outage.endedAt, + durationMs: outage.durationMs + }]; + }); + + return { + schemaVersion: 2, + snapshotId: SPAIN_BELGIUM_MATCHROOM_ID, + generatedAt: study.generatedAt, + mode: "captured_replay", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + match: { + fixtureId: study.fixtureId, + eventSlug: manifest.eventSlug, + competition: "World Cup", + stage: "Round of 16", + kickoffUtc: config.txline.kickoffUtc, + originalMatchDate: config.txline.kickoffUtc.slice(0, 10), + home: { name: config.txline.home, code: "ESP" }, + away: { name: config.txline.away, code: "BEL" }, + scoreAtCursor: { home: 1, away: 0 }, + clockSeconds: firstGoal.clockSeconds, + clockLabel: clockLabel(firstGoal.clockSeconds) + }, + market: { + family: "match_result", + outcome: "draw", + label: "Match result · Draw", + period: "90 minutes plus stoppage time", + mappingStatus: "research_only" + }, + replay: { + firstSeenAt, + firstSeenLatencyMs: firstGoal.txlineFirstSeenLatencyMs, + firstMaterialMoveLatencyMs: requiredNumber(matchResult.firstMaterialMoveLatencyMs, "material move latency"), + preTriggerMarketMoveBps: Math.round(requiredNumber(matchResult.preTriggerFiveSecondMove, "pre-trigger move") * 10_000), + activeStateId: "goal", + states: [states.pre, states.goal, states.post], + chart, + availabilityGaps + }, + decision: { + disposition: "no_trade", + semanticStatus: "disciplined_pass", + label: "No trade", + primaryReason: "Market moved before signal", + explanation: states.goal.decisionExplanation, + capitalMovedMicros: 0, + ordersPlaced: 0, + walletAccessed: false, + stages: [ + { id: "signal", label: "Goal observed", detail: "STALE_QUOTE feasibility · research only", status: "complete", timingLabel: "+0ms" }, + { id: "evidence", label: "Evidence checked", detail: "First material update", status: "complete", timingLabel: `+${requiredNumber(matchResult.firstMaterialMoveLatencyMs, "material move latency")}ms` }, + { id: "pass", label: "Opportunity passed", detail: "No clean stale window", status: "passed", timingLabel: "Final" }, + { id: "execution", label: "Execution not reached", detail: "Gate remained closed", status: "locked", timingLabel: "$0.00" } + ] + }, + evidence: createEvidence(states), + proof: { + captureStatus: "verified", + identityParity: manifest.verification.identityParity, + identityHash: manifest.verification.identityHash, + headHash: manifest.verification.headHash, + canonicalEvents: manifest.verification.rows, + replayMode: manifest.verification.replayMode, + feedOutageCount: study.feedHealth.polymarketOutages.outages.length, + feedDowntimeMs: study.feedHealth.polymarketOutages.totalDowntimeMs, + maximumFeedDowntimeMs: study.feedHealth.polymarketOutages.maximumDowntimeMs, + gateCases: study.gateReadout.marketEventCases, + movedBeforeTxlineCases: study.gateReadout.polymarketMovedBeforeTxline, + noMaterialRepriceCases: study.gateReadout.noMaterialRepriceInWindow, + cleanStaleWindows: 0 + }, + publicDataPolicy: { + derivedOnly: true, + txlineProbabilityDisplay: "bucketed_movement_only", + txlineMovementBucketBps: TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS, + credentialsRequired: false, + walletControlsExposed: false + } + }; +} + +export async function buildSpainBelgiumDashboardResponse(repoRoot: string): Promise { + return { data: await buildSpainBelgiumMatchroomSnapshot(repoRoot) }; +} diff --git a/src/dash/public-bundle.ts b/src/dash/public-bundle.ts new file mode 100644 index 0000000..70ffc92 --- /dev/null +++ b/src/dash/public-bundle.ts @@ -0,0 +1,665 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { stableJson } from "../domain/json.js"; +import { + CASEBOOK_SNAPSHOT_ID, + COMMAND_SNAPSHOT_ID, + SPAIN_BELGIUM_MATCHROOM_ID, + STUDY_SNAPSHOT_ID, + TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS +} from "./public-contract.js"; + +export const SPAIN_BELGIUM_API_PATH = `/api/v1/matchroom/${SPAIN_BELGIUM_MATCHROOM_ID}` as const; +export const COMMAND_API_PATH = "/api/v1/command" as const; +export const CASEBOOK_API_PATH = "/api/v1/casebook" as const; +export const STUDY_API_PATH = "/api/v1/study" as const; +export const PUBLIC_DASHBOARD_BUNDLE_RELATIVE_DIR = "public/artifacts/dashboard" as const; +export const PUBLIC_DASHBOARD_BUNDLE_ID = "samaritan-dashboard-public-v1" as const; +export const PUBLIC_DASHBOARD_CANONICALIZATION = "samaritan-stable-json-v1" as const; +export const PUBLIC_DASHBOARD_BUNDLE_HASH_DOMAIN = "samaritan.dashboard-public-bundle/v1" as const; +export const PUBLIC_SYNTHETIC_RECEIPT_FILENAME = "synthetic-decision-receipt.json" as const; + +export const PUBLIC_DASHBOARD_FILES = [ + { apiPath: COMMAND_API_PATH, file: "command.json", snapshotId: COMMAND_SNAPSHOT_ID }, + { apiPath: CASEBOOK_API_PATH, file: "casebook.json", snapshotId: CASEBOOK_SNAPSHOT_ID }, + { apiPath: STUDY_API_PATH, file: "study.json", snapshotId: STUDY_SNAPSHOT_ID }, + { apiPath: SPAIN_BELGIUM_API_PATH, file: "matchroom-spain-belgium.json", snapshotId: SPAIN_BELGIUM_MATCHROOM_ID } +] as const; + +export type PublicDashboardApiPath = typeof PUBLIC_DASHBOARD_FILES[number]["apiPath"]; +export type PublicDashboardFilename = typeof PUBLIC_DASHBOARD_FILES[number]["file"]; + +const isoSchema = z.string().datetime(); +const hashSchema = z.string().regex(/^[a-f0-9]{64}$/); +const probabilitySchema = z.number().finite().min(0).max(1); +const finiteSchema = z.number().finite(); +const nonnegativeIntegerSchema = z.number().int().nonnegative().safe(); + +export const publicDataPolicySchema = z.object({ + derivedOnly: z.literal(true), + txlineProbabilityDisplay: z.literal("bucketed_movement_only"), + txlineMovementBucketBps: z.literal(TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS), + credentialsRequired: z.literal(false), + walletControlsExposed: z.literal(false) +}).strict(); + +const publicBookPointSchema = z.object({ + offsetMs: z.number().int(), + bestBid: probabilitySchema, + bestAsk: probabilitySchema, + spread: probabilitySchema, + available: z.literal(true) +}).strict(); + +const replayStateSchema = z.object({ + id: z.enum(["pre", "goal", "post"]), + label: z.string().min(1), + offsetMs: z.number().int(), + observedAt: isoSchema, + consensusMoveFromBaselineBps: z.number().int().multipleOf(TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS), + bestBid: probabilitySchema, + bestAsk: probabilitySchema, + spread: probabilitySchema, + conclusionTitle: z.string().min(1), + conclusionBody: z.string().min(1), + decisionExplanation: z.string().min(1) +}).strict(); + +const decisionStageSchema = z.object({ + id: z.enum(["signal", "evidence", "pass", "execution"]), + label: z.string().min(1), + detail: z.string().min(1), + status: z.enum(["complete", "passed", "locked"]), + timingLabel: z.string().min(1) +}).strict(); + +const evidenceRowSchema = z.object({ + replayStateId: z.enum(["pre", "goal", "post"]), + observedAt: isoSchema, + offsetLabel: z.string().min(1), + source: z.enum(["Polymarket", "TXLine", "Samaritan"]), + observation: z.string().min(1), + bestAsk: probabilitySchema, + assessment: z.enum(["Moved first", "Pass", "No trade"]) +}).strict(); + +const matchSchema = z.object({ + fixtureId: z.string().min(1), + eventSlug: z.string().min(1), + competition: z.literal("World Cup"), + stage: z.literal("Round of 16"), + kickoffUtc: isoSchema, + originalMatchDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + home: z.object({ name: z.string().min(1), code: z.literal("ESP") }).strict(), + away: z.object({ name: z.string().min(1), code: z.literal("BEL") }).strict(), + scoreAtCursor: z.object({ home: z.literal(1), away: z.literal(0) }).strict(), + clockSeconds: nonnegativeIntegerSchema, + clockLabel: z.string().min(1) +}).strict(); + +const decisionSchema = z.object({ + disposition: z.literal("no_trade"), + semanticStatus: z.literal("disciplined_pass"), + label: z.literal("No trade"), + primaryReason: z.literal("Market moved before signal"), + explanation: z.string().min(1), + capitalMovedMicros: z.literal(0), + ordersPlaced: z.literal(0), + walletAccessed: z.literal(false), + stages: z.array(decisionStageSchema).length(4) +}).strict(); + +const proofSchema = z.object({ + captureStatus: z.literal("verified"), + identityParity: z.literal(true), + identityHash: hashSchema, + headHash: hashSchema, + canonicalEvents: z.number().int().positive().safe(), + replayMode: z.literal("capture-order-per-source"), + feedOutageCount: nonnegativeIntegerSchema, + feedDowntimeMs: nonnegativeIntegerSchema, + maximumFeedDowntimeMs: nonnegativeIntegerSchema, + gateCases: nonnegativeIntegerSchema, + movedBeforeTxlineCases: nonnegativeIntegerSchema, + noMaterialRepriceCases: nonnegativeIntegerSchema, + cleanStaleWindows: z.literal(0) +}).strict(); + +export const matchroomApiResponseSchema = z.object({ + data: z.object({ + schemaVersion: z.literal(2), + snapshotId: z.literal(SPAIN_BELGIUM_MATCHROOM_ID), + generatedAt: isoSchema, + mode: z.literal("captured_replay"), + executionMode: z.literal("paper"), + realMoneyGate: z.literal("closed"), + tradeable: z.literal(false), + match: matchSchema, + market: z.object({ + family: z.literal("match_result"), + outcome: z.literal("draw"), + label: z.literal("Match result · Draw"), + period: z.literal("90 minutes plus stoppage time"), + mappingStatus: z.literal("research_only") + }).strict(), + replay: z.object({ + firstSeenAt: isoSchema, + firstSeenLatencyMs: z.number().int(), + firstMaterialMoveLatencyMs: z.number().int(), + preTriggerMarketMoveBps: z.number().int(), + activeStateId: z.literal("goal"), + states: z.array(replayStateSchema).length(3), + chart: z.array(publicBookPointSchema).min(1), + availabilityGaps: z.array(z.object({ + startedAt: isoSchema, + endedAt: isoSchema, + durationMs: nonnegativeIntegerSchema + }).strict()) + }).strict(), + decision: decisionSchema, + evidence: z.array(evidenceRowSchema).length(3), + proof: proofSchema, + publicDataPolicy: publicDataPolicySchema + }).strict() +}).strict(); + +const commandCaseSchema = z.object({ + caseId: z.literal("ESP-BEL-01"), + matchroomId: z.literal(SPAIN_BELGIUM_MATCHROOM_ID), + fixtureId: z.string().min(1), + fixtureLabel: z.literal("Spain vs Belgium"), + occurredAt: isoSchema, + marketLabel: z.literal("Match result · Draw"), + candidateLabel: z.literal("Live-lane gate readout"), + disposition: z.literal("no_trade"), + dispositionLabel: z.literal("No trade"), + reason: z.literal("Market moved before signal"), + evidenceStatus: z.literal("verified_replay"), + preTriggerMarketMoveBps: z.number().int(), + consensusMoveFromBaselineBps: z.number().int().multipleOf(TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS), + bestAsk: probabilitySchema +}).strict(); + +export const commandApiResponseSchema = z.object({ + data: z.object({ + schemaVersion: z.literal(2), + snapshotId: z.literal(COMMAND_SNAPSHOT_ID), + generatedAt: isoSchema, + mode: z.literal("offline_artifact"), + executionMode: z.literal("paper"), + realMoneyGate: z.literal("closed"), + tradeable: z.literal(false), + system: z.object({ + posture: z.enum(["standing_by", "capture_window", "awaiting_verification"]), + label: z.string().min(1), + detail: z.string().min(1), + feeds: z.array(z.object({ + id: z.enum(["txline", "polymarket", "decision_ledger", "replay_proof"]), + label: z.string().min(1), + status: z.enum(["scheduled", "initialized", "verified"]), + statusLabel: z.string().min(1), + detail: z.string().min(1) + }).strict()).length(4) + }).strict(), + featuredCase: commandCaseSchema.extend({ + scoreLabel: z.literal("1–0"), + clockLabel: z.string().min(1), + conclusion: z.string().min(1), + canonicalEvents: z.number().int().positive().safe(), + identityParity: z.literal(true), + chart: z.array(publicBookPointSchema).min(1) + }).strict(), + fixtureSchedule: z.array(z.object({ + fixtureId: z.string().min(1), + home: z.object({ name: z.string().min(1), code: z.string().min(2) }).strict(), + away: z.object({ name: z.string().min(1), code: z.string().min(2) }).strict(), + kickoffUtc: isoSchema, + captureStartUtc: isoSchema, + signalCutoffUtc: isoSchema, + eventSlug: z.string().min(1), + phase: z.enum(["scheduled", "capture_window", "awaiting_verification"]), + statusLabel: z.string().min(1), + identityStatus: z.literal("exact_match_confirmed"), + captureOnly: z.literal(true), + tradeable: z.literal(false) + }).strict()).min(1), + recentCases: z.array(commandCaseSchema).length(1), + additionalCaseState: z.object({ + status: z.literal("waiting_for_eligible_capture"), + label: z.literal("No active study can admit cases"), + detail: z.string().min(1) + }).strict(), + study: z.object({ + protocolVersion: z.string().min(1), + protocolStatus: z.literal("invalidated_suspended"), + configHash: hashSchema, + startedAt: isoSchema, + status: z.literal("suspended"), + statusLabel: z.literal("V1 suspended"), + filledMatches: z.literal(0), + requiredFilledMatches: z.number().int().positive(), + fills: z.literal(0), + requiredFills: z.number().int().positive(), + bountyStatus: z.literal("exploratory"), + stoppingRuleMet: z.literal(false), + reason: z.string().min(1) + }).strict(), + proof: z.object({ + replayIdentityParity: z.literal(true), + replayIdentityHash: hashSchema, + canonicalEvents: z.number().int().positive().safe(), + bountyLedgerValid: z.literal(true), + bountyLedgerRows: z.number().int().positive(), + longRunLedgerValid: z.literal(true), + longRunLedgerRows: z.number().int().positive(), + evidenceFixtures: nonnegativeIntegerSchema, + pairedBookReplays: nonnegativeIntegerSchema, + signalResearchOnly: nonnegativeIntegerSchema + }).strict(), + sourceFreshness: z.object({ + paperReportGeneratedAt: isoSchema, + fixtureUniverseGeneratedAt: isoSchema, + replayGeneratedAt: isoSchema + }).strict(), + publicDataPolicy: publicDataPolicySchema + }).strict() +}).strict(); + +const casebookSummarySchema = z.object({ + caseId: z.literal("ESP-BEL-01"), + matchroomId: z.literal(SPAIN_BELGIUM_MATCHROOM_ID), + occurredAt: isoSchema, + fixtureId: z.string().min(1), + fixtureLabel: z.literal("Spain vs Belgium"), + homeCode: z.literal("ESP"), + awayCode: z.literal("BEL"), + marketFamily: z.literal("Match result"), + marketLabel: z.literal("Match result · Draw"), + detector: z.literal("STALE_QUOTE_FEASIBILITY"), + disposition: z.literal("No trade"), + executionOutcome: z.literal("Not executed"), + evidenceLane: z.literal("Research only"), + source: z.literal("Captured replay"), + verificationStatus: z.literal("Verified"), + reason: z.literal("Market moved before signal"), + preTriggerMarketMoveBps: z.number().int() +}).strict(); + +export const casebookApiResponseSchema = z.object({ + data: z.object({ + schemaVersion: z.literal(2), + snapshotId: z.literal(CASEBOOK_SNAPSHOT_ID), + generatedAt: isoSchema, + mode: z.literal("offline_artifact"), + executionMode: z.literal("paper"), + realMoneyGate: z.literal("closed"), + tradeable: z.literal(false), + statistics: z.object({ + totalCases: z.literal(1), + noTradeCases: z.literal(1), + executedCases: z.literal(0), + verifiedCases: z.literal(1), + capitalMovedMicros: z.literal(0) + }).strict(), + filterOptions: z.object({ + fixtures: z.array(z.string()), + marketFamilies: z.array(z.string()), + detectors: z.array(z.string()), + dispositions: z.array(z.string()), + executionOutcomes: z.array(z.string()), + evidenceLanes: z.array(z.string()), + sources: z.array(z.string()) + }).strict(), + cases: z.array(casebookSummarySchema).length(1), + selectedCase: z.object({ + summary: casebookSummarySchema, + match: matchSchema, + decision: decisionSchema, + lifecycle: z.array(decisionStageSchema).length(4), + evidenceReadout: z.object({ + consensusMoveFromBaselineBps: z.number().int().multipleOf(TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS), + bestBid: probabilitySchema, + bestAsk: probabilitySchema, + spread: probabilitySchema, + preTriggerMarketMoveBps: z.number().int(), + movementConclusion: z.literal("Polymarket moved before TXLine") + }).strict(), + analysis: z.object({ + thesisStatus: z.literal("not_requested"), + thesisReason: z.string().min(1), + invalidation: z.string().min(1), + costStatus: z.literal("not_applicable"), + costMicros: z.literal(0), + costReason: z.string().min(1) + }).strict(), + evidence: z.array(evidenceRowSchema).length(3), + proof: proofSchema + }).strict(), + nextEvidence: z.object({ + status: z.literal("waiting_for_verified_capture"), + label: z.string().min(1), + detail: z.string().min(1) + }).strict(), + publicDataPolicy: publicDataPolicySchema + }).strict() +}).strict(); + +const studyCountsSchema = z.object({ + matches: nonnegativeIntegerSchema, + signals: nonnegativeIntegerSchema, + filledMatches: z.literal(0), + fills: z.literal(0), + settledFills: z.literal(0) +}).strict(); + +const studyIntervalSchema = z.object({ + iterations: z.number().int().positive(), + seed: z.number().int(), + matches: z.number().int().positive(), + signals: z.number().int().positive(), + low: finiteSchema, + median: finiteSchema, + high: finiteSchema +}).strict(); + +const studyEndpointsSchema = z.object({ + meanNetClvBps: finiteSchema, + netClvInterval: studyIntervalSchema, + meanSettlementPnlMicroUsd: z.number().int(), + settlementPnlInterval: studyIntervalSchema, + noTradeBaselineClvBps: z.literal(0), + randomDirectionControlClvBps: finiteSchema, + fractionSettledMatchesNetPositive: probabilitySchema +}).strict(); + +const studyGuardrailsSchema = z.object({ + fillRate: probabilitySchema, + fillRatePassed: z.boolean(), + meanSlippageBps: finiteSchema.nonnegative().nullable(), + slippagePassed: z.boolean(), + maxDrawdownMicroUsd: nonnegativeIntegerSchema, + drawdownPassed: z.boolean(), + selectedDepthComplete: z.boolean(), + closeMarksComplete: z.boolean(), + settlementComplete: z.boolean() +}).strict(); + +const studyRowSchema = z.object({ + fixtureId: z.string().min(1), + kickoffUtc: isoSchema, + selectedLine: finiteSchema, + signals: nonnegativeIntegerSchema, + fills: nonnegativeIntegerSchema, + fillRate: probabilitySchema, + meanHalfSpreadBps: finiteSchema.nonnegative().nullable(), + meanSlippageBps: finiteSchema.nonnegative().nullable(), + grossClvBps: finiteSchema.nullable(), + netClvBps: finiteSchema.nullable(), + settlementPnlMicroUsd: z.number().int().nullable(), + netReturnBps: finiteSchema.nullable() +}).strict(); + +export const correctedHistoricalCandidateSchema = z.object({ + schemaVersion: z.literal(4), + generatedAt: isoSchema, + protocolId: z.literal("historical-gate-causal-economic-v4-2026-07-14"), + configurationHash: hashSchema, + status: z.literal("historical_signal_candidate_for_forward_paper_review"), + registration: z.literal("engineering_candidate_unregistered"), + activeStudy: z.literal(false), + detector: z.literal("CONSENSUS_MOVE"), + marketFamily: z.literal("Full-time totals"), + trainingNormalizedCases: z.literal(135), + heldoutNormalizedCases: z.literal(38), + heldoutFixtures: z.literal(18), + costProxyBps: z.literal(100), + meanNetAfterCostProxyBps: z.literal(132.7), + matchClustered95Bps: z.object({ + iterations: z.literal(10_000), + cluster: z.literal("fixture"), + low: z.literal(14.3), + high: z.literal(243.9) + }).strict(), + evidenceClass: z.literal("historical_sampled_price_signal_research"), + executionEvidence: z.literal("not_established_no_historical_bid_ask_or_depth"), + executable: z.literal(false), + claimBoundary: z.literal("Forward paper review candidate only; not alpha, profitability, fill proof, or permission to trade.") +}).strict(); + +export const syntheticProofReceiptSchema = z.object({ + label: z.literal("Synthetic full-lifecycle proving fixture"), + path: z.literal("/artifacts/dashboard/synthetic-decision-receipt.json"), + lifecycleStatus: z.literal("filled_settled"), + offlineVerified: z.literal(true), + performanceUse: z.literal("excluded_synthetic"), + externalCalls: z.literal(0), + solanaAnchorStatus: z.literal("not_submitted"), + explanation: z.literal("Closed-world production-component demo; separate from historical evidence and excluded from every performance claim.") +}).strict(); + +export const studyApiResponseSchema = z.object({ + data: z.object({ + schemaVersion: z.literal(2), + snapshotId: z.literal(STUDY_SNAPSHOT_ID), + generatedAt: isoSchema, + mode: z.literal("offline_artifact"), + executionMode: z.literal("paper"), + realMoneyGate: z.literal("closed"), + tradeable: z.literal(false), + protocol: z.object({ + version: z.string().min(1), + status: z.literal("invalidated_suspended"), + active: z.literal(false), + configHash: hashSchema, + startedAt: isoSchema, + candidate: z.object({ + detector: z.literal("CONSENSUS_MOVE"), + marketFamily: z.literal("Full-time totals only"), + moveAbsZ: finiteSchema.positive(), + cusumThresholdBps: finiteSchema.positive(), + minimumGapBps: finiteSchema.positive(), + minimumUpdates: z.number().int().positive(), + selector: z.literal("Closest to even"), + minimumCoveragePoints: z.number().int().positive(), + maximumDistanceFromEven: probabilitySchema + }).strict(), + evaluation: z.object({ + unitOfAnalysis: z.literal("match"), + primaryEndpoint: z.literal("Executable CLV net of measured costs"), + minimumFilledMatches: z.number().int().positive(), + minimumFills: z.number().int().positive(), + targetMatches: z.literal(30), + bootstrapIterations: z.number().int().positive(), + bootstrapSeed: z.number().int(), + randomDirectionControl: z.literal("Seeded matched-cost sign flip") + }).strict(), + risk: z.object({ + bankrollMicroUsd: z.number().int().positive(), + perTradeStakeMicroUsd: z.number().int().positive(), + aggregateExposureMicroUsd: z.number().int().positive(), + drawdownStopMicroUsd: z.number().int().positive() + }).strict(), + guardrailThresholds: z.object({ + minimumFillRate: probabilitySchema, + maximumMeanSlippageBps: finiteSchema.positive(), + maximumDrawdownMicroUsd: z.number().int().positive(), + selectedDepthRequired: z.literal(true) + }).strict() + }).strict(), + lanes: z.object({ + bounty: z.object({ + label: z.literal("Preserved v1 bounty ledger"), + status: z.literal("exploratory"), + statusLabel: z.literal("Exploratory"), + reason: z.string().min(1), + counts: studyCountsSchema, + chain: z.object({ valid: z.literal(true), rows: z.number().int().positive(), headHash: hashSchema }).strict(), + canSatisfyGate: z.literal(false) + }).strict(), + longRun: z.object({ + label: z.literal("Preserved v1 long-run ledger"), + status: z.enum(["sealed", "accept", "reject", "inconclusive"]), + statusLabel: z.string().min(1), + reason: z.string().min(1), + counts: studyCountsSchema, + stoppingRuleMet: z.boolean(), + chain: z.object({ valid: z.literal(true), rows: z.number().int().positive(), headHash: hashSchema }).strict(), + canSatisfyGate: z.literal(false) + }).strict() + }).strict(), + results: z.discriminatedUnion("visibility", [ + z.object({ visibility: z.literal("sealed"), rows: z.null(), endpoints: z.null(), guardrails: z.null() }).strict(), + z.object({ + visibility: z.literal("open"), + rows: z.array(studyRowSchema), + endpoints: studyEndpointsSchema.nullable(), + guardrails: studyGuardrailsSchema + }).strict() + ]), + correctedHistoricalCandidate: correctedHistoricalCandidateSchema, + syntheticProof: syntheticProofReceiptSchema, + fixtureUniverse: z.object({ + generatedAt: isoSchema, + evidenceFixtures: nonnegativeIntegerSchema, + pairedBookReplays: nonnegativeIntegerSchema, + executableBookReplays: nonnegativeIntegerSchema, + signalResearchOnly: nonnegativeIntegerSchema, + longRunEligible: nonnegativeIntegerSchema + }).strict(), + decisionRules: z.object({ + accept: z.array(z.string().min(1)).min(1), + reject: z.array(z.string().min(1)).min(1), + inconclusive: z.array(z.string().min(1)).min(1) + }).strict(), + publicDataPolicy: publicDataPolicySchema + }).strict() +}).strict(); + +const manifestEntrySchema = z.object({ + apiPath: z.enum([COMMAND_API_PATH, CASEBOOK_API_PATH, STUDY_API_PATH, SPAIN_BELGIUM_API_PATH]), + file: z.enum(["command.json", "casebook.json", "study.json", "matchroom-spain-belgium.json"]), + snapshotId: z.enum([COMMAND_SNAPSHOT_ID, CASEBOOK_SNAPSHOT_ID, STUDY_SNAPSHOT_ID, SPAIN_BELGIUM_MATCHROOM_ID]), + sha256: hashSchema, + bytes: z.number().int().positive().safe() +}).strict(); + +export type PublicDashboardManifestEntry = z.infer; + +const manifestDownloadSchema = z.object({ + id: z.literal("synthetic-decision-receipt"), + file: z.literal(PUBLIC_SYNTHETIC_RECEIPT_FILENAME), + mediaType: z.literal("application/json"), + sha256: hashSchema, + bytes: z.number().int().positive().safe(), + synthetic: z.literal(true), + performanceUse: z.literal("excluded_synthetic") +}).strict(); + +export type PublicDashboardManifestDownload = z.infer; + +export const publicDashboardManifestSchema = z.object({ + schemaVersion: z.literal(1), + bundleId: z.literal(PUBLIC_DASHBOARD_BUNDLE_ID), + generatedAt: isoSchema, + canonicalization: z.literal(PUBLIC_DASHBOARD_CANONICALIZATION), + bundleSha256: hashSchema, + files: z.array(manifestEntrySchema).length(PUBLIC_DASHBOARD_FILES.length), + downloads: z.array(manifestDownloadSchema).length(1), + publicDataPolicy: publicDataPolicySchema +}).strict(); + +export type PublicDashboardManifest = z.infer; + +export function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function publicDashboardBundleHash( + generatedAt: string, + entries: readonly PublicDashboardManifestEntry[], + downloads: readonly PublicDashboardManifestDownload[] +): string { + return sha256(`${PUBLIC_DASHBOARD_BUNDLE_HASH_DOMAIN}\n${stableJson({ + schemaVersion: 1, + bundleId: PUBLIC_DASHBOARD_BUNDLE_ID, + generatedAt, + canonicalization: PUBLIC_DASHBOARD_CANONICALIZATION, + files: entries, + downloads, + publicDataPolicy: { + derivedOnly: true, + txlineProbabilityDisplay: "bucketed_movement_only", + txlineMovementBucketBps: TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS, + credentialsRequired: false, + walletControlsExposed: false + } + })}`); +} + +export function parsePublicDashboardResponse(pathname: PublicDashboardApiPath, value: unknown): unknown { + if (pathname === COMMAND_API_PATH) return commandApiResponseSchema.parse(value); + if (pathname === CASEBOOK_API_PATH) return casebookApiResponseSchema.parse(value); + if (pathname === STUDY_API_PATH) return studyApiResponseSchema.parse(value); + return matchroomApiResponseSchema.parse(value); +} + +export function parsePublicDashboardManifest(value: unknown): PublicDashboardManifest { + const manifest = publicDashboardManifestSchema.parse(value); + for (const [index, definition] of PUBLIC_DASHBOARD_FILES.entries()) { + const entry = manifest.files[index]; + if ( + !entry || + entry.apiPath !== definition.apiPath || + entry.file !== definition.file || + entry.snapshotId !== definition.snapshotId + ) { + throw new Error(`Public dashboard manifest entry ${index} does not match the frozen route map`); + } + } + const expectedBundleHash = publicDashboardBundleHash(manifest.generatedAt, manifest.files, manifest.downloads); + if (manifest.bundleSha256 !== expectedBundleHash) { + throw new Error("Public dashboard manifest bundle hash is invalid"); + } + return manifest; +} + +export type FrozenDashboardResponse = { + body: string; + manifest: PublicDashboardManifest; +}; + +export async function readFrozenDashboardResponse( + repoRoot: string, + pathname: PublicDashboardApiPath +): Promise { + const bundleRoot = resolve(repoRoot, PUBLIC_DASHBOARD_BUNDLE_RELATIVE_DIR); + let manifestValue: unknown; + try { + manifestValue = JSON.parse(await readFile(resolve(bundleRoot, "manifest.json"), "utf8")) as unknown; + } catch (error) { + throw new Error(`Frozen dashboard manifest is unavailable: ${error instanceof Error ? error.message : String(error)}`); + } + const manifest = parsePublicDashboardManifest(manifestValue); + const entry = manifest.files.find((candidate) => candidate.apiPath === pathname); + if (!entry) throw new Error(`Frozen dashboard manifest has no entry for ${pathname}`); + const body = await readFile(resolve(bundleRoot, entry.file), "utf8"); + if (Buffer.byteLength(body, "utf8") !== entry.bytes) { + throw new Error(`Frozen dashboard artifact byte count changed for ${entry.file}`); + } + if (sha256(body) !== entry.sha256) { + throw new Error(`Frozen dashboard artifact hash changed for ${entry.file}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch { + throw new Error(`Frozen dashboard artifact is invalid JSON: ${entry.file}`); + } + const response = parsePublicDashboardResponse(pathname, parsed) as { data?: { snapshotId?: unknown } }; + if (response.data?.snapshotId !== entry.snapshotId) { + throw new Error(`Frozen dashboard artifact snapshot identity changed for ${entry.file}`); + } + return { body, manifest }; +} diff --git a/src/dash/public-contract.ts b/src/dash/public-contract.ts new file mode 100644 index 0000000..6a086d1 --- /dev/null +++ b/src/dash/public-contract.ts @@ -0,0 +1,509 @@ +export const SPAIN_BELGIUM_MATCHROOM_ID = "paired-spain-belgium-2026-07-10"; +export const COMMAND_SNAPSHOT_ID = "command-2026-world-cup"; +export const CASEBOOK_SNAPSHOT_ID = "casebook-2026-world-cup"; +export const STUDY_SNAPSHOT_ID = "paper-study-2026-world-cup"; +export const TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS = 25; + +export type ReplayStepId = "pre" | "goal" | "post"; + +export type PublicBookPoint = { + offsetMs: number; + bestBid: number; + bestAsk: number; + spread: number; + available: true; +}; + +export type ReplayState = { + id: ReplayStepId; + label: string; + offsetMs: number; + observedAt: string; + consensusMoveFromBaselineBps: number; + bestBid: number; + bestAsk: number; + spread: number; + conclusionTitle: string; + conclusionBody: string; + decisionExplanation: string; +}; + +export type PublicDataPolicy = { + derivedOnly: true; + txlineProbabilityDisplay: "bucketed_movement_only"; + txlineMovementBucketBps: typeof TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS; + credentialsRequired: false; + walletControlsExposed: false; +}; + +export type DecisionStage = { + id: "signal" | "evidence" | "pass" | "execution"; + label: string; + detail: string; + status: "complete" | "passed" | "locked"; + timingLabel: string; +}; + +export type EvidenceRow = { + replayStateId: ReplayStepId; + observedAt: string; + offsetLabel: string; + source: "Polymarket" | "TXLine" | "Samaritan"; + observation: string; + bestAsk: number; + assessment: "Moved first" | "Pass" | "No trade"; +}; + +export type AvailabilityGap = { + startedAt: string; + endedAt: string; + durationMs: number; +}; + +export type MatchroomSnapshot = { + schemaVersion: 2; + snapshotId: string; + generatedAt: string; + mode: "captured_replay"; + executionMode: "paper"; + realMoneyGate: "closed"; + tradeable: false; + match: { + fixtureId: string; + eventSlug: string; + competition: "World Cup"; + stage: "Round of 16"; + kickoffUtc: string; + originalMatchDate: string; + home: { name: string; code: "ESP" }; + away: { name: string; code: "BEL" }; + scoreAtCursor: { home: 1; away: 0 }; + clockSeconds: number; + clockLabel: string; + }; + market: { + family: "match_result"; + outcome: "draw"; + label: "Match result · Draw"; + period: "90 minutes plus stoppage time"; + mappingStatus: "research_only"; + }; + replay: { + firstSeenAt: string; + firstSeenLatencyMs: number; + firstMaterialMoveLatencyMs: number; + preTriggerMarketMoveBps: number; + activeStateId: "goal"; + states: ReplayState[]; + chart: PublicBookPoint[]; + availabilityGaps: AvailabilityGap[]; + }; + decision: { + disposition: "no_trade"; + semanticStatus: "disciplined_pass"; + label: "No trade"; + primaryReason: "Market moved before signal"; + explanation: string; + capitalMovedMicros: 0; + ordersPlaced: 0; + walletAccessed: false; + stages: DecisionStage[]; + }; + evidence: EvidenceRow[]; + proof: { + captureStatus: "verified"; + identityParity: true; + identityHash: string; + headHash: string; + canonicalEvents: number; + replayMode: "capture-order-per-source"; + feedOutageCount: number; + feedDowntimeMs: number; + maximumFeedDowntimeMs: number; + gateCases: number; + movedBeforeTxlineCases: number; + noMaterialRepriceCases: number; + cleanStaleWindows: 0; + }; + publicDataPolicy: PublicDataPolicy; +}; + +export type DashboardApiResponse = { + data: MatchroomSnapshot; +}; + +export type CommandFixturePhase = "scheduled" | "capture_window" | "awaiting_verification"; + +export type CommandFeedState = { + id: "txline" | "polymarket" | "decision_ledger" | "replay_proof"; + label: string; + status: "scheduled" | "initialized" | "verified"; + statusLabel: string; + detail: string; +}; + +export type CommandFixture = { + fixtureId: string; + home: { name: string; code: string }; + away: { name: string; code: string }; + kickoffUtc: string; + captureStartUtc: string; + signalCutoffUtc: string; + eventSlug: string; + phase: CommandFixturePhase; + statusLabel: string; + identityStatus: "exact_match_confirmed"; + captureOnly: true; + tradeable: false; +}; + +export type CommandCase = { + caseId: "ESP-BEL-01"; + matchroomId: typeof SPAIN_BELGIUM_MATCHROOM_ID; + fixtureId: string; + fixtureLabel: "Spain vs Belgium"; + occurredAt: string; + marketLabel: "Match result · Draw"; + candidateLabel: "Live-lane gate readout"; + disposition: "no_trade"; + dispositionLabel: "No trade"; + reason: "Market moved before signal"; + evidenceStatus: "verified_replay"; + preTriggerMarketMoveBps: number; + consensusMoveFromBaselineBps: number; + bestAsk: number; +}; + +export type CommandSnapshot = { + schemaVersion: 2; + snapshotId: typeof COMMAND_SNAPSHOT_ID; + generatedAt: string; + mode: "offline_artifact"; + executionMode: "paper"; + realMoneyGate: "closed"; + tradeable: false; + system: { + posture: "standing_by" | "capture_window" | "awaiting_verification"; + label: string; + detail: string; + feeds: CommandFeedState[]; + }; + featuredCase: CommandCase & { + scoreLabel: "1–0"; + clockLabel: string; + conclusion: string; + canonicalEvents: number; + identityParity: true; + chart: PublicBookPoint[]; + }; + fixtureSchedule: CommandFixture[]; + recentCases: CommandCase[]; + additionalCaseState: { + status: "waiting_for_eligible_capture"; + label: "No active study can admit cases"; + detail: string; + }; + study: { + protocolVersion: string; + protocolStatus: "invalidated_suspended"; + configHash: string; + startedAt: string; + status: "suspended"; + statusLabel: "V1 suspended"; + filledMatches: number; + requiredFilledMatches: number; + fills: number; + requiredFills: number; + bountyStatus: "exploratory"; + stoppingRuleMet: false; + reason: string; + }; + proof: { + replayIdentityParity: true; + replayIdentityHash: string; + canonicalEvents: number; + bountyLedgerValid: true; + bountyLedgerRows: number; + longRunLedgerValid: true; + longRunLedgerRows: number; + evidenceFixtures: number; + pairedBookReplays: number; + signalResearchOnly: number; + }; + sourceFreshness: { + paperReportGeneratedAt: string; + fixtureUniverseGeneratedAt: string; + replayGeneratedAt: string; + }; + publicDataPolicy: PublicDataPolicy; +}; + +export type CommandApiResponse = { + data: CommandSnapshot; +}; + +export type CasebookCaseSummary = { + caseId: "ESP-BEL-01"; + matchroomId: typeof SPAIN_BELGIUM_MATCHROOM_ID; + occurredAt: string; + fixtureId: string; + fixtureLabel: "Spain vs Belgium"; + homeCode: "ESP"; + awayCode: "BEL"; + marketFamily: "Match result"; + marketLabel: "Match result · Draw"; + detector: "STALE_QUOTE_FEASIBILITY"; + disposition: "No trade"; + executionOutcome: "Not executed"; + evidenceLane: "Research only"; + source: "Captured replay"; + verificationStatus: "Verified"; + reason: "Market moved before signal"; + preTriggerMarketMoveBps: number; +}; + +export type CasebookSnapshot = { + schemaVersion: 2; + snapshotId: typeof CASEBOOK_SNAPSHOT_ID; + generatedAt: string; + mode: "offline_artifact"; + executionMode: "paper"; + realMoneyGate: "closed"; + tradeable: false; + statistics: { + totalCases: 1; + noTradeCases: 1; + executedCases: 0; + verifiedCases: 1; + capitalMovedMicros: 0; + }; + filterOptions: { + fixtures: string[]; + marketFamilies: string[]; + detectors: string[]; + dispositions: string[]; + executionOutcomes: string[]; + evidenceLanes: string[]; + sources: string[]; + }; + cases: CasebookCaseSummary[]; + selectedCase: { + summary: CasebookCaseSummary; + match: MatchroomSnapshot["match"]; + decision: MatchroomSnapshot["decision"]; + lifecycle: DecisionStage[]; + evidenceReadout: { + consensusMoveFromBaselineBps: number; + bestBid: number; + bestAsk: number; + spread: number; + preTriggerMarketMoveBps: number; + movementConclusion: "Polymarket moved before TXLine"; + }; + analysis: { + thesisStatus: "not_requested"; + thesisReason: string; + invalidation: string; + costStatus: "not_applicable"; + costMicros: 0; + costReason: string; + }; + evidence: EvidenceRow[]; + proof: MatchroomSnapshot["proof"]; + }; + nextEvidence: { + status: "waiting_for_verified_capture"; + label: string; + detail: string; + }; + publicDataPolicy: PublicDataPolicy; +}; + +export type CasebookApiResponse = { + data: CasebookSnapshot; +}; + +export type StudyCounts = { + matches: number; + signals: number; + filledMatches: number; + fills: number; + settledFills: number; +}; + +export type StudyMatchRow = { + fixtureId: string; + kickoffUtc: string; + selectedLine: number; + signals: number; + fills: number; + fillRate: number; + meanHalfSpreadBps: number | null; + meanSlippageBps: number | null; + grossClvBps: number | null; + netClvBps: number | null; + settlementPnlMicroUsd: number | null; + netReturnBps: number | null; +}; + +export type StudyEndpoints = { + meanNetClvBps: number; + netClvInterval: { iterations: number; seed: number; matches: number; signals: number; low: number; median: number; high: number }; + meanSettlementPnlMicroUsd: number; + settlementPnlInterval: { iterations: number; seed: number; matches: number; signals: number; low: number; median: number; high: number }; + noTradeBaselineClvBps: 0; + randomDirectionControlClvBps: number; + fractionSettledMatchesNetPositive: number; +}; + +export type StudyGuardrails = { + fillRate: number; + fillRatePassed: boolean; + meanSlippageBps: number | null; + slippagePassed: boolean; + maxDrawdownMicroUsd: number; + drawdownPassed: boolean; + selectedDepthComplete: boolean; + closeMarksComplete: boolean; + settlementComplete: boolean; +}; + +export type CorrectedHistoricalCandidate = { + schemaVersion: 4; + generatedAt: string; + protocolId: "historical-gate-causal-economic-v4-2026-07-14"; + configurationHash: string; + status: "historical_signal_candidate_for_forward_paper_review"; + registration: "engineering_candidate_unregistered"; + activeStudy: false; + detector: "CONSENSUS_MOVE"; + marketFamily: "Full-time totals"; + trainingNormalizedCases: 135; + heldoutNormalizedCases: 38; + heldoutFixtures: 18; + costProxyBps: 100; + meanNetAfterCostProxyBps: 132.7; + matchClustered95Bps: { + iterations: 10_000; + cluster: "fixture"; + low: 14.3; + high: 243.9; + }; + evidenceClass: "historical_sampled_price_signal_research"; + executionEvidence: "not_established_no_historical_bid_ask_or_depth"; + executable: false; + claimBoundary: "Forward paper review candidate only; not alpha, profitability, fill proof, or permission to trade."; +}; + +export type SyntheticProofReceipt = { + label: "Synthetic full-lifecycle proving fixture"; + path: "/artifacts/dashboard/synthetic-decision-receipt.json"; + lifecycleStatus: "filled_settled"; + offlineVerified: true; + performanceUse: "excluded_synthetic"; + externalCalls: 0; + solanaAnchorStatus: "not_submitted"; + explanation: "Closed-world production-component demo; separate from historical evidence and excluded from every performance claim."; +}; + +export type StudySnapshot = { + schemaVersion: 2; + snapshotId: typeof STUDY_SNAPSHOT_ID; + generatedAt: string; + mode: "offline_artifact"; + executionMode: "paper"; + realMoneyGate: "closed"; + tradeable: false; + protocol: { + version: string; + status: "invalidated_suspended"; + active: false; + configHash: string; + startedAt: string; + candidate: { + detector: "CONSENSUS_MOVE"; + marketFamily: "Full-time totals only"; + moveAbsZ: number; + cusumThresholdBps: number; + minimumGapBps: number; + minimumUpdates: number; + selector: "Closest to even"; + minimumCoveragePoints: number; + maximumDistanceFromEven: number; + }; + evaluation: { + unitOfAnalysis: "match"; + primaryEndpoint: "Executable CLV net of measured costs"; + minimumFilledMatches: number; + minimumFills: number; + targetMatches: 30; + bootstrapIterations: number; + bootstrapSeed: number; + randomDirectionControl: "Seeded matched-cost sign flip"; + }; + risk: { + bankrollMicroUsd: number; + perTradeStakeMicroUsd: number; + aggregateExposureMicroUsd: number; + drawdownStopMicroUsd: number; + }; + guardrailThresholds: { + minimumFillRate: number; + maximumMeanSlippageBps: number; + maximumDrawdownMicroUsd: number; + selectedDepthRequired: true; + }; + }; + lanes: { + bounty: { + label: "Preserved v1 bounty ledger"; + status: "exploratory"; + statusLabel: "Exploratory"; + reason: string; + counts: StudyCounts; + chain: { valid: true; rows: number; headHash: string }; + canSatisfyGate: false; + }; + longRun: { + label: "Preserved v1 long-run ledger"; + status: "sealed" | "accept" | "reject" | "inconclusive"; + statusLabel: string; + reason: string; + counts: StudyCounts; + stoppingRuleMet: boolean; + chain: { valid: true; rows: number; headHash: string }; + canSatisfyGate: false; + }; + }; + results: { + visibility: "sealed"; + rows: null; + endpoints: null; + guardrails: null; + } | { + visibility: "open"; + rows: StudyMatchRow[]; + endpoints: StudyEndpoints | null; + guardrails: StudyGuardrails; + }; + correctedHistoricalCandidate: CorrectedHistoricalCandidate; + syntheticProof: SyntheticProofReceipt; + fixtureUniverse: { + generatedAt: string; + evidenceFixtures: number; + pairedBookReplays: number; + executableBookReplays: number; + signalResearchOnly: number; + longRunEligible: number; + }; + decisionRules: { + accept: string[]; + reject: string[]; + inconclusive: string[]; + }; + publicDataPolicy: PublicDataPolicy; +}; + +export type StudyApiResponse = { + data: StudySnapshot; +}; diff --git a/src/dash/server.ts b/src/dash/server.ts new file mode 100644 index 0000000..3b07273 --- /dev/null +++ b/src/dash/server.ts @@ -0,0 +1,92 @@ +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { handleDashboardApi, writeApiResult } from "./api.js"; + +const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const staticRoot = resolve(repoRoot, "apps/dashboard/dist"); +const port = Number(process.env.DASHBOARD_PORT ?? process.env.PORT ?? 4173); +const host = process.env.DASHBOARD_HOST ?? process.env.HOST ?? "127.0.0.1"; + +if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("DASHBOARD_PORT/PORT must be an integer between 1 and 65535"); +} +if (host.trim().length === 0 || /[\s/]/.test(host)) { + throw new Error("DASHBOARD_HOST/HOST is invalid"); +} + +const contentTypes: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".woff2": "font/woff2" +}; + +const securityHeaders: Record = { + "content-security-policy": "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'", + "cross-origin-opener-policy": "same-origin", + "permissions-policy": "camera=(), geolocation=(), microphone=(), payment=(), usb=()", + "referrer-policy": "no-referrer", + "x-frame-options": "DENY" +}; + +async function staticFilePath(pathname: string): Promise { + const decoded = decodeURIComponent(pathname); + const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, ""); + const candidate = resolve(staticRoot, relative); + if (candidate !== staticRoot && !candidate.startsWith(`${staticRoot}${sep}`)) return null; + try { + if ((await stat(candidate)).isFile()) return candidate; + } catch { + // SPA routes fall through to index.html. + } + if (extname(relative) !== "") return null; + const index = resolve(staticRoot, "index.html"); + try { + return (await stat(index)).isFile() ? index : null; + } catch { + return null; + } +} + +const server = createServer(async (request, response) => { + try { + for (const [name, value] of Object.entries(securityHeaders)) response.setHeader(name, value); + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { allow: "GET, HEAD", "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: "method_not_allowed" })); + return; + } + const url = new URL(request.url ?? "/", "http://localhost"); + const apiResult = await handleDashboardApi(url.pathname, repoRoot); + if (apiResult) { + writeApiResult(response, apiResult, request.method === "HEAD"); + return; + } + const path = await staticFilePath(url.pathname); + if (!path) { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + const body = await readFile(path); + response.writeHead(200, { + "cache-control": path.endsWith("index.html") ? "no-cache" : "public, max-age=31536000, immutable", + "content-type": contentTypes[extname(path)] ?? "application/octet-stream", + "x-content-type-options": "nosniff" + }); + response.end(request.method === "HEAD" ? undefined : body); + } catch (error) { + console.error(error); + response.writeHead(500, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: "internal_error" })); + } +}); + +server.listen(port, host, () => { + const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host; + console.log(`Samaritan dashboard listening at http://${displayHost}:${port} (bound ${host})`); +}); diff --git a/src/dash/study-projection.ts b/src/dash/study-projection.ts new file mode 100644 index 0000000..3b77505 --- /dev/null +++ b/src/dash/study-projection.ts @@ -0,0 +1,434 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { + STUDY_SNAPSHOT_ID, + TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS, + type CorrectedHistoricalCandidate, + type StudyApiResponse, + type StudyEndpoints, + type StudyGuardrails, + type StudyMatchRow, + type StudySnapshot +} from "./public-contract.js"; + +const countsSchema = z.object({ + matches: z.number().int().nonnegative(), + signals: z.number().int().nonnegative(), + filledMatches: z.number().int().nonnegative(), + fills: z.number().int().nonnegative(), + settledFills: z.number().int().nonnegative() +}); + +const intervalSchema = z.object({ + iterations: z.number().int().positive(), + seed: z.number().int(), + matches: z.number().int().positive(), + signals: z.number().int().positive(), + low: z.number().finite(), + median: z.number().finite(), + high: z.number().finite() +}); + +const endpointsSchema = z.object({ + meanNetClvBps: z.number().finite(), + netClvInterval: intervalSchema, + meanSettlementPnlMicroUsd: z.number().int(), + settlementPnlInterval: intervalSchema, + noTradeBaselineClvBps: z.literal(0), + randomDirectionControlClvBps: z.number().finite(), + fractionSettledMatchesNetPositive: z.number().min(0).max(1) +}); + +const guardrailsSchema = z.object({ + fillRate: z.number().min(0).max(1), + fillRatePassed: z.boolean(), + meanSlippageBps: z.number().finite().nonnegative().nullable(), + slippagePassed: z.boolean(), + maxDrawdownMicroUsd: z.number().int().nonnegative(), + drawdownPassed: z.boolean(), + selectedDepthComplete: z.boolean(), + closeMarksComplete: z.boolean(), + settlementComplete: z.boolean() +}); + +const rowSchema = z.object({ + fixtureId: z.string().min(1), + kickoffTsMs: z.number().int().nonnegative(), + selectedLineMilli: z.number().int(), + signals: z.number().int().nonnegative(), + fills: z.number().int().nonnegative(), + fillRate: z.number().min(0).max(1), + meanHalfSpreadBps: z.number().finite().nonnegative().nullable(), + meanSlippageBps: z.number().finite().nonnegative().nullable(), + grossClvBps: z.number().finite().nullable(), + netClvBps: z.number().finite().nullable(), + settlementPnlMicroUsd: z.number().int().nullable(), + netReturnBps: z.number().finite().nullable() +}); + +const reportSchema = z.object({ + lane: z.enum(["bounty", "long_run"]), + counts: countsSchema, + stoppingRuleMet: z.boolean(), + status: z.enum(["sealed", "exploratory", "accept", "reject", "inconclusive"]), + reason: z.string().min(1), + rows: z.array(rowSchema).nullable(), + endpoints: endpointsSchema.nullable(), + guardrails: guardrailsSchema.nullable() +}); + +const initializationSchema = z.object({ + configHash: z.string().regex(/^[a-f0-9]{64}$/), + protocolVersion: z.string().min(1), + realMoneyGate: z.literal("closed"), + startedAt: z.string().datetime(), + frozenConfig: z.object({ + detector: z.object({ + consensusCusumThreshold: z.number().finite().positive(), + consensusMinimumRawGap: z.number().finite().positive(), + consensusMinimumUpdates: z.number().int().positive(), + consensusMoveAbsZ: z.number().finite().positive() + }), + evaluation: z.object({ + bootstrapIterations: z.number().int().positive(), + bootstrapSeed: z.number().int(), + maximumDrawdownMicroUsd: z.number().int().positive(), + maximumMeanSlippageBps: z.number().finite().positive(), + minimumFilledMatches: z.number().int().positive(), + minimumFillRate: z.number().min(0).max(1), + minimumFills: z.number().int().positive() + }), + risk: z.object({ + aggregateExposureMicroUsd: z.number().int().positive(), + bankrollMicroUsd: z.number().int().positive(), + drawdownStopMicroUsd: z.number().int().positive(), + perTradeStakeMicroUsd: z.number().int().positive(), + realMoneyGate: z.literal("closed") + }), + selector: z.object({ + maximumDistanceFromEven: z.number().min(0).max(0.5), + minimumCoveragePoints: z.number().int().positive() + }) + }) +}); + +const laneSchema = z.object({ + initialization: initializationSchema, + chain: z.object({ valid: z.literal(true), rows: z.number().int().positive(), headHash: z.string().regex(/^[a-f0-9]{64}$/) }), + report: reportSchema +}); + +const artifactSchema = z.object({ + generatedAt: z.string().datetime(), + protocolVersion: z.string().min(1), + configHash: z.string().regex(/^[a-f0-9]{64}$/), + realMoneyGate: z.literal("closed"), + fixtureUniverseGeneratedAt: z.string().datetime(), + lanes: z.object({ bounty: laneSchema, longRun: laneSchema }) +}); + +const universeSchema = z.object({ + generatedAt: z.string().datetime(), + summary: z.object({ + fixtures: z.number().int().nonnegative(), + pairedBookReplays: z.number().int().nonnegative(), + executableBookReplays: z.number().int().nonnegative(), + signalResearchOnly: z.number().int().nonnegative(), + longRunEligible: z.number().int().nonnegative() + }) +}); + +const correctedHistoricalCandidateSourceSchema = z.object({ + schemaVersion: z.literal(4), + generatedAt: z.string().datetime(), + protocolId: z.literal("historical-gate-causal-economic-v4-2026-07-14"), + configurationHash: z.string().regex(/^[a-f0-9]{64}$/), + status: z.literal("research_evidence_only"), + auditStatus: z.literal("causal_selector_and_economic_case_normalization_repaired_pending_human_review"), + realMoneyGate: z.literal("closed"), + configuration: z.object({ + costProxyProbability: z.literal(0.01), + bootstrap: z.object({ + iterations: z.literal(10_000), + seed: z.literal(20_260_714), + cluster: z.literal("fixture") + }).passthrough() + }).passthrough(), + forwardPaperCandidate: z.object({ + detector: z.literal("CONSENSUS_MOVE"), + marketFamily: z.literal("total_goals"), + configurationSelection: z.literal("training_only_before_heldout_totals_review"), + trainingNormalizedCases: z.literal(135), + heldoutNormalizedCases: z.literal(38), + heldoutFixtures: z.literal(18), + meanNetAfterCostProxy: z.number().finite().positive(), + matchClusteredNetClv95: z.object({ + iterations: z.literal(10_000), + seed: z.literal(20_260_714), + fixtures: z.literal(18), + signals: z.literal(38), + low: z.number().finite().positive(), + median: z.number().finite().positive(), + high: z.number().finite().positive() + }).strict(), + status: z.literal("historical_signal_candidate_for_forward_paper_review"), + executionEvidence: z.literal("not_established_sampled_prices_only"), + registration: z.literal("engineering_candidate_unregistered") + }).strict() +}).passthrough(); + +function validCounts(counts: z.infer): boolean { + return counts.filledMatches <= counts.matches && counts.fills <= counts.signals && counts.settledFills <= counts.fills; +} + +function mapRows(rows: z.infer[]): StudyMatchRow[] { + return rows.map((row) => ({ + fixtureId: row.fixtureId, + kickoffUtc: new Date(row.kickoffTsMs).toISOString(), + selectedLine: row.selectedLineMilli / 1_000, + signals: row.signals, + fills: row.fills, + fillRate: row.fillRate, + meanHalfSpreadBps: row.meanHalfSpreadBps, + meanSlippageBps: row.meanSlippageBps, + grossClvBps: row.grossClvBps, + netClvBps: row.netClvBps, + settlementPnlMicroUsd: row.settlementPnlMicroUsd, + netReturnBps: row.netReturnBps + })); +} + +function probabilityToOneDecimalBps(value: number): number { + return Math.round(value * 100_000) / 10; +} + +function buildCorrectedHistoricalCandidate( + source: z.infer +): CorrectedHistoricalCandidate { + const candidate = source.forwardPaperCandidate; + const interval = candidate.matchClusteredNetClv95; + const meanNetAfterCostProxyBps = probabilityToOneDecimalBps(candidate.meanNetAfterCostProxy); + const low = probabilityToOneDecimalBps(interval.low); + const high = probabilityToOneDecimalBps(interval.high); + if (meanNetAfterCostProxyBps !== 132.7 || low !== 14.3 || high !== 243.9 || interval.low >= interval.high) { + throw new Error("Study projection failed closed: corrected v4 aggregate evidence changed"); + } + return { + schemaVersion: 4, + generatedAt: source.generatedAt, + protocolId: source.protocolId, + configurationHash: source.configurationHash, + status: candidate.status, + registration: candidate.registration, + activeStudy: false, + detector: candidate.detector, + marketFamily: "Full-time totals", + trainingNormalizedCases: candidate.trainingNormalizedCases, + heldoutNormalizedCases: candidate.heldoutNormalizedCases, + heldoutFixtures: candidate.heldoutFixtures, + costProxyBps: 100, + meanNetAfterCostProxyBps, + matchClustered95Bps: { + iterations: interval.iterations, + cluster: "fixture", + low, + high + }, + evidenceClass: "historical_sampled_price_signal_research", + executionEvidence: "not_established_no_historical_bid_ask_or_depth", + executable: false, + claimBoundary: "Forward paper review candidate only; not alpha, profitability, fill proof, or permission to trade." + }; +} + +export async function buildStudySnapshot(repoRoot: string): Promise { + const [artifactValue, universeValue, correctedCandidateValue] = await Promise.all([ + readFile(resolve(repoRoot, "data/paper/reports/current.json"), "utf8").then(JSON.parse), + readFile(resolve(repoRoot, "data/research/paper-fixture-universe.json"), "utf8").then(JSON.parse), + readFile(resolve(repoRoot, "data/research/historical-gate-study-causal-economic-v4.json"), "utf8").then(JSON.parse) + ]); + const artifact = artifactSchema.parse(artifactValue); + const universe = universeSchema.parse(universeValue); + const correctedCandidateSource = correctedHistoricalCandidateSourceSchema.parse(correctedCandidateValue); + const bounty = artifact.lanes.bounty; + const longRun = artifact.lanes.longRun; + if (artifact.protocolVersion !== "paper-study-v1-2026-07-12") { + throw new Error("Study projection failed closed: no registered corrected protocol is approved for public display"); + } + if ( + bounty.report.lane !== "bounty" || + bounty.report.status !== "exploratory" || + longRun.report.lane !== "long_run" || + longRun.report.status === "exploratory" || + artifact.configHash !== bounty.initialization.configHash || + artifact.configHash !== longRun.initialization.configHash || + artifact.protocolVersion !== longRun.initialization.protocolVersion || + artifact.fixtureUniverseGeneratedAt !== universe.generatedAt || + !validCounts(bounty.report.counts) || + !validCounts(longRun.report.counts) + ) { + throw new Error("Study projection failed closed: paper artifact identity or counts changed"); + } + if ( + Object.values(bounty.report.counts).some((value) => value !== 0) || + Object.values(longRun.report.counts).some((value) => value !== 0) + ) { + throw new Error("Study projection failed closed: invalidated v1 ledger contains observations"); + } + const sealed = longRun.report.status === "sealed"; + if (sealed && (longRun.report.stoppingRuleMet || longRun.report.rows !== null || longRun.report.endpoints !== null || longRun.report.guardrails !== null)) { + throw new Error("Study projection failed closed: sealed long-run results leaked"); + } + if (!sealed && (!longRun.report.stoppingRuleMet || longRun.report.rows === null || longRun.report.guardrails === null)) { + throw new Error("Study projection failed closed: long-run results opened before the stopping rule"); + } + if (longRun.report.status === "accept") { + const endpoints = longRun.report.endpoints; + const guardrails = longRun.report.guardrails; + if ( + endpoints === null || guardrails === null || + endpoints.meanNetClvBps <= 0 || endpoints.netClvInterval.low <= 0 || + endpoints.meanSettlementPnlMicroUsd <= 0 || + endpoints.meanNetClvBps <= endpoints.noTradeBaselineClvBps || + endpoints.meanNetClvBps <= endpoints.randomDirectionControlClvBps || + !guardrails.fillRatePassed || !guardrails.slippagePassed || !guardrails.drawdownPassed || + !guardrails.selectedDepthComplete || !guardrails.closeMarksComplete || !guardrails.settlementComplete + ) { + throw new Error("Study projection failed closed: ACCEPT does not satisfy the registered decision rule"); + } + } + + const frozen = longRun.initialization.frozenConfig; + const evaluation = frozen.evaluation; + const results: StudySnapshot["results"] = sealed ? { + visibility: "sealed", + rows: null, + endpoints: null, + guardrails: null + } : { + visibility: "open", + rows: mapRows(longRun.report.rows!), + endpoints: longRun.report.endpoints as StudyEndpoints | null, + guardrails: longRun.report.guardrails as StudyGuardrails + }; + + return { + schemaVersion: 2, + snapshotId: STUDY_SNAPSHOT_ID, + generatedAt: artifact.generatedAt, + mode: "offline_artifact", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + protocol: { + version: artifact.protocolVersion, + status: "invalidated_suspended", + active: false, + configHash: artifact.configHash, + startedAt: longRun.initialization.startedAt, + candidate: { + detector: "CONSENSUS_MOVE", + marketFamily: "Full-time totals only", + moveAbsZ: frozen.detector.consensusMoveAbsZ, + cusumThresholdBps: frozen.detector.consensusCusumThreshold * 10_000, + minimumGapBps: frozen.detector.consensusMinimumRawGap * 10_000, + minimumUpdates: frozen.detector.consensusMinimumUpdates, + selector: "Closest to even", + minimumCoveragePoints: frozen.selector.minimumCoveragePoints, + maximumDistanceFromEven: frozen.selector.maximumDistanceFromEven + }, + evaluation: { + unitOfAnalysis: "match", + primaryEndpoint: "Executable CLV net of measured costs", + minimumFilledMatches: evaluation.minimumFilledMatches, + minimumFills: evaluation.minimumFills, + targetMatches: 30, + bootstrapIterations: evaluation.bootstrapIterations, + bootstrapSeed: evaluation.bootstrapSeed, + randomDirectionControl: "Seeded matched-cost sign flip" + }, + risk: { + bankrollMicroUsd: frozen.risk.bankrollMicroUsd, + perTradeStakeMicroUsd: frozen.risk.perTradeStakeMicroUsd, + aggregateExposureMicroUsd: frozen.risk.aggregateExposureMicroUsd, + drawdownStopMicroUsd: frozen.risk.drawdownStopMicroUsd + }, + guardrailThresholds: { + minimumFillRate: evaluation.minimumFillRate, + maximumMeanSlippageBps: evaluation.maximumMeanSlippageBps, + maximumDrawdownMicroUsd: evaluation.maximumDrawdownMicroUsd, + selectedDepthRequired: true + } + }, + lanes: { + bounty: { + label: "Preserved v1 bounty ledger", + status: "exploratory", + statusLabel: "Exploratory", + reason: bounty.report.reason, + counts: bounty.report.counts, + chain: bounty.chain, + canSatisfyGate: false + }, + longRun: { + label: "Preserved v1 long-run ledger", + status: longRun.report.status as StudySnapshot["lanes"]["longRun"]["status"], + statusLabel: "V1 suspended", + reason: longRun.report.reason, + counts: longRun.report.counts, + stoppingRuleMet: longRun.report.stoppingRuleMet, + chain: longRun.chain, + canSatisfyGate: false + } + }, + results, + correctedHistoricalCandidate: buildCorrectedHistoricalCandidate(correctedCandidateSource), + syntheticProof: { + label: "Synthetic full-lifecycle proving fixture", + path: "/artifacts/dashboard/synthetic-decision-receipt.json", + lifecycleStatus: "filled_settled", + offlineVerified: true, + performanceUse: "excluded_synthetic", + externalCalls: 0, + solanaAnchorStatus: "not_submitted", + explanation: "Closed-world production-component demo; separate from historical evidence and excluded from every performance claim." + }, + fixtureUniverse: { + generatedAt: universe.generatedAt, + evidenceFixtures: universe.summary.fixtures, + pairedBookReplays: universe.summary.pairedBookReplays, + executableBookReplays: universe.summary.executableBookReplays, + signalResearchOnly: universe.summary.signalResearchOnly, + longRunEligible: universe.summary.longRunEligible + }, + decisionRules: { + accept: [ + "Inactive v1 rule retained for audit only; it cannot produce an acceptance decision.", + "Mean net executable CLV is positive and its match-clustered 95% CI lower bound is above zero.", + "Mean settlement P&L is positive and the strategy beats no-trade and random-direction baselines.", + "Every registered fill, slippage, drawdown, and depth guardrail holds." + ], + reject: [ + "V1 was invalidated before observations because upstream selection used future information.", + "The executable CLV interval touches or falls below zero.", + "Any registered guardrail fails materially." + ], + inconclusive: [ + "No active study exists; corrected v2 remains an engineering candidate until Deborah registers it.", + "Capture-only evidence cannot authorize model spend, paper admission, or real money." + ] + }, + publicDataPolicy: { + derivedOnly: true, + txlineProbabilityDisplay: "bucketed_movement_only", + txlineMovementBucketBps: TXLINE_PUBLIC_MOVEMENT_BUCKET_BPS, + credentialsRequired: false, + walletControlsExposed: false + } + }; +} + +export async function buildStudyDashboardResponse(repoRoot: string): Promise { + return { data: await buildStudySnapshot(repoRoot) }; +} diff --git a/src/demo/run-synthetic-judge-case.ts b/src/demo/run-synthetic-judge-case.ts new file mode 100644 index 0000000..98d5061 --- /dev/null +++ b/src/demo/run-synthetic-judge-case.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { runSyntheticJudgeCase } from "./synthetic-judge-case.js"; + +try { + const report = await runSyntheticJudgeCase(); + const { receipt, ...summary } = report; + process.stdout.write(`${JSON.stringify({ + ...summary, + receiptCommitment: { + receiptId: receipt.receiptId, + receiptHash: receipt.integrity.receiptHash, + committedLedgerHead: receipt.ledger.finalHeadHash, + lifecycleStatus: receipt.lifecycle.finalStatus, + disclosure: receipt.disclosure, + solanaAnchor: receipt.solanaAnchor + } + }, null, 2)}\n`); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`SYNTHETIC JUDGE CASE FAILED: ${message}\n`); + process.exitCode = 1; +} diff --git a/src/demo/synthetic-judge-case.ts b/src/demo/synthetic-judge-case.ts new file mode 100644 index 0000000..7981e4d --- /dev/null +++ b/src/demo/synthetic-judge-case.ts @@ -0,0 +1,509 @@ +import { CLAUDE_MODEL, CLAUDE_PRICING } from "../agents/claude-pricing.js"; +import type { AnalystAgent, TriageAgent } from "../agents/contracts.js"; +import { CanonicalEventBus } from "../bus/event-bus.js"; +import { + CANONICAL_SCHEMA_VERSION, + type CanonicalEvent, + type CanonicalMarket, + type OddsQuoteEvent, + type PolymarketBookEvent, + type PolymarketResolutionEvent +} from "../bus/events.js"; +import { PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS } from "../config/paper-study.js"; +import { probability } from "../domain/probability.js"; +import { OrderBookPaperExecutor, type PolymarketFeeParameters } from "../exec/paper.js"; +import { PaperCasePipeline } from "../harness/paper-pipeline.js"; +import { createFrozenPaperStudyRuntime, type PaperRuntimeBatch } from "../harness/paper-runtime.js"; +import { PaperCaseScheduler } from "../harness/paper-scheduler.js"; +import { initializePaperStudyLedger } from "../harness/paper-study-ledger.js"; +import { PaperCloseScheduler } from "../portfolio/paper-close-scheduler.js"; +import { PaperSettlementScheduler } from "../portfolio/paper-settlement-scheduler.js"; +import { PaperPortfolio } from "../portfolio/paper.js"; +import { + generateDecisionReceipt, + hashJsonEvidence, + sourceEvidenceCommitment +} from "../proof/decision-receipt.js"; +import { + receiptReferenceHash, + sha256, + verifyDecisionReceipt, + type DecisionReceipt, + type DecisionReceiptVerification, + type ReceiptAgentRun +} from "../proof/decision-receipt-schema.js"; +import { APPROVED_PAPER_RISK_CONFIG } from "../risk/paper.js"; + +const BASE_TS_MS = Date.UTC(2026, 6, 1, 0, 0, 0); +const FIXTURE_ID = "synthetic-judge-fixture-v1"; +const CONDITION_ID = "synthetic-judge-condition-v1"; +const OVER_ASSET_ID = "synthetic-judge-over-asset-v1"; +const UNDER_ASSET_ID = "synthetic-judge-under-asset-v1"; +const LINE_MILLI = 2_500; +const DECISION_LATENCY_MS = 300; +const MAXIMUM_PENDING_MS = 60_000; +const KICKOFF_TS_MS = BASE_TS_MS + 4 * 60 * 60_000; +const DEMO_PROCESSING_TS_MS = Date.UTC(2026, 6, 14, 10, 0, 0); +const DEMO_RECEIPT_GENERATED_AT_TS_MS = DEMO_PROCESSING_TS_MS + 60_000; + +const MARKET: CanonicalMarket = Object.freeze({ + family: "total_goals", + period: "full_time", + lineMilli: LINE_MILLI, + key: `${FIXTURE_ID}:total_goals:full_time:${LINE_MILLI}` +}); + +const LABEL = + "SYNTHETIC JUDGE CASE — no real match, licensed feed record, external model call, venue order, wallet, network request, or performance evidence"; + +type SyntheticBoundaryAudit = { + triageStubCalls: number; + analystStubCalls: number; + anthropicCalls: 0; + txlineApiCalls: 0; + polymarketApiCalls: 0; + walletCalls: 0; + solanaRpcCalls: 0; + realOrders: 0; +}; + +export type SyntheticJudgeCaseReport = { + schemaVersion: 1; + label: typeof LABEL; + synthetic: true; + performanceUse: "excluded_synthetic"; + protocolStatus: "engineering_candidate_unregistered"; + realMoneyGate: "closed"; + boundaries: SyntheticBoundaryAudit; + runtime: { + canonicalEventsPublished: number; + rawSignals: number; + normalizedSignals: number; + routedSignals: number; + paperExecutionStatus: "filled" | "partial"; + closeMarks: number; + settlements: number; + finalLifecycleStatus: "filled_settled" | "partial_settled"; + ledgerRows: number; + ledgerHashSchemaVersions: number[]; + ledgerHeadSha256: string; + }; + receiptVerification: DecisionReceiptVerification; + receipt: DecisionReceipt; +}; + +function oddsQuote(sequence: number, tsMs: number, overProbability: number): OddsQuoteEvent { + const underProbability = 1 - overProbability; + return { + schemaVersion: CANONICAL_SCHEMA_VERSION, + kind: "odds.quote", + eventId: `synthetic-txline-quote-${sequence}`, + source: "txline", + sourceTsMs: tsMs, + observedTsMs: tsMs + 10, + fixtureId: FIXTURE_ID, + sourceMessageId: `synthetic-message-${sequence}`, + bookmaker: "SYNTHETIC_TXLINE_STABLE_PRICE", + bookmakerId: 10021, + inRunning: false, + gameState: null, + market: MARKET, + outcomes: [ + { + outcome: "over", + oddsX1000: Math.round(1_000 / overProbability), + fairProbability: probability(overProbability) + }, + { + outcome: "under", + oddsX1000: Math.round(1_000 / underProbability), + fairProbability: probability(underProbability) + } + ] + }; +} + +function book(input: { + id: string; + sourceTsMs: number; + observedTsMs: number; + bid: number; + ask: number; + secondAsk?: number; +}): PolymarketBookEvent { + return { + schemaVersion: CANONICAL_SCHEMA_VERSION, + kind: "polymarket.book", + eventId: input.id, + source: "polymarket", + sourceTsMs: input.sourceTsMs, + observedTsMs: input.observedTsMs, + fixtureId: FIXTURE_ID, + market: MARKET, + mappingStatus: "verified", + conditionId: CONDITION_ID, + assetId: OVER_ASSET_ID, + outcome: "over", + tokenRole: "canonical", + bids: [{ price: probability(input.bid), size: "100" }], + asks: input.secondAsk === undefined + ? [{ price: probability(input.ask), size: "100" }] + : [ + { price: probability(input.ask), size: "2" }, + { price: probability(input.secondAsk), size: "100" } + ], + lastTradePrice: probability((input.bid + input.ask) / 2), + tickSize: "0.01" + }; +} + +function resolution(): PolymarketResolutionEvent { + const sourceTsMs = KICKOFF_TS_MS + 2 * 60 * 60_000; + return { + schemaVersion: CANONICAL_SCHEMA_VERSION, + kind: "polymarket.resolution", + eventId: "synthetic-polymarket-resolution", + source: "polymarket", + sourceTsMs, + observedTsMs: sourceTsMs + 10, + fixtureId: FIXTURE_ID, + market: MARKET, + mappingStatus: "verified", + conditionId: CONDITION_ID, + assetIds: [OVER_ASSET_ID, UNDER_ASSET_ID], + winningAssetId: OVER_ASSET_ID, + winningOutcomeLabel: "Over" + }; +} + +function syntheticAgentRun(input: { + stage: "triage" | "analyst"; + response: unknown; +}): ReceiptAgentRun { + const model = input.stage === "triage" ? CLAUDE_MODEL.triage : CLAUDE_MODEL.analyst; + const pricing = CLAUDE_PRICING[model]; + return { + stage: input.stage, + invocationClass: "synthetic_stub", + model, + promptVersion: `synthetic-judge-${input.stage}-v1`, + promptSha256: sha256(`synthetic-judge:${input.stage}:fixed-prompt-v1`), + responseSha256: hashJsonEvidence(input.response), + billingEvidenceRefSha256: receiptReferenceHash("synthetic_billing", `judge:${input.stage}:zero`), + status: "success", + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + }, + pricing: { + pricingVersion: "anthropic-public-2026-07-12", + currency: "nano_usd", + inputNanoUsdPerToken: pricing.inputNanoUsdPerToken, + outputNanoUsdPerToken: pricing.outputNanoUsdPerToken, + cacheWriteNanoUsdPerToken: pricing.cacheWriteNanoUsdPerToken, + cacheReadNanoUsdPerToken: pricing.cacheReadNanoUsdPerToken + }, + actualCostNanoUsd: 0 + }; +} + +function requiredBatch(value: T | undefined, message: string): T { + if (value === undefined) throw new Error(message); + return value; +} + +/** + * Runs a closed-world proving case through the production paper components. + * All canonical records are invented inside this module and carry synthetic + * identities. Nothing reads a file, environment secret, API, RPC, wallet, or + * external model, and the resulting receipt is permanently performance-excluded. + */ +export async function runSyntheticJudgeCase(): Promise { + let durableInsertTsMs = DEMO_PROCESSING_TS_MS; + const boundaryAudit: SyntheticBoundaryAudit = { + triageStubCalls: 0, + analystStubCalls: 0, + anthropicCalls: 0, + txlineApiCalls: 0, + polymarketApiCalls: 0, + walletCalls: 0, + solanaRpcCalls: 0, + realOrders: 0 + }; + const handle = initializePaperStudyLedger({ + path: ":memory:", + lane: "bounty", + startedAtTsMs: BASE_TS_MS - 60_000, + insertedAtNow: () => durableInsertTsMs++ + }); + + try { + const triageAgent: TriageAgent = { + triage: async () => { + boundaryAudit.triageStubCalls += 1; + return { + decision: "escalate", + priority: "normal", + rationale: "Synthetic proving signal meets the fixed escalation path; performance use is forbidden." + }; + } + }; + const analystAgent: AnalystAgent = { + investigate: async ({ signal, asOfTsMs }) => { + boundaryAudit.analystStubCalls += 1; + return { + schemaVersion: 1, + signalId: signal.signalId, + fixtureId: signal.fixtureId, + marketKey: signal.market.key, + outcome: signal.outcome, + direction: signal.direction, + recommendation: "paper_trade", + fairProbability: signal.evidence.consensusProbability, + thesisSummary: "Synthetic consensus movement remains ahead of the synthetic paper book.", + evidenceFor: ["The synthetic executable gap clears the frozen deterministic paper threshold."], + steelmanAgainst: "The invented movement could reverse before the invented kickoff.", + invalidationConditions: ["The synthetic ask reaches the deterministic private boundary."], + submittedAtTsMs: asOfTsMs, + expiresAtTsMs: asOfTsMs + 15 * 60_000, + analystModel: CLAUDE_MODEL.analyst + }; + } + }; + const pipeline = new PaperCasePipeline({ + triageAgent, + analystAgent, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger: handle.ledger, + now: () => 0 + }); + const portfolio = new PaperPortfolio({ + lane: "bounty", + bankrollMicroUsd: APPROVED_PAPER_RISK_CONFIG.bankrollMicroUsd, + drawdownStopMicroUsd: APPROVED_PAPER_RISK_CONFIG.drawdownStopMicroUsd, + ledger: handle.ledger + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: "bounty", + executionLatencyMs: DECISION_LATENCY_MS, + maximumPendingMs: MAXIMUM_PENDING_MS, + minimumSignalToKickoffMs: PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS, + eligibleMarketKeys: new Set([MARKET.key]), + kickoffByFixtureId: new Map([[FIXTURE_ID, KICKOFF_TS_MS]]) + }, + pipeline, + portfolio, + feeResolver: async (executionBook, asOfTsMs): Promise => ({ + source: "polymarket_clob_market_info", + conditionId: executionBook.conditionId, + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: asOfTsMs + }) + }); + const closeScheduler = new PaperCloseScheduler({ + portfolio, + kickoffByFixtureId: new Map([[FIXTURE_ID, KICKOFF_TS_MS]]) + }); + const settlementScheduler = new PaperSettlementScheduler(portfolio); + const runtime = createFrozenPaperStudyRuntime(scheduler, { closeScheduler, settlementScheduler }); + const bus = new CanonicalEventBus(); + const batches: Array<{ event: CanonicalEvent; batch: PaperRuntimeBatch }> = []; + bus.subscribe(async (event) => { + batches.push({ event, batch: await runtime.ingest(event) }); + }); + + const baselineProbabilities = [0.5, 0.5002, 0.4999, 0.5001, 0.5, 0.5002, 0.4999, 0.5001]; + for (const [index, fair] of baselineProbabilities.entries()) { + const tsMs = BASE_TS_MS + index * 60_000; + await bus.publish(oddsQuote(index, tsMs, fair)); + await bus.publish(book({ + id: `synthetic-baseline-book-${index}`, + sourceTsMs: tsMs + 100, + observedTsMs: tsMs + 110, + bid: 0.5, + ask: 0.52 + })); + } + + const triggerQuote = oddsQuote(8, BASE_TS_MS + 8 * 60_000, 0.55); + await bus.publish(triggerQuote); + requiredBatch( + batches.find(({ batch }) => batch.routedSignalIds.length === 1), + "Frozen synthetic detector path did not route exactly one signal" + ); + const signalBook = requiredBatch( + batches.find(({ event }) => event.eventId === "synthetic-baseline-book-7")?.event, + "Synthetic signal-side paper book is missing" + ); + if (signalBook.kind !== "polymarket.book") { + throw new Error("Synthetic signal-side evidence is not a canonical Polymarket book"); + } + + const executionBook = book({ + id: "synthetic-execution-book", + sourceTsMs: triggerQuote.sourceTsMs + 2_000, + observedTsMs: triggerQuote.observedTsMs + 2_000, + bid: 0.5, + ask: 0.52, + secondAsk: 0.53 + }); + await bus.publish(executionBook); + const executionBatch = requiredBatch( + batches.find(({ batch }) => batch.caseResults.length === 1), + "Synthetic scheduler did not produce one paper execution result" + ); + const executionResult = requiredBatch( + executionBatch.batch.caseResults[0], + "Synthetic paper execution result is missing" + ); + if (executionResult.status !== "filled" && executionResult.status !== "partial") { + throw new Error(`Synthetic paper execution failed closed with status ${executionResult.status}`); + } + + const closeBook = book({ + id: "synthetic-closing-book", + sourceTsMs: KICKOFF_TS_MS - 1_000, + observedTsMs: KICKOFF_TS_MS - 990, + bid: 0.57, + ask: 0.59 + }); + await bus.publish(closeBook); + const settlementEvent = resolution(); + await bus.publish(settlementEvent); + const lifecycleBatch = requiredBatch( + batches.find(({ batch }) => batch.settlementResults.length === 1), + "Synthetic lifecycle did not settle exactly one paper position" + ); + if (lifecycleBatch.batch.closeResults.length !== 1) { + throw new Error("Synthetic lifecycle did not create exactly one kickoff close mark"); + } + + const caseEntries = handle.ledger.entries(executionResult.caseId); + const triageResponse = requiredBatch( + caseEntries.find((entry) => entry.kind === "triage_decision")?.payload, + "Synthetic triage ledger commitment is missing" + ); + const analystResponse = requiredBatch( + caseEntries.find((entry) => entry.kind === "thesis_submitted")?.payload, + "Synthetic thesis ledger commitment is missing" + ); + const sourceEvidence = [ + sourceEvidenceCommitment({ + source: "txline", + role: "signal", + sourceTsMs: triggerQuote.sourceTsMs, + observedAtTsMs: triggerQuote.observedTsMs, + payload: JSON.parse(JSON.stringify(triggerQuote)) + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "signal", + sourceTsMs: signalBook.sourceTsMs, + observedAtTsMs: signalBook.observedTsMs, + payload: JSON.parse(JSON.stringify(signalBook)) + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "execution", + sourceTsMs: executionBook.sourceTsMs, + observedAtTsMs: executionBook.observedTsMs, + payload: JSON.parse(JSON.stringify(executionBook)) + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "close", + sourceTsMs: closeBook.sourceTsMs, + observedAtTsMs: closeBook.observedTsMs, + payload: JSON.parse(JSON.stringify(closeBook)) + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "settlement", + sourceTsMs: settlementEvent.sourceTsMs, + observedAtTsMs: settlementEvent.observedTsMs, + payload: JSON.parse(JSON.stringify(settlementEvent)) + }) + ]; + const receipt = generateDecisionReceipt({ + ledger: handle.ledger, + caseId: executionResult.caseId, + context: { + generatedAtTsMs: DEMO_RECEIPT_GENERATED_AT_TS_MS, + provenance: { + evidenceClass: "synthetic_proving_fixture", + synthetic: true, + performanceUse: "excluded_synthetic", + label: LABEL + }, + build: { + codeVersion: "synthetic-judge-case-v1", + codeSha256: sha256("samaritan:synthetic-judge-case:v1"), + configSha256: handle.initialization.configHash + }, + sourceEvidence, + agentRuns: [ + syntheticAgentRun({ stage: "triage", response: triageResponse }), + syntheticAgentRun({ stage: "analyst", response: analystResponse }) + ], + executionBookEvidenceRefSha256: sourceEvidence[2]!.evidenceRefSha256, + closeBookEvidenceRefSha256: sourceEvidence[3]!.evidenceRefSha256, + settlementEvidenceRefSha256: sourceEvidence[4]!.evidenceRefSha256, + solanaAnchor: null + } + }); + const receiptVerification = verifyDecisionReceipt(receipt); + const ledgerVerification = handle.ledger.verifyChain(); + const summary = portfolio.summary(); + if ( + boundaryAudit.triageStubCalls !== 1 || + boundaryAudit.analystStubCalls !== 1 || + summary.positions !== 1 || + summary.openPositions !== 0 || + summary.settledPositions !== 1 + ) { + throw new Error("Synthetic proving case did not preserve its bounded one-case invariants"); + } + if ( + receiptVerification.lifecycleStatus !== "filled_settled" && + receiptVerification.lifecycleStatus !== "partial_settled" + ) { + throw new Error(`Unexpected synthetic receipt lifecycle ${receiptVerification.lifecycleStatus}`); + } + + return { + schemaVersion: 1, + label: LABEL, + synthetic: true, + performanceUse: "excluded_synthetic", + protocolStatus: handle.initialization.protocolStatus, + realMoneyGate: handle.initialization.realMoneyGate, + boundaries: boundaryAudit, + runtime: { + canonicalEventsPublished: batches.length, + rawSignals: batches.reduce((sum, item) => sum + item.batch.rawSignals.length, 0), + normalizedSignals: batches.reduce((sum, item) => sum + item.batch.signals.length, 0), + routedSignals: batches.reduce((sum, item) => sum + item.batch.routedSignalIds.length, 0), + paperExecutionStatus: executionResult.status, + closeMarks: batches.reduce((sum, item) => sum + item.batch.closeResults.length, 0), + settlements: batches.reduce((sum, item) => sum + item.batch.settlementResults.length, 0), + finalLifecycleStatus: receiptVerification.lifecycleStatus, + ledgerRows: ledgerVerification.rows, + ledgerHashSchemaVersions: ledgerVerification.hashSchemaVersions, + ledgerHeadSha256: ledgerVerification.headHash + }, + receiptVerification, + receipt + }; + } finally { + handle.ledger.close(); + } +} diff --git a/src/detectors/bank.ts b/src/detectors/bank.ts index 272d98a..7a20d23 100644 --- a/src/detectors/bank.ts +++ b/src/detectors/bank.ts @@ -228,6 +228,7 @@ export class DetectorBank { signalId: signalId(identity), kind, detectedAtTsMs: snapshot.asOfTsMs, + observedAtTsMs: snapshot.observedAtTsMs, fixtureId: snapshot.fixtureId, market: snapshot.market, outcome: snapshot.outcome, diff --git a/src/detectors/economic-cases.ts b/src/detectors/economic-cases.ts new file mode 100644 index 0000000..1fce2cb --- /dev/null +++ b/src/detectors/economic-cases.ts @@ -0,0 +1,181 @@ +import { stableJson } from "../domain/json.js"; +import type { CanonicalOutcome } from "../bus/events.js"; +import type { DetectorSignal } from "./types.js"; + +export type EconomicCaseDispositionReason = + | "executable_buy_retained" + | "duplicate_executable_buy_collapsed" + | "complementary_sell_collapsed_into_executable_buy" + | "sell_only_unproven_executable_ask" + | "unsupported_total_goals_outcome" + | "non_binary_market_passthrough"; + +export type EconomicCaseDisposition = { + signalId: string; + disposition: "retained" | "collapsed" | "dropped"; + reason: EconomicCaseDispositionReason; + economicOutcome: "over" | "under" | null; +}; + +export type EconomicCaseNormalizationSummary = { + rawEmissions: number; + normalizedCases: number; + executableTotalGoalsCases: number; + nonBinaryMarketSignalsPassedThrough: number; + duplicateExecutableBuysCollapsed: number; + complementarySellsCollapsed: number; + sellOnlySignalsDropped: number; + unsupportedTotalGoalsSignalsDropped: number; +}; + +export type EconomicCaseNormalizationResult = { + /** + * Signals safe to pass to the next stage. Total-goals entries in this list are + * always actual BUY signals; non-binary markets are deliberately untouched. + */ + signals: DetectorSignal[]; + dispositions: EconomicCaseDisposition[]; + summary: EconomicCaseNormalizationSummary; +}; + +type IndexedSignal = { signal: DetectorSignal; index: number; economicOutcome: "over" | "under" }; + +function isBinaryTotalOutcome(outcome: CanonicalOutcome): outcome is "over" | "under" { + return outcome === "over" || outcome === "under"; +} + +function complement(outcome: "over" | "under"): "over" | "under" { + return outcome === "over" ? "under" : "over"; +} + +function economicOutcome(signal: DetectorSignal): "over" | "under" { + const outcome = signal.outcome as "over" | "under"; + return signal.direction === "buy" ? outcome : complement(outcome); +} + +function economicCaseKey(item: IndexedSignal): string { + const { signal } = item; + return stableJson({ + detector: signal.kind, + fixtureId: signal.fixtureId, + marketKey: signal.market.key, + detectedAtTsMs: signal.detectedAtTsMs, + observedAtTsMs: signal.observedAtTsMs, + economicOutcome: item.economicOutcome + }); +} + +/** + * Convert raw binary Total Goals detector emissions into executable economic + * cases without ever synthesizing an order. A Total Goals sell is only an + * economic description of buying the complementary token; DetectorSignal does + * not carry proof of that token's executable ask, so a sell-only case fails + * closed. When the same batch contains the actual complementary BUY, that BUY + * is retained and the sell expression is collapsed into it. + * + * Match Result is three-way and therefore has no one-token complement. Those + * signals pass through byte-for-byte and are not deduplicated by this module. + */ +export function normalizeExecutableEconomicCases( + rawSignals: readonly DetectorSignal[] +): EconomicCaseNormalizationResult { + const dispositions: Array = new Array(rawSignals.length); + const retainedIndexes = new Set(); + const groups = new Map(); + + for (const [index, signal] of rawSignals.entries()) { + if (signal.market.family !== "total_goals") { + retainedIndexes.add(index); + dispositions[index] = { + signalId: signal.signalId, + disposition: "retained", + reason: "non_binary_market_passthrough", + economicOutcome: null + }; + continue; + } + if (!isBinaryTotalOutcome(signal.outcome)) { + dispositions[index] = { + signalId: signal.signalId, + disposition: "dropped", + reason: "unsupported_total_goals_outcome", + economicOutcome: null + }; + continue; + } + const item: IndexedSignal = { signal, index, economicOutcome: economicOutcome(signal) }; + const key = economicCaseKey(item); + const group = groups.get(key) ?? []; + group.push(item); + groups.set(key, group); + } + + for (const group of groups.values()) { + const executableBuy = group.find(({ signal, economicOutcome: outcome }) => + signal.direction === "buy" && signal.outcome === outcome + ); + if (!executableBuy) { + for (const item of group) { + dispositions[item.index] = { + signalId: item.signal.signalId, + disposition: "dropped", + reason: "sell_only_unproven_executable_ask", + economicOutcome: item.economicOutcome + }; + } + continue; + } + + retainedIndexes.add(executableBuy.index); + dispositions[executableBuy.index] = { + signalId: executableBuy.signal.signalId, + disposition: "retained", + reason: "executable_buy_retained", + economicOutcome: executableBuy.economicOutcome + }; + for (const item of group) { + if (item.index === executableBuy.index) continue; + const isDuplicateBuy = item.signal.direction === "buy"; + dispositions[item.index] = { + signalId: item.signal.signalId, + disposition: "collapsed", + reason: isDuplicateBuy + ? "duplicate_executable_buy_collapsed" + : "complementary_sell_collapsed_into_executable_buy", + economicOutcome: item.economicOutcome + }; + } + } + + const completeDispositions = dispositions.map((item, index) => { + if (!item) throw new Error(`Missing economic-case disposition for signal index ${index}`); + return item; + }); + const signals = rawSignals.filter((_, index) => retainedIndexes.has(index)); + return { + signals, + dispositions: completeDispositions, + summary: { + rawEmissions: rawSignals.length, + normalizedCases: signals.length, + executableTotalGoalsCases: completeDispositions.filter( + (item) => item.disposition === "retained" && item.reason === "executable_buy_retained" + ).length, + nonBinaryMarketSignalsPassedThrough: completeDispositions.filter( + (item) => item.reason === "non_binary_market_passthrough" + ).length, + duplicateExecutableBuysCollapsed: completeDispositions.filter( + (item) => item.reason === "duplicate_executable_buy_collapsed" + ).length, + complementarySellsCollapsed: completeDispositions.filter( + (item) => item.reason === "complementary_sell_collapsed_into_executable_buy" + ).length, + sellOnlySignalsDropped: completeDispositions.filter( + (item) => item.reason === "sell_only_unproven_executable_ask" + ).length, + unsupportedTotalGoalsSignalsDropped: completeDispositions.filter( + (item) => item.reason === "unsupported_total_goals_outcome" + ).length + } + }; +} diff --git a/src/detectors/types.ts b/src/detectors/types.ts index 1ca5626..b1cf8fe 100644 --- a/src/detectors/types.ts +++ b/src/detectors/types.ts @@ -6,7 +6,10 @@ export type SignalDirection = "buy" | "sell"; export type DetectorSignal = { signalId: string; kind: SignalKind; + /** Market/source time of the triggering evidence; retained for provenance and ordering. */ detectedAtTsMs: number; + /** Knowledge time when Samaritan actually observed and could process the signal. */ + observedAtTsMs: number; fixtureId: string; market: CanonicalMarket; outcome: CanonicalOutcome; diff --git a/src/domain/money.ts b/src/domain/money.ts new file mode 100644 index 0000000..f257918 --- /dev/null +++ b/src/domain/money.ts @@ -0,0 +1,16 @@ +export const USD_MICRO_UNITS = 1_000_000; + +export type MicroUsd = number & { readonly __brand: "MicroUsd" }; + +export function microUsd(value: number): MicroUsd { + if (!Number.isSafeInteger(value)) { + throw new RangeError(`Money must be represented as safe integer USD micro-units; received ${value}`); + } + return value as MicroUsd; +} + +export function nonnegativeMicroUsd(value: number): MicroUsd { + const amount = microUsd(value); + if (amount < 0) throw new RangeError(`Money amount must be non-negative; received ${value}`); + return amount; +} diff --git a/src/exec/paper.ts b/src/exec/paper.ts new file mode 100644 index 0000000..60dbb6e --- /dev/null +++ b/src/exec/paper.ts @@ -0,0 +1,310 @@ +import type { PolymarketBookEvent } from "../bus/events.js"; +import type { SignalDirection } from "../detectors/types.js"; +import { microUsd, type MicroUsd, USD_MICRO_UNITS } from "../domain/money.js"; +import type { Probability } from "../domain/probability.js"; + +/** Current Polymarket delay before marketable sports orders are eligible to match. */ +export const POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS = 1_000 as const; + +export type PolymarketFeeParameters = { + source: "polymarket_clob_market_info"; + conditionId: string; + feesEnabled: boolean; + takerFeeRate: number; + feeCurveExponent: number; + takerOnly: boolean; + minimumOrderSize: number; + minimumTickSize: number; + fetchedAtTsMs: number; +}; + +export type PaperOrderIntent = { + lane: "bounty" | "long_run"; + caseId: string; + signalId: string; + fixtureId: string; + marketKey: string; + outcome: string; + direction: SignalDirection; + stakeMicroUsd: MicroUsd; + limitProbability: Probability; + availableShares: number; +}; + +export type PaperFill = { + adapter: "paper"; + status: "filled" | "partial" | "no_fill"; + reason: string | null; + assetId: string; + conditionId: string; + direction: SignalDirection; + requestedStakeMicroUsd: MicroUsd; + grossMicroUsd: MicroUsd; + feeMicroUsd: MicroUsd; + netConsiderationMicroUsd: MicroUsd; + filledShares: number; + averagePrice: number | null; + bestPrice: number | null; + halfSpreadBps: number | null; + executableDepthUsd: number; + slippageProbabilityBps: number | null; + bookObservedTsMs: number; + feeParameters: PolymarketFeeParameters; +}; + +export type PaperExecutor = { + execute( + intent: PaperOrderIntent, + book: PolymarketBookEvent, + fees: PolymarketFeeParameters + ): Promise; +}; + +type FillAccumulator = { + shares: number; + grossMicro: number; + feeMicro: number; +}; + +type BookEvidence = { + halfSpreadBps: number | null; + executableDepthUsd: number; +}; + +function parseSize(value: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) throw new RangeError(`Invalid order-book size: ${value}`); + return parsed; +} + +export function takerFeePerShare(price: number, fees: PolymarketFeeParameters): number { + return fees.feesEnabled ? fees.takerFeeRate * price * (1 - price) : 0; +} + +function floorShares(value: number): number { + return Math.floor(value * 1_000_000) / 1_000_000; +} + +function roundedFeeMicro(value: number): number { + return Math.ceil(value / 10) * 10; +} + +function tickAligned(price: number, tickSize: number): boolean { + return Math.abs(price / tickSize - Math.round(price / tickSize)) < 1e-8; +} + +function fillStatus(requestedMicro: number, usedMicro: number): "filled" | "partial" { + return requestedMicro - usedMicro <= Math.max(10, Math.ceil(requestedMicro / 1_000_000)) + ? "filled" + : "partial"; +} + +function bookEvidence(intent: PaperOrderIntent, book: PolymarketBookEvent): BookEvidence { + const bid = book.bids.length === 0 ? null : Math.max(...book.bids.map((level) => level.price)); + const ask = book.asks.length === 0 ? null : Math.min(...book.asks.map((level) => level.price)); + const levels = intent.direction === "buy" + ? book.asks.filter((level) => level.price <= intent.limitProbability) + : book.bids.filter((level) => level.price >= intent.limitProbability); + return { + halfSpreadBps: bid === null || ask === null || bid > ask ? null : (ask - bid) / 2 * 10_000, + executableDepthUsd: levels.reduce((sum, level) => sum + parseSize(level.size) * level.price, 0) + }; +} + +export class OrderBookPaperExecutor implements PaperExecutor { + async execute( + intent: PaperOrderIntent, + book: PolymarketBookEvent, + fees: PolymarketFeeParameters + ): Promise { + if (!Number.isSafeInteger(intent.stakeMicroUsd) || intent.stakeMicroUsd <= 0) { + throw new RangeError("Paper stake must be a positive integer USD micro-unit amount"); + } + if (!Number.isFinite(intent.availableShares) || intent.availableShares < 0) { + throw new RangeError("Available shares must be finite and non-negative"); + } + if (!Number.isFinite(intent.limitProbability) || intent.limitProbability < 0 || intent.limitProbability > 1) { + throw new RangeError("Paper limit probability must be between zero and one"); + } + if ( + book.fixtureId !== intent.fixtureId || + book.market.key !== intent.marketKey || + book.outcome !== intent.outcome || + book.tokenRole !== "canonical" + ) { + throw new Error("Paper order intent does not match the canonical order book"); + } + if ( + !Number.isSafeInteger(book.sourceTsMs) || + !Number.isSafeInteger(book.observedTsMs) || + book.sourceTsMs < 0 || + book.observedTsMs < 0 + ) { + throw new Error("Canonical order book has impossible timestamps"); + } + if (book.mappingStatus === "rejected") throw new Error("Rejected mappings cannot be paper executed"); + if (fees.conditionId !== book.conditionId) throw new Error("Fee metadata does not match the order book"); + if (!Number.isSafeInteger(fees.fetchedAtTsMs) || fees.fetchedAtTsMs < 0) { + throw new RangeError("Fee metadata timestamp must be a non-negative integer"); + } + if (!fees.feesEnabled && fees.takerFeeRate !== 0) { + throw new Error("Fee-disabled market supplied a non-zero taker rate"); + } + if (fees.feesEnabled && (!(fees.takerFeeRate > 0) || !Number.isFinite(fees.takerFeeRate))) { + throw new Error("Fee-enabled market is missing a valid taker rate"); + } + if (fees.feeCurveExponent !== 1) { + throw new Error(`Unsupported fee-curve exponent: ${fees.feeCurveExponent}`); + } + if (!fees.takerOnly) throw new Error("Paper executor currently supports taker-only fee metadata"); + if (!(fees.minimumOrderSize > 0) || !Number.isFinite(fees.minimumOrderSize)) { + throw new Error("Market is missing a valid minimum order size"); + } + if (!(fees.minimumTickSize > 0) || !Number.isFinite(fees.minimumTickSize)) { + throw new Error("Market is missing a valid minimum tick size"); + } + if (book.tickSize !== null && Number(book.tickSize) !== fees.minimumTickSize) { + throw new Error("Canonical book tick size disagrees with CLOB market info"); + } + if ([...book.bids, ...book.asks].some((level) => !tickAligned(level.price, fees.minimumTickSize))) { + throw new Error("Canonical book contains a price outside the market tick grid"); + } + if (intent.direction === "sell" && intent.availableShares <= 0) { + return this.#empty(intent, book, fees, "sell_requires_owned_inventory"); + } + return intent.direction === "buy" + ? this.#buy(intent, book, fees) + : this.#sell(intent, book, fees); + } + + #buy(intent: PaperOrderIntent, book: PolymarketBookEvent, fees: PolymarketFeeParameters): PaperFill { + const evidence = bookEvidence(intent, book); + const levels = [...book.asks] + .sort((left, right) => left.price - right.price) + .filter((level) => level.price <= intent.limitProbability); + if (levels.length === 0) return this.#empty(intent, book, fees, "no_ask_within_limit"); + const bestPrice = levels[0]!.price; + const filled: FillAccumulator = { shares: 0, grossMicro: 0, feeMicro: 0 }; + for (const level of levels) { + const remainingMicro = intent.stakeMicroUsd - filled.grossMicro - filled.feeMicro; + if (remainingMicro <= 11) break; + const grossPerShareMicro = level.price * USD_MICRO_UNITS; + const feePerShareMicro = takerFeePerShare(level.price, fees) * USD_MICRO_UNITS; + const shares = floorShares(Math.min( + parseSize(level.size), + (remainingMicro - 11) / (grossPerShareMicro + feePerShareMicro) + )); + if (shares <= 0) continue; + const grossMicro = Math.ceil(shares * grossPerShareMicro); + const feeMicro = roundedFeeMicro(shares * feePerShareMicro); + if (grossMicro + feeMicro > remainingMicro) continue; + filled.shares += shares; + filled.grossMicro += grossMicro; + filled.feeMicro += feeMicro; + } + if (filled.shares === 0) return this.#empty(intent, book, fees, "insufficient_ask_depth"); + if (filled.shares < fees.minimumOrderSize) { + return this.#empty(intent, book, fees, "below_minimum_order_size"); + } + const averagePrice = filled.grossMicro / USD_MICRO_UNITS / filled.shares; + const consideration = filled.grossMicro + filled.feeMicro; + return { + adapter: "paper", + status: fillStatus(intent.stakeMicroUsd, consideration), + reason: null, + assetId: book.assetId, + conditionId: book.conditionId, + direction: intent.direction, + requestedStakeMicroUsd: intent.stakeMicroUsd, + grossMicroUsd: microUsd(filled.grossMicro), + feeMicroUsd: microUsd(filled.feeMicro), + netConsiderationMicroUsd: microUsd(consideration), + filledShares: filled.shares, + averagePrice, + bestPrice, + ...evidence, + slippageProbabilityBps: (averagePrice - bestPrice) * 10_000, + bookObservedTsMs: book.observedTsMs, + feeParameters: fees + }; + } + + #sell(intent: PaperOrderIntent, book: PolymarketBookEvent, fees: PolymarketFeeParameters): PaperFill { + const evidence = bookEvidence(intent, book); + const levels = [...book.bids] + .sort((left, right) => right.price - left.price) + .filter((level) => level.price >= intent.limitProbability); + if (levels.length === 0) return this.#empty(intent, book, fees, "no_bid_within_limit"); + const bestPrice = levels[0]!.price; + const filled: FillAccumulator = { shares: 0, grossMicro: 0, feeMicro: 0 }; + let remainingShares = intent.availableShares; + for (const level of levels) { + const remainingGrossTarget = intent.stakeMicroUsd - filled.grossMicro; + if (remainingGrossTarget <= 0 || remainingShares <= 0) break; + const shares = floorShares(Math.min( + parseSize(level.size), + remainingShares, + remainingGrossTarget / (level.price * USD_MICRO_UNITS) + )); + if (shares <= 0) continue; + const grossMicro = Math.floor(shares * level.price * USD_MICRO_UNITS); + const feeMicro = roundedFeeMicro(shares * takerFeePerShare(level.price, fees) * USD_MICRO_UNITS); + filled.shares += shares; + filled.grossMicro += grossMicro; + filled.feeMicro += feeMicro; + remainingShares -= shares; + } + if (filled.shares === 0) return this.#empty(intent, book, fees, "insufficient_bid_depth"); + if (filled.shares < fees.minimumOrderSize) { + return this.#empty(intent, book, fees, "below_minimum_order_size"); + } + const averagePrice = filled.grossMicro / USD_MICRO_UNITS / filled.shares; + return { + adapter: "paper", + status: fillStatus(intent.stakeMicroUsd, filled.grossMicro), + reason: null, + assetId: book.assetId, + conditionId: book.conditionId, + direction: intent.direction, + requestedStakeMicroUsd: intent.stakeMicroUsd, + grossMicroUsd: microUsd(filled.grossMicro), + feeMicroUsd: microUsd(filled.feeMicro), + netConsiderationMicroUsd: microUsd(filled.grossMicro - filled.feeMicro), + filledShares: filled.shares, + averagePrice, + bestPrice, + ...evidence, + slippageProbabilityBps: (bestPrice - averagePrice) * 10_000, + bookObservedTsMs: book.observedTsMs, + feeParameters: fees + }; + } + + #empty( + intent: PaperOrderIntent, + book: PolymarketBookEvent, + fees: PolymarketFeeParameters, + reason: string + ): PaperFill { + const evidence = bookEvidence(intent, book); + return { + adapter: "paper", + status: "no_fill", + reason, + assetId: book.assetId, + conditionId: book.conditionId, + direction: intent.direction, + requestedStakeMicroUsd: intent.stakeMicroUsd, + grossMicroUsd: microUsd(0), + feeMicroUsd: microUsd(0), + netConsiderationMicroUsd: microUsd(0), + filledShares: 0, + averagePrice: null, + bestPrice: null, + ...evidence, + slippageProbabilityBps: null, + bookObservedTsMs: book.observedTsMs, + feeParameters: fees + }; + } +} diff --git a/src/features/engine.ts b/src/features/engine.ts index 8c645eb..cc988f9 100644 --- a/src/features/engine.ts +++ b/src/features/engine.ts @@ -16,6 +16,22 @@ export type FeatureEngineConfig = { freshnessMaxAgeMs: number; }; +export type FeatureEngineDiagnostics = { + rejectedSourceRegressions: { + txline: number; + polymarket: number; + }; + rejectedObservedRegressions: { + txline: number; + polymarket: number; + }; + rejectedInvalidTimestamps: number; + sourceObservationDeltaMs: { + txline: { minimum: number | null; maximum: number | null }; + polymarket: { minimum: number | null; maximum: number | null }; + }; +}; + export type VelocityFeature = EwmaObservation & { windowMs: number; velocity: number | null; @@ -25,7 +41,10 @@ export type VelocityFeature = EwmaObservation & { export type FeatureSnapshot = { triggerEventId: string; triggerSource: "txline" | "polymarket"; + /** Market/source clock for the event that caused this snapshot. */ asOfTsMs: number; + /** Knowledge clock: when Samaritan actually observed the triggering event. */ + observedAtTsMs: number; fixtureId: string; market: CanonicalMarket; outcome: CanonicalOutcome; @@ -110,7 +129,17 @@ function bestAsk(levels: Array<{ price: Probability }>): Probability | null { export class FeatureEngine { readonly #states = new Map(); readonly #scores = new Map(); + readonly #lastObservedTsMs = new Map<"txline" | "polymarket", number>(); readonly #retentionMs: number; + readonly #diagnostics: FeatureEngineDiagnostics = { + rejectedSourceRegressions: { txline: 0, polymarket: 0 }, + rejectedObservedRegressions: { txline: 0, polymarket: 0 }, + rejectedInvalidTimestamps: 0, + sourceObservationDeltaMs: { + txline: { minimum: null, maximum: null }, + polymarket: { minimum: null, maximum: null } + } + }; constructor(readonly config: FeatureEngineConfig) { if (config.velocityWindowsMs.length === 0 || config.velocityWindowsMs.some((window) => window <= 0)) { @@ -123,6 +152,7 @@ export class FeatureEngine { } ingest(event: CanonicalEvent): FeatureSnapshot[] { + if (!this.#acceptEventTimestamp(event)) return []; if (event.kind === "score.update") { const scores = this.#scores.get(event.fixtureId) ?? []; scores.push(event); @@ -138,10 +168,12 @@ export class FeatureEngine { return event.outcomes.flatMap((outcome, index) => { if (outcome.fairProbability === null) return []; const state = this.#state(event.fixtureId, event.market, outcome.outcome); + if (!this.#updateSource(state.consensus, outcome.fairProbability, event.sourceTsMs, "txline")) { + return []; + } const crossCheck = rawSum > 0 ? rawImplied[index]! / rawSum : null; state.devigCrossCheckProbability = crossCheck; state.devigDiscrepancy = crossCheck === null ? null : Math.abs(outcome.fairProbability - crossCheck); - this.#updateSource(state.consensus, outcome.fairProbability, event.sourceTsMs); const previous = state.consensus.series.latest; if (previous && previous.tsMs === event.sourceTsMs && state.consensus.updateCount > 1) { const deltaPoint = state.consensus.series.valueAtOrBefore(event.sourceTsMs - 1); @@ -151,43 +183,84 @@ export class FeatureEngine { state.cusumDown = cusum.down; } } - return [this.#snapshot(state, event.eventId, event.source, event.sourceTsMs)]; + return [this.#snapshot(state, event.eventId, event.source, event.sourceTsMs, event.observedTsMs)]; }); } if (event.kind === "polymarket.price") { if (event.tokenRole !== "canonical") return []; const state = this.#state(event.fixtureId, event.market, event.outcome); - state.mappingStatus = event.mappingStatus; - state.polymarketObservation = event.observation; - if (event.bestBid !== null) state.bestBid = event.bestBid; - if (event.bestAsk !== null) state.bestAsk = event.bestAsk; + const proposedBid = event.bestBid ?? state.bestBid; + const proposedAsk = event.bestAsk ?? state.bestAsk; const value = - state.bestBid !== null && state.bestAsk !== null - ? ((state.bestBid + state.bestAsk) / 2 as Probability) + proposedBid !== null && proposedAsk !== null + ? ((proposedBid + proposedAsk) / 2 as Probability) : event.price; - if (value === null) return [this.#snapshot(state, event.eventId, event.source, event.sourceTsMs)]; - this.#updateSource(state.polymarket, value, event.sourceTsMs); - return [this.#snapshot(state, event.eventId, event.source, event.sourceTsMs)]; + if (value === null) { + if (this.#isSourceRegression(state.polymarket, event.sourceTsMs, "polymarket")) return []; + } else if (!this.#updateSource(state.polymarket, value, event.sourceTsMs, "polymarket")) { + return []; + } + state.mappingStatus = event.mappingStatus; + state.polymarketObservation = event.observation; + state.bestBid = proposedBid; + state.bestAsk = proposedAsk; + return [this.#snapshot(state, event.eventId, event.source, event.sourceTsMs, event.observedTsMs)]; } if (event.kind === "polymarket.book") { if (event.tokenRole !== "canonical") return []; const state = this.#state(event.fixtureId, event.market, event.outcome); + const proposedBid = bestBid(event.bids); + const proposedAsk = bestAsk(event.asks); + if (proposedBid !== null && proposedAsk !== null) { + if ( + !this.#updateSource( + state.polymarket, + ((proposedBid + proposedAsk) / 2) as Probability, + event.sourceTsMs, + "polymarket" + ) + ) { + return []; + } + } else if (this.#isSourceRegression(state.polymarket, event.sourceTsMs, "polymarket")) { + return []; + } state.mappingStatus = event.mappingStatus; state.polymarketObservation = "book"; - state.bestBid = bestBid(event.bids); - state.bestAsk = bestAsk(event.asks); - if (state.bestBid !== null && state.bestAsk !== null) { - this.#updateSource( - state.polymarket, - ((state.bestBid + state.bestAsk) / 2) as Probability, - event.sourceTsMs - ); - } - return [this.#snapshot(state, event.eventId, event.source, event.sourceTsMs)]; + state.bestBid = proposedBid; + state.bestAsk = proposedAsk; + return [this.#snapshot(state, event.eventId, event.source, event.sourceTsMs, event.observedTsMs)]; } return []; } + diagnostics(): FeatureEngineDiagnostics { + return structuredClone(this.#diagnostics); + } + + #acceptEventTimestamp(event: CanonicalEvent): boolean { + if ( + !Number.isSafeInteger(event.sourceTsMs) || + !Number.isSafeInteger(event.observedTsMs) || + event.sourceTsMs < 0 || + event.observedTsMs < 0 + ) { + this.#diagnostics.rejectedInvalidTimestamps += 1; + return false; + } + const previous = this.#lastObservedTsMs.get(event.source); + if (previous !== undefined && event.observedTsMs < previous) { + this.#diagnostics.rejectedObservedRegressions[event.source] += 1; + return false; + } + this.#lastObservedTsMs.set(event.source, event.observedTsMs); + const delta = event.observedTsMs - event.sourceTsMs; + const deltas = this.#diagnostics.sourceObservationDeltaMs[event.source]; + deltas.minimum = deltas.minimum === null ? delta : Math.min(deltas.minimum, delta); + deltas.maximum = deltas.maximum === null ? delta : Math.max(deltas.maximum, delta); + return true; + } + #sourceState(): SourceState { return { series: new RollingSeries(this.#retentionMs), @@ -237,10 +310,18 @@ export class FeatureEngine { return state; } - #updateSource(source: SourceState, value: Probability, tsMs: number): void { + #updateSource( + source: SourceState, + value: Probability, + tsMs: number, + sourceName: "txline" | "polymarket" + ): boolean { const previous = source.series.latest; const added = source.series.add({ tsMs, value }); - if (!added.accepted) return; + if (!added.accepted) { + this.#diagnostics.rejectedSourceRegressions[sourceName] += 1; + return false; + } source.probability = value; source.sourceTsMs = tsMs; source.updateCount += 1; @@ -270,13 +351,25 @@ export class FeatureEngine { ...source.moments.get(windowMs)!.observe(velocity, tsMs) }; }); + return true; + } + + #isSourceRegression( + source: SourceState, + tsMs: number, + sourceName: "txline" | "polymarket" + ): boolean { + if (source.sourceTsMs === null || tsMs >= source.sourceTsMs) return false; + this.#diagnostics.rejectedSourceRegressions[sourceName] += 1; + return true; } #snapshot( state: OutcomeState, triggerEventId: string, triggerSource: "txline" | "polymarket", - asOfTsMs: number + asOfTsMs: number, + observedAtTsMs: number ): FeatureSnapshot { const txAge = state.consensus.sourceTsMs === null ? null : asOfTsMs - state.consensus.sourceTsMs; const pmAge = state.polymarket.sourceTsMs === null ? null : asOfTsMs - state.polymarket.sourceTsMs; @@ -301,6 +394,7 @@ export class FeatureEngine { triggerEventId, triggerSource, asOfTsMs, + observedAtTsMs, fixtureId: state.fixtureId, market: state.market, outcome: state.outcome, diff --git a/src/harness/build-capture-readiness.ts b/src/harness/build-capture-readiness.ts new file mode 100644 index 0000000..f3b393e --- /dev/null +++ b/src/harness/build-capture-readiness.ts @@ -0,0 +1,32 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS } from "../config/paper-study.js"; +import { buildCaptureReadiness, renderCaptureReadinessMarkdown } from "./capture-readiness.js"; + +function argument(name: string, fallback: string): string { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; +} + +const gammaPath = resolve(argument("gamma", "data/live/gamma-discovery/open-world-cup-events.json")); +const txlinePath = resolve(argument("txline", "samples/fixtures/mainnet-world-cup-fixtures.json")); +const outputPath = resolve(argument("output", "data/research/capture-readiness.json")); +const reportPath = resolve(argument("report", "docs/research/capture-readiness.md")); +const [gammaEvents, txlineFixtures] = await Promise.all([ + readFile(gammaPath, "utf8").then((value) => JSON.parse(value) as Array>), + readFile(txlinePath, "utf8").then((value) => JSON.parse(value) as Array>) +]); +const readiness = buildCaptureReadiness({ + generatedAt: new Date().toISOString(), + nowTsMs: Date.now(), + requiredLeadMs: 3 * 60 * 60_000, + signalCutoffMs: PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS, + gammaEvents, + txlineFixtures +}); +await Promise.all([mkdir(dirname(outputPath), { recursive: true }), mkdir(dirname(reportPath), { recursive: true })]); +await Promise.all([ + writeFile(outputPath, `${JSON.stringify(readiness, null, 2)}\n`), + writeFile(reportPath, renderCaptureReadinessMarkdown(readiness)) +]); +console.log(JSON.stringify({ outputPath, reportPath, fixtures: readiness.fixtures }, null, 2)); diff --git a/src/harness/build-paper-fixture-universe.ts b/src/harness/build-paper-fixture-universe.ts new file mode 100644 index 0000000..03c3d35 --- /dev/null +++ b/src/harness/build-paper-fixture-universe.ts @@ -0,0 +1,104 @@ +import { createReadStream } from "node:fs"; +import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { dirname, join, resolve } from "node:path"; +import { PAPER_STUDY_TOTAL_SELECTOR_CONFIG } from "../config/paper-study.js"; +import type { MappingRecord } from "../mapping/registry.js"; +import type { TotalLineEvidence } from "../research/main-total-selector.js"; +import { + buildPaperFixtureUniverse, + renderPaperFixtureUniverseMarkdown, + type PairedCaptureEvidence +} from "./paper-fixture-universe.js"; + +function argument(name: string, fallback: string): string { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; +} + +function optionalArgument(name: string): string | null { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : null; +} + +async function pairedCaptures(root: string): Promise { + const captures: PairedCaptureEvidence[] = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith("paired-")) continue; + const path = join(root, entry.name, "analysis-manifest.json"); + try { + await access(path); + } catch { + continue; + } + const manifest = JSON.parse(await readFile(path, "utf8")) as { + runId: string; + status: string; + fixtureId: string; + eventSlug: string; + capture: { logComplete: boolean; mappingConfirmed: boolean; polymarketDir: string }; + verification: { identityParity: boolean; replayMode: string; rows: number }; + }; + if (manifest.status !== "verified") continue; + const messagesPath = resolve(manifest.capture.polymarketDir, "messages.ndjson"); + const lines = createInterface({ input: createReadStream(messagesPath, { encoding: "utf8" }), crlfDelay: Infinity }); + let firstObservedTsMs: number | null = null; + for await (const line of lines) { + if (line.trim() === "") continue; + const first = JSON.parse(line) as { receivedAt?: string }; + firstObservedTsMs = Date.parse(first.receivedAt ?? ""); + break; + } + lines.close(); + if (firstObservedTsMs === null || !Number.isFinite(firstObservedTsMs)) { + throw new Error(`Paired capture ${manifest.runId} lacks a valid first observation timestamp`); + } + captures.push({ + runId: manifest.runId, + status: "verified", + fixtureId: manifest.fixtureId, + eventSlug: manifest.eventSlug, + logComplete: manifest.capture.logComplete, + mappingConfirmed: manifest.capture.mappingConfirmed, + identityParity: manifest.verification.identityParity, + replayMode: manifest.verification.replayMode, + rows: manifest.verification.rows, + firstObservedTsMs + }); + } + return captures; +} + +const mappingsPath = resolve(argument("mappings", "data/live/gamma-discovery/candidate-mappings.json")); +const evidencePath = resolve(argument("total-evidence", "data/research/main-total-line-evidence.json")); +const historiesDir = resolve(argument("histories", "samples/polymarket-history/world-cup-2026-v1/histories")); +const liveRoot = resolve(argument("live-root", "data/live")); +const outputPath = resolve(argument("output", "data/research/paper-fixture-universe.json")); +const reportPath = resolve(argument("report", "docs/research/paper-fixture-universe.md")); +const ledgerManifestPath = resolve(argument("ledger-manifest", "data/paper/study-ledgers.json")); +const laneStartArgument = optionalArgument("lane-start"); +const laneStartTsMs = laneStartArgument === null + ? (JSON.parse(await readFile(ledgerManifestPath, "utf8")) as { longRun: { startedAtTsMs: number } }).longRun.startedAtTsMs + : Date.parse(laneStartArgument); +const generatedAt = new Date().toISOString(); +const [mappingFile, evidenceFile, historyFiles, captures] = await Promise.all([ + readFile(mappingsPath, "utf8").then((value) => JSON.parse(value) as { records?: MappingRecord[] }), + readFile(evidencePath, "utf8").then((value) => JSON.parse(value) as { evidence?: TotalLineEvidence[] }), + readdir(historiesDir), + pairedCaptures(liveRoot) +]); +const universe = buildPaperFixtureUniverse({ + generatedAt, + laneStartTsMs, + mappings: mappingFile.records ?? [], + totalEvidence: evidenceFile.evidence ?? [], + pairedCaptures: captures, + sampledHistoryAssetIds: new Set(historyFiles.filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5))), + selectorConfig: PAPER_STUDY_TOTAL_SELECTOR_CONFIG +}); +await Promise.all([mkdir(dirname(outputPath), { recursive: true }), mkdir(dirname(reportPath), { recursive: true })]); +await Promise.all([ + writeFile(outputPath, `${JSON.stringify(universe, null, 2)}\n`), + writeFile(reportPath, renderPaperFixtureUniverseMarkdown(universe)) +]); +console.log(JSON.stringify({ outputPath, reportPath, summary: universe.summary }, null, 2)); diff --git a/src/harness/capture-config.ts b/src/harness/capture-config.ts new file mode 100644 index 0000000..d3d7f28 --- /dev/null +++ b/src/harness/capture-config.ts @@ -0,0 +1,186 @@ +import { resolve } from "node:path"; +import { z } from "zod"; + +const captureConfigSchema = z.object({ + captureId: z.string().min(1), + status: z.enum(["pending_human_confirmation", "human_confirmed_for_capture_only"]), + tradeable: z.literal(false), + confirmedBy: z.literal("Deborah").optional(), + confirmedAt: z.string().date().optional(), + txline: z.object({ + fixtureId: z.string().min(1), + home: z.string().min(1), + away: z.string().min(1), + kickoffUtc: z.string().datetime() + }), + polymarket: z.object({ + eventId: z.string().min(1), + eventSlug: z.string().min(1), + totalsEventId: z.string().min(1), + totalsEventSlug: z.string().min(1), + home: z.string().min(1), + away: z.string().min(1), + kickoffUtc: z.string().datetime(), + rulesPeriod: z.literal("first_90_minutes_plus_stoppage_time") + }), + capture: z.object({ + scheduledStartUtc: z.string().datetime(), + durationMinutes: z.number().int().min(300).max(720), + runLabel: z.string().regex(/^paired-[a-z0-9-]+$/) + }), + evidence: z.object({ + txlineFixtures: z.string().min(1), + polymarketEvents: z.string().min(1), + readinessReport: z.string().min(1) + }), + note: z.string().min(1) +}).strict().superRefine((config, context) => { + if (config.status === "human_confirmed_for_capture_only" && (!config.confirmedBy || !config.confirmedAt)) { + context.addIssue({ code: "custom", path: ["confirmedBy"], message: "Confirmed capture requires Deborah and date" }); + } + if (config.status === "pending_human_confirmation" && (config.confirmedBy || config.confirmedAt)) { + context.addIssue({ code: "custom", path: ["confirmedBy"], message: "Pending capture cannot claim confirmation" }); + } +}); + +export type CaptureConfig = z.infer; + +type TxLineFixture = { + FixtureId?: string | number; + Participant1?: string; + Participant2?: string; + StartTime?: string | number; +}; + +type GammaEvent = { + id?: string | number; + slug?: string; + teams?: Array<{ name?: string }>; + startTime?: string; + markets?: Array<{ + sportsMarketType?: string; + gameStartTime?: string; + description?: string; + }>; +}; + +export type CaptureValidation = { + config: CaptureConfig; + evidenceValid: true; + readyToSchedule: boolean; + reason: "ready" | "human_confirmation_required"; + launch: { + cwd: string; + command: string; + logPath: string; + pidPath: string; + scheduledStartTsMs: number; + } | null; +}; + +function timestamp(value: string | number | undefined): number { + if (typeof value === "number") return value; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + return Date.parse(String(value ?? "")); +} + +function eventTimestamp(event: GammaEvent): number { + return (event.markets ?? []) + .map((market) => Date.parse(String(market.gameStartTime ?? ""))) + .find(Number.isFinite) ?? Date.parse(String(event.startTime ?? "")); +} + +function eventTeams(event: GammaEvent): string[] { + return (event.teams ?? []).map((team) => String(team.name ?? "")).filter(Boolean); +} + +function assertEvent(input: { + event: GammaEvent | undefined; + id: string; + slug: string; + home: string; + away: string; + kickoffTsMs: number; + totals: boolean; +}): void { + const { event } = input; + if (!event || String(event.id ?? "") !== input.id || event.slug !== input.slug) { + throw new Error(`Polymarket event identity changed for ${input.slug}`); + } + if (eventTeams(event).join("|") !== `${input.home}|${input.away}` || eventTimestamp(event) !== input.kickoffTsMs) { + throw new Error(`Polymarket teams or kickoff changed for ${input.slug}`); + } + if (input.totals) { + const totals = (event.markets ?? []).filter((market) => market.sportsMarketType === "totals"); + if (totals.length === 0 || totals.some((market) => + !String(market.description ?? "").includes("first 90 minutes of regular play plus stoppage time") + )) { + throw new Error(`Polymarket full-time totals rules are missing or changed for ${input.slug}`); + } + } +} + +export function validateCaptureConfig(input: { + repoRoot: string; + config: unknown; + txlineFixtures: readonly TxLineFixture[]; + polymarketEvents: readonly GammaEvent[]; +}): CaptureValidation { + const repoRoot = resolve(input.repoRoot); + const config = captureConfigSchema.parse(input.config); + const kickoffTsMs = Date.parse(config.txline.kickoffUtc); + if ( + config.txline.home !== config.polymarket.home || + config.txline.away !== config.polymarket.away || + config.txline.kickoffUtc !== config.polymarket.kickoffUtc + ) { + throw new Error("Capture config teams or kickoff disagree across sources"); + } + const fixture = input.txlineFixtures.find((candidate) => String(candidate.FixtureId ?? "") === config.txline.fixtureId); + if ( + !fixture || + fixture.Participant1 !== config.txline.home || + fixture.Participant2 !== config.txline.away || + timestamp(fixture.StartTime) !== kickoffTsMs + ) { + throw new Error(`TXLine fixture ${config.txline.fixtureId} teams or kickoff changed`); + } + const bySlug = new Map(input.polymarketEvents.map((event) => [String(event.slug ?? ""), event])); + assertEvent({ + event: bySlug.get(config.polymarket.eventSlug), + id: config.polymarket.eventId, + slug: config.polymarket.eventSlug, + home: config.polymarket.home, + away: config.polymarket.away, + kickoffTsMs, + totals: false + }); + assertEvent({ + event: bySlug.get(config.polymarket.totalsEventSlug), + id: config.polymarket.totalsEventId, + slug: config.polymarket.totalsEventSlug, + home: config.polymarket.home, + away: config.polymarket.away, + kickoffTsMs, + totals: true + }); + const scheduledStartTsMs = Date.parse(config.capture.scheduledStartUtc); + if (scheduledStartTsMs !== kickoffTsMs - 3 * 60 * 60_000) { + throw new Error("Paired capture must start exactly three hours before kickoff"); + } + const readyToSchedule = config.status === "human_confirmed_for_capture_only"; + return { + config, + evidenceValid: true, + readyToSchedule, + reason: readyToSchedule ? "ready" : "human_confirmation_required", + launch: readyToSchedule ? { + cwd: resolve(repoRoot, "phase0"), + command: `pnpm capture:paired -- --network mainnet --txline-fixture-id ${config.txline.fixtureId} --duration-minutes ${config.capture.durationMinutes} --run-label ${config.capture.runLabel}`, + logPath: resolve(repoRoot, "samples/_logs", `${config.capture.runLabel}.log`), + pidPath: resolve(repoRoot, "samples/_logs", `${config.capture.runLabel}.pid`), + scheduledStartTsMs + } : null + }; +} diff --git a/src/harness/capture-readiness.ts b/src/harness/capture-readiness.ts new file mode 100644 index 0000000..b9d53fe --- /dev/null +++ b/src/harness/capture-readiness.ts @@ -0,0 +1,149 @@ +export type CaptureReadiness = { + generatedAt: string; + requiredLeadMs: number; + fixtures: Array<{ + home: string; + away: string; + kickoffTsMs: number; + eventSlugs: string[]; + polymarketEventIds: string[]; + txlineFixtureId: string | null; + status: "ready_for_human_confirmation" | "blocked_missing_txline_fixture"; + recommendedCaptureStartTsMs: number; + signalCutoffTsMs: number; + }>; +}; + +type GammaEvent = { + id?: string | number; + slug?: string; + title?: string; + teams?: Array<{ name?: string }>; + markets?: Array<{ gameStartTime?: string }>; +}; + +type TxLineFixture = { + FixtureId?: string | number; + Participant1?: string; + Participant2?: string; + StartTime?: string | number; +}; + +function normalizeTeam(value: unknown): string { + const normalized = String(value ?? "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLocaleLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + return ({ + usa: "united states", + "korea republic": "south korea", + czechia: "czech republic", + turkiye: "turkey", + "cabo verde": "cape verde" + } as Record)[normalized] ?? normalized; +} + +function kickoffMs(value: string | number | undefined): number { + if (typeof value === "number") return value; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + return Date.parse(String(value ?? "")); +} + +function gammaKickoff(event: GammaEvent): number { + return (event.markets ?? []) + .map((market) => Date.parse(String(market.gameStartTime ?? ""))) + .find(Number.isFinite) ?? Number.NaN; +} + +function gammaTeams(event: GammaEvent): [string, string] | null { + const teams = (event.teams ?? []).map((team) => String(team.name ?? "")).filter(Boolean); + if (teams.length >= 2) return [teams[0]!, teams[1]!]; + const title = String(event.title ?? "").replace(/\s+-\s+More Markets.*$/i, ""); + const match = title.match(/^(.+?)\s+vs\.?\s+(.+?)$/i); + return match ? [match[1]!, match[2]!] : null; +} + +export function buildCaptureReadiness(input: { + generatedAt: string; + nowTsMs: number; + requiredLeadMs: number; + signalCutoffMs: number; + gammaEvents: readonly GammaEvent[]; + txlineFixtures: readonly TxLineFixture[]; +}): CaptureReadiness { + if (!Number.isSafeInteger(input.nowTsMs) || input.requiredLeadMs <= input.signalCutoffMs) { + throw new Error("Capture readiness requires valid time and lead settings"); + } + const groups = new Map; + eventIds: Set; + }>(); + for (const event of input.gammaEvents) { + const teams = gammaTeams(event); + const kickoffTsMs = gammaKickoff(event); + if (!teams || !Number.isFinite(kickoffTsMs) || kickoffTsMs <= input.nowTsMs) continue; + const slug = String(event.slug ?? ""); + if (!/^fifwc-/.test(slug) || (!slug.endsWith("-more-markets") && /-(halftime|second-half|exact-score|first-to-score|total-corners|player-props)/.test(slug))) { + continue; + } + const teamKey = teams.map(normalizeTeam).sort().join("|"); + const key = `${teamKey}:${kickoffTsMs}`; + const group = groups.get(key) ?? { + home: teams[0], + away: teams[1], + kickoffTsMs, + eventSlugs: new Set(), + eventIds: new Set() + }; + if (slug) group.eventSlugs.add(slug); + if (event.id !== undefined) group.eventIds.add(String(event.id)); + groups.set(key, group); + } + const fixtures = [...groups.values()] + .sort((left, right) => left.kickoffTsMs - right.kickoffTsMs) + .map((group) => { + const teamKey = [group.home, group.away].map(normalizeTeam).sort().join("|"); + const matches = input.txlineFixtures.filter((fixture) => + [fixture.Participant1, fixture.Participant2].map(normalizeTeam).sort().join("|") === teamKey && + Math.abs(kickoffMs(fixture.StartTime) - group.kickoffTsMs) <= 15 * 60_000 + ); + if (matches.length > 1) throw new Error(`Multiple TXLine fixtures match ${group.home} vs ${group.away}`); + const fixture = matches[0]; + return { + home: group.home, + away: group.away, + kickoffTsMs: group.kickoffTsMs, + eventSlugs: [...group.eventSlugs].sort(), + polymarketEventIds: [...group.eventIds].sort(), + txlineFixtureId: fixture ? String(fixture.FixtureId ?? "") : null, + status: fixture ? "ready_for_human_confirmation" as const : "blocked_missing_txline_fixture" as const, + recommendedCaptureStartTsMs: group.kickoffTsMs - input.requiredLeadMs, + signalCutoffTsMs: group.kickoffTsMs - input.signalCutoffMs + }; + }); + return { generatedAt: input.generatedAt, requiredLeadMs: input.requiredLeadMs, fixtures }; +} + +export function renderCaptureReadinessMarkdown(readiness: CaptureReadiness): string { + const rows = readiness.fixtures.map((fixture) => + `| ${fixture.home} vs ${fixture.away} | ${new Date(fixture.kickoffTsMs).toISOString()} | ${fixture.txlineFixtureId ?? "missing"} | ${fixture.status} | ${new Date(fixture.recommendedCaptureStartTsMs).toISOString()} | ${new Date(fixture.signalCutoffTsMs).toISOString()} |` + ); + return [ + "# Paired Capture Readiness", + "", + `Generated ${readiness.generatedAt}. Public Polymarket discovery plus the local TXLine fixture snapshot; no authenticated refresh was performed.`, + "", + "| Match | Kickoff UTC | TXLine fixture | Status | Recommended capture start | Latest registered signal time |", + "|---|---:|---:|---|---:|---:|", + ...rows, + "", + "A capture remains blocked until TXLine fixture ID, teams, and kickoff match an exact Polymarket event family and Deborah confirms the mapping. Recommended start is three hours before kickoff so the frozen feature windows warm up before the 15-minute signal cutoff.", + "" + ].join("\n"); +} diff --git a/src/harness/claude-paper-study.ts b/src/harness/claude-paper-study.ts new file mode 100644 index 0000000..b8d2e86 --- /dev/null +++ b/src/harness/claude-paper-study.ts @@ -0,0 +1,123 @@ +import { + ClaudeAnalystAgent, + ClaudeTriageAgent, + createAnthropicMessagesClient, + type ClaudeMessagesClient +} from "../agents/claude.js"; +import { CLAUDE_HARD_CEILING_NANO_USD } from "../agents/claude-pricing.js"; +import { ClaudeSpendLedger } from "../agents/claude-spend-ledger.js"; +import type { AnalystAgent, TriageAgent } from "../agents/contracts.js"; +import type { DecisionLedger } from "../store/decision-ledger.js"; +import { + applyPaperFixtureAdmission, + createPersistentPaperLaneRuntime, + planPaperFixtureAdmission, + type PersistentPaperLaneRuntime +} from "./paper-lane-runtime.js"; +import type { PaperFixtureUniverse } from "./paper-fixture-universe.js"; +import type { PolymarketFeeResolver } from "./paper-scheduler.js"; +import type { PaperStudyInitialization } from "./paper-study-ledger.js"; + +type ClaudeSource = { + apiKey?: string; + client?: ClaudeMessagesClient; +}; + +type LaneResources = { + initialization: PaperStudyInitialization; + ledger: DecisionLedger; +}; + +export type PersistentClaudePaperStudy = { + agents: { + triage: TriageAgent; + analyst: AnalystAgent; + }; + spendLedger: ClaudeSpendLedger; + bounty: PersistentPaperLaneRuntime; + longRun: PersistentPaperLaneRuntime; + admitUniverse(universe: PaperFixtureUniverse): { + bounty: string[]; + longRun: string[]; + }; +}; + +function messageClient(source: ClaudeSource): ClaudeMessagesClient { + const hasApiKey = source.apiKey !== undefined; + const hasClient = source.client !== undefined; + if (hasApiKey === hasClient) { + throw new Error("Claude paper study requires exactly one API key or injected Messages client"); + } + return source.client ?? createAnthropicMessagesClient(source.apiKey!); +} + +export function createPersistentClaudePaperStudy(input: ClaudeSource & { + spendLedger: ClaudeSpendLedger; + bounty: LaneResources; + longRun: LaneResources; + universe: PaperFixtureUniverse; + feeResolver: PolymarketFeeResolver; + minimumDecisionLatencyMs?: number; + maximumPendingMs: number; +}): PersistentClaudePaperStudy { + if (input.bounty.initialization.protocolStatus !== input.longRun.initialization.protocolStatus) { + throw new Error("Paper study lanes have different protocol registration states"); + } + if ( + input.apiKey !== undefined && + input.bounty.initialization.protocolStatus !== ("registered" as string) + ) { + throw new Error( + `Anthropic API use is disabled for ${input.bounty.initialization.protocolStatus} paper protocols` + ); + } + if (input.spendLedger.hardCeilingNanoUsd > CLAUDE_HARD_CEILING_NANO_USD) { + throw new Error("Claude paper study spend ceiling exceeds the locked project maximum"); + } + if (input.bounty.ledger === input.longRun.ledger) { + throw new Error("Bounty and long-run paper studies require separate decision ledgers"); + } + if (input.universe.laneStartTsMs !== input.longRun.initialization.startedAtTsMs) { + throw new Error("Paper fixture universe does not match the long-run ledger start"); + } + const client = messageClient(input); + const agents = { + triage: new ClaudeTriageAgent({ client, spendLedger: input.spendLedger }), + analyst: new ClaudeAnalystAgent({ client, spendLedger: input.spendLedger }) + }; + const common = { + universe: input.universe, + triageAgent: agents.triage, + analystAgent: agents.analyst, + feeResolver: input.feeResolver, + executionLatencyMs: input.minimumDecisionLatencyMs ?? 1, + maximumPendingMs: input.maximumPendingMs, + processingNow: Date.now + }; + const bounty = createPersistentPaperLaneRuntime({ + lane: "bounty", + initialization: input.bounty.initialization, + ledger: input.bounty.ledger, + ...common + }); + const longRun = createPersistentPaperLaneRuntime({ + lane: "long_run", + initialization: input.longRun.initialization, + ledger: input.longRun.ledger, + ...common + }); + return { + agents, + spendLedger: input.spendLedger, + bounty, + longRun, + admitUniverse: (universe) => { + const bountyPlan = planPaperFixtureAdmission(bounty, universe); + const longRunPlan = planPaperFixtureAdmission(longRun, universe); + return { + bounty: applyPaperFixtureAdmission(bounty, bountyPlan), + longRun: applyPaperFixtureAdmission(longRun, longRunPlan) + }; + } + }; +} diff --git a/src/harness/initialize-paper-study-ledgers.ts b/src/harness/initialize-paper-study-ledgers.ts new file mode 100644 index 0000000..d6e94bd --- /dev/null +++ b/src/harness/initialize-paper-study-ledgers.ts @@ -0,0 +1,88 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { evaluatePaperStudyLedger } from "../metrics/paper-study-observations.js"; +import { + PAPER_STUDY_PROTOCOL_STATUS, + initializePaperStudyLedger +} from "./paper-study-ledger.js"; + +function argument(name: string, fallback: string): string { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; +} + +const root = resolve(argument("root", "data/paper")); +const manifestPath = resolve(argument("manifest", "data/paper/study-ledgers.json")); +const reportPath = resolve(argument("report", "docs/research/paper-study-ledger-initialization.md")); +if (!process.argv.includes("--candidate")) { + throw new Error( + `Paper protocol is ${PAPER_STUDY_PROTOCOL_STATUS}; pass --candidate only for isolated engineering fixtures. ` + + "Do not replace or append to the preserved v1 study ledgers before Deborah registers the corrected protocol." + ); +} +const now = Date.now(); +const bounty = initializePaperStudyLedger({ + path: resolve(root, "bounty", "decision-ledger.sqlite"), + lane: "bounty", + startedAtTsMs: now +}); +const longRun = initializePaperStudyLedger({ + path: resolve(root, "long-run", "decision-ledger.sqlite"), + lane: "long_run", + startedAtTsMs: now +}); +try { + const bountyChain = bounty.ledger.verifyChain(); + const longRunChain = longRun.ledger.verifyChain(); + const longRunReport = evaluatePaperStudyLedger({ + lane: "long_run", + ledger: longRun.ledger, + kickoffByFixtureId: new Map() + }); + const manifest = { + protocolVersion: bounty.initialization.protocolVersion, + protocolStatus: bounty.initialization.protocolStatus, + configHash: bounty.initialization.configHash, + realMoneyGate: "closed", + bounty: { + path: "data/paper/bounty/decision-ledger.sqlite", + startedAtTsMs: bounty.initialization.startedAtTsMs, + startedAt: bounty.initialization.startedAt, + created: bounty.created, + chain: bountyChain + }, + longRun: { + path: "data/paper/long-run/decision-ledger.sqlite", + startedAtTsMs: longRun.initialization.startedAtTsMs, + startedAt: longRun.initialization.startedAt, + created: longRun.created, + chain: longRunChain, + report: longRunReport + } + }; + const markdown = [ + "# Paper Study Ledger Initialization", + "", + `Protocol: \`${manifest.protocolVersion}\` `, + `Protocol status: **${manifest.protocolStatus}** `, + `Frozen config SHA-256: \`${manifest.configHash}\` `, + "Real-money gate: **closed**", + "", + `- Bounty ledger start: ${manifest.bounty.startedAt}; chain rows: ${manifest.bounty.chain.rows}; status: exploratory.`, + `- Long-run ledger start: ${manifest.longRun.startedAt}; chain rows: ${manifest.longRun.chain.rows}; report status: ${manifest.longRun.report.status}.`, + `- Long-run stopping counts at initialization: ${manifest.longRun.report.counts.filledMatches} filled matches / ${manifest.longRun.report.counts.fills} fills.`, + "- Fixtures before the long-run start timestamp are excluded from its stopping count.", + "", + "No wallet, venue authentication, token, deposit, approval, order, or money movement is part of either ledger initialization.", + "" + ].join("\n"); + await Promise.all([mkdir(dirname(manifestPath), { recursive: true }), mkdir(dirname(reportPath), { recursive: true })]); + await Promise.all([ + writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`), + writeFile(reportPath, markdown) + ]); + console.log(JSON.stringify({ manifestPath, reportPath, configHash: manifest.configHash, bounty: manifest.bounty, longRun: manifest.longRun }, null, 2)); +} finally { + bounty.ledger.close(); + longRun.ledger.close(); +} diff --git a/src/harness/paper-fixture-universe.ts b/src/harness/paper-fixture-universe.ts new file mode 100644 index 0000000..fd7bdc0 --- /dev/null +++ b/src/harness/paper-fixture-universe.ts @@ -0,0 +1,266 @@ +import type { MappingRecord } from "../mapping/registry.js"; +import { + PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS, + PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS +} from "../config/paper-study.js"; +import { + assertCausalTotalSelectorConfig, + selectMainTotalLine, + type TotalLineEvidence, + type TotalLineSelectorConfig +} from "../research/main-total-selector.js"; + +export type PairedCaptureEvidence = { + runId: string; + status: "verified"; + fixtureId: string; + eventSlug: string; + logComplete: boolean; + mappingConfirmed: boolean; + identityParity: boolean; + replayMode: string; + rows: number; + firstObservedTsMs: number; +}; + +export type PaperFixtureEvidence = { + fixtureId: string; + home: string; + away: string; + kickoffTsMs: number; + eventSlugs: string[]; + mappingStatus: "candidate" | "verified"; + selectedTotal: { + marketId: string; + marketKey: string; + conditionId: string; + lineMilli: number; + preKickoffOverProbability: number; + preKickoffPointTsMs: number; + coveragePoints: number; + assetIds: string[]; + }; + evidenceGrade: "paired_order_books" | "sampled_price_history" | "metadata_only"; + capabilities: { + signalResearchReplay: boolean; + executablePaperReplay: boolean; + kickoffCloseReplay: boolean; + publicResolutionReplay: boolean; + }; + bountyLane: { + mode: "executable_book_replay" | "book_lifecycle_replay" | "signal_research_only" | "unavailable"; + exploratory: true; + reason: string; + }; + longRunLane: { + eligible: boolean; + reason: + | "predates_long_run_lane_start" + | "mapping_not_verified" + | "executable_capture_required" + | null; + }; + pairedCapture: PairedCaptureEvidence | null; +}; + +export type PaperFixtureUniverse = { + generatedAt: string; + laneStartTsMs: number; + selectorConfig: TotalLineSelectorConfig; + fixtures: PaperFixtureEvidence[]; + summary: { + fixtures: number; + pairedBookReplays: number; + executableBookReplays: number; + bookLifecycleReplays: number; + signalResearchOnly: number; + unavailable: number; + longRunEligible: number; + }; +}; + +function totalRecord(records: readonly MappingRecord[], fixtureId: string): MappingRecord { + const candidates = records.filter((record) => + record.txlineFixtureId === fixtureId && + record.conditions.some((condition) => condition.family === "total_goals" && condition.period === "full_time") + ); + if (candidates.length !== 1) { + throw new Error(`Fixture ${fixtureId} requires exactly one full-time totals mapping record`); + } + return candidates[0]!; +} + +function validateFixtureIdentity(records: readonly MappingRecord[], fixtureId: string): { + home: string; + away: string; + kickoffTsMs: number; + eventSlugs: string[]; +} { + const fixtures = records.filter((record) => record.txlineFixtureId === fixtureId); + const first = fixtures[0]; + if (!first) throw new Error(`Missing mapping records for fixture ${fixtureId}`); + for (const record of fixtures) { + if ( + record.teams.home.canonical !== first.teams.home.canonical || + record.teams.away.canonical !== first.teams.away.canonical || + record.kickoff.txlineTsMs !== first.kickoff.txlineTsMs || + record.kickoff.polymarketTsMs !== first.kickoff.polymarketTsMs + ) { + throw new Error(`Fixture ${fixtureId} has conflicting identity evidence`); + } + } + if (first.kickoff.txlineTsMs !== first.kickoff.polymarketTsMs) { + throw new Error(`Fixture ${fixtureId} kickoff does not match across sources`); + } + return { + home: first.teams.home.canonical, + away: first.teams.away.canonical, + kickoffTsMs: first.kickoff.txlineTsMs, + eventSlugs: [...new Set(fixtures.map((record) => record.polymarketEventSlug))].sort() + }; +} + +export function buildPaperFixtureUniverse(input: { + generatedAt: string; + laneStartTsMs: number; + mappings: readonly MappingRecord[]; + totalEvidence: readonly TotalLineEvidence[]; + pairedCaptures: readonly PairedCaptureEvidence[]; + sampledHistoryAssetIds: ReadonlySet; + selectorConfig: TotalLineSelectorConfig; +}): PaperFixtureUniverse { + if (!Number.isSafeInteger(input.laneStartTsMs)) throw new Error("Lane start must be integer milliseconds"); + if (!Number.isFinite(Date.parse(input.generatedAt))) throw new Error("Fixture universe requires generatedAt ISO time"); + assertCausalTotalSelectorConfig(input.selectorConfig); + const fixtureIds = [...new Set( + input.mappings + .filter((record) => record.conditions.some((condition) => + condition.family === "total_goals" && condition.period === "full_time" + )) + .map((record) => record.txlineFixtureId) + )].sort(); + const fixtures = fixtureIds.map((fixtureId): PaperFixtureEvidence => { + const identity = validateFixtureIdentity(input.mappings, fixtureId); + const record = totalRecord(input.mappings, fixtureId); + const selection = selectMainTotalLine( + fixtureId, + input.totalEvidence.filter((row) => row.fixtureId === fixtureId), + input.selectorConfig, + identity.kickoffTsMs - PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS + ); + if (!selection.selected || selection.selected.preKickoffOverProbability === null || selection.selected.preKickoffPointTsMs === null) { + throw new Error(`Fixture ${fixtureId} has no eligible frozen total selection`); + } + const condition = record.conditions.find((candidate) => + candidate.family === "total_goals" && + candidate.period === "full_time" && + candidate.lineMilli === selection.selected!.lineMilli && + candidate.polymarketMarketId === selection.selected!.marketId + ); + if (!condition) throw new Error(`Fixture ${fixtureId} selected total is absent from its mapping record`); + const assetIds = condition.tokens.map((token) => token.assetId).sort(); + const sampledHistoryComplete = assetIds.every((assetId) => input.sampledHistoryAssetIds.has(assetId)); + const pairedCapture = input.pairedCaptures.find((capture) => capture.fixtureId === fixtureId) ?? null; + if (pairedCapture && !identity.eventSlugs.includes(pairedCapture.eventSlug)) { + throw new Error(`Fixture ${fixtureId} paired capture event slug does not match its mapping evidence`); + } + const pairedVerified = pairedCapture !== null && + pairedCapture.logComplete && + pairedCapture.mappingConfirmed && + pairedCapture.identityParity; + const pairedPreMatchWindow = pairedVerified && + pairedCapture.firstObservedTsMs <= identity.kickoffTsMs - PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS; + const evidenceGrade = pairedVerified + ? "paired_order_books" + : sampledHistoryComplete + ? "sampled_price_history" + : "metadata_only"; + const mode = pairedPreMatchWindow + ? "executable_book_replay" + : pairedVerified + ? "book_lifecycle_replay" + : sampledHistoryComplete + ? "signal_research_only" + : "unavailable"; + const reason = pairedPreMatchWindow + ? "Verified synchronized TXLine and Polymarket book capture is available" + : pairedVerified + ? "Verified paired books start inside the 15-minute signal cutoff; lifecycle replay only" + : sampledHistoryComplete + ? "Sampled prices exist, but synchronized executable order-book depth was not captured" + : "Selected total lacks complete replay price evidence"; + const afterLaneStart = identity.kickoffTsMs >= input.laneStartTsMs; + const mappingVerified = record.status === "verified"; + const longRunEligible = afterLaneStart && mappingVerified && pairedPreMatchWindow; + const longRunReason = !afterLaneStart + ? "predates_long_run_lane_start" + : !mappingVerified + ? "mapping_not_verified" + : !pairedPreMatchWindow + ? "executable_capture_required" + : null; + return { + fixtureId, + ...identity, + mappingStatus: record.status === "verified" ? "verified" : "candidate", + selectedTotal: { + marketId: condition.polymarketMarketId, + marketKey: selection.selected.marketKey, + conditionId: condition.conditionId, + lineMilli: condition.lineMilli!, + preKickoffOverProbability: selection.selected.preKickoffOverProbability, + preKickoffPointTsMs: selection.selected.preKickoffPointTsMs, + coveragePoints: selection.selected.coveragePoints, + assetIds + }, + evidenceGrade, + capabilities: { + signalResearchReplay: sampledHistoryComplete || pairedVerified, + executablePaperReplay: pairedPreMatchWindow, + kickoffCloseReplay: pairedVerified, + publicResolutionReplay: pairedVerified + }, + bountyLane: { mode, exploratory: true, reason }, + longRunLane: { + eligible: longRunEligible, + reason: longRunReason + }, + pairedCapture + }; + }); + return { + generatedAt: input.generatedAt, + laneStartTsMs: input.laneStartTsMs, + selectorConfig: input.selectorConfig, + fixtures, + summary: { + fixtures: fixtures.length, + pairedBookReplays: fixtures.filter((fixture) => fixture.evidenceGrade === "paired_order_books").length, + executableBookReplays: fixtures.filter((fixture) => fixture.bountyLane.mode === "executable_book_replay").length, + bookLifecycleReplays: fixtures.filter((fixture) => fixture.bountyLane.mode === "book_lifecycle_replay").length, + signalResearchOnly: fixtures.filter((fixture) => fixture.bountyLane.mode === "signal_research_only").length, + unavailable: fixtures.filter((fixture) => fixture.bountyLane.mode === "unavailable").length, + longRunEligible: fixtures.filter((fixture) => fixture.longRunLane.eligible).length + } + }; +} + +export function renderPaperFixtureUniverseMarkdown(universe: PaperFixtureUniverse): string { + const rows = universe.fixtures.map((fixture) => + `| ${fixture.fixtureId} | ${fixture.home} vs ${fixture.away} | ${new Date(fixture.kickoffTsMs).toISOString()} | O/U ${(fixture.selectedTotal.lineMilli / 1_000).toFixed(1)} | ${fixture.evidenceGrade} | ${fixture.bountyLane.mode} | ${fixture.longRunLane.eligible ? "eligible" : `no (${fixture.longRunLane.reason})`} |` + ); + return [ + "# Paper Fixture Evidence Universe", + "", + `Generated ${universe.generatedAt}. This is a derived metadata inventory; it does not redistribute raw TXLine data.`, + "", + "| Fixture | Match | Kickoff UTC | Frozen total | Evidence grade | Bounty lane | Long-run lane |", + "|---|---|---:|---:|---|---|---|", + ...rows, + "", + `Summary: ${universe.summary.pairedBookReplays} paired-book replay (${universe.summary.executableBookReplays} strategy-executable; ${universe.summary.bookLifecycleReplays} lifecycle-only), ${universe.summary.signalResearchOnly} sampled-history research fixtures, ${universe.summary.unavailable} unavailable.`, + "", + "Sampled-price fixtures may demonstrate signal generation but cannot produce registered paper fills, executable CLV, or profitability evidence. A paired capture that starts inside the 15-minute signal cutoff may prove book/close/resolution lifecycle behavior but cannot create a registered strategy fill. Every listed fixture predates the fresh long-run lane and is excluded from its stopping count.", + "" + ].join("\n"); +} diff --git a/src/harness/paper-lane-runtime.ts b/src/harness/paper-lane-runtime.ts new file mode 100644 index 0000000..f6ba990 --- /dev/null +++ b/src/harness/paper-lane-runtime.ts @@ -0,0 +1,196 @@ +import type { AnalystAgent, TriageAgent } from "../agents/contracts.js"; +import { PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS } from "../config/paper-study.js"; +import { OrderBookPaperExecutor } from "../exec/paper.js"; +import { PaperCloseScheduler } from "../portfolio/paper-close-scheduler.js"; +import { PaperSettlementScheduler } from "../portfolio/paper-settlement-scheduler.js"; +import { PaperPortfolio } from "../portfolio/paper.js"; +import { APPROVED_PAPER_RISK_CONFIG } from "../risk/paper.js"; +import type { DecisionLedger } from "../store/decision-ledger.js"; +import { stableJson } from "../domain/json.js"; +import { PaperCasePipeline, type PaperStudyLane } from "./paper-pipeline.js"; +import type { PaperFixtureEvidence, PaperFixtureUniverse } from "./paper-fixture-universe.js"; +import { createFrozenPaperStudyRuntime, type PaperStudyRuntime } from "./paper-runtime.js"; +import { PaperCaseScheduler, type PolymarketFeeResolver } from "./paper-scheduler.js"; +import type { PaperStudyInitialization } from "./paper-study-ledger.js"; +import { rehydratePaperState, type RehydratedPaperState } from "./paper-state-rehydrator.js"; + +export type PersistentPaperLaneRuntime = { + lane: PaperStudyLane; + initialization: PaperStudyInitialization; + fixtures: PaperFixtureEvidence[]; + runtime: PaperStudyRuntime; + scheduler: PaperCaseScheduler; + portfolio: PaperPortfolio; + eligibleMarketKeys: Set; + kickoffByFixtureId: Map; + rehydratedState: RehydratedPaperState; +}; + +export function eligiblePaperLaneFixtures( + lane: PaperStudyLane, + initialization: PaperStudyInitialization, + universe: PaperFixtureUniverse +): PaperFixtureEvidence[] { + return universe.fixtures.filter((fixture) => { + const executable = fixture.capabilities.executablePaperReplay && + fixture.bountyLane.mode === "executable_book_replay" && + fixture.mappingStatus === "verified"; + if (lane === "bounty") return executable; + return executable && fixture.longRunLane.eligible && fixture.kickoffTsMs >= initialization.startedAtTsMs; + }); +} + +export type PaperFixtureAdmissionPlan = { + lane: PaperStudyLane; + fixtures: PaperFixtureEvidence[]; +}; + +function executionIdentity(fixture: PaperFixtureEvidence): unknown { + return { + fixtureId: fixture.fixtureId, + home: fixture.home, + away: fixture.away, + kickoffTsMs: fixture.kickoffTsMs, + eventSlugs: fixture.eventSlugs, + mappingStatus: fixture.mappingStatus, + selectedTotal: fixture.selectedTotal, + capabilities: fixture.capabilities, + bountyLane: fixture.bountyLane, + longRunLane: fixture.longRunLane + }; +} + +export function planPaperFixtureAdmission( + runtime: PersistentPaperLaneRuntime, + universe: PaperFixtureUniverse +): PaperFixtureAdmissionPlan { + if (universe.laneStartTsMs !== runtime.initialization.startedAtTsMs) { + throw new Error("Refreshed fixture universe does not match the persistent lane start"); + } + const candidates = eligiblePaperLaneFixtures(runtime.lane, runtime.initialization, universe); + const candidateById = new Map(candidates.map((fixture) => [fixture.fixtureId, fixture])); + for (const existing of runtime.fixtures) { + const next = candidateById.get(existing.fixtureId); + if (!next) throw new Error(`Refreshed universe removed admitted fixture ${existing.fixtureId}`); + if (stableJson(executionIdentity(existing)) !== stableJson(executionIdentity(next))) { + throw new Error(`Refreshed universe changed admitted fixture ${existing.fixtureId}`); + } + } + const existingIds = new Set(runtime.fixtures.map((fixture) => fixture.fixtureId)); + const existingMarketOwners = new Map(runtime.fixtures.map((fixture) => [ + fixture.selectedTotal.marketKey, + fixture.fixtureId + ])); + const additions = candidates + .filter((fixture) => !existingIds.has(fixture.fixtureId)) + .sort((left, right) => left.kickoffTsMs - right.kickoffTsMs || left.fixtureId.localeCompare(right.fixtureId)); + for (const fixture of additions) { + const owner = existingMarketOwners.get(fixture.selectedTotal.marketKey); + if (owner && owner !== fixture.fixtureId) { + throw new Error(`Market key collision between fixtures ${owner} and ${fixture.fixtureId}`); + } + existingMarketOwners.set(fixture.selectedTotal.marketKey, fixture.fixtureId); + } + return { lane: runtime.lane, fixtures: additions }; +} + +export function applyPaperFixtureAdmission( + runtime: PersistentPaperLaneRuntime, + plan: PaperFixtureAdmissionPlan +): string[] { + if (plan.lane !== runtime.lane) throw new Error("Fixture admission plan lane mismatch"); + for (const fixture of plan.fixtures) { + runtime.fixtures.push(structuredClone(fixture)); + runtime.eligibleMarketKeys.add(fixture.selectedTotal.marketKey); + runtime.kickoffByFixtureId.set(fixture.fixtureId, fixture.kickoffTsMs); + } + return plan.fixtures.map((fixture) => fixture.fixtureId); +} + +export function createPersistentPaperLaneRuntime(input: { + lane: PaperStudyLane; + initialization: PaperStudyInitialization; + universe: PaperFixtureUniverse; + ledger: DecisionLedger; + triageAgent: TriageAgent; + analystAgent: AnalystAgent; + feeResolver: PolymarketFeeResolver; + executionLatencyMs: number; + maximumPendingMs: number; + /** Knowledge-clock position at restart; defaults to the latest committed event. */ + rehydrationAsOfTsMs?: number; + processingNow?: () => number; +}): PersistentPaperLaneRuntime { + if (input.initialization.lane !== input.lane) { + throw new Error("Paper runtime lane does not match its persistent ledger initialization"); + } + if (input.initialization.realMoneyGate !== "closed") { + throw new Error("Persistent paper runtime requires a closed real-money gate"); + } + const committedEntries = input.ledger.entries(); + const studyEntries = committedEntries.filter((entry) => entry.kind === "study_initialized"); + if ( + studyEntries.length !== 1 || + stableJson(studyEntries[0]!.payload) !== stableJson(input.initialization) + ) { + throw new Error("Persistent paper runtime initialization does not match its committed study record"); + } + const fixtures = eligiblePaperLaneFixtures(input.lane, input.initialization, input.universe); + const kickoffByFixtureId = new Map(fixtures.map((fixture) => [fixture.fixtureId, fixture.kickoffTsMs])); + const eligibleMarketKeys = new Set(fixtures.map((fixture) => fixture.selectedTotal.marketKey)); + const caseEntries = committedEntries.filter((entry) => entry.kind !== "study_initialized"); + const latestCommittedCaseTsMs = caseEntries.length === 0 + ? input.initialization.startedAtTsMs + : Math.max(...caseEntries.map((entry) => entry.atTsMs)); + const rehydratedState = rehydratePaperState({ + ledger: input.ledger, + lane: input.lane, + bankrollMicroUsd: APPROVED_PAPER_RISK_CONFIG.bankrollMicroUsd, + drawdownStopMicroUsd: APPROVED_PAPER_RISK_CONFIG.drawdownStopMicroUsd, + maximumPendingMs: input.maximumPendingMs, + kickoffByFixtureId, + asOfTsMs: input.rehydrationAsOfTsMs ?? latestCommittedCaseTsMs + }); + const pipeline = new PaperCasePipeline({ + triageAgent: input.triageAgent, + analystAgent: input.analystAgent, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger: input.ledger + }); + const portfolio = new PaperPortfolio({ + lane: input.lane, + bankrollMicroUsd: APPROVED_PAPER_RISK_CONFIG.bankrollMicroUsd, + drawdownStopMicroUsd: APPROVED_PAPER_RISK_CONFIG.drawdownStopMicroUsd, + ledger: input.ledger, + initialState: rehydratedState.portfolioInitialState + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: input.lane, + executionLatencyMs: input.executionLatencyMs, + maximumPendingMs: input.maximumPendingMs, + minimumSignalToKickoffMs: PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS, + eligibleMarketKeys, + kickoffByFixtureId + }, + pipeline, + feeResolver: input.feeResolver, + portfolio, + initialState: rehydratedState.schedulerInitialState, + ...(input.processingNow === undefined ? {} : { processingNow: input.processingNow }) + }); + const closeScheduler = new PaperCloseScheduler({ portfolio, kickoffByFixtureId }); + const settlementScheduler = new PaperSettlementScheduler(portfolio); + return { + lane: input.lane, + initialization: input.initialization, + fixtures, + scheduler, + portfolio, + eligibleMarketKeys, + kickoffByFixtureId, + rehydratedState, + runtime: createFrozenPaperStudyRuntime(scheduler, { closeScheduler, settlementScheduler }) + }; +} diff --git a/src/harness/paper-pipeline.ts b/src/harness/paper-pipeline.ts new file mode 100644 index 0000000..249d8f5 --- /dev/null +++ b/src/harness/paper-pipeline.ts @@ -0,0 +1,408 @@ +import { createHash } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { + assertThesisMatchesSignal, + tradeThesisSchema, + triageDecisionSchema, + type AnalystAgent, + type TradeThesis, + type TriageAgent, + type TriageDecision +} from "../agents/contracts.js"; +import type { PolymarketBookEvent } from "../bus/events.js"; +import type { DetectorSignal } from "../detectors/types.js"; +import type { + PaperExecutor, + PaperFill, + PaperOrderIntent, + PolymarketFeeParameters +} from "../exec/paper.js"; +import { POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS } from "../exec/paper.js"; +import { + reviewPaperRisk, + type PaperRiskConfig, + type PaperRiskState, + type PaperRiskVerdict +} from "../risk/paper.js"; +import { DecisionLedger, type DecisionEventKind } from "../store/decision-ledger.js"; +import type { JsonValue } from "../domain/json.js"; + +export type PaperPipelineResult = { + caseId: string; + status: "dropped" | "no_trade" | "vetoed" | "filled" | "partial" | "no_fill" | "failed"; + triage?: TriageDecision; + thesis?: TradeThesis; + riskVerdict?: PaperRiskVerdict; + fill?: PaperFill; + reason?: string; +}; + +export type PaperPipelineInput = { + lane: PaperStudyLane; + signal: DetectorSignal; + book: PolymarketBookEvent; + fees: PolymarketFeeParameters; + riskState: PaperRiskState; + asOfTsMs: number; + feeValidationTsMs?: number; + executionLatencyMs: number; + availableShares?: number; +}; + +export type PreparedPaperCase = { + caseId: string; + status: "ready"; + triage: TriageDecision; + thesis: TradeThesis; + decisionLatencyMs: number; + /** Analysis completion on the signal knowledge clock. */ + readyAtTsMs: number; + /** First time a delayed Polymarket sports order could match. */ + orderEligibleAtTsMs: number; + venuePlacementDelayMs: typeof POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS; +}; + +export type PaperAnalysisResult = PreparedPaperCase | (PaperPipelineResult & { + decisionLatencyMs: number; +}); + +export type PaperStudyLane = "bounty" | "long_run"; + +function caseId(lane: PaperStudyLane, signalId: string): string { + return `case_${createHash("sha256").update(`paper:${lane}:${signalId}`).digest("hex").slice(0, 24)}`; +} + +function json(value: unknown): JsonValue { + return JSON.parse(JSON.stringify(value)) as JsonValue; +} + +function assertSignalTiming(signal: DetectorSignal): void { + if ( + !Number.isSafeInteger(signal.detectedAtTsMs) || + !Number.isSafeInteger(signal.observedAtTsMs) || + signal.detectedAtTsMs < 0 || + signal.observedAtTsMs < 0 + ) { + throw new RangeError("Paper signal has impossible source/observation timestamps"); + } +} + +function timestampAfter(baseTsMs: number, elapsedMs: number, label: string): number { + const result = baseTsMs + elapsedMs; + if (!Number.isSafeInteger(result) || result < baseTsMs) { + throw new RangeError(`${label} timestamp is outside the safe event-time range`); + } + return result; +} + +export class PaperCasePipeline { + constructor(readonly dependencies: { + triageAgent: TriageAgent; + analystAgent: AnalystAgent; + riskConfig: PaperRiskConfig; + executor: PaperExecutor; + ledger: DecisionLedger; + now?: () => number; + }) {} + + recordSignal(lane: PaperStudyLane, signal: DetectorSignal): string { + assertSignalTiming(signal); + const id = caseId(lane, signal.signalId); + this.dependencies.ledger.append({ + entryId: `${id}:1:signal_received`, + caseId: id, + kind: "signal_received", + atTsMs: signal.observedAtTsMs, + payload: json({ lane, signal }) + }); + return id; + } + + terminateRecordedSignal(input: { + lane: PaperStudyLane; + signal: DetectorSignal; + atTsMs: number; + reason: string; + }): void { + assertSignalTiming(input.signal); + if (!Number.isSafeInteger(input.atTsMs) || input.atTsMs < input.signal.observedAtTsMs) { + throw new RangeError("Paper terminal timestamp precedes signal knowledge time"); + } + const id = this.recordSignal(input.lane, input.signal); + const entrySequence = this.dependencies.ledger.entries(id).length + 1; + this.dependencies.ledger.append({ + entryId: `${id}:${entrySequence}:case_terminal`, + caseId: id, + kind: "case_terminal", + atTsMs: input.atTsMs, + payload: json({ status: "failed", reason: input.reason }) + }); + } + + async prepare(input: { + lane: PaperStudyLane; + signal: DetectorSignal; + minimumDecisionLatencyMs: number; + }): Promise { + if (!Number.isSafeInteger(input.minimumDecisionLatencyMs) || input.minimumDecisionLatencyMs <= 0) { + throw new Error("Minimum decision latency must be a positive integer number of milliseconds"); + } + const id = this.recordSignal(input.lane, input.signal); + const monotonicNow = this.dependencies.now ?? (() => performance.now()); + const processingStartedAtMs = monotonicNow(); + let entrySequence = this.dependencies.ledger.entries(id).length; + const append = (kind: DecisionEventKind, atTsMs: number, payload: unknown) => { + entrySequence += 1; + this.dependencies.ledger.append({ + entryId: `${id}:${entrySequence}:${kind}`, + caseId: id, + kind, + atTsMs, + payload: json(payload) + }); + }; + const elapsed = () => Math.max( + input.minimumDecisionLatencyMs, + Math.ceil(monotonicNow() - processingStartedAtMs), + 1 + ); + const terminal = ( + status: Extract, + reason: string, + decisionLatencyMs: number + ) => { + append("case_terminal", timestampAfter( + input.signal.observedAtTsMs, + decisionLatencyMs, + "Terminal decision" + ), { + status, + reason, + decisionLatencyMs + }); + }; + + let triage: TriageDecision; + try { + triage = triageDecisionSchema.parse(await this.dependencies.triageAgent.triage({ + caseId: id, + signal: input.signal + })); + } catch (error) { + const decisionLatencyMs = elapsed(); + const reason = `triage_failed:${error instanceof Error ? error.message : String(error)}`; + terminal("failed", reason, decisionLatencyMs); + return { caseId: id, status: "failed", reason, decisionLatencyMs }; + } + const triageLatencyMs = elapsed(); + append("triage_decision", timestampAfter( + input.signal.observedAtTsMs, + triageLatencyMs, + "Triage decision" + ), triage); + if (triage.decision === "drop") { + terminal("dropped", triage.rationale, triageLatencyMs); + return { + caseId: id, + status: "dropped", + triage, + reason: triage.rationale, + decisionLatencyMs: triageLatencyMs + }; + } + + let thesis: TradeThesis; + try { + const submitted = tradeThesisSchema.parse(await this.dependencies.analystAgent.investigate({ + caseId: id, + signal: input.signal, + triage, + asOfTsMs: timestampAfter( + input.signal.observedAtTsMs, + triageLatencyMs, + "Analyst as-of" + ) + })); + assertThesisMatchesSignal(submitted, input.signal); + const decisionLatencyMs = elapsed(); + const submittedAtTsMs = timestampAfter( + input.signal.observedAtTsMs, + decisionLatencyMs, + "Thesis submission" + ); + thesis = tradeThesisSchema.parse({ + ...submitted, + submittedAtTsMs, + expiresAtTsMs: timestampAfter(submittedAtTsMs, 15 * 60_000, "Thesis expiry") + }); + } catch (error) { + const decisionLatencyMs = elapsed(); + const reason = `analysis_failed:${error instanceof Error ? error.message : String(error)}`; + terminal("failed", reason, decisionLatencyMs); + return { caseId: id, status: "failed", triage, reason, decisionLatencyMs }; + } + const decisionLatencyMs = thesis.submittedAtTsMs - input.signal.observedAtTsMs; + const orderEligibleAtTsMs = timestampAfter( + thesis.submittedAtTsMs, + POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS, + "Order eligibility" + ); + append("thesis_submitted", thesis.submittedAtTsMs, thesis); + append("analysis_completed", thesis.submittedAtTsMs, { + signalSourceTsMs: input.signal.detectedAtTsMs, + signalObservedTsMs: input.signal.observedAtTsMs, + decisionLatencyMs, + readyAtTsMs: thesis.submittedAtTsMs, + venuePlacementDelayMs: POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS, + orderEligibleAtTsMs, + recommendation: thesis.recommendation + }); + if (thesis.recommendation === "no_trade") { + terminal("no_trade", thesis.thesisSummary, decisionLatencyMs); + return { + caseId: id, + status: "no_trade", + triage, + thesis, + reason: thesis.thesisSummary, + decisionLatencyMs + }; + } + return { + caseId: id, + status: "ready", + triage, + thesis, + decisionLatencyMs, + readyAtTsMs: thesis.submittedAtTsMs, + orderEligibleAtTsMs, + venuePlacementDelayMs: POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS + }; + } + + async executePrepared(input: Omit & { + prepared: PreparedPaperCase; + }): Promise { + const { prepared } = input; + const id = caseId(input.lane, input.signal.signalId); + if (prepared.caseId !== id) throw new Error("Prepared paper case identity mismatch"); + assertSignalTiming(input.signal); + const expectedReadyAtTsMs = timestampAfter( + input.signal.observedAtTsMs, + prepared.decisionLatencyMs, + "Prepared analysis readiness" + ); + const expectedOrderEligibleAtTsMs = timestampAfter( + expectedReadyAtTsMs, + POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS, + "Prepared order eligibility" + ); + if ( + prepared.readyAtTsMs !== expectedReadyAtTsMs || + prepared.thesis.submittedAtTsMs !== expectedReadyAtTsMs || + prepared.orderEligibleAtTsMs !== expectedOrderEligibleAtTsMs || + prepared.venuePlacementDelayMs !== POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS + ) { + throw new Error("Prepared paper case timing invariant mismatch"); + } + let entrySequence = this.dependencies.ledger.entries(id).length; + const append = (kind: DecisionEventKind, atTsMs: number, payload: unknown) => { + entrySequence += 1; + this.dependencies.ledger.append({ + entryId: `${id}:${entrySequence}:${kind}`, + caseId: id, + kind, + atTsMs, + payload: json(payload) + }); + }; + const terminal = (status: PaperPipelineResult["status"], reason: string) => { + append("case_terminal", input.asOfTsMs, { status, reason }); + }; + + const riskVerdict = reviewPaperRisk({ + config: this.dependencies.riskConfig, + state: input.riskState, + signal: input.signal, + thesis: prepared.thesis, + book: input.book, + fees: input.fees, + asOfTsMs: input.asOfTsMs, + ...(input.feeValidationTsMs === undefined ? {} : { feeValidationTsMs: input.feeValidationTsMs }), + executionLatencyMs: prepared.decisionLatencyMs, + ...(input.availableShares === undefined ? {} : { availableShares: input.availableShares }) + }); + append("risk_verdict", input.asOfTsMs, riskVerdict); + if (riskVerdict.decision === "veto") { + const reason = riskVerdict.reasons.join(","); + terminal("vetoed", reason); + return { + caseId: id, + status: "vetoed", + triage: prepared.triage, + thesis: prepared.thesis, + riskVerdict, + reason + }; + } + + const intent: PaperOrderIntent = { + lane: input.lane, + caseId: id, + signalId: input.signal.signalId, + fixtureId: input.signal.fixtureId, + marketKey: input.signal.market.key, + outcome: input.signal.outcome, + direction: input.signal.direction, + stakeMicroUsd: riskVerdict.stakeMicroUsd, + limitProbability: riskVerdict.limitProbability, + availableShares: input.availableShares ?? 0 + }; + append("execution_intent", input.asOfTsMs, intent); + let fill: PaperFill; + try { + fill = await this.dependencies.executor.execute(intent, input.book, input.fees); + } catch (error) { + const reason = `paper_execution_failed:${error instanceof Error ? error.message : String(error)}`; + terminal("failed", reason); + return { + caseId: id, + status: "failed", + triage: prepared.triage, + thesis: prepared.thesis, + riskVerdict, + reason + }; + } + append("paper_execution", input.asOfTsMs, fill); + terminal(fill.status, fill.reason ?? fill.status); + return { + caseId: id, + status: fill.status, + triage: prepared.triage, + thesis: prepared.thesis, + riskVerdict, + fill + }; + } + + async run(input: PaperPipelineInput): Promise { + const analysis = await this.prepare({ + lane: input.lane, + signal: input.signal, + minimumDecisionLatencyMs: input.executionLatencyMs + }); + if (analysis.status !== "ready") return analysis; + return this.executePrepared({ + lane: input.lane, + signal: input.signal, + book: input.book, + fees: input.fees, + riskState: input.riskState, + asOfTsMs: input.asOfTsMs, + prepared: analysis, + ...(input.feeValidationTsMs === undefined ? {} : { feeValidationTsMs: input.feeValidationTsMs }), + ...(input.availableShares === undefined ? {} : { availableShares: input.availableShares }) + }); + } +} diff --git a/src/harness/paper-runtime.ts b/src/harness/paper-runtime.ts new file mode 100644 index 0000000..9bfec4c --- /dev/null +++ b/src/harness/paper-runtime.ts @@ -0,0 +1,85 @@ +import type { CanonicalEvent } from "../bus/events.js"; +import { PAPER_STUDY_DETECTOR_CONFIG, PAPER_STUDY_FEATURE_CONFIG } from "../config/paper-study.js"; +import { DetectorBank } from "../detectors/bank.js"; +import { + normalizeExecutableEconomicCases, + type EconomicCaseNormalizationSummary +} from "../detectors/economic-cases.js"; +import type { DetectorSignal } from "../detectors/types.js"; +import { FeatureEngine, type FeatureSnapshot } from "../features/engine.js"; +import type { PaperCloseResult, PaperCloseScheduler } from "../portfolio/paper-close-scheduler.js"; +import type { + PaperSettlementResult, + PaperSettlementScheduler +} from "../portfolio/paper-settlement-scheduler.js"; +import { PaperCaseScheduler } from "./paper-scheduler.js"; +import type { PaperPipelineResult } from "./paper-pipeline.js"; + +export type FeatureProcessor = { + ingest(event: CanonicalEvent): FeatureSnapshot[]; +}; + +export type DetectorProcessor = { + ingest(snapshot: FeatureSnapshot): DetectorSignal[]; +}; + +export type PaperRuntimeBatch = { + rawSignals: DetectorSignal[]; + /** Total Goals emissions normalized to actual executable BUY cases. */ + signals: DetectorSignal[]; + economicCaseNormalization: EconomicCaseNormalizationSummary; + routedSignalIds: string[]; + caseResults: PaperPipelineResult[]; + closeResults: PaperCloseResult[]; + settlementResults: PaperSettlementResult[]; +}; + +export class PaperStudyRuntime { + constructor(readonly dependencies: { + featureProcessor: FeatureProcessor; + detectorProcessor: DetectorProcessor; + scheduler: PaperCaseScheduler; + closeScheduler?: PaperCloseScheduler; + settlementScheduler?: PaperSettlementScheduler; + }) {} + + async ingest(event: CanonicalEvent): Promise { + // Existing cases see this event before a signal created by the same event can be queued. + const caseResults = await this.dependencies.scheduler.ingest(event); + const closeResults = this.dependencies.closeScheduler?.ingest(event) ?? []; + const settlementResults = this.dependencies.settlementScheduler?.ingest(event) ?? []; + const rawSignals = this.dependencies.featureProcessor + .ingest(event) + .flatMap((snapshot) => this.dependencies.detectorProcessor.ingest(snapshot)); + const normalization = normalizeExecutableEconomicCases(rawSignals); + const signals = normalization.signals; + const routedSignalIds: string[] = []; + for (const signal of signals) { + if (await this.dependencies.scheduler.enqueue(signal)) routedSignalIds.push(signal.signalId); + } + return { + rawSignals, + signals, + economicCaseNormalization: normalization.summary, + routedSignalIds, + caseResults, + closeResults, + settlementResults + }; + } +} + +export function createFrozenPaperStudyRuntime( + scheduler: PaperCaseScheduler, + lifecycle: { + closeScheduler?: PaperCloseScheduler; + settlementScheduler?: PaperSettlementScheduler; + } = {} +): PaperStudyRuntime { + return new PaperStudyRuntime({ + featureProcessor: new FeatureEngine(PAPER_STUDY_FEATURE_CONFIG), + detectorProcessor: new DetectorBank(PAPER_STUDY_DETECTOR_CONFIG), + scheduler, + ...lifecycle + }); +} diff --git a/src/harness/paper-scheduler.ts b/src/harness/paper-scheduler.ts new file mode 100644 index 0000000..3546523 --- /dev/null +++ b/src/harness/paper-scheduler.ts @@ -0,0 +1,297 @@ +import type { CanonicalEvent, PolymarketBookEvent } from "../bus/events.js"; +import type { DetectorSignal } from "../detectors/types.js"; +import { microUsd } from "../domain/money.js"; +import type { PolymarketFeeParameters } from "../exec/paper.js"; +import type { PaperPortfolio } from "../portfolio/paper.js"; +import { paperRiskState, type PaperRiskState } from "../risk/paper.js"; +import { + PaperCasePipeline, + type PreparedPaperCase, + type PaperPipelineResult, + type PaperStudyLane +} from "./paper-pipeline.js"; + +export type PolymarketFeeResolver = ( + book: PolymarketBookEvent, + asOfTsMs: number +) => Promise; + +export type PaperSchedulerConfig = { + lane: PaperStudyLane; + executionLatencyMs: number; + maximumPendingMs: number; + minimumSignalToKickoffMs: number; + eligibleMarketKeys: ReadonlySet; + kickoffByFixtureId: ReadonlyMap; +}; + +export type PaperPendingSignalState = { + signal: DetectorSignal; + prepared: PreparedPaperCase; + expiresAtTsMs: number; +}; + +export type PaperSchedulerInitialState = { + seenSignalIds: readonly string[]; + pending: readonly PaperPendingSignalState[]; + lastObservedTsMs: number | null; +}; + +export class PaperCaseScheduler { + readonly #pending = new Map(); + readonly #seen = new Set(); + readonly #lastBookSourceTsMs = new Map(); + #lastObservedTsMs: number | null = null; + #riskState: PaperRiskState; + + constructor(readonly dependencies: { + config: PaperSchedulerConfig; + pipeline: PaperCasePipeline; + feeResolver: PolymarketFeeResolver; + portfolio?: PaperPortfolio; + initialRiskState?: PaperRiskState; + initialState?: PaperSchedulerInitialState; + processingNow?: () => number; + }) { + if (!Number.isSafeInteger(dependencies.config.executionLatencyMs) || dependencies.config.executionLatencyMs <= 0) { + throw new RangeError("Execution latency must be a positive integer number of milliseconds"); + } + if (!Number.isSafeInteger(dependencies.config.maximumPendingMs) || dependencies.config.maximumPendingMs <= 0) { + throw new RangeError("Maximum pending time must be a positive integer number of milliseconds"); + } + if ( + !Number.isSafeInteger(dependencies.config.minimumSignalToKickoffMs) || + dependencies.config.minimumSignalToKickoffMs <= 0 + ) { + throw new RangeError("Minimum signal-to-kickoff time must be a positive integer number of milliseconds"); + } + if (dependencies.portfolio) { + if (dependencies.initialRiskState) { + throw new Error("Portfolio-backed scheduling cannot also accept independent initial risk state"); + } + if (dependencies.portfolio.options.lane !== dependencies.config.lane) { + throw new Error("Paper portfolio lane does not match scheduler lane"); + } + if (dependencies.portfolio.options.ledger !== dependencies.pipeline.dependencies.ledger) { + throw new Error("Paper portfolio and pipeline must share one decision ledger"); + } + } + this.#riskState = dependencies.initialRiskState ?? paperRiskState(); + if (dependencies.initialState) this.#restore(dependencies.initialState); + } + + async enqueue(signal: DetectorSignal): Promise { + const kickoffTsMs = this.dependencies.config.kickoffByFixtureId.get(signal.fixtureId); + if ( + !Number.isSafeInteger(signal.detectedAtTsMs) || + !Number.isSafeInteger(signal.observedAtTsMs) || + signal.detectedAtTsMs < 0 || + signal.observedAtTsMs < 0 || + signal.kind !== "CONSENSUS_MOVE" || + signal.eligibility !== "pretrade_review_required" || + signal.market.family !== "total_goals" || + signal.market.period !== "full_time" || + !this.dependencies.config.eligibleMarketKeys.has(signal.market.key) || + kickoffTsMs === undefined || + signal.detectedAtTsMs > kickoffTsMs - this.dependencies.config.minimumSignalToKickoffMs || + signal.observedAtTsMs > kickoffTsMs - this.dependencies.config.minimumSignalToKickoffMs + ) { + return false; + } + if (this.#lastObservedTsMs !== null && signal.observedAtTsMs < this.#lastObservedTsMs) return false; + if (this.#seen.has(signal.signalId)) return false; + this.#lastObservedTsMs = signal.observedAtTsMs; + this.#seen.add(signal.signalId); + const prepared = await this.dependencies.pipeline.prepare({ + lane: this.dependencies.config.lane, + signal, + minimumDecisionLatencyMs: this.dependencies.config.executionLatencyMs + }); + if (prepared.status !== "ready") return true; + if (prepared.orderEligibleAtTsMs >= kickoffTsMs) { + this.dependencies.pipeline.terminateRecordedSignal({ + lane: this.dependencies.config.lane, + signal, + atTsMs: prepared.orderEligibleAtTsMs, + reason: "order_became_eligible_at_or_after_kickoff" + }); + return true; + } + const expiresAtTsMs = prepared.orderEligibleAtTsMs + this.dependencies.config.maximumPendingMs; + if (!Number.isSafeInteger(expiresAtTsMs)) { + this.dependencies.pipeline.terminateRecordedSignal({ + lane: this.dependencies.config.lane, + signal, + atTsMs: prepared.orderEligibleAtTsMs, + reason: "pending_expiry_timestamp_out_of_range" + }); + return true; + } + this.#pending.set(signal.signalId, { + signal, + prepared, + expiresAtTsMs + }); + return true; + } + + async ingest(event: CanonicalEvent): Promise { + if (!this.#acceptEventTimestamp(event)) return []; + this.#expire(event.observedTsMs); + if (event.kind !== "polymarket.book" || event.tokenRole !== "canonical") return []; + const bookClockKey = `${event.conditionId}:${event.assetId}`; + const previousSourceTsMs = this.#lastBookSourceTsMs.get(bookClockKey); + if (previousSourceTsMs !== undefined && event.sourceTsMs < previousSourceTsMs) return []; + this.#lastBookSourceTsMs.set(bookClockKey, event.sourceTsMs); + const ready = [...this.#pending.values()].filter(({ signal, prepared }) => + signal.fixtureId === event.fixtureId && + signal.market.key === event.market.key && + signal.outcome === event.outcome && + event.sourceTsMs < (this.dependencies.config.kickoffByFixtureId.get(signal.fixtureId) ?? 0) && + event.observedTsMs < (this.dependencies.config.kickoffByFixtureId.get(signal.fixtureId) ?? 0) && + event.observedTsMs >= prepared.orderEligibleAtTsMs + ); + const results: PaperPipelineResult[] = []; + for (const pending of ready) { + this.#pending.delete(pending.signal.signalId); + let fees: PolymarketFeeParameters; + try { + fees = await this.dependencies.feeResolver(event, event.observedTsMs); + } catch (error) { + this.dependencies.pipeline.terminateRecordedSignal({ + lane: this.dependencies.config.lane, + signal: pending.signal, + atTsMs: event.observedTsMs, + reason: `fee_resolution_failed:${error instanceof Error ? error.message : String(error)}` + }); + continue; + } + const result = await this.dependencies.pipeline.executePrepared({ + lane: this.dependencies.config.lane, + signal: pending.signal, + book: event, + fees, + riskState: this.dependencies.portfolio?.riskState() ?? this.#riskState, + asOfTsMs: event.observedTsMs, + feeValidationTsMs: this.dependencies.processingNow?.() ?? event.observedTsMs, + prepared: pending.prepared + }); + results.push(result); + if (result.fill && result.fill.direction === "buy" && result.fill.status !== "no_fill") { + if (this.dependencies.portfolio) { + this.dependencies.portfolio.open({ + caseId: result.caseId, + signal: pending.signal, + fill: result.fill, + openedAtTsMs: event.observedTsMs + }); + } + this.#riskState = { + ...this.#riskState, + openExposureMicroUsd: microUsd( + this.#riskState.openExposureMicroUsd + result.fill.netConsiderationMicroUsd + ) + }; + } + } + return results; + } + + pendingCount(): number { + return this.#pending.size; + } + + seenCount(): number { + return this.#seen.size; + } + + riskState(): PaperRiskState { + return { ...(this.dependencies.portfolio?.riskState() ?? this.#riskState) }; + } + + #restore(initial: PaperSchedulerInitialState): void { + if ( + initial.lastObservedTsMs !== null && + (!Number.isSafeInteger(initial.lastObservedTsMs) || initial.lastObservedTsMs < 0) + ) { + throw new Error("Restored scheduler observation timestamp is invalid"); + } + for (const signalId of initial.seenSignalIds) { + if (signalId.length === 0 || this.#seen.has(signalId)) { + throw new Error(`Duplicate or empty restored signal ID: ${signalId}`); + } + this.#seen.add(signalId); + } + for (const pending of initial.pending) { + const { signal, prepared, expiresAtTsMs } = pending; + if (!this.#seen.has(signal.signalId)) { + throw new Error(`Restored pending signal was not marked seen: ${signal.signalId}`); + } + if (this.#pending.has(signal.signalId)) { + throw new Error(`Duplicate restored pending signal: ${signal.signalId}`); + } + if ( + prepared.thesis.signalId !== signal.signalId || + prepared.thesis.fixtureId !== signal.fixtureId || + prepared.thesis.marketKey !== signal.market.key || + prepared.thesis.outcome !== signal.outcome || + prepared.thesis.direction !== signal.direction + ) { + throw new Error(`Restored pending identity mismatch: ${signal.signalId}`); + } + if ( + !this.dependencies.config.eligibleMarketKeys.has(signal.market.key) || + this.dependencies.config.kickoffByFixtureId.get(signal.fixtureId) === undefined + ) { + throw new Error(`Restored pending signal is outside the admitted fixture universe: ${signal.signalId}`); + } + if ( + prepared.readyAtTsMs !== signal.observedAtTsMs + prepared.decisionLatencyMs || + prepared.thesis.submittedAtTsMs !== prepared.readyAtTsMs || + prepared.orderEligibleAtTsMs !== prepared.readyAtTsMs + prepared.venuePlacementDelayMs + ) { + throw new Error(`Restored pending timing mismatch: ${signal.signalId}`); + } + const expectedExpiry = prepared.orderEligibleAtTsMs + this.dependencies.config.maximumPendingMs; + if (!Number.isSafeInteger(expectedExpiry) || expiresAtTsMs !== expectedExpiry) { + throw new Error(`Restored pending expiry mismatch: ${signal.signalId}`); + } + if (initial.lastObservedTsMs !== null && signal.observedAtTsMs > initial.lastObservedTsMs) { + throw new Error(`Restored pending signal is newer than the scheduler clock: ${signal.signalId}`); + } + this.#pending.set(signal.signalId, structuredClone(pending)); + } + this.#lastObservedTsMs = initial.lastObservedTsMs; + } + + #acceptEventTimestamp(event: CanonicalEvent): boolean { + if ( + !Number.isSafeInteger(event.sourceTsMs) || + !Number.isSafeInteger(event.observedTsMs) || + event.sourceTsMs < 0 || + event.observedTsMs < 0 || + (this.#lastObservedTsMs !== null && event.observedTsMs < this.#lastObservedTsMs) + ) { + return false; + } + this.#lastObservedTsMs = event.observedTsMs; + return true; + } + + #expire(asOfTsMs: number): void { + for (const [signalId, pending] of this.#pending) { + const kickoffTsMs = this.dependencies.config.kickoffByFixtureId.get(pending.signal.fixtureId); + const kickoffReached = kickoffTsMs !== undefined && asOfTsMs >= kickoffTsMs; + if (!kickoffReached && asOfTsMs < pending.expiresAtTsMs) continue; + this.dependencies.pipeline.terminateRecordedSignal({ + lane: this.dependencies.config.lane, + signal: pending.signal, + atTsMs: asOfTsMs, + reason: kickoffReached + ? "no_post_venue_delay_executable_book_before_kickoff" + : "no_post_venue_delay_executable_book_before_expiry" + }); + this.#pending.delete(signalId); + } + } +} diff --git a/src/harness/paper-state-rehydrator.ts b/src/harness/paper-state-rehydrator.ts new file mode 100644 index 0000000..fef318d --- /dev/null +++ b/src/harness/paper-state-rehydrator.ts @@ -0,0 +1,859 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { + assertThesisMatchesSignal, + tradeThesisSchema, + triageDecisionSchema, + type TradeThesis, + type TriageDecision +} from "../agents/contracts.js"; +import type { DetectorSignal } from "../detectors/types.js"; +import { microUsd, type MicroUsd, USD_MICRO_UNITS } from "../domain/money.js"; +import { POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS, type PaperFill, type PaperOrderIntent } from "../exec/paper.js"; +import type { PaperCloseMark, PaperPortfolioInitialState, PaperPosition, PaperSettlement } from "../portfolio/paper.js"; +import type { PaperRiskState, PaperRiskVerdict } from "../risk/paper.js"; +import type { + DecisionLedger, + DecisionLedgerEntry, + DecisionLedgerVerification +} from "../store/decision-ledger.js"; +import type { PreparedPaperCase, PaperStudyLane } from "./paper-pipeline.js"; +import type { PaperPendingSignalState, PaperSchedulerInitialState } from "./paper-scheduler.js"; + +const DECISION_EVENT_KINDS = new Set([ + "study_initialized", + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "execution_intent", + "paper_execution", + "position_opened", + "position_closed", + "position_settled", + "case_terminal" +]); + +const safeInteger = z.number().refine(Number.isSafeInteger, "must be a safe integer"); +const nonnegativeSafeInteger = safeInteger.refine((value) => value >= 0, "must be non-negative"); +const positiveSafeInteger = safeInteger.refine((value) => value > 0, "must be positive"); +const finiteNumber = z.number().finite(); +const probability = finiteNumber.min(0).max(1); + +const marketSchema = z.object({ + family: z.enum(["match_result", "total_goals"]), + period: z.enum(["full_time", "first_half", "extra_time", "other"]), + lineMilli: safeInteger.nullable(), + key: z.string().min(1) +}).strict(); + +const detectorSignalSchema = z.object({ + signalId: z.string().min(1), + kind: z.enum(["CONSENSUS_MOVE", "XMARKET_DIVERGENCE", "FADER_CANDIDATE"]), + detectedAtTsMs: nonnegativeSafeInteger, + observedAtTsMs: nonnegativeSafeInteger, + fixtureId: z.string().min(1), + market: marketSchema, + outcome: z.enum(["home", "draw", "away", "over", "under"]), + direction: z.enum(["buy", "sell"]), + eligibility: z.enum(["research_only", "pretrade_review_required"]), + reason: z.string(), + evidence: z.object({ + consensusProbability: probability, + polymarketProbability: probability, + consensusVelocity: finiteNumber.nullable(), + consensusZScore: finiteNumber.nullable(), + polymarketVelocity: finiteNumber.nullable(), + polymarketZScore: finiteNumber.nullable(), + cusumUp: finiteNumber, + cusumDown: finiteNumber, + rawGap: finiteNumber, + gapBasis: z.enum(["live_book", "sampled_history_proxy"]), + persistenceMs: nonnegativeSafeInteger, + mappingStatus: z.string().nullable(), + scoreContextActions: z.array(z.string()) + }).strict() +}).strict(); + +const signalPayloadSchema = z.object({ + lane: z.enum(["bounty", "long_run"]), + signal: detectorSignalSchema +}).strict(); + +const analysisSchema = z.object({ + signalSourceTsMs: nonnegativeSafeInteger, + signalObservedTsMs: nonnegativeSafeInteger, + decisionLatencyMs: positiveSafeInteger, + readyAtTsMs: nonnegativeSafeInteger, + venuePlacementDelayMs: z.literal(POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS), + orderEligibleAtTsMs: nonnegativeSafeInteger, + recommendation: z.enum(["paper_trade", "no_trade"]) +}).strict(); + +const riskVerdictSchema = z.discriminatedUnion("decision", [ + z.object({ + decision: z.literal("veto"), + reasons: z.array(z.string().min(1)).min(1) + }).strict(), + z.object({ + decision: z.literal("approve"), + stakeMicroUsd: positiveSafeInteger, + limitProbability: probability, + realMoneyGate: z.literal("closed") + }).strict() +]); + +const executionIntentSchema = z.object({ + lane: z.enum(["bounty", "long_run"]), + caseId: z.string().min(1), + signalId: z.string().min(1), + fixtureId: z.string().min(1), + marketKey: z.string().min(1), + outcome: z.enum(["home", "draw", "away", "over", "under"]), + direction: z.enum(["buy", "sell"]), + stakeMicroUsd: positiveSafeInteger, + limitProbability: probability, + availableShares: finiteNumber.nonnegative() +}).strict(); + +const feeParametersSchema = z.object({ + source: z.literal("polymarket_clob_market_info"), + conditionId: z.string().min(1), + feesEnabled: z.boolean(), + takerFeeRate: finiteNumber.nonnegative(), + feeCurveExponent: finiteNumber, + takerOnly: z.boolean(), + minimumOrderSize: finiteNumber.positive(), + minimumTickSize: finiteNumber.positive(), + fetchedAtTsMs: nonnegativeSafeInteger +}).strict(); + +const paperFillSchema = z.object({ + adapter: z.literal("paper"), + status: z.enum(["filled", "partial", "no_fill"]), + reason: z.string().nullable(), + assetId: z.string().min(1), + conditionId: z.string().min(1), + direction: z.enum(["buy", "sell"]), + requestedStakeMicroUsd: positiveSafeInteger, + grossMicroUsd: nonnegativeSafeInteger, + feeMicroUsd: nonnegativeSafeInteger, + netConsiderationMicroUsd: nonnegativeSafeInteger, + filledShares: finiteNumber.nonnegative(), + averagePrice: probability.nullable(), + bestPrice: probability.nullable(), + halfSpreadBps: finiteNumber.nonnegative().nullable(), + executableDepthUsd: finiteNumber.nonnegative(), + slippageProbabilityBps: finiteNumber.nullable(), + bookObservedTsMs: nonnegativeSafeInteger, + feeParameters: feeParametersSchema +}).strict(); + +const closeMarkSchema = z.object({ + cutoffTsMs: nonnegativeSafeInteger, + markedAtTsMs: nonnegativeSafeInteger, + bookSourceTsMs: nonnegativeSafeInteger, + bookObservedTsMs: nonnegativeSafeInteger, + closeBid: probability, + closeAsk: probability, + closeMidpoint: probability, + grossMidpointClvBps: finiteNumber, + netMidpointClvBps: finiteNumber, + executableLiquidationClvBps: finiteNumber +}).strict(); + +const settlementSchema = z.object({ + settledAtTsMs: nonnegativeSafeInteger, + won: z.boolean(), + payoutMicroUsd: nonnegativeSafeInteger, + pnlMicroUsd: safeInteger, + returnBps: finiteNumber, + entryBrier: finiteNumber.min(0).max(1) +}).strict(); + +const positionSchema = z.object({ + caseId: z.string().min(1), + lane: z.enum(["bounty", "long_run"]), + signalId: z.string().min(1), + fixtureId: z.string().min(1), + marketKey: z.string().min(1), + conditionId: z.string().min(1), + assetId: z.string().min(1), + outcome: z.enum(["home", "draw", "away", "over", "under"]), + selectedLineMilli: safeInteger, + openedAtTsMs: nonnegativeSafeInteger, + filledShares: finiteNumber.positive(), + averageEntryPrice: probability, + entryGrossMicroUsd: positiveSafeInteger, + entryFeeMicroUsd: nonnegativeSafeInteger, + entryCostMicroUsd: positiveSafeInteger, + fillStatus: z.enum(["filled", "partial"]), + entryHalfSpreadBps: finiteNumber.nonnegative(), + entrySlippageBps: finiteNumber, + selectedDepthUsd: finiteNumber.nonnegative(), + status: z.enum(["open", "marked", "settled"]), + closeMark: closeMarkSchema.nullable(), + settlement: settlementSchema.nullable() +}).strict(); + +const terminalSchema = z.object({ + status: z.enum(["dropped", "no_trade", "vetoed", "filled", "partial", "no_fill", "failed"]), + reason: z.string().min(1), + decisionLatencyMs: positiveSafeInteger.optional() +}).strict(); + +type AnalysisEvidence = z.infer; +type TerminalEvidence = z.infer; + +type MutableCase = { + caseId: string; + lastAtTsMs: number; + signal: DetectorSignal | null; + triage: TriageDecision | null; + thesis: TradeThesis | null; + analysis: AnalysisEvidence | null; + riskVerdict: PaperRiskVerdict | null; + intent: PaperOrderIntent | null; + fill: PaperFill | null; + terminal: (TerminalEvidence & { atTsMs: number }) | null; + position: PaperPosition | null; + closeMark: PaperCloseMark | null; + settlement: PaperSettlement | null; +}; + +export type RehydratedPendingExpiration = { + caseId: string; + signalId: string; + timeoutAtTsMs: number; + kickoffAtTsMs: number; + effectiveExpiresAtTsMs: number; + reason: "no_post_venue_delay_executable_book_before_expiry" | + "no_post_venue_delay_executable_book_before_kickoff"; + expiredAsOfTsMs: boolean; +}; + +export type RehydratedPaperCase = { + caseId: string; + signalId: string; + status: "pending" | "terminal"; + terminal: (TerminalEvidence & { atTsMs: number }) | null; + pendingExpiration: RehydratedPendingExpiration | null; + positionStatus: "none" | "open" | "closed" | "settled"; +}; + +export type RehydratedPaperState = { + lane: PaperStudyLane; + asOfTsMs: number; + chain: DecisionLedgerVerification; + seenSignalIds: string[]; + cases: RehydratedPaperCase[]; + pendingExpirations: RehydratedPendingExpiration[]; + positions: PaperPosition[]; + openPositions: PaperPosition[]; + closedPositions: PaperPosition[]; + settledPositions: PaperPosition[]; + aggregateExposureMicroUsd: MicroUsd; + realizedPnlMicroUsd: MicroUsd; + equityMicroUsd: MicroUsd; + peakEquityMicroUsd: MicroUsd; + currentDrawdownMicroUsd: MicroUsd; + riskState: PaperRiskState; + portfolioInitialState: PaperPortfolioInitialState; + schedulerInitialState: PaperSchedulerInitialState; +}; + +export type RehydratePaperStateInput = { + ledger: DecisionLedger; + lane: PaperStudyLane; + bankrollMicroUsd: MicroUsd; + drawdownStopMicroUsd: MicroUsd; + maximumPendingMs: number; + kickoffByFixtureId: ReadonlyMap; + asOfTsMs: number; +}; + +export function paperCaseIdForSignal(lane: PaperStudyLane, signalId: string): string { + return `case_${createHash("sha256").update(`paper:${lane}:${signalId}`).digest("hex").slice(0, 24)}`; +} + +function parsePayload(schema: z.ZodType, entry: DecisionLedgerEntry): T { + const parsed = schema.safeParse(entry.payload); + if (!parsed.success) { + throw new Error(`Malformed ${entry.kind} payload at ${entry.entryId}: ${parsed.error.message}`); + } + return parsed.data; +} + +function requireState(states: Map, entry: DecisionLedgerEntry): MutableCase { + const state = states.get(entry.caseId); + if (!state?.signal) throw new Error(`${entry.kind} precedes signal_received for ${entry.caseId}`); + if (entry.atTsMs < state.lastAtTsMs) { + throw new Error(`Decision timestamp regressed for ${entry.caseId} at ${entry.entryId}`); + } + state.lastAtTsMs = entry.atTsMs; + return state; +} + +function assertAbsent(value: unknown, label: string, caseId: string): void { + if (value !== null) throw new Error(`Duplicate ${label} for ${caseId}`); +} + +function assertSame(actual: unknown, expected: unknown, label: string, caseId: string): void { + if (actual !== expected) throw new Error(`${label} mismatch for ${caseId}`); +} + +function assertNear(actual: number, expected: number, label: string, caseId: string): void { + if (Math.abs(actual - expected) > 1e-9) { + throw new Error(`${label} mismatch for ${caseId}`); + } +} + +function validateFill(fill: PaperFill, intent: PaperOrderIntent, caseId: string): void { + assertSame(fill.direction, intent.direction, "Fill direction", caseId); + assertSame(fill.requestedStakeMicroUsd, intent.stakeMicroUsd, "Fill requested stake", caseId); + assertSame(fill.feeParameters.conditionId, fill.conditionId, "Fill fee condition", caseId); + if (fill.status === "no_fill") { + if ( + fill.filledShares !== 0 || + fill.grossMicroUsd !== 0 || + fill.feeMicroUsd !== 0 || + fill.netConsiderationMicroUsd !== 0 || + fill.averagePrice !== null + ) { + throw new Error(`No-fill contains execution value for ${caseId}`); + } + return; + } + if (fill.filledShares <= 0 || fill.averagePrice === null) { + throw new Error(`Filled execution lacks shares or price for ${caseId}`); + } + const reconstructedGross = fill.filledShares * fill.averagePrice * USD_MICRO_UNITS; + if (Math.abs(reconstructedGross - fill.grossMicroUsd) > 1e-6) { + throw new Error(`Fill gross amount does not match shares and average price for ${caseId}`); + } + const expectedConsideration = fill.direction === "buy" + ? fill.grossMicroUsd + fill.feeMicroUsd + : fill.grossMicroUsd - fill.feeMicroUsd; + assertSame(fill.netConsiderationMicroUsd, expectedConsideration, "Fill net consideration", caseId); + if (fill.direction === "buy" && fill.netConsiderationMicroUsd > intent.stakeMicroUsd) { + throw new Error(`Buy fill exceeds approved stake for ${caseId}`); + } + if (fill.direction === "sell" && fill.filledShares > intent.availableShares) { + throw new Error(`Sell fill exceeds approved inventory for ${caseId}`); + } +} + +function validateTerminal(state: MutableCase, terminal: TerminalEvidence): void { + const { caseId } = state; + switch (terminal.status) { + case "dropped": + if (state.triage?.decision !== "drop" || state.thesis || state.riskVerdict || state.fill) { + throw new Error(`Dropped terminal lacks a drop decision for ${caseId}`); + } + return; + case "no_trade": + if (state.thesis?.recommendation !== "no_trade" || !state.analysis || state.riskVerdict || state.fill) { + throw new Error(`No-trade terminal lacks a completed no-trade thesis for ${caseId}`); + } + return; + case "vetoed": + if (state.riskVerdict?.decision !== "veto" || state.intent || state.fill) { + throw new Error(`Veto terminal lacks a deterministic veto for ${caseId}`); + } + return; + case "filled": + case "partial": + case "no_fill": + if (!state.intent || !state.fill || state.fill.status !== terminal.status) { + throw new Error(`${terminal.status} terminal lacks its committed execution for ${caseId}`); + } + return; + case "failed": + if (state.fill || state.riskVerdict?.decision === "veto") { + throw new Error(`Failed terminal conflicts with committed outcome for ${caseId}`); + } + return; + } +} + +function validatePosition(state: MutableCase, position: PaperPosition, entry: DecisionLedgerEntry, lane: PaperStudyLane): void { + const { signal, fill, intent, terminal, caseId } = state; + if (!signal || !fill || !intent || !terminal || !["filled", "partial"].includes(terminal.status)) { + throw new Error(`Position opened without approved committed fill for ${caseId}`); + } + if (state.riskVerdict?.decision !== "approve") { + throw new Error(`Position opened without risk approval for ${caseId}`); + } + if (fill.direction !== "buy") throw new Error(`Paper position is not a canonical-token buy for ${caseId}`); + if (fill.halfSpreadBps === null || fill.slippageProbabilityBps === null) { + throw new Error(`Paper position fill lacks spread or slippage evidence for ${caseId}`); + } + assertSame(position.caseId, caseId, "Position case", caseId); + assertSame(position.lane, lane, "Position lane", caseId); + assertSame(position.signalId, signal.signalId, "Position signal", caseId); + assertSame(position.fixtureId, signal.fixtureId, "Position fixture", caseId); + assertSame(position.marketKey, signal.market.key, "Position market", caseId); + assertSame(position.outcome, signal.outcome, "Position outcome", caseId); + assertSame(position.conditionId, fill.conditionId, "Position condition", caseId); + assertSame(position.assetId, fill.assetId, "Position asset", caseId); + assertSame(position.selectedLineMilli, signal.market.lineMilli, "Position selected line", caseId); + assertSame(position.openedAtTsMs, entry.atTsMs, "Position opened timestamp", caseId); + assertSame(position.openedAtTsMs, fill.bookObservedTsMs, "Position fill timestamp", caseId); + assertNear(position.filledShares, fill.filledShares, "Position filled shares", caseId); + assertNear(position.averageEntryPrice, fill.averagePrice!, "Position entry price", caseId); + assertSame(position.entryGrossMicroUsd, fill.grossMicroUsd, "Position gross", caseId); + assertSame(position.entryFeeMicroUsd, fill.feeMicroUsd, "Position fee", caseId); + assertSame(position.entryCostMicroUsd, fill.netConsiderationMicroUsd, "Position cost", caseId); + assertSame(position.fillStatus, fill.status, "Position fill status", caseId); + assertNear(position.entryHalfSpreadBps, fill.halfSpreadBps!, "Position spread", caseId); + assertNear(position.entrySlippageBps, fill.slippageProbabilityBps!, "Position slippage", caseId); + assertNear(position.selectedDepthUsd, fill.executableDepthUsd, "Position depth", caseId); + if (position.status !== "open" || position.closeMark !== null || position.settlement !== null) { + throw new Error(`Position-opened event contains future lifecycle state for ${caseId}`); + } +} + +function validateCloseMark(position: PaperPosition, mark: PaperCloseMark, entry: DecisionLedgerEntry): void { + const caseId = position.caseId; + assertSame(mark.markedAtTsMs, entry.atTsMs, "Close mark timestamp", caseId); + if ( + mark.markedAtTsMs < position.openedAtTsMs || + mark.markedAtTsMs < mark.bookObservedTsMs || + mark.bookSourceTsMs > mark.cutoffTsMs || + mark.closeBid > mark.closeAsk + ) { + throw new Error(`Impossible close-mark chronology or book for ${caseId}`); + } + const midpoint = (mark.closeBid + mark.closeAsk) / 2; + const unitCost = position.entryCostMicroUsd / USD_MICRO_UNITS / position.filledShares; + assertNear(mark.closeMidpoint, midpoint, "Close midpoint", caseId); + assertNear(mark.grossMidpointClvBps, (midpoint - position.averageEntryPrice) * 10_000, "Gross CLV", caseId); + assertNear(mark.netMidpointClvBps, (midpoint - unitCost) * 10_000, "Net CLV", caseId); + assertNear(mark.executableLiquidationClvBps, (mark.closeBid - unitCost) * 10_000, "Executable CLV", caseId); +} + +function validateSettlement(position: PaperPosition, settlement: PaperSettlement, entry: DecisionLedgerEntry): void { + const caseId = position.caseId; + assertSame(settlement.settledAtTsMs, entry.atTsMs, "Settlement timestamp", caseId); + if (!position.closeMark || settlement.settledAtTsMs < position.closeMark.markedAtTsMs) { + throw new Error(`Settlement precedes close evidence for ${caseId}`); + } + const payout = settlement.won ? Math.floor(position.filledShares * USD_MICRO_UNITS) : 0; + const pnl = payout - position.entryCostMicroUsd; + assertSame(settlement.payoutMicroUsd, payout, "Settlement payout", caseId); + assertSame(settlement.pnlMicroUsd, pnl, "Settlement P&L", caseId); + assertNear( + settlement.returnBps, + position.entryCostMicroUsd === 0 ? 0 : pnl / position.entryCostMicroUsd * 10_000, + "Settlement return", + caseId + ); + assertNear( + settlement.entryBrier, + (position.averageEntryPrice - (settlement.won ? 1 : 0)) ** 2, + "Settlement Brier score", + caseId + ); +} + +function processCaseEntry( + states: Map, + signalOwners: Map, + entry: DecisionLedgerEntry, + lane: PaperStudyLane +): void { + if (entry.kind === "signal_received") { + if (states.has(entry.caseId)) throw new Error(`Duplicate signal_received for ${entry.caseId}`); + const payload = parsePayload(signalPayloadSchema, entry); + if (payload.lane !== lane) throw new Error(`Signal lane mismatch for ${entry.caseId}`); + const signal = payload.signal as DetectorSignal; + if (entry.atTsMs !== signal.observedAtTsMs) { + throw new Error(`Signal ledger timestamp mismatch for ${entry.caseId}`); + } + const expectedCaseId = paperCaseIdForSignal(lane, signal.signalId); + if (entry.caseId !== expectedCaseId) throw new Error(`Signal case ID mismatch for ${entry.caseId}`); + const owner = signalOwners.get(signal.signalId); + if (owner) throw new Error(`Signal ID ${signal.signalId} is duplicated across ${owner} and ${entry.caseId}`); + signalOwners.set(signal.signalId, entry.caseId); + states.set(entry.caseId, { + caseId: entry.caseId, + lastAtTsMs: entry.atTsMs, + signal, + triage: null, + thesis: null, + analysis: null, + riskVerdict: null, + intent: null, + fill: null, + terminal: null, + position: null, + closeMark: null, + settlement: null + }); + return; + } + + const state = requireState(states, entry); + if (state.terminal && !["position_opened", "position_closed", "position_settled"].includes(entry.kind)) { + throw new Error(`${entry.kind} appears after case_terminal for ${entry.caseId}`); + } + switch (entry.kind) { + case "triage_decision": { + assertAbsent(state.triage, "triage_decision", state.caseId); + if (state.thesis || state.analysis || state.riskVerdict) throw new Error(`Triage is out of order for ${state.caseId}`); + state.triage = parsePayload(triageDecisionSchema, entry); + break; + } + case "thesis_submitted": { + assertAbsent(state.thesis, "thesis_submitted", state.caseId); + if (state.triage?.decision !== "escalate" || state.analysis || state.riskVerdict) { + throw new Error(`Thesis lacks an escalation for ${state.caseId}`); + } + const thesis = parsePayload(tradeThesisSchema, entry); + assertThesisMatchesSignal(thesis, state.signal!); + assertSame(entry.atTsMs, thesis.submittedAtTsMs, "Thesis timestamp", state.caseId); + state.thesis = thesis; + break; + } + case "analysis_completed": { + assertAbsent(state.analysis, "analysis_completed", state.caseId); + if (!state.thesis || state.riskVerdict) throw new Error(`Analysis completion lacks a thesis for ${state.caseId}`); + const analysis = parsePayload(analysisSchema, entry); + assertSame(analysis.signalSourceTsMs, state.signal!.detectedAtTsMs, "Analysis source time", state.caseId); + assertSame(analysis.signalObservedTsMs, state.signal!.observedAtTsMs, "Analysis observed time", state.caseId); + assertSame(analysis.readyAtTsMs, state.thesis.submittedAtTsMs, "Analysis readiness", state.caseId); + assertSame(analysis.readyAtTsMs, entry.atTsMs, "Analysis event time", state.caseId); + assertSame( + analysis.decisionLatencyMs, + state.thesis.submittedAtTsMs - state.signal!.observedAtTsMs, + "Analysis latency", + state.caseId + ); + assertSame( + analysis.orderEligibleAtTsMs, + analysis.readyAtTsMs + POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS, + "Venue eligibility", + state.caseId + ); + assertSame(analysis.recommendation, state.thesis.recommendation, "Analysis recommendation", state.caseId); + state.analysis = analysis; + break; + } + case "risk_verdict": { + assertAbsent(state.riskVerdict, "risk_verdict", state.caseId); + if (!state.analysis || state.thesis?.recommendation !== "paper_trade") { + throw new Error(`Risk verdict lacks a paper-trade analysis for ${state.caseId}`); + } + if (entry.atTsMs < state.analysis.orderEligibleAtTsMs) { + throw new Error(`Risk verdict predates venue eligibility for ${state.caseId}`); + } + state.riskVerdict = parsePayload(riskVerdictSchema, entry) as PaperRiskVerdict; + break; + } + case "execution_intent": { + assertAbsent(state.intent, "execution_intent", state.caseId); + if (state.riskVerdict?.decision !== "approve" || state.fill) { + throw new Error(`Execution intent lacks risk approval for ${state.caseId}`); + } + const intent = parsePayload(executionIntentSchema, entry) as PaperOrderIntent; + const signal = state.signal!; + assertSame(intent.lane, lane, "Intent lane", state.caseId); + assertSame(intent.caseId, state.caseId, "Intent case", state.caseId); + assertSame(intent.signalId, signal.signalId, "Intent signal", state.caseId); + assertSame(intent.fixtureId, signal.fixtureId, "Intent fixture", state.caseId); + assertSame(intent.marketKey, signal.market.key, "Intent market", state.caseId); + assertSame(intent.outcome, signal.outcome, "Intent outcome", state.caseId); + assertSame(intent.direction, signal.direction, "Intent direction", state.caseId); + assertSame(intent.stakeMicroUsd, state.riskVerdict.stakeMicroUsd, "Intent stake", state.caseId); + assertSame(intent.limitProbability, state.riskVerdict.limitProbability, "Intent limit", state.caseId); + state.intent = intent; + break; + } + case "paper_execution": { + assertAbsent(state.fill, "paper_execution", state.caseId); + if (!state.intent || state.riskVerdict?.decision !== "approve") { + throw new Error(`Paper execution lacks intent and approval for ${state.caseId}`); + } + const fill = parsePayload(paperFillSchema, entry) as PaperFill; + validateFill(fill, state.intent, state.caseId); + assertSame(entry.atTsMs, fill.bookObservedTsMs, "Execution book time", state.caseId); + state.fill = fill; + break; + } + case "case_terminal": { + assertAbsent(state.terminal, "case_terminal", state.caseId); + const terminal = parsePayload(terminalSchema, entry); + validateTerminal(state, terminal); + state.terminal = { ...terminal, atTsMs: entry.atTsMs }; + break; + } + case "position_opened": { + assertAbsent(state.position, "position_opened", state.caseId); + const position = parsePayload(positionSchema, entry) as PaperPosition; + validatePosition(state, position, entry, lane); + state.position = structuredClone(position); + break; + } + case "position_closed": { + assertAbsent(state.closeMark, "position_closed", state.caseId); + if (!state.position || state.position.status !== "open" || state.settlement) { + throw new Error(`Position close lacks an open position for ${state.caseId}`); + } + const mark = parsePayload(closeMarkSchema, entry) as PaperCloseMark; + validateCloseMark(state.position, mark, entry); + state.closeMark = mark; + state.position.status = "marked"; + state.position.closeMark = structuredClone(mark); + break; + } + case "position_settled": { + assertAbsent(state.settlement, "position_settled", state.caseId); + if (!state.position || state.position.status !== "marked" || !state.closeMark) { + throw new Error(`Settlement lacks a closed position for ${state.caseId}`); + } + const settlement = parsePayload(settlementSchema, entry) as PaperSettlement; + validateSettlement(state.position, settlement, entry); + state.settlement = settlement; + state.position.status = "settled"; + state.position.settlement = structuredClone(settlement); + break; + } + case "study_initialized": + throw new Error(`Unexpected ${entry.kind} in case lifecycle for ${entry.caseId}`); + } +} + +function validateStudyInitialization(entry: DecisionLedgerEntry, lane: PaperStudyLane): void { + if (entry.payload === null || Array.isArray(entry.payload) || typeof entry.payload !== "object") { + throw new Error("Malformed study_initialized payload"); + } + const payload = entry.payload as Record; + if (payload.lane !== lane || payload.realMoneyGate !== "closed") { + throw new Error("Paper study initialization lane or real-money gate mismatch"); + } +} + +export function rehydratePaperState(input: RehydratePaperStateInput): RehydratedPaperState { + if (input.bankrollMicroUsd <= 0 || input.drawdownStopMicroUsd <= 0) { + throw new Error("Rehydration bankroll and drawdown stop must be positive"); + } + if (!Number.isSafeInteger(input.maximumPendingMs) || input.maximumPendingMs <= 0) { + throw new Error("Rehydration maximum pending time must be a positive integer"); + } + if (!Number.isSafeInteger(input.asOfTsMs) || input.asOfTsMs < 0) { + throw new Error("Rehydration as-of time must be a non-negative integer"); + } + + // This is deliberately the first ledger operation. No payload is trusted + // until the complete v1/v2-compatible hash chain has verified. + const chain = input.ledger.verifyChain(); + const entries = input.ledger.entries(); + if ( + entries.length !== chain.rows || + (entries.at(-1)?.entryHash ?? "0".repeat(64)) !== chain.headHash + ) { + throw new Error("Decision ledger changed while restart state was being read"); + } + for (const [index, entry] of entries.entries()) { + if ( + entry.sequence !== index + 1 || + !Number.isSafeInteger(entry.atTsMs) || + entry.atTsMs < 0 || + !Number.isSafeInteger(entry.insertedAtMs) || + entry.insertedAtMs < 0 || + entry.entryId.length === 0 || + entry.caseId.length === 0 || + !DECISION_EVENT_KINDS.has(entry.kind) + ) { + throw new Error(`Invalid decision ledger metadata at sequence ${entry.sequence}`); + } + } + const caseEntryTimestamps = entries + .filter((entry) => entry.kind !== "study_initialized") + .map((entry) => entry.atTsMs); + const latestCaseTsMs = caseEntryTimestamps.length === 0 ? null : Math.max(...caseEntryTimestamps); + if (latestCaseTsMs !== null && input.asOfTsMs < latestCaseTsMs) { + throw new Error("Rehydration as-of time precedes committed case state"); + } + + const states = new Map(); + const signalOwners = new Map(); + let studyInitialization: DecisionLedgerEntry | null = null; + let sawCaseEvent = false; + for (const entry of entries) { + if (entry.kind === "study_initialized") { + if (studyInitialization) throw new Error("Duplicate study_initialized event"); + if (sawCaseEvent) throw new Error("study_initialized appears after case activity"); + validateStudyInitialization(entry, input.lane); + studyInitialization = entry; + continue; + } + sawCaseEvent = true; + processCaseEntry(states, signalOwners, entry, input.lane); + } + + const pending: PaperPendingSignalState[] = []; + const pendingExpirations: RehydratedPendingExpiration[] = []; + const cases: RehydratedPaperCase[] = []; + const positions: PaperPosition[] = []; + for (const state of states.values()) { + const { signal } = state; + if (!signal) throw new Error(`Case ${state.caseId} has no signal`); + if (!state.terminal) { + if ( + !state.triage || + state.triage.decision !== "escalate" || + !state.thesis || + state.thesis.recommendation !== "paper_trade" || + !state.analysis || + state.riskVerdict || + state.intent || + state.fill || + state.position + ) { + throw new Error(`Case ${state.caseId} stopped at an unsafe, non-resumable lifecycle seam`); + } + const timeoutAtTsMs = state.analysis.orderEligibleAtTsMs + input.maximumPendingMs; + if (!Number.isSafeInteger(timeoutAtTsMs)) { + throw new Error(`Pending expiry is outside the safe timestamp range for ${state.caseId}`); + } + const kickoffAtTsMs = input.kickoffByFixtureId.get(signal.fixtureId); + if (kickoffAtTsMs === undefined || !Number.isSafeInteger(kickoffAtTsMs) || kickoffAtTsMs < 0) { + throw new Error(`Missing or invalid kickoff for pending case ${state.caseId}`); + } + const kickoffFirst = kickoffAtTsMs <= timeoutAtTsMs; + const expiration: RehydratedPendingExpiration = { + caseId: state.caseId, + signalId: signal.signalId, + timeoutAtTsMs, + kickoffAtTsMs, + effectiveExpiresAtTsMs: kickoffFirst ? kickoffAtTsMs : timeoutAtTsMs, + reason: kickoffFirst + ? "no_post_venue_delay_executable_book_before_kickoff" + : "no_post_venue_delay_executable_book_before_expiry", + expiredAsOfTsMs: input.asOfTsMs >= (kickoffFirst ? kickoffAtTsMs : timeoutAtTsMs) + }; + const prepared: PreparedPaperCase = { + caseId: state.caseId, + status: "ready", + triage: structuredClone(state.triage), + thesis: structuredClone(state.thesis), + decisionLatencyMs: state.analysis.decisionLatencyMs, + readyAtTsMs: state.analysis.readyAtTsMs, + orderEligibleAtTsMs: state.analysis.orderEligibleAtTsMs, + venuePlacementDelayMs: state.analysis.venuePlacementDelayMs + }; + pending.push({ + signal: structuredClone(signal), + prepared, + expiresAtTsMs: timeoutAtTsMs + }); + pendingExpirations.push(expiration); + cases.push({ + caseId: state.caseId, + signalId: signal.signalId, + status: "pending", + terminal: null, + pendingExpiration: expiration, + positionStatus: "none" + }); + continue; + } + + if (state.riskVerdict || state.intent || state.fill) { + if (state.riskVerdict?.decision === "approve" && !state.intent) { + throw new Error(`Approved case lacks execution intent for ${state.caseId}`); + } + if (state.intent && !state.fill && state.terminal.status !== "failed") { + throw new Error(`Execution intent lacks a committed fill for ${state.caseId}`); + } + } + if (state.fill?.direction === "sell" && state.fill.status !== "no_fill") { + throw new Error(`Restart reconstruction does not support an inventory sale for ${state.caseId}`); + } + if ( + state.fill?.direction === "buy" && + state.fill.status !== "no_fill" && + !state.position + ) { + throw new Error(`Committed buy fill lacks position_opened for ${state.caseId}`); + } + if (state.position) { + const kickoffTsMs = input.kickoffByFixtureId.get(state.position.fixtureId); + if (kickoffTsMs === undefined || !Number.isSafeInteger(kickoffTsMs) || kickoffTsMs < 0) { + throw new Error(`Missing or invalid kickoff for position ${state.caseId}`); + } + if (state.position.closeMark && state.position.closeMark.cutoffTsMs !== kickoffTsMs) { + throw new Error(`Position close cutoff does not match fixture kickoff for ${state.caseId}`); + } + positions.push(structuredClone(state.position)); + } + const positionStatus = state.position?.status === "marked" ? "closed" : (state.position?.status ?? "none"); + cases.push({ + caseId: state.caseId, + signalId: signal.signalId, + status: "terminal", + terminal: structuredClone(state.terminal), + pendingExpiration: null, + positionStatus + }); + } + + let realizedPnl = 0; + let peakEquity = input.bankrollMicroUsd; + for (const entry of entries) { + if (entry.kind !== "position_settled") continue; + const settlement = parsePayload(settlementSchema, entry); + realizedPnl += settlement.pnlMicroUsd; + const equity = input.bankrollMicroUsd + realizedPnl; + if (!Number.isSafeInteger(equity)) throw new Error("Rehydrated equity exceeds safe money range"); + peakEquity = Math.max(peakEquity, equity) as MicroUsd; + } + const realizedPnlMicroUsd = microUsd(realizedPnl); + const equityMicroUsd = microUsd(input.bankrollMicroUsd + realizedPnlMicroUsd); + const peakEquityMicroUsd = microUsd(peakEquity); + const currentDrawdownMicroUsd = microUsd(Math.max(0, peakEquityMicroUsd - equityMicroUsd)); + const aggregateExposureMicroUsd = microUsd(positions + .filter((position) => position.status !== "settled") + .reduce((sum, position) => sum + position.entryCostMicroUsd, 0)); + const riskState: PaperRiskState = { + openExposureMicroUsd: aggregateExposureMicroUsd, + currentDrawdownMicroUsd, + halted: currentDrawdownMicroUsd >= input.drawdownStopMicroUsd + }; + const portfolioInitialState: PaperPortfolioInitialState = { + positions: positions.map((position) => structuredClone(position)), + realizedPnlMicroUsd, + peakEquityMicroUsd, + currentDrawdownMicroUsd + }; + const schedulerInitialState: PaperSchedulerInitialState = { + seenSignalIds: [...signalOwners.keys()], + pending: pending.map((value) => structuredClone(value)), + // The study registration timestamp is not part of the replay event clock. + // Using only committed case knowledge preserves the live/replay code path. + lastObservedTsMs: latestCaseTsMs + }; + + return { + lane: input.lane, + asOfTsMs: input.asOfTsMs, + chain, + seenSignalIds: [...signalOwners.keys()], + cases, + pendingExpirations, + positions, + openPositions: positions.filter((position) => position.status === "open"), + closedPositions: positions.filter((position) => position.status === "marked"), + settledPositions: positions.filter((position) => position.status === "settled"), + aggregateExposureMicroUsd, + realizedPnlMicroUsd, + equityMicroUsd, + peakEquityMicroUsd, + currentDrawdownMicroUsd, + riskState, + portfolioInitialState, + schedulerInitialState + }; +} diff --git a/src/harness/paper-study-ledger.ts b/src/harness/paper-study-ledger.ts new file mode 100644 index 0000000..f0fe95b --- /dev/null +++ b/src/harness/paper-study-ledger.ts @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; +import type { JsonValue } from "../domain/json.js"; +import { stableJson } from "../domain/json.js"; +import { PAPER_STUDY_EVALUATION_CANDIDATE } from "../metrics/paper-study.js"; +import { APPROVED_PAPER_RISK_CONFIG } from "../risk/paper.js"; +import { POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS } from "../exec/paper.js"; +import { DecisionLedger } from "../store/decision-ledger.js"; +import { + PAPER_STUDY_DETECTOR_CONFIG, + PAPER_STUDY_ECONOMIC_CASE_CONFIG, + PAPER_STUDY_FEATURE_CONFIG, + PAPER_STUDY_HISTORICAL_EVIDENCE, + PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS, + PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS, + PAPER_STUDY_TOTAL_SELECTOR_CONFIG +} from "../config/paper-study.js"; +import type { PaperStudyLane } from "./paper-pipeline.js"; + +export const PAPER_STUDY_PROTOCOL_VERSION = "paper-study-v2-candidate-2026-07-14"; +export const PAPER_STUDY_PROTOCOL_STATUS = "engineering_candidate_unregistered" as const; + +export type PaperStudyInitialization = { + protocolVersion: typeof PAPER_STUDY_PROTOCOL_VERSION; + protocolStatus: typeof PAPER_STUDY_PROTOCOL_STATUS; + lane: PaperStudyLane; + startedAtTsMs: number; + startedAt: string; + configHash: string; + realMoneyGate: "closed"; + frozenConfig: { + feature: typeof PAPER_STUDY_FEATURE_CONFIG; + detector: typeof PAPER_STUDY_DETECTOR_CONFIG; + selector: typeof PAPER_STUDY_TOTAL_SELECTOR_CONFIG; + selectorAsOfBeforeKickoffMs: typeof PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS; + economicCases: typeof PAPER_STUDY_ECONOMIC_CASE_CONFIG; + historicalEvidence: typeof PAPER_STUDY_HISTORICAL_EVIDENCE; + minimumSignalToKickoffMs: number; + executionTiming: { + readinessClock: "observed_ts_ms"; + decisionLatency: "max_measured_or_minimum"; + venuePlacementDelayMs: typeof POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS; + }; + risk: typeof APPROVED_PAPER_RISK_CONFIG; + evaluation: typeof PAPER_STUDY_EVALUATION_CANDIDATE; + }; +}; + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function frozenConfig(): PaperStudyInitialization["frozenConfig"] { + return { + feature: PAPER_STUDY_FEATURE_CONFIG, + detector: PAPER_STUDY_DETECTOR_CONFIG, + selector: PAPER_STUDY_TOTAL_SELECTOR_CONFIG, + selectorAsOfBeforeKickoffMs: PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS, + economicCases: PAPER_STUDY_ECONOMIC_CASE_CONFIG, + historicalEvidence: PAPER_STUDY_HISTORICAL_EVIDENCE, + minimumSignalToKickoffMs: PAPER_STUDY_MINIMUM_SIGNAL_TO_KICKOFF_MS, + executionTiming: { + readinessClock: "observed_ts_ms", + decisionLatency: "max_measured_or_minimum", + venuePlacementDelayMs: POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS + }, + risk: APPROVED_PAPER_RISK_CONFIG, + evaluation: PAPER_STUDY_EVALUATION_CANDIDATE + }; +} + +function initialization(lane: PaperStudyLane, startedAtTsMs: number): PaperStudyInitialization { + if (!Number.isSafeInteger(startedAtTsMs) || startedAtTsMs < 0) { + throw new Error("Paper study start must be non-negative integer milliseconds"); + } + const config = frozenConfig(); + return { + protocolVersion: PAPER_STUDY_PROTOCOL_VERSION, + protocolStatus: PAPER_STUDY_PROTOCOL_STATUS, + lane, + startedAtTsMs, + startedAt: new Date(startedAtTsMs).toISOString(), + configHash: sha256(stableJson(config)), + realMoneyGate: "closed", + frozenConfig: config + }; +} + +function parseInitialization(payload: JsonValue): PaperStudyInitialization { + if (payload === null || Array.isArray(payload) || typeof payload !== "object") { + throw new Error("Paper study initialization payload is malformed"); + } + return payload as unknown as PaperStudyInitialization; +} + +export function initializePaperStudyLedger(input: { + path: string; + lane: PaperStudyLane; + startedAtTsMs: number; + insertedAtNow?: () => number; +}): { ledger: DecisionLedger; initialization: PaperStudyInitialization; created: boolean } { + const ledger = new DecisionLedger(input.path, { + ...(input.insertedAtNow === undefined ? {} : { now: input.insertedAtNow }) + }); + const entries = ledger.entries(); + const existing = entries.find((entry) => entry.kind === "study_initialized"); + if (existing) { + const value = parseInitialization(existing.payload); + const expected = initialization(input.lane, value.startedAtTsMs); + if ( + value.lane !== input.lane || + value.protocolVersion !== expected.protocolVersion || + value.protocolStatus !== expected.protocolStatus || + value.configHash !== expected.configHash || + value.realMoneyGate !== "closed" || + stableJson(value.frozenConfig) !== stableJson(expected.frozenConfig) + ) { + ledger.close(); + throw new Error(`Existing ${input.lane} paper-study ledger does not match the frozen protocol`); + } + return { ledger, initialization: value, created: false }; + } + if (entries.length > 0) { + ledger.close(); + throw new Error(`Cannot initialize non-empty ${input.lane} ledger without a study start record`); + } + const value = initialization(input.lane, input.startedAtTsMs); + ledger.append({ + entryId: `study:${input.lane}:initialized:${PAPER_STUDY_PROTOCOL_VERSION}`, + caseId: `study:${input.lane}`, + kind: "study_initialized", + atTsMs: input.startedAtTsMs, + payload: JSON.parse(JSON.stringify(value)) as JsonValue + }); + return { ledger, initialization: value, created: true }; +} diff --git a/src/harness/validate-capture-configs.ts b/src/harness/validate-capture-configs.ts new file mode 100644 index 0000000..f636ef5 --- /dev/null +++ b/src/harness/validate-capture-configs.ts @@ -0,0 +1,25 @@ +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateCaptureConfig } from "./capture-config.js"; + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); +const configsDir = resolve(repoRoot, "config/captures"); +const txlineFixtures = JSON.parse(await readFile(resolve(repoRoot, "samples/fixtures/mainnet-world-cup-fixtures.json"), "utf8")); +const polymarketEvents = JSON.parse(await readFile(resolve(repoRoot, "data/live/gamma-discovery/open-world-cup-events.json"), "utf8")); +const results = []; +for (const name of (await readdir(configsDir)).filter((value) => value.endsWith(".json")).sort()) { + const config = JSON.parse(await readFile(resolve(configsDir, name), "utf8")); + if (config === null || typeof config !== "object" || !("capture" in config)) { + results.push({ name, status: "legacy_capture_config", evidenceValid: true, readyToSchedule: false, reason: "not_a_future_scheduled_capture", launch: null }); + continue; + } + try { + const validation = validateCaptureConfig({ repoRoot, config, txlineFixtures, polymarketEvents }); + results.push({ name, status: validation.config.status, evidenceValid: true, readyToSchedule: validation.readyToSchedule, reason: validation.reason, launch: validation.launch }); + } catch (error) { + results.push({ name, evidenceValid: false, error: error instanceof Error ? error.message : String(error) }); + } +} +console.log(JSON.stringify(results, null, 2)); +if (results.some((result) => !result.evidenceValid)) process.exitCode = 1; diff --git a/src/harness/verify-claude-paper-runtime.ts b/src/harness/verify-claude-paper-runtime.ts new file mode 100644 index 0000000..4978381 --- /dev/null +++ b/src/harness/verify-claude-paper-runtime.ts @@ -0,0 +1,77 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { loadEnvFile } from "node:process"; +import { ClaudeSpendLedger } from "../agents/claude-spend-ledger.js"; +import { PolymarketClobFeeResolver } from "../ingest/polymarket/fees.js"; +import { createPersistentClaudePaperStudy } from "./claude-paper-study.js"; +import type { PaperFixtureUniverse } from "./paper-fixture-universe.js"; +import { + PAPER_STUDY_PROTOCOL_STATUS, + initializePaperStudyLedger +} from "./paper-study-ledger.js"; + +if (PAPER_STUDY_PROTOCOL_STATUS !== ("registered" as string)) { + throw new Error( + `Claude paper runtime is disabled: protocol status is ${PAPER_STUDY_PROTOCOL_STATUS}. ` + + "Deborah must register the corrected paper protocol before any model spend or study admission." + ); +} + +loadEnvFile(resolve(".env")); +const apiKey = process.env.ANTHROPIC_API_KEY; +if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not configured"); + +const manifest = JSON.parse(await readFile(resolve("data/paper/study-ledgers.json"), "utf8")) as { + bounty: { path: string; startedAtTsMs: number }; + longRun: { path: string; startedAtTsMs: number }; +}; +const universe = JSON.parse( + await readFile(resolve("data/research/paper-fixture-universe.json"), "utf8") +) as PaperFixtureUniverse; +const bounty = initializePaperStudyLedger({ + path: resolve(manifest.bounty.path), + lane: "bounty", + startedAtTsMs: manifest.bounty.startedAtTsMs +}); +const longRun = initializePaperStudyLedger({ + path: resolve(manifest.longRun.path), + lane: "long_run", + startedAtTsMs: manifest.longRun.startedAtTsMs +}); +const spendLedger = new ClaudeSpendLedger(resolve("data/agents/claude-spend.sqlite")); +try { + const spendBefore = spendLedger.verifyChain(); + const feeResolver = new PolymarketClobFeeResolver(); + const study = createPersistentClaudePaperStudy({ + apiKey, + spendLedger, + bounty, + longRun, + universe, + feeResolver: (book) => feeResolver.resolve(book), + maximumPendingMs: 5 * 60_000 + }); + const spendAfter = spendLedger.verifyChain(); + if (spendAfter.rows !== spendBefore.rows || spendAfter.headHash !== spendBefore.headHash) { + throw new Error("Claude runtime readiness unexpectedly changed the spend ledger"); + } + process.stdout.write(`${JSON.stringify({ + ok: true, + realMoneyGate: "closed", + apiKeyConfigured: true, + apiRequestsPerformed: 0, + spendChain: spendAfter, + bounty: { + decisionChain: bounty.ledger.verifyChain(), + eligibleFixtures: study.bounty.fixtures.map((fixture) => fixture.fixtureId) + }, + longRun: { + decisionChain: longRun.ledger.verifyChain(), + eligibleFixtures: study.longRun.fixtures.map((fixture) => fixture.fixtureId) + } + }, null, 2)}\n`); +} finally { + bounty.ledger.close(); + longRun.ledger.close(); + spendLedger.close(); +} diff --git a/src/harness/write-paper-study-report.ts b/src/harness/write-paper-study-report.ts new file mode 100644 index 0000000..0fbcca0 --- /dev/null +++ b/src/harness/write-paper-study-report.ts @@ -0,0 +1,81 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { evaluatePaperStudyLedger } from "../metrics/paper-study-observations.js"; +import { + renderPaperStudyEvidence, + type PaperStudyEvidenceArtifact +} from "../metrics/paper-study-report.js"; +import type { PaperFixtureUniverse } from "./paper-fixture-universe.js"; +import { initializePaperStudyLedger } from "./paper-study-ledger.js"; + +function argument(name: string, fallback: string): string { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; +} + +const manifest = JSON.parse( + await readFile(resolve(argument("manifest", "data/paper/study-ledgers.json")), "utf8") +) as { + protocolVersion: string; + configHash: string; + realMoneyGate: "closed"; + bounty: { path: string; startedAtTsMs: number }; + longRun: { path: string; startedAtTsMs: number }; +}; +const universe = JSON.parse( + await readFile(resolve(argument("universe", "data/research/paper-fixture-universe.json")), "utf8") +) as PaperFixtureUniverse; +const bounty = initializePaperStudyLedger({ + path: resolve(manifest.bounty.path), + lane: "bounty", + startedAtTsMs: manifest.bounty.startedAtTsMs +}); +const longRun = initializePaperStudyLedger({ + path: resolve(manifest.longRun.path), + lane: "long_run", + startedAtTsMs: manifest.longRun.startedAtTsMs +}); +try { + const kickoffByFixtureId = new Map( + universe.fixtures.map((fixture) => [fixture.fixtureId, fixture.kickoffTsMs]) + ); + const artifact: PaperStudyEvidenceArtifact = { + generatedAt: new Date().toISOString(), + protocolVersion: manifest.protocolVersion, + configHash: manifest.configHash, + realMoneyGate: "closed", + fixtureUniverseGeneratedAt: universe.generatedAt, + lanes: { + bounty: { + initialization: bounty.initialization, + chain: bounty.ledger.verifyChain(), + report: evaluatePaperStudyLedger({ lane: "bounty", ledger: bounty.ledger, kickoffByFixtureId }) + }, + longRun: { + initialization: longRun.initialization, + chain: longRun.ledger.verifyChain(), + report: evaluatePaperStudyLedger({ lane: "long_run", ledger: longRun.ledger, kickoffByFixtureId }) + } + } + }; + const jsonPath = resolve(argument("output", "data/paper/reports/current.json")); + const markdownPath = resolve(argument("report", "docs/research/paper-study-current.md")); + await Promise.all([ + mkdir(dirname(jsonPath), { recursive: true }), + mkdir(dirname(markdownPath), { recursive: true }) + ]); + await Promise.all([ + writeFile(jsonPath, `${JSON.stringify(artifact, null, 2)}\n`), + writeFile(markdownPath, renderPaperStudyEvidence(artifact)) + ]); + process.stdout.write(`${JSON.stringify({ + jsonPath, + markdownPath, + bountyStatus: artifact.lanes.bounty.report.status, + longRunStatus: artifact.lanes.longRun.report.status, + longRunCounts: artifact.lanes.longRun.report.counts + }, null, 2)}\n`); +} finally { + bounty.ledger.close(); + longRun.ledger.close(); +} diff --git a/src/ingest/polymarket/fees.ts b/src/ingest/polymarket/fees.ts new file mode 100644 index 0000000..ae29f44 --- /dev/null +++ b/src/ingest/polymarket/fees.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import type { PolymarketBookEvent } from "../../bus/events.js"; +import type { PolymarketFeeParameters } from "../../exec/paper.js"; + +export const POLYMARKET_CLOB_ORIGIN = "https://clob.polymarket.com"; + +const marketInfoSchema = z.object({ + c: z.string().min(1), + t: z.array(z.object({ + t: z.string().min(1), + o: z.string().min(1) + })).min(1), + mos: z.number().finite().positive(), + mts: z.number().finite().positive(), + tbf: z.number().int().nonnegative(), + fd: z.object({ + r: z.number().finite().nonnegative().nullable(), + e: z.number().finite().nonnegative().nullable(), + to: z.boolean().nullable() + }).optional() +}); + +type CacheEntry = { + fetchedAtTsMs: number; + tokenIds: Set; + fees: PolymarketFeeParameters; +}; + +export type PolymarketClobFeeResolverOptions = { + origin?: string; + cacheTtlMs?: number; + requestTimeoutMs?: number; + fetchImpl?: typeof fetch; + now?: () => number; +}; + +export class PolymarketClobFeeResolver { + readonly #origin: string; + readonly #cacheTtlMs: number; + readonly #requestTimeoutMs: number; + readonly #fetchImpl: typeof fetch; + readonly #now: () => number; + readonly #cache = new Map(); + + constructor(options: PolymarketClobFeeResolverOptions = {}) { + this.#origin = (options.origin ?? POLYMARKET_CLOB_ORIGIN).replace(/\/$/, ""); + this.#cacheTtlMs = options.cacheTtlMs ?? 60_000; + this.#requestTimeoutMs = options.requestTimeoutMs ?? 10_000; + this.#fetchImpl = options.fetchImpl ?? fetch; + this.#now = options.now ?? Date.now; + if (!Number.isSafeInteger(this.#cacheTtlMs) || this.#cacheTtlMs <= 0) { + throw new RangeError("Fee cache TTL must be a positive integer number of milliseconds"); + } + if (!Number.isSafeInteger(this.#requestTimeoutMs) || this.#requestTimeoutMs <= 0) { + throw new RangeError("Fee request timeout must be a positive integer number of milliseconds"); + } + } + + async resolve(book: PolymarketBookEvent): Promise { + const now = this.#now(); + const cached = this.#cache.get(book.conditionId); + if (cached && now - cached.fetchedAtTsMs <= this.#cacheTtlMs) { + if (!cached.tokenIds.has(book.assetId)) throw new Error("Cached CLOB market info does not contain book asset"); + return cached.fees; + } + + const response = await this.#fetchImpl( + `${this.#origin}/clob-markets/${encodeURIComponent(book.conditionId)}`, + { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(this.#requestTimeoutMs) + } + ); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Polymarket CLOB market-info request failed ${response.status}: ${text.slice(0, 300)}`); + } + const info = marketInfoSchema.parse(JSON.parse(text) as unknown); + if (info.c !== book.conditionId) throw new Error("CLOB market-info condition does not match canonical book"); + const tokenIds = new Set(info.t.map((token) => token.t)); + if (!tokenIds.has(book.assetId)) throw new Error("CLOB market info does not contain canonical book asset"); + + const feeDetails = info.fd; + if (info.tbf > 0 && (!feeDetails || feeDetails.r === null || feeDetails.e === null || feeDetails.to === null)) { + throw new Error("Fee-enabled CLOB market omitted explicit V2 fee-curve details"); + } + const feesEnabled = (feeDetails?.r ?? 0) > 0; + const fetchedAtTsMs = this.#now(); + const fees: PolymarketFeeParameters = { + source: "polymarket_clob_market_info", + conditionId: book.conditionId, + feesEnabled, + takerFeeRate: feesEnabled ? feeDetails!.r! : 0, + feeCurveExponent: feesEnabled ? feeDetails!.e! : 1, + takerOnly: feesEnabled ? feeDetails!.to! : true, + minimumOrderSize: info.mos, + minimumTickSize: info.mts, + fetchedAtTsMs + }; + this.#cache.set(book.conditionId, { fetchedAtTsMs, tokenIds, fees }); + return fees; + } + + clear(): void { + this.#cache.clear(); + } +} diff --git a/src/ingest/polymarket/normalizer.ts b/src/ingest/polymarket/normalizer.ts index 7fb8d2d..f53bc4b 100644 --- a/src/ingest/polymarket/normalizer.ts +++ b/src/ingest/polymarket/normalizer.ts @@ -4,7 +4,8 @@ import { CANONICAL_SCHEMA_VERSION, type CanonicalEvent, type PolymarketBookEvent, - type PolymarketPriceEvent + type PolymarketPriceEvent, + type PolymarketResolutionEvent } from "../../bus/events.js"; import { stableJson } from "../../domain/json.js"; import { probability, type Probability } from "../../domain/probability.js"; @@ -61,6 +62,15 @@ const lastTradeSchema = z.object({ size: decimalSchema.optional() }); +const marketResolvedSchema = z.object({ + market: z.string().min(1), + assets_ids: z.array(z.string().min(1)).min(2), + winning_asset_id: z.string().min(1), + winning_outcome: z.string().min(1), + timestamp: decimalSchema, + event_type: z.literal("market_resolved") +}); + function timestampMs(value: string): number { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) throw new RangeError(`Invalid Polymarket timestamp: ${value}`); @@ -137,6 +147,38 @@ function normalizeBook( }; } +function normalizeResolution( + value: unknown, + observedTsMs: number, + registry: MappingRegistry +): PolymarketResolutionEvent { + const row = marketResolvedSchema.parse(value); + if (!row.assets_ids.includes(row.winning_asset_id)) { + throw new Error("Polymarket resolution winner is absent from assets_ids"); + } + const mapped = registry.resolveAsset(row.winning_asset_id); + validateCondition(row.market, mapped); + for (const assetId of row.assets_ids) { + validateCondition(row.market, registry.resolveAsset(assetId)); + } + const sourceTsMs = timestampMs(row.timestamp); + return { + schemaVersion: CANONICAL_SCHEMA_VERSION, + kind: "polymarket.resolution", + eventId: `polymarket:resolution:${createHash("sha256").update(stableJson(row)).digest("hex")}`, + source: "polymarket", + sourceTsMs, + observedTsMs, + fixtureId: mapped.fixtureId, + market: mapped.market, + mappingStatus: mapped.mappingStatus, + conditionId: mapped.conditionId, + assetIds: row.assets_ids, + winningAssetId: row.winning_asset_id, + winningOutcomeLabel: row.winning_outcome + }; +} + export function normalizePolymarketPayload( payload: unknown, observedTsMs: number, @@ -147,6 +189,7 @@ export function normalizePolymarketPayload( } const eventType = (payload as { event_type?: unknown } | null)?.event_type; if (eventType === "book") return [normalizeBook(payload, observedTsMs, registry)]; + if (eventType === "market_resolved") return [normalizeResolution(payload, observedTsMs, registry)]; if (eventType === "price_change") { const row = priceChangeSchema.parse(payload); const sourceTs = timestampMs(row.timestamp); diff --git a/src/ingest/txline/normalizer.ts b/src/ingest/txline/normalizer.ts index f1276bf..d8493d6 100644 --- a/src/ingest/txline/normalizer.ts +++ b/src/ingest/txline/normalizer.ts @@ -96,6 +96,7 @@ function normalizeOdds(value: unknown, envelope: IngestEnvelope): OddsQuoteEvent const row = oddsSchema.parse(value); const definition = marketDefinition(row); if (definition === null) return null; + if (row.Prices.length === 0 && row.Pct.length === 0) return null; if (row.Prices.length !== definition.outcomes.length || row.Pct.length !== definition.outcomes.length) { throw new Error(`TXLine outcome array lengths disagree for ${row.MessageId}`); } diff --git a/src/metrics/paper-study-observations.ts b/src/metrics/paper-study-observations.ts new file mode 100644 index 0000000..241a080 --- /dev/null +++ b/src/metrics/paper-study-observations.ts @@ -0,0 +1,100 @@ +import type { DetectorSignal } from "../detectors/types.js"; +import type { JsonValue } from "../domain/json.js"; +import type { PaperStudyLane } from "../harness/paper-pipeline.js"; +import type { PaperCloseMark, PaperPosition, PaperSettlement } from "../portfolio/paper.js"; +import type { DecisionLedger } from "../store/decision-ledger.js"; +import { + evaluatePaperStudy, + type PaperStudyEvaluationConfig, + type PaperStudyObservation, + type PaperStudyReport +} from "./paper-study.js"; + +function record(value: JsonValue): Record { + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new Error("Expected an object in the paper decision ledger"); + } + return value; +} + +function signalFromPayload(payload: JsonValue): { lane: PaperStudyLane; signal: DetectorSignal } { + const value = record(payload); + if (value["lane"] !== "bounty" && value["lane"] !== "long_run") { + throw new Error("Signal ledger entry is missing its paper-study lane"); + } + const signal = record(value["signal"] ?? null); + const market = record(signal["market"] ?? null); + if ( + typeof signal["signalId"] !== "string" || + typeof signal["fixtureId"] !== "string" || + typeof market["lineMilli"] !== "number" + ) { + throw new Error("Signal ledger entry is missing canonical identity fields"); + } + return { lane: value["lane"], signal: signal as unknown as DetectorSignal }; +} + +export function buildPaperStudyObservations(input: { + lane: PaperStudyLane; + ledger: DecisionLedger; + kickoffByFixtureId: ReadonlyMap; +}): PaperStudyObservation[] { + const entries = input.ledger.entries(); + const opened = new Map(); + const closed = new Map(); + const settled = new Map(); + for (const entry of entries) { + if (entry.kind === "position_opened") opened.set(entry.caseId, entry.payload as unknown as PaperPosition); + if (entry.kind === "position_closed") closed.set(entry.caseId, entry.payload as unknown as PaperCloseMark); + if (entry.kind === "position_settled") settled.set(entry.caseId, entry.payload as unknown as PaperSettlement); + } + return entries + .filter((entry) => entry.kind === "signal_received") + .map((entry) => { + const { lane, signal } = signalFromPayload(entry.payload); + if (lane !== input.lane) throw new Error("Decision ledger contains a different paper-study lane"); + const kickoffTsMs = input.kickoffByFixtureId.get(signal.fixtureId); + if (kickoffTsMs === undefined) throw new Error(`Missing kickoff for fixture ${signal.fixtureId}`); + if (signal.market.lineMilli === null) throw new Error("Paper-study signal is missing a totals line"); + const position = opened.get(entry.caseId); + if (position && position.signalId !== signal.signalId) { + throw new Error(`Position ${entry.caseId} does not match its ledgered signal`); + } + const closeMark = closed.get(entry.caseId) ?? null; + const settlement = settled.get(entry.caseId) ?? null; + return { + caseId: entry.caseId, + lane, + fixtureId: signal.fixtureId, + kickoffTsMs, + selectedLineMilli: signal.market.lineMilli, + signalId: signal.signalId, + fill: position ? { + status: position.fillStatus, + entryCostMicroUsd: position.entryCostMicroUsd, + halfSpreadBps: position.entryHalfSpreadBps, + slippageBps: position.entrySlippageBps, + selectedDepthUsd: position.selectedDepthUsd, + grossClvBps: closeMark?.grossMidpointClvBps ?? null, + netClvBps: closeMark?.netMidpointClvBps ?? null, + executableLiquidationClvBps: closeMark?.executableLiquidationClvBps ?? null, + settledAtTsMs: settlement?.settledAtTsMs ?? null, + settlementPnlMicroUsd: settlement?.pnlMicroUsd ?? null + } : null + } satisfies PaperStudyObservation; + }); +} + +export function evaluatePaperStudyLedger(input: { + lane: PaperStudyLane; + ledger: DecisionLedger; + kickoffByFixtureId: ReadonlyMap; + config?: PaperStudyEvaluationConfig; +}): PaperStudyReport { + const observations = buildPaperStudyObservations(input); + return evaluatePaperStudy({ + lane: input.lane, + observations, + ...(input.config === undefined ? {} : { config: input.config }) + }); +} diff --git a/src/metrics/paper-study-report.ts b/src/metrics/paper-study-report.ts new file mode 100644 index 0000000..c1bc303 --- /dev/null +++ b/src/metrics/paper-study-report.ts @@ -0,0 +1,122 @@ +import type { PaperStudyInitialization } from "../harness/paper-study-ledger.js"; +import type { PaperStudyReport } from "./paper-study.js"; + +export type LedgerChainEvidence = { + valid: true; + rows: number; + headHash: string; +}; + +export type PaperStudyEvidenceArtifact = { + generatedAt: string; + protocolVersion: string; + configHash: string; + realMoneyGate: "closed"; + fixtureUniverseGeneratedAt: string; + lanes: { + bounty: { + initialization: PaperStudyInitialization; + chain: LedgerChainEvidence; + report: PaperStudyReport; + }; + longRun: { + initialization: PaperStudyInitialization; + chain: LedgerChainEvidence; + report: PaperStudyReport; + }; + }; +}; + +function decimal(value: number | null, digits = 1): string { + return value === null ? "-" : value.toFixed(digits); +} + +function usd(microUsd: number | null): string { + return microUsd === null ? "-" : (microUsd / 1_000_000).toFixed(2); +} + +function percent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function laneSection(name: string, report: PaperStudyReport): string[] { + if ( + report.status === "sealed" && + (report.rows !== null || report.endpoints !== null || report.guardrails !== null) + ) { + throw new Error("Sealed paper-study report contains unsealed results"); + } + const lines = [ + `## ${name}`, + "", + `Status: **${report.status.toUpperCase()}** `, + `Reason: ${report.reason} `, + `Counts: ${report.counts.matches} matches / ${report.counts.signals} signals / ${report.counts.filledMatches} filled matches / ${report.counts.fills} fills / ${report.counts.settledFills} settled fills.`, + "" + ]; + if (report.rows === null) { + lines.push( + "Primary endpoints and per-match rows are sealed until both registered stopping thresholds are met.", + "" + ); + return lines; + } + lines.push( + "| Fixture | Kickoff UTC | Total | Signals | Fills | Fill rate | Half-spread bps | Slippage bps | Gross CLV bps | Net CLV bps | Settlement P&L ($) | Net return bps |", + "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ...report.rows.map((row) => + `| ${row.fixtureId} | ${new Date(row.kickoffTsMs).toISOString()} | ${(row.selectedLineMilli / 1_000).toFixed(1)} | ${row.signals} | ${row.fills} | ${percent(row.fillRate)} | ${decimal(row.meanHalfSpreadBps)} | ${decimal(row.meanSlippageBps)} | ${decimal(row.grossClvBps)} | ${decimal(row.netClvBps)} | ${usd(row.settlementPnlMicroUsd)} | ${decimal(row.netReturnBps)} |` + ), + "" + ); + if (report.endpoints === null) { + lines.push("Endpoints are unavailable until close and settlement evidence is complete.", ""); + } else { + const endpoints = report.endpoints; + lines.push( + "### Endpoints", + "", + `- Mean net executable CLV: ${decimal(endpoints.meanNetClvBps)} bps; match-clustered 95% CI [${decimal(endpoints.netClvInterval.low)}, ${decimal(endpoints.netClvInterval.high)}], median ${decimal(endpoints.netClvInterval.median)} (${endpoints.netClvInterval.iterations.toLocaleString("en-US")} iterations).`, + `- Mean settlement P&L: $${usd(endpoints.meanSettlementPnlMicroUsd)}; match-clustered 95% CI [$${usd(endpoints.settlementPnlInterval.low)}, $${usd(endpoints.settlementPnlInterval.high)}].`, + `- Settled matches net-positive: ${percent(endpoints.fractionSettledMatchesNetPositive)}.`, + `- No-trade baseline: ${decimal(endpoints.noTradeBaselineClvBps)} bps; matched-cost random-direction control: ${decimal(endpoints.randomDirectionControlClvBps)} bps.`, + "" + ); + } + if (report.guardrails !== null) { + const guardrails = report.guardrails; + lines.push( + "### Guardrails", + "", + `| Fill rate | Slippage | Drawdown | Depth complete | Close complete | Settlement complete |`, + `|---:|---:|---:|---|---|---|`, + `| ${percent(guardrails.fillRate)} (${guardrails.fillRatePassed ? "pass" : "fail"}) | ${decimal(guardrails.meanSlippageBps)} bps (${guardrails.slippagePassed ? "pass" : "fail"}) | $${usd(guardrails.maxDrawdownMicroUsd)} (${guardrails.drawdownPassed ? "pass" : "fail"}) | ${guardrails.selectedDepthComplete ? "yes" : "no"} | ${guardrails.closeMarksComplete ? "yes" : "no"} | ${guardrails.settlementComplete ? "yes" : "no"} |`, + "" + ); + } + return lines; +} + +export function renderPaperStudyEvidence(artifact: PaperStudyEvidenceArtifact): string { + return [ + "# Samaritan Paper Study Evidence", + "", + `Generated: ${artifact.generatedAt} `, + `Protocol: \`${artifact.protocolVersion}\` `, + `Frozen config SHA-256: \`${artifact.configHash}\` `, + `Fixture universe generated: ${artifact.fixtureUniverseGeneratedAt} `, + "Real-money gate: **CLOSED**", + "", + "This report is reconstructed from append-only decision ledgers. Bounty results are exploratory. Long-run rows and endpoints remain sealed until the registered stopping rule is met.", + "", + "| Lane | Chain rows | Chain head |", + "|---|---:|---|", + `| Bounty | ${artifact.lanes.bounty.chain.rows} | \`${artifact.lanes.bounty.chain.headHash}\` |`, + `| Long-run | ${artifact.lanes.longRun.chain.rows} | \`${artifact.lanes.longRun.chain.headHash}\` |`, + "", + ...laneSection("Bounty Demonstration Lane", artifact.lanes.bounty.report), + ...laneSection("Long-Run Profitability Lane", artifact.lanes.longRun.report), + "No result in this artifact authorizes real-money execution.", + "" + ].join("\n"); +} diff --git a/src/metrics/paper-study.ts b/src/metrics/paper-study.ts new file mode 100644 index 0000000..c12694b --- /dev/null +++ b/src/metrics/paper-study.ts @@ -0,0 +1,449 @@ +import { createHash } from "node:crypto"; +import type { PaperStudyLane } from "../harness/paper-pipeline.js"; + +export type PaperStudyFillEvidence = { + status: "filled" | "partial"; + entryCostMicroUsd: number; + halfSpreadBps: number; + slippageBps: number; + selectedDepthUsd: number | null; + grossClvBps: number | null; + netClvBps: number | null; + executableLiquidationClvBps: number | null; + settledAtTsMs: number | null; + settlementPnlMicroUsd: number | null; +}; + +export type PaperStudyObservation = { + caseId: string; + lane: PaperStudyLane; + fixtureId: string; + kickoffTsMs: number; + selectedLineMilli: number; + signalId: string; + fill: PaperStudyFillEvidence | null; +}; + +export type BootstrapInterval = { + iterations: number; + seed: number; + matches: number; + signals: number; + low: number; + median: number; + high: number; +}; + +export type PaperMatchRow = { + fixtureId: string; + kickoffTsMs: number; + selectedLineMilli: number; + signals: number; + fills: number; + fillRate: number; + meanHalfSpreadBps: number | null; + meanSlippageBps: number | null; + grossClvBps: number | null; + netClvBps: number | null; + settlementPnlMicroUsd: number | null; + netReturnBps: number | null; +}; + +export type PaperStudyCounts = { + matches: number; + signals: number; + filledMatches: number; + fills: number; + settledFills: number; +}; + +export type PaperStudyEndpoints = { + meanNetClvBps: number; + netClvInterval: BootstrapInterval; + meanSettlementPnlMicroUsd: number; + settlementPnlInterval: BootstrapInterval; + noTradeBaselineClvBps: 0; + randomDirectionControlClvBps: number; + fractionSettledMatchesNetPositive: number; +}; + +export type PaperStudyGuardrails = { + fillRate: number; + fillRatePassed: boolean; + meanSlippageBps: number | null; + slippagePassed: boolean; + maxDrawdownMicroUsd: number; + drawdownPassed: boolean; + selectedDepthComplete: boolean; + closeMarksComplete: boolean; + settlementComplete: boolean; +}; + +export type PaperStudyReport = { + lane: PaperStudyLane; + status: "sealed" | "exploratory" | "accept" | "reject" | "inconclusive"; + reason: string; + counts: PaperStudyCounts; + stoppingRuleMet: boolean; + rows: PaperMatchRow[] | null; + endpoints: PaperStudyEndpoints | null; + guardrails: PaperStudyGuardrails | null; + bootstrap: { iterations: number; seed: number }; + randomDirectionControl: "seeded_sign_flip_of_gross_clv_minus_observed_cost"; +}; + +export type PaperStudyEvaluationConfig = { + minimumFilledMatches: number; + minimumFills: number; + minimumFillRate: number; + maximumMeanSlippageBps: number; + maximumDrawdownMicroUsd: number; + bootstrapIterations: number; + bootstrapSeed: number; +}; + +export const PAPER_STUDY_EVALUATION_CANDIDATE: PaperStudyEvaluationConfig = Object.freeze({ + minimumFilledMatches: 20, + minimumFills: 40, + minimumFillRate: 0.6, + maximumMeanSlippageBps: 100, + maximumDrawdownMicroUsd: 20_000_000, + bootstrapIterations: 10_000, + bootstrapSeed: 20_260_714 +}); + +export type PaperStudyEvaluationInput = { + lane: PaperStudyLane; + observations: readonly PaperStudyObservation[]; + config?: PaperStudyEvaluationConfig; +}; + +function mean(values: readonly number[]): number { + if (values.length === 0) throw new Error("Cannot compute a mean without observations"); + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function nullableMean(values: readonly number[]): number | null { + return values.length === 0 ? null : mean(values); +} + +function percentile(sorted: readonly number[], probability: number): number { + if (sorted.length === 0) throw new Error("Cannot compute a percentile without observations"); + const index = (sorted.length - 1) * probability; + const lower = Math.floor(index); + const upper = Math.ceil(index); + const weight = index - lower; + return sorted[lower]! * (1 - weight) + sorted[upper]! * weight; +} + +function mulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ value >>> 15, value | 1); + value ^= value + Math.imul(value ^ value >>> 7, value | 61); + return ((value ^ value >>> 14) >>> 0) / 4_294_967_296; + }; +} + +function clusteredBootstrap( + groups: ReadonlyMap, + iterations: number, + seed: number +): BootstrapInterval { + const matches = [...groups.entries()]; + if (matches.length === 0 || matches.some(([, values]) => values.length === 0)) { + throw new Error("Clustered bootstrap requires non-empty match groups"); + } + const random = mulberry32(seed); + const distribution: number[] = []; + for (let iteration = 0; iteration < iterations; iteration += 1) { + let total = 0; + let count = 0; + for (let index = 0; index < matches.length; index += 1) { + const [, values] = matches[Math.floor(random() * matches.length)]!; + for (const value of values) { + total += value; + count += 1; + } + } + distribution.push(total / count); + } + distribution.sort((left, right) => left - right); + return { + iterations, + seed, + matches: matches.length, + signals: matches.reduce((sum, [, values]) => sum + values.length, 0), + low: percentile(distribution, 0.025), + median: percentile(distribution, 0.5), + high: percentile(distribution, 0.975) + }; +} + +function randomDirectionSign(seed: number, signalId: string): 1 | -1 { + const hash = createHash("sha256").update(`${seed}:${signalId}`).digest(); + return (hash[0]! & 1) === 0 ? 1 : -1; +} + +function validateConfig(config: PaperStudyEvaluationConfig): void { + if ( + !Number.isSafeInteger(config.minimumFilledMatches) || config.minimumFilledMatches <= 0 || + !Number.isSafeInteger(config.minimumFills) || config.minimumFills <= 0 || + !Number.isSafeInteger(config.bootstrapIterations) || config.bootstrapIterations <= 0 || + !Number.isSafeInteger(config.bootstrapSeed) + ) { + throw new Error("Paper-study count, bootstrap iteration, and seed settings must be valid integers"); + } + if ( + !Number.isFinite(config.minimumFillRate) || config.minimumFillRate < 0 || config.minimumFillRate > 1 || + !Number.isFinite(config.maximumMeanSlippageBps) || config.maximumMeanSlippageBps < 0 || + !Number.isSafeInteger(config.maximumDrawdownMicroUsd) || config.maximumDrawdownMicroUsd <= 0 + ) { + throw new Error("Paper-study guardrail settings are invalid"); + } +} + +function validateObservations( + observations: readonly PaperStudyObservation[], + lane: PaperStudyLane +): void { + const caseIds = new Set(); + const signalIds = new Set(); + for (const observation of observations) { + if (observation.lane !== lane) throw new Error("Paper-study lanes cannot be mixed in one report"); + if (caseIds.has(observation.caseId) || signalIds.has(observation.signalId)) { + throw new Error("Paper-study case and signal IDs must be unique"); + } + caseIds.add(observation.caseId); + signalIds.add(observation.signalId); + if (!Number.isSafeInteger(observation.kickoffTsMs) || !Number.isSafeInteger(observation.selectedLineMilli)) { + throw new Error("Kickoff and selected line must use integer canonical units"); + } + const fill = observation.fill; + if (!fill) continue; + if (!Number.isSafeInteger(fill.entryCostMicroUsd) || fill.entryCostMicroUsd <= 0) { + throw new Error("Filled observations require positive integer micro-USD entry cost"); + } + if (![fill.halfSpreadBps, fill.slippageBps].every((value) => Number.isFinite(value) && value >= 0)) { + throw new Error("Spread and slippage evidence must be finite and non-negative"); + } + if ( + ![fill.grossClvBps, fill.netClvBps, fill.executableLiquidationClvBps] + .every((value) => value === null || Number.isFinite(value)) + ) { + throw new Error("CLV evidence must be finite when present"); + } + if (fill.settledAtTsMs !== null && !Number.isSafeInteger(fill.settledAtTsMs)) { + throw new Error("Settlement timestamps must be integer milliseconds"); + } + if ( + fill.settlementPnlMicroUsd !== null && + (!Number.isSafeInteger(fill.settlementPnlMicroUsd) || fill.settledAtTsMs === null) + ) { + throw new Error("Settlement P&L requires integer micro-USD and a settlement timestamp"); + } + } +} + +function groupValues( + observations: readonly PaperStudyObservation[], + value: (observation: PaperStudyObservation) => number | null +): Map { + const groups = new Map(); + for (const observation of observations) { + const result = value(observation); + if (result === null) continue; + const values = groups.get(observation.fixtureId) ?? []; + values.push(result); + groups.set(observation.fixtureId, values); + } + return groups; +} + +function matchRows(observations: readonly PaperStudyObservation[]): PaperMatchRow[] { + const groups = new Map(); + for (const observation of observations) { + const rows = groups.get(observation.fixtureId) ?? []; + rows.push(observation); + groups.set(observation.fixtureId, rows); + } + return [...groups.values()] + .sort((left, right) => left[0]!.kickoffTsMs - right[0]!.kickoffTsMs || left[0]!.fixtureId.localeCompare(right[0]!.fixtureId)) + .map((group) => { + const reference = group[0]!; + if (group.some((observation) => observation.selectedLineMilli !== reference.selectedLineMilli)) { + throw new Error(`Fixture ${reference.fixtureId} contains multiple selected total lines`); + } + const fills = group.flatMap((observation) => observation.fill ? [observation.fill] : []); + const settled = fills.filter( + (fill): fill is PaperStudyFillEvidence & { settlementPnlMicroUsd: number } => + fill.settlementPnlMicroUsd !== null + ); + const entryCost = settled.reduce((sum, fill) => sum + fill.entryCostMicroUsd, 0); + const pnl = settled.reduce((sum, fill) => sum + fill.settlementPnlMicroUsd, 0); + return { + fixtureId: reference.fixtureId, + kickoffTsMs: reference.kickoffTsMs, + selectedLineMilli: reference.selectedLineMilli, + signals: group.length, + fills: fills.length, + fillRate: fills.length / group.length, + meanHalfSpreadBps: nullableMean(fills.map((fill) => fill.halfSpreadBps)), + meanSlippageBps: nullableMean(fills.map((fill) => fill.slippageBps)), + grossClvBps: nullableMean(fills.flatMap((fill) => fill.grossClvBps === null ? [] : [fill.grossClvBps])), + netClvBps: nullableMean(fills.flatMap((fill) => fill.netClvBps === null ? [] : [fill.netClvBps])), + settlementPnlMicroUsd: settled.length === 0 ? null : pnl, + netReturnBps: entryCost === 0 ? null : pnl / entryCost * 10_000 + }; + }); +} + +function maximumDrawdown(observations: readonly PaperStudyObservation[]): number { + const settlements = observations + .flatMap((observation) => { + const fill = observation.fill; + return !fill || fill.settlementPnlMicroUsd === null || fill.settledAtTsMs === null + ? [] + : [{ + atTsMs: fill.settledAtTsMs, + caseId: observation.caseId, + pnlMicroUsd: fill.settlementPnlMicroUsd + }]; + }) + .sort((left, right) => left.atTsMs - right.atTsMs || left.caseId.localeCompare(right.caseId)); + let equity = 0; + let peak = 0; + let drawdown = 0; + for (const settlement of settlements) { + equity += settlement.pnlMicroUsd; + peak = Math.max(peak, equity); + drawdown = Math.max(drawdown, peak - equity); + } + return drawdown; +} + +export function evaluatePaperStudy(input: PaperStudyEvaluationInput): PaperStudyReport { + const { lane, observations } = input; + const config = input.config ?? PAPER_STUDY_EVALUATION_CANDIDATE; + validateConfig(config); + validateObservations(observations, lane); + const fills = observations.flatMap((observation) => observation.fill ? [observation.fill] : []); + const fixtureIds = new Set(observations.map((observation) => observation.fixtureId)); + const filledFixtureIds = new Set(observations.filter((observation) => observation.fill).map((observation) => observation.fixtureId)); + const counts: PaperStudyCounts = { + matches: fixtureIds.size, + signals: observations.length, + filledMatches: filledFixtureIds.size, + fills: fills.length, + settledFills: fills.filter((fill) => fill.settlementPnlMicroUsd !== null).length + }; + const stoppingRuleMet = + counts.filledMatches >= config.minimumFilledMatches && counts.fills >= config.minimumFills; + const base = { + lane, + counts, + stoppingRuleMet, + bootstrap: { iterations: config.bootstrapIterations, seed: config.bootstrapSeed }, + randomDirectionControl: "seeded_sign_flip_of_gross_clv_minus_observed_cost" as const + }; + if (lane === "long_run" && !stoppingRuleMet) { + return { + ...base, + status: "sealed", + reason: `Long-run endpoints sealed until ${config.minimumFilledMatches} filled matches and ${config.minimumFills} fills`, + rows: null, + endpoints: null, + guardrails: null + }; + } + + const rows = matchRows(observations); + const closeMarksComplete = fills.length > 0 && fills.every((fill) => + fill.grossClvBps !== null && fill.netClvBps !== null && fill.executableLiquidationClvBps !== null + ); + const settlementComplete = fills.length > 0 && fills.every((fill) => + fill.settledAtTsMs !== null && fill.settlementPnlMicroUsd !== null + ); + const selectedDepthComplete = fills.length > 0 && + fills.every((fill) => fill.selectedDepthUsd !== null && fill.selectedDepthUsd > 0); + const fillRate = observations.length === 0 ? 0 : fills.length / observations.length; + const meanSlippageBps = fills.length === 0 ? null : mean(fills.map((fill) => fill.slippageBps)); + const maxDrawdownMicroUsd = maximumDrawdown(observations); + const guardrails: PaperStudyGuardrails = { + fillRate, + fillRatePassed: fillRate >= config.minimumFillRate, + meanSlippageBps, + slippagePassed: meanSlippageBps !== null && meanSlippageBps <= config.maximumMeanSlippageBps, + maxDrawdownMicroUsd, + drawdownPassed: maxDrawdownMicroUsd < config.maximumDrawdownMicroUsd, + selectedDepthComplete, + closeMarksComplete, + settlementComplete + }; + if (fills.length === 0 || !closeMarksComplete || !settlementComplete) { + return { + ...base, + status: lane === "bounty" ? "exploratory" : "inconclusive", + reason: "Complete close and settlement evidence is required before endpoints are computed", + rows, + endpoints: null, + guardrails + }; + } + + const clvGroups = groupValues(observations, (observation) => observation.fill?.netClvBps ?? null); + const pnlGroups = groupValues(observations, (observation) => observation.fill?.settlementPnlMicroUsd ?? null); + const netClvValues = fills.map((fill) => fill.netClvBps!); + const settlementValues = fills.map((fill) => fill.settlementPnlMicroUsd!); + const randomDirectionValues = observations.flatMap((observation) => { + const fill = observation.fill; + if (!fill) return []; + const observedCost = fill.grossClvBps! - fill.netClvBps!; + return [randomDirectionSign(config.bootstrapSeed, observation.signalId) * fill.grossClvBps! - observedCost]; + }); + const settledRows = rows.filter((row): row is PaperMatchRow & { settlementPnlMicroUsd: number } => + row.settlementPnlMicroUsd !== null + ); + const endpoints: PaperStudyEndpoints = { + meanNetClvBps: mean(netClvValues), + netClvInterval: clusteredBootstrap(clvGroups, config.bootstrapIterations, config.bootstrapSeed), + meanSettlementPnlMicroUsd: mean(settlementValues), + settlementPnlInterval: clusteredBootstrap(pnlGroups, config.bootstrapIterations, config.bootstrapSeed + 1), + noTradeBaselineClvBps: 0, + randomDirectionControlClvBps: mean(randomDirectionValues), + fractionSettledMatchesNetPositive: + settledRows.filter((row) => row.settlementPnlMicroUsd > 0).length / settledRows.length + }; + if (lane === "bounty") { + return { + ...base, + status: "exploratory", + reason: "Bounty-lane evidence is visible but excluded from the registered profitability decision", + rows, + endpoints, + guardrails + }; + } + + const guardrailsPassed = Object.entries(guardrails).every(([key, value]) => + key.endsWith("Passed") || key.endsWith("Complete") ? value === true : true + ); + const accepted = + endpoints.meanNetClvBps > 0 && + endpoints.netClvInterval.low > 0 && + endpoints.meanSettlementPnlMicroUsd > 0 && + endpoints.meanNetClvBps > endpoints.randomDirectionControlClvBps && + guardrailsPassed; + return { + ...base, + status: accepted ? "accept" : "reject", + reason: accepted + ? "All registered endpoints and guardrails passed; real money still requires separate approval" + : "One or more registered endpoints or guardrails failed", + rows, + endpoints, + guardrails + }; +} diff --git a/src/metrics/threshold-grid.ts b/src/metrics/threshold-grid.ts index 215823f..9003829 100644 --- a/src/metrics/threshold-grid.ts +++ b/src/metrics/threshold-grid.ts @@ -1,6 +1,10 @@ import { createHash } from "node:crypto"; import { DetectorBank, type DetectorBankConfig } from "../detectors/bank.js"; -import type { SignalKind } from "../detectors/types.js"; +import { + normalizeExecutableEconomicCases, + type EconomicCaseNormalizationSummary +} from "../detectors/economic-cases.js"; +import type { DetectorSignal, SignalKind } from "../detectors/types.js"; import { stableJson } from "../domain/json.js"; import type { FeatureSnapshot } from "../features/engine.js"; import { @@ -18,7 +22,11 @@ export type ThresholdGridResult = { configId: string; config: DetectorBankConfig; snapshots: number; + /** Executable economic cases after binary Total Goals normalization. */ signalCounts: Record; + /** Raw detector emissions retained for audit, never for minimum-n gates. */ + rawSignalCounts: Record; + economicCaseNormalization: EconomicCaseNormalizationSummary; classification: ClassificationSummary[]; }; @@ -28,7 +36,8 @@ type GridEntry = { bank: DetectorBank; metrics: ClassificationMetrics; snapshots: number; - signalCounts: Record; + rawSignalCounts: Record; + rawSignals: Array<{ signal: DetectorSignal; caseId: string }>; }; const signalKinds: SignalKind[] = [ @@ -66,6 +75,7 @@ export function expandDetectorThresholdGrid( export class DetectorThresholdGrid { readonly #entries: GridEntry[]; readonly #pendingPredictions = new Map>>(); + readonly #labeledCaseIds = new Set(); constructor(configs: readonly DetectorBankConfig[]) { if (configs.length === 0) throw new Error("Threshold grid requires at least one detector configuration"); @@ -80,14 +90,17 @@ export class DetectorThresholdGrid { bank: new DetectorBank(config), metrics: new ClassificationMetrics(), snapshots: 0, - signalCounts: emptySignalCounts() + rawSignalCounts: emptySignalCounts(), + rawSignals: [] }; }); } ingest(snapshot: FeatureSnapshot, labels: DetectorLabels = {}): void { + const id = detectorCaseId(snapshot); const predictions = this.#predict(snapshot); this.#recordLabels(predictions, labels); + this.#labeledCaseIds.add(id); } ingestDeferred(snapshot: FeatureSnapshot): string { @@ -102,6 +115,7 @@ export class DetectorThresholdGrid { if (!predictions) throw new Error(`Unknown deferred detector case: ${caseId}`); this.#recordLabels(predictions, labels); this.#pendingPredictions.delete(caseId); + this.#labeledCaseIds.add(caseId); } discard(caseId: string): void { @@ -116,12 +130,16 @@ export class DetectorThresholdGrid { #predict(snapshot: FeatureSnapshot): Map> { const predictions = new Map>(); + const caseId = detectorCaseId(snapshot); for (const entry of this.#entries) { entry.snapshots += 1; const signals = entry.bank.ingest(snapshot); const predictedKinds = new Set(signals.map((signal) => signal.kind)); predictions.set(entry.configId, predictedKinds); - for (const signal of signals) entry.signalCounts[signal.kind] += 1; + for (const signal of signals) { + entry.rawSignalCounts[signal.kind] += 1; + entry.rawSignals.push({ signal, caseId }); + } } return predictions; } @@ -139,12 +157,22 @@ export class DetectorThresholdGrid { } results(): ThresholdGridResult[] { - return this.#entries.map((entry) => ({ - configId: entry.configId, - config: { ...entry.config }, - snapshots: entry.snapshots, - signalCounts: { ...entry.signalCounts }, - classification: entry.metrics.summaries() - })); + return this.#entries.map((entry) => { + const labeledSignals = entry.rawSignals + .filter((item) => this.#labeledCaseIds.has(item.caseId)) + .map((item) => item.signal); + const normalization = normalizeExecutableEconomicCases(labeledSignals); + const signalCounts = emptySignalCounts(); + for (const signal of normalization.signals) signalCounts[signal.kind] += 1; + return { + configId: entry.configId, + config: { ...entry.config }, + snapshots: entry.snapshots, + signalCounts, + rawSignalCounts: { ...entry.rawSignalCounts }, + economicCaseNormalization: normalization.summary, + classification: entry.metrics.summaries() + }; + }); } } diff --git a/src/portfolio/paper-close-scheduler.ts b/src/portfolio/paper-close-scheduler.ts new file mode 100644 index 0000000..26fc7d8 --- /dev/null +++ b/src/portfolio/paper-close-scheduler.ts @@ -0,0 +1,77 @@ +import type { CanonicalEvent, PolymarketBookEvent } from "../bus/events.js"; +import type { PaperCloseMark, PaperPortfolio } from "./paper.js"; + +export type PaperCloseResult = { + caseId: string; + fixtureId: string; + cutoffTsMs: number; + mark: PaperCloseMark; +}; + +function bookKey(book: PolymarketBookEvent): string { + return `${book.fixtureId}:${book.market.key}:${book.conditionId}:${book.assetId}:${book.outcome}`; +} + +function positionKey(position: { + fixtureId: string; + marketKey: string; + conditionId: string; + assetId: string; + outcome: string; +}): string { + return `${position.fixtureId}:${position.marketKey}:${position.conditionId}:${position.assetId}:${position.outcome}`; +} + +export class PaperCloseScheduler { + readonly #books = new Map(); + + constructor(readonly dependencies: { + portfolio: PaperPortfolio; + kickoffByFixtureId: ReadonlyMap; + }) {} + + ingest(event: CanonicalEvent): PaperCloseResult[] { + if (event.kind === "polymarket.book" && event.tokenRole === "canonical") { + this.#cachePreKickoffBook(event); + } + return this.#markReadyPositions(event.observedTsMs); + } + + cachedBookCount(): number { + return this.#books.size; + } + + #cachePreKickoffBook(book: PolymarketBookEvent): void { + const kickoffTsMs = this.dependencies.kickoffByFixtureId.get(book.fixtureId); + if (kickoffTsMs === undefined || book.sourceTsMs > kickoffTsMs) return; + const key = bookKey(book); + const current = this.#books.get(key); + if ( + current && + (book.sourceTsMs < current.sourceTsMs || + (book.sourceTsMs === current.sourceTsMs && book.observedTsMs <= current.observedTsMs)) + ) { + return; + } + this.#books.set(key, structuredClone(book)); + } + + #markReadyPositions(asOfTsMs: number): PaperCloseResult[] { + const results: PaperCloseResult[] = []; + for (const position of this.dependencies.portfolio.positions()) { + if (position.status !== "open") continue; + const cutoffTsMs = this.dependencies.kickoffByFixtureId.get(position.fixtureId); + if (cutoffTsMs === undefined || asOfTsMs < cutoffTsMs) continue; + const book = this.#books.get(positionKey(position)); + if (!book) continue; + const mark = this.dependencies.portfolio.markAtClose({ + caseId: position.caseId, + book, + cutoffTsMs, + markedAtTsMs: asOfTsMs + }); + results.push({ caseId: position.caseId, fixtureId: position.fixtureId, cutoffTsMs, mark }); + } + return results; + } +} diff --git a/src/portfolio/paper-settlement-scheduler.ts b/src/portfolio/paper-settlement-scheduler.ts new file mode 100644 index 0000000..5fbce68 --- /dev/null +++ b/src/portfolio/paper-settlement-scheduler.ts @@ -0,0 +1,45 @@ +import type { CanonicalEvent } from "../bus/events.js"; +import type { PaperPortfolio, PaperSettlement } from "./paper.js"; + +export type PaperSettlementResult = { + caseId: string; + fixtureId: string; + conditionId: string; + winningAssetId: string; + won: boolean; + settlement: PaperSettlement; +}; + +export class PaperSettlementScheduler { + constructor(readonly portfolio: PaperPortfolio) {} + + ingest(event: CanonicalEvent): PaperSettlementResult[] { + if (event.kind !== "polymarket.resolution") return []; + const results: PaperSettlementResult[] = []; + for (const position of this.portfolio.positions()) { + if ( + position.status !== "marked" || + position.fixtureId !== event.fixtureId || + position.marketKey !== event.market.key || + position.conditionId !== event.conditionId + ) { + continue; + } + const won = position.assetId === event.winningAssetId; + const settlement = this.portfolio.settle({ + caseId: position.caseId, + won, + settledAtTsMs: event.observedTsMs + }); + results.push({ + caseId: position.caseId, + fixtureId: position.fixtureId, + conditionId: position.conditionId, + winningAssetId: event.winningAssetId, + won, + settlement + }); + } + return results; + } +} diff --git a/src/portfolio/paper.ts b/src/portfolio/paper.ts new file mode 100644 index 0000000..e76ea09 --- /dev/null +++ b/src/portfolio/paper.ts @@ -0,0 +1,328 @@ +import type { PolymarketBookEvent } from "../bus/events.js"; +import type { DetectorSignal } from "../detectors/types.js"; +import { microUsd, type MicroUsd, USD_MICRO_UNITS } from "../domain/money.js"; +import type { PaperFill } from "../exec/paper.js"; +import type { PaperRiskState } from "../risk/paper.js"; +import { DecisionLedger } from "../store/decision-ledger.js"; +import type { JsonValue } from "../domain/json.js"; +import type { PaperStudyLane } from "../harness/paper-pipeline.js"; + +export type PaperCloseMark = { + cutoffTsMs: number; + markedAtTsMs: number; + bookSourceTsMs: number; + bookObservedTsMs: number; + closeBid: number; + closeAsk: number; + closeMidpoint: number; + grossMidpointClvBps: number; + netMidpointClvBps: number; + executableLiquidationClvBps: number; +}; + +export type PaperSettlement = { + settledAtTsMs: number; + won: boolean; + payoutMicroUsd: MicroUsd; + pnlMicroUsd: MicroUsd; + returnBps: number; + entryBrier: number; +}; + +export type PaperPosition = { + caseId: string; + lane: PaperStudyLane; + signalId: string; + fixtureId: string; + marketKey: string; + conditionId: string; + assetId: string; + outcome: string; + selectedLineMilli: number; + openedAtTsMs: number; + filledShares: number; + averageEntryPrice: number; + entryGrossMicroUsd: MicroUsd; + entryFeeMicroUsd: MicroUsd; + entryCostMicroUsd: MicroUsd; + fillStatus: "filled" | "partial"; + entryHalfSpreadBps: number; + entrySlippageBps: number; + selectedDepthUsd: number; + status: "open" | "marked" | "settled"; + closeMark: PaperCloseMark | null; + settlement: PaperSettlement | null; +}; + +/** + * Trusted, already-verified state used only when rebuilding a portfolio from + * the append-only decision ledger. The constructor still validates the + * aggregate arithmetic and position identities before accepting it. + */ +export type PaperPortfolioInitialState = { + positions: readonly PaperPosition[]; + realizedPnlMicroUsd: MicroUsd; + peakEquityMicroUsd: MicroUsd; + currentDrawdownMicroUsd: MicroUsd; +}; + +function json(value: unknown): JsonValue { + return JSON.parse(JSON.stringify(value)) as JsonValue; +} + +function bestBid(book: PolymarketBookEvent): number | null { + return book.bids.length === 0 ? null : Math.max(...book.bids.map((level) => level.price)); +} + +function bestAsk(book: PolymarketBookEvent): number | null { + return book.asks.length === 0 ? null : Math.min(...book.asks.map((level) => level.price)); +} + +export class PaperPortfolio { + readonly #positions = new Map(); + #realizedPnlMicroUsd = microUsd(0); + #peakEquityMicroUsd: MicroUsd; + #currentDrawdownMicroUsd = microUsd(0); + + constructor(readonly options: { + lane: PaperStudyLane; + bankrollMicroUsd: MicroUsd; + drawdownStopMicroUsd: MicroUsd; + ledger: DecisionLedger; + initialState?: PaperPortfolioInitialState; + }) { + if (options.bankrollMicroUsd <= 0 || options.drawdownStopMicroUsd <= 0) { + throw new RangeError("Paper portfolio bankroll and drawdown stop must be positive"); + } + this.#peakEquityMicroUsd = options.bankrollMicroUsd; + if (options.initialState) this.#restore(options.initialState); + } + + open(input: { + caseId: string; + signal: DetectorSignal; + fill: PaperFill; + openedAtTsMs: number; + }): PaperPosition { + if (this.#positions.has(input.caseId)) throw new Error(`Paper position already exists for ${input.caseId}`); + if (input.fill.status === "no_fill" || input.fill.filledShares <= 0 || input.fill.averagePrice === null) { + throw new Error("Cannot open a paper position from a no-fill result"); + } + if (input.fill.direction !== "buy") { + throw new Error("Opening paper positions currently requires a canonical-token buy"); + } + if ( + input.signal.market.lineMilli === null || + input.fill.halfSpreadBps === null || + input.fill.slippageProbabilityBps === null + ) { + throw new Error("Paper positions require totals line, spread, and slippage evidence"); + } + const position: PaperPosition = { + caseId: input.caseId, + lane: this.options.lane, + signalId: input.signal.signalId, + fixtureId: input.signal.fixtureId, + marketKey: input.signal.market.key, + conditionId: input.fill.conditionId, + assetId: input.fill.assetId, + outcome: input.signal.outcome, + selectedLineMilli: input.signal.market.lineMilli, + openedAtTsMs: input.openedAtTsMs, + filledShares: input.fill.filledShares, + averageEntryPrice: input.fill.averagePrice, + entryGrossMicroUsd: input.fill.grossMicroUsd, + entryFeeMicroUsd: input.fill.feeMicroUsd, + entryCostMicroUsd: input.fill.netConsiderationMicroUsd, + fillStatus: input.fill.status, + entryHalfSpreadBps: input.fill.halfSpreadBps, + entrySlippageBps: input.fill.slippageProbabilityBps, + selectedDepthUsd: input.fill.executableDepthUsd, + status: "open", + closeMark: null, + settlement: null + }; + this.options.ledger.append({ + entryId: `${input.caseId}:position_opened`, + caseId: input.caseId, + kind: "position_opened", + atTsMs: input.openedAtTsMs, + payload: json(position) + }); + this.#positions.set(input.caseId, position); + return structuredClone(position); + } + + markAtClose(input: { + caseId: string; + book: PolymarketBookEvent; + cutoffTsMs: number; + markedAtTsMs: number; + }): PaperCloseMark { + const position = this.#requiredPosition(input.caseId); + if (position.status !== "open") throw new Error(`Paper position ${input.caseId} is not open`); + if (!Number.isSafeInteger(input.markedAtTsMs) || input.markedAtTsMs < input.book.observedTsMs) { + throw new Error("Closing mark timestamp cannot precede the canonical book observation time"); + } + if (!Number.isSafeInteger(input.cutoffTsMs) || input.book.sourceTsMs > input.cutoffTsMs) { + throw new Error("Closing book source timestamp must be at or before the registered cutoff"); + } + if ( + input.book.fixtureId !== position.fixtureId || + input.book.market.key !== position.marketKey || + input.book.conditionId !== position.conditionId || + input.book.assetId !== position.assetId || + input.book.outcome !== position.outcome || + input.book.tokenRole !== "canonical" + ) { + throw new Error("Closing book does not match the paper position"); + } + const closeBid = bestBid(input.book); + const closeAsk = bestAsk(input.book); + if (closeBid === null || closeAsk === null || closeBid > closeAsk) { + throw new Error("Closing book must have a valid bid and ask"); + } + const closeMidpoint = (closeBid + closeAsk) / 2; + const unitCost = position.entryCostMicroUsd / USD_MICRO_UNITS / position.filledShares; + const closeMark: PaperCloseMark = { + cutoffTsMs: input.cutoffTsMs, + markedAtTsMs: input.markedAtTsMs, + bookSourceTsMs: input.book.sourceTsMs, + bookObservedTsMs: input.book.observedTsMs, + closeBid, + closeAsk, + closeMidpoint, + grossMidpointClvBps: (closeMidpoint - position.averageEntryPrice) * 10_000, + netMidpointClvBps: (closeMidpoint - unitCost) * 10_000, + executableLiquidationClvBps: (closeBid - unitCost) * 10_000 + }; + this.options.ledger.append({ + entryId: `${input.caseId}:position_closed:${input.markedAtTsMs}`, + caseId: input.caseId, + kind: "position_closed", + atTsMs: input.markedAtTsMs, + payload: json(closeMark) + }); + position.status = "marked"; + position.closeMark = closeMark; + return structuredClone(closeMark); + } + + settle(input: { caseId: string; won: boolean; settledAtTsMs: number }): PaperSettlement { + const position = this.#requiredPosition(input.caseId); + if (position.status !== "marked") throw new Error(`Paper position ${input.caseId} lacks a closing mark`); + if ( + !Number.isSafeInteger(input.settledAtTsMs) || + input.settledAtTsMs < position.closeMark!.markedAtTsMs + ) { + throw new Error("Settlement timestamp cannot precede the closing mark"); + } + const payoutMicroUsd = microUsd(input.won ? Math.floor(position.filledShares * USD_MICRO_UNITS) : 0); + const pnlMicroUsd = microUsd(payoutMicroUsd - position.entryCostMicroUsd); + const settlement: PaperSettlement = { + settledAtTsMs: input.settledAtTsMs, + won: input.won, + payoutMicroUsd, + pnlMicroUsd, + returnBps: position.entryCostMicroUsd === 0 ? 0 : pnlMicroUsd / position.entryCostMicroUsd * 10_000, + entryBrier: (position.averageEntryPrice - (input.won ? 1 : 0)) ** 2 + }; + this.options.ledger.append({ + entryId: `${input.caseId}:position_settled:${input.settledAtTsMs}`, + caseId: input.caseId, + kind: "position_settled", + atTsMs: input.settledAtTsMs, + payload: json(settlement) + }); + position.status = "settled"; + position.settlement = settlement; + this.#realizedPnlMicroUsd = microUsd(this.#realizedPnlMicroUsd + pnlMicroUsd); + const equity = microUsd(this.options.bankrollMicroUsd + this.#realizedPnlMicroUsd); + if (equity > this.#peakEquityMicroUsd) this.#peakEquityMicroUsd = equity; + this.#currentDrawdownMicroUsd = microUsd(Math.max(0, this.#peakEquityMicroUsd - equity)); + return structuredClone(settlement); + } + + positions(): PaperPosition[] { + return [...this.#positions.values()].map((position) => structuredClone(position)); + } + + riskState(): PaperRiskState { + const openExposure = [...this.#positions.values()] + .filter((position) => position.status !== "settled") + .reduce((sum, position) => sum + position.entryCostMicroUsd, 0); + return { + openExposureMicroUsd: microUsd(openExposure), + currentDrawdownMicroUsd: this.#currentDrawdownMicroUsd, + halted: this.#currentDrawdownMicroUsd >= this.options.drawdownStopMicroUsd + }; + } + + summary(): { + positions: number; + openPositions: number; + settledPositions: number; + realizedPnlMicroUsd: MicroUsd; + peakEquityMicroUsd: MicroUsd; + currentDrawdownMicroUsd: MicroUsd; + } { + const positions = [...this.#positions.values()]; + return { + positions: positions.length, + openPositions: positions.filter((position) => position.status !== "settled").length, + settledPositions: positions.filter((position) => position.status === "settled").length, + realizedPnlMicroUsd: this.#realizedPnlMicroUsd, + peakEquityMicroUsd: this.#peakEquityMicroUsd, + currentDrawdownMicroUsd: this.#currentDrawdownMicroUsd + }; + } + + #requiredPosition(caseId: string): PaperPosition { + const position = this.#positions.get(caseId); + if (!position) throw new Error(`Unknown paper position: ${caseId}`); + return position; + } + + #restore(initial: PaperPortfolioInitialState): void { + const seen = new Set(); + let realizedPnlMicroUsd = 0; + for (const supplied of initial.positions) { + if (seen.has(supplied.caseId)) { + throw new Error(`Duplicate restored paper position: ${supplied.caseId}`); + } + seen.add(supplied.caseId); + if (supplied.lane !== this.options.lane) { + throw new Error(`Restored paper position lane mismatch: ${supplied.caseId}`); + } + if (supplied.status === "open" && (supplied.closeMark !== null || supplied.settlement !== null)) { + throw new Error(`Open restored paper position has closing state: ${supplied.caseId}`); + } + if (supplied.status === "marked" && (supplied.closeMark === null || supplied.settlement !== null)) { + throw new Error(`Marked restored paper position has inconsistent closing state: ${supplied.caseId}`); + } + if (supplied.status === "settled" && (supplied.closeMark === null || supplied.settlement === null)) { + throw new Error(`Settled restored paper position is incomplete: ${supplied.caseId}`); + } + if (supplied.status === "settled") realizedPnlMicroUsd += supplied.settlement!.pnlMicroUsd; + this.#positions.set(supplied.caseId, structuredClone(supplied)); + } + const checkedRealized = microUsd(realizedPnlMicroUsd); + if (checkedRealized !== initial.realizedPnlMicroUsd) { + throw new Error("Restored paper realized P&L does not match settled positions"); + } + const currentEquityMicroUsd = microUsd(this.options.bankrollMicroUsd + checkedRealized); + if ( + initial.peakEquityMicroUsd < this.options.bankrollMicroUsd || + initial.peakEquityMicroUsd < currentEquityMicroUsd + ) { + throw new Error("Restored paper peak equity is inconsistent"); + } + const expectedDrawdown = microUsd(Math.max(0, initial.peakEquityMicroUsd - currentEquityMicroUsd)); + if (expectedDrawdown !== initial.currentDrawdownMicroUsd) { + throw new Error("Restored paper drawdown is inconsistent"); + } + this.#realizedPnlMicroUsd = checkedRealized; + this.#peakEquityMicroUsd = initial.peakEquityMicroUsd; + this.#currentDrawdownMicroUsd = initial.currentDrawdownMicroUsd; + } +} diff --git a/src/proof/decision-receipt-schema.ts b/src/proof/decision-receipt-schema.ts new file mode 100644 index 0000000..f521665 --- /dev/null +++ b/src/proof/decision-receipt-schema.ts @@ -0,0 +1,831 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { CLAUDE_MODEL, CLAUDE_PRICING } from "../agents/claude-pricing.js"; +import { stableJson } from "../domain/json.js"; +import type { DecisionEventKind } from "../store/decision-ledger.js"; + +export const DECISION_RECEIPT_SCHEMA_VERSION = 1 as const; +export const DECISION_RECEIPT_HASH_DOMAIN = "samaritan.decision-receipt/v1" as const; +export const DECISION_RECEIPT_CANONICALIZATION = "samaritan-stable-json-v1" as const; + +const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/); +const timestampSchema = z.number().int().nonnegative().safe(); +const nonnegativeIntegerSchema = z.number().int().nonnegative().safe(); +const probabilitySchema = z.number().finite().min(0).max(1); + +const provenanceSchema = z.object({ + evidenceClass: z.enum(["synthetic_proving_fixture", "captured_paper_case"]), + synthetic: z.boolean(), + performanceUse: z.enum([ + "excluded_synthetic", + "excluded_unregistered", + "subject_to_registered_evaluation" + ]), + label: z.string().min(1).max(300) +}).strict(); + +const buildCommitmentSchema = z.object({ + codeVersion: z.string().min(1).max(100), + codeSha256: sha256Schema, + configSha256: sha256Schema +}).strict(); + +export const receiptSourceEvidenceSchema = z.object({ + evidenceRefSha256: sha256Schema, + source: z.enum(["txline", "polymarket", "public_web", "internal"]), + role: z.enum(["signal", "execution", "close", "settlement"]), + sourceTsMs: timestampSchema, + observedAtTsMs: timestampSchema, + payloadSha256: sha256Schema, + disclosure: z.literal("hash_only") +}).strict(); + +const usageSchema = z.object({ + inputTokens: nonnegativeIntegerSchema, + outputTokens: nonnegativeIntegerSchema, + cacheCreationInputTokens: nonnegativeIntegerSchema, + cacheReadInputTokens: nonnegativeIntegerSchema +}).strict(); + +const pricingSchema = z.object({ + pricingVersion: z.literal("anthropic-public-2026-07-12"), + currency: z.literal("nano_usd"), + inputNanoUsdPerToken: nonnegativeIntegerSchema, + outputNanoUsdPerToken: nonnegativeIntegerSchema, + cacheWriteNanoUsdPerToken: nonnegativeIntegerSchema, + cacheReadNanoUsdPerToken: nonnegativeIntegerSchema +}).strict(); + +export const receiptAgentRunSchema = z.object({ + stage: z.enum(["triage", "analyst"]), + invocationClass: z.enum(["anthropic_api", "synthetic_stub"]), + model: z.enum([CLAUDE_MODEL.triage, CLAUDE_MODEL.analyst]), + promptVersion: z.string().min(1).max(100), + promptSha256: sha256Schema, + responseSha256: sha256Schema, + billingEvidenceRefSha256: sha256Schema, + status: z.literal("success"), + usage: usageSchema, + pricing: pricingSchema, + actualCostNanoUsd: nonnegativeIntegerSchema +}).strict(); + +const signalEvidenceSchema = z.object({ + signalRefSha256: sha256Schema, + fixtureRefSha256: sha256Schema, + marketRefSha256: sha256Schema, + lineRefSha256: sha256Schema.nullable(), + detector: z.enum(["CONSENSUS_MOVE", "XMARKET_DIVERGENCE", "FADER_CANDIDATE"]), + sourceTsMs: timestampSchema, + observedAtTsMs: timestampSchema, + marketFamily: z.enum(["match_result", "total_goals"]), + marketPeriod: z.enum(["full_time", "first_half", "extra_time", "other"]), + outcome: z.enum(["home", "draw", "away", "over", "under"]), + direction: z.enum(["buy", "sell"]), + eligibility: z.enum(["research_only", "pretrade_review_required"]), + derivedEvidence: z.object({ + txlineMovementBucketBps: z.literal(25), + consensusVelocityBucketBps: z.number().finite().nullable(), + consensusZScore: z.number().finite().nullable(), + polymarketVelocityBucketBps: z.number().finite().nullable(), + polymarketZScore: z.number().finite().nullable(), + cusumUpScore: z.number().finite(), + cusumDownScore: z.number().finite(), + relativeValueDirection: z.enum(["consensus_above_venue", "venue_above_consensus", "aligned"]), + gapBasis: z.enum(["live_book", "sampled_history_proxy"]), + persistenceMs: nonnegativeIntegerSchema, + mappingStatus: z.string().min(1).max(100).nullable(), + scoreContextActionCount: nonnegativeIntegerSchema + }).strict(), + sourceEvidenceRefs: z.array(sha256Schema).min(1), + ledgerEntryHash: sha256Schema +}).strict(); + +const triageReceiptSchema = z.object({ + decision: z.enum(["drop", "escalate"]), + priority: z.enum(["low", "normal", "high"]), + rationale: z.string().min(1).max(500), + ledgerEntryHash: sha256Schema +}).strict(); + +const thesisReceiptSchema = z.object({ + schemaVersion: z.literal(1), + signalRefSha256: sha256Schema, + fixtureRefSha256: sha256Schema, + marketRefSha256: sha256Schema, + outcome: z.enum(["home", "draw", "away", "over", "under"]), + direction: z.enum(["buy", "sell"]), + recommendation: z.enum(["paper_trade", "no_trade"]), + disclosure: z.literal("private_payload_sha256_commitment"), + thesisPayloadSha256: sha256Schema, + submittedAtTsMs: timestampSchema, + expiresAtTsMs: timestampSchema, + analystModel: z.enum([CLAUDE_MODEL.analyst]), + ledgerEntryHash: sha256Schema +}).strict(); + +const analysisReceiptSchema = z.object({ + signalSourceTsMs: timestampSchema, + signalObservedTsMs: timestampSchema, + decisionLatencyMs: z.number().int().positive().safe(), + readyAtTsMs: timestampSchema, + venuePlacementDelayMs: z.number().int().positive().safe(), + orderEligibleAtTsMs: timestampSchema, + recommendation: z.enum(["paper_trade", "no_trade"]), + ledgerEntryHash: sha256Schema +}).strict(); + +const riskReceiptSchema = z.object({ + decision: z.enum(["approve", "veto"]), + reasons: z.array(z.string().min(1).max(300)), + stakeMicroUsd: nonnegativeIntegerSchema.nullable(), + limitPolicy: z.literal("deterministic_private_fair_value_boundary").nullable(), + realMoneyGate: z.literal("closed").nullable(), + ledgerEntryHash: sha256Schema +}).strict(); + +const intentReceiptSchema = z.object({ + lane: z.enum(["bounty", "long_run"]), + signalRefSha256: sha256Schema, + fixtureRefSha256: sha256Schema, + marketRefSha256: sha256Schema, + outcome: z.string().min(1).max(100), + direction: z.enum(["buy", "sell"]), + stakeMicroUsd: nonnegativeIntegerSchema, + limitPolicy: z.literal("deterministic_private_fair_value_boundary"), + availableShares: z.number().finite().nonnegative(), + ledgerEntryHash: sha256Schema +}).strict(); + +const executionReceiptSchema = z.object({ + adapter: z.literal("paper"), + status: z.enum(["filled", "partial", "no_fill"]), + reason: z.string().min(1).max(300).nullable(), + assetRefSha256: sha256Schema, + conditionRefSha256: sha256Schema, + direction: z.enum(["buy", "sell"]), + requestedStakeMicroUsd: nonnegativeIntegerSchema, + grossMicroUsd: nonnegativeIntegerSchema, + feeMicroUsd: nonnegativeIntegerSchema, + netConsiderationMicroUsd: nonnegativeIntegerSchema, + filledShares: z.number().finite().nonnegative(), + averagePrice: probabilitySchema.nullable(), + bestPrice: probabilitySchema.nullable(), + halfSpreadBps: z.number().finite().nonnegative().nullable(), + executableDepthUsd: z.number().finite().nonnegative(), + slippageProbabilityBps: z.number().finite().nonnegative().nullable(), + bookSourceTsMs: timestampSchema, + bookObservedTsMs: timestampSchema, + bookEvidenceRefSha256: sha256Schema, + feeMetadataRefSha256: sha256Schema, + ledgerEntryHash: sha256Schema +}).strict(); + +const positionReceiptSchema = z.object({ + lane: z.enum(["bounty", "long_run"]), + signalRefSha256: sha256Schema, + fixtureRefSha256: sha256Schema, + marketRefSha256: sha256Schema, + conditionRefSha256: sha256Schema, + assetRefSha256: sha256Schema, + outcome: z.string().min(1).max(100), + lineRefSha256: sha256Schema, + openedAtTsMs: timestampSchema, + filledShares: z.number().finite().positive(), + averageEntryPrice: probabilitySchema, + entryGrossMicroUsd: nonnegativeIntegerSchema, + entryFeeMicroUsd: nonnegativeIntegerSchema, + entryCostMicroUsd: nonnegativeIntegerSchema, + fillStatus: z.enum(["filled", "partial"]), + entryHalfSpreadBps: z.number().finite().nonnegative(), + entrySlippageBps: z.number().finite().nonnegative(), + selectedDepthUsd: z.number().finite().nonnegative(), + ledgerEntryHash: sha256Schema +}).strict(); + +const closeReceiptSchema = z.object({ + cutoffTsMs: timestampSchema, + markedAtTsMs: timestampSchema, + bookSourceTsMs: timestampSchema, + bookObservedTsMs: timestampSchema, + closeBid: probabilitySchema, + closeAsk: probabilitySchema, + closeMidpoint: probabilitySchema, + grossMidpointClvBps: z.number().finite(), + netMidpointClvBps: z.number().finite(), + executableLiquidationClvBps: z.number().finite(), + bookEvidenceRefSha256: sha256Schema, + ledgerEntryHash: sha256Schema +}).strict(); + +const settlementReceiptSchema = z.object({ + settledAtTsMs: timestampSchema, + won: z.boolean(), + payoutMicroUsd: nonnegativeIntegerSchema, + pnlMicroUsd: z.number().int().safe(), + returnBps: z.number().finite(), + entryBrier: z.number().finite().min(0).max(1), + resolutionEvidenceRefSha256: sha256Schema, + ledgerEntryHash: sha256Schema +}).strict(); + +const terminalReceiptSchema = z.object({ + status: z.enum(["dropped", "no_trade", "vetoed", "filled", "partial", "no_fill"]), + reason: z.string().min(1).max(1_000), + decisionLatencyMs: z.number().int().positive().safe().nullable(), + ledgerEntryHash: sha256Schema +}).strict(); + +const finalStatusSchema = z.enum([ + "dropped", + "no_trade", + "vetoed", + "no_fill", + "filled_open", + "filled_marked", + "filled_settled", + "partial_open", + "partial_marked", + "partial_settled" +]); + +const decisionEventKindSchema = z.enum([ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "execution_intent", + "paper_execution", + "case_terminal", + "position_opened", + "position_closed", + "position_settled" +]); + +const lifecycleSchema = z.object({ + finalStatus: finalStatusSchema, + orderedEventKinds: z.array(decisionEventKindSchema).min(3), + signal: signalEvidenceSchema, + triage: triageReceiptSchema, + thesis: thesisReceiptSchema.nullable(), + analysis: analysisReceiptSchema.nullable(), + risk: riskReceiptSchema.nullable(), + intent: intentReceiptSchema.nullable(), + execution: executionReceiptSchema.nullable(), + position: positionReceiptSchema.nullable(), + close: closeReceiptSchema.nullable(), + settlement: settlementReceiptSchema.nullable(), + terminal: terminalReceiptSchema +}).strict(); + +const ledgerEntryCommitmentSchema = z.object({ + sequence: z.number().int().positive().safe(), + kind: decisionEventKindSchema, + atTsMs: timestampSchema, + insertedAtMs: timestampSchema, + entryIdSha256: sha256Schema, + payloadSha256: sha256Schema, + previousHash: sha256Schema, + entryHash: sha256Schema, + hashSchemaVersion: z.literal(2) +}).strict(); + +const ledgerCommitmentSchema = z.object({ + verificationAtGeneration: z.literal("full_v2_chain_valid"), + payloadDisclosure: z.literal("sha256_commitments_only"), + caseIdSha256: sha256Schema, + rowsAtGeneration: z.number().int().positive().safe(), + finalHeadSequence: z.number().int().positive().safe(), + finalHeadHash: sha256Schema, + caseEntries: z.array(ledgerEntryCommitmentSchema).min(3) +}).strict(); + +export const receiptSolanaAnchorSchema = z.object({ + network: z.enum(["mainnet-beta", "devnet"]), + transactionSignature: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{64,88}$/), + slot: z.number().int().nonnegative().safe(), + blockTimeTsMs: timestampSchema.nullable(), + memoProgramId: z.literal("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), + commitmentType: z.literal("decision_ledger_head_sha256"), + committedHash: sha256Schema, + networkVerification: z.literal("not_performed_by_offline_verifier") +}).strict(); + +export const decisionReceiptBodySchema = z.object({ + schemaVersion: z.literal(DECISION_RECEIPT_SCHEMA_VERSION), + receiptType: z.literal("samaritan_decision_receipt"), + receiptId: sha256Schema, + generatedAtTsMs: timestampSchema, + provenance: provenanceSchema, + disclosure: z.object({ + policy: z.literal("hashes_and_derived_signals_only"), + rawTxlineFieldsIncluded: z.literal(false) + }).strict(), + build: buildCommitmentSchema, + sourceEvidence: z.array(receiptSourceEvidenceSchema).min(1), + agents: z.object({ + runs: z.array(receiptAgentRunSchema).min(1).max(2), + totalActualCostNanoUsd: nonnegativeIntegerSchema + }).strict(), + lifecycle: lifecycleSchema, + ledger: ledgerCommitmentSchema, + solanaAnchor: receiptSolanaAnchorSchema.nullable() +}).strict(); + +export const decisionReceiptSchema = decisionReceiptBodySchema.extend({ + integrity: z.object({ + algorithm: z.literal("sha256"), + canonicalization: z.literal(DECISION_RECEIPT_CANONICALIZATION), + domain: z.literal(DECISION_RECEIPT_HASH_DOMAIN), + receiptHash: sha256Schema + }).strict() +}).strict(); + +export type DecisionReceiptBody = z.infer; +export type DecisionReceipt = z.infer; +export type ReceiptSourceEvidence = z.infer; +export type ReceiptAgentRun = z.infer; +export type ReceiptSolanaAnchor = z.infer; + +export function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function receiptReferenceHash(namespace: string, value: string | number): string { + return sha256(`samaritan.receipt.reference/v1\n${namespace}\n${String(value)}`); +} + +export function hashDecisionReceiptBody(body: DecisionReceiptBody): string { + return sha256(`${DECISION_RECEIPT_HASH_DOMAIN}\n${stableJson(body)}`); +} + +function expectedKinds(status: z.infer): DecisionEventKind[] { + if (status === "dropped") { + return ["signal_received", "triage_decision", "case_terminal"]; + } + if (status === "no_trade") { + return [ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "case_terminal" + ]; + } + if (status === "vetoed") { + return [ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "case_terminal" + ]; + } + const base: DecisionEventKind[] = [ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "execution_intent", + "paper_execution", + "case_terminal" + ]; + if (status === "no_fill") return base; + base.push("position_opened"); + if (status.endsWith("_marked") || status.endsWith("_settled")) base.push("position_closed"); + if (status.endsWith("_settled")) base.push("position_settled"); + return base; +} + +function cost(run: ReceiptAgentRun): number { + return ( + run.usage.inputTokens * run.pricing.inputNanoUsdPerToken + + run.usage.outputTokens * run.pricing.outputNanoUsdPerToken + + run.usage.cacheCreationInputTokens * run.pricing.cacheWriteNanoUsdPerToken + + run.usage.cacheReadInputTokens * run.pricing.cacheReadNanoUsdPerToken + ); +} + +/** + * Checks relationships a canonical hash alone cannot express. This is kept + * separate from the structural Zod schema so both the generator and offline + * verifier execute the exact same semantic checks. + */ +export function assertDecisionReceiptSemantics(receipt: DecisionReceipt): void { + const issues: string[] = []; + const fail = (message: string) => issues.push(message); + const { lifecycle, ledger } = receipt; + + const forbiddenPublicKeys = new Set([ + "fairProbability", + "consensusProbability", + "polymarketProbability", + "marketProbability", + "limitProbability", + "Pct", + "Prices", + "rawGap", + "crossMarketGapBps", + "executableGap", + "exactGap", + "gapBps", + "consensusVelocityBps", + "polymarketVelocityBps" + ]); + const visitKeys = (value: unknown): void => { + if (Array.isArray(value)) { + for (const child of value) visitKeys(child); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, child] of Object.entries(value as Record)) { + if (forbiddenPublicKeys.has(key)) fail(`Receipt contains forbidden public field ${key}`); + visitKeys(child); + } + }; + visitKeys(receipt); + + if (receipt.provenance.synthetic) { + if (receipt.provenance.evidenceClass !== "synthetic_proving_fixture") { + fail("Synthetic provenance must use the synthetic proving-fixture class"); + } + if (receipt.provenance.performanceUse !== "excluded_synthetic") { + fail("Synthetic receipts must be excluded from performance evidence"); + } + if (receipt.agents.runs.some((run) => run.invocationClass !== "synthetic_stub")) { + fail("Synthetic receipts cannot claim Anthropic API invocations"); + } + } else { + if (receipt.provenance.evidenceClass !== "captured_paper_case") { + fail("Captured receipts must use the captured paper-case class"); + } + if (receipt.provenance.performanceUse === "excluded_synthetic") { + fail("Captured receipts cannot use the synthetic performance classification"); + } + if (receipt.agents.runs.some((run) => run.invocationClass !== "anthropic_api")) { + fail("Captured receipts require real invocation evidence"); + } + } + + const sourceRefs = new Set(); + for (const source of receipt.sourceEvidence) { + if (sourceRefs.has(source.evidenceRefSha256)) fail("Source evidence references must be unique"); + sourceRefs.add(source.evidenceRefSha256); + const expectedReference = receiptReferenceHash( + `${source.source}_${source.role}_evidence`, + `${source.sourceTsMs}:${source.observedAtTsMs}:${source.payloadSha256}` + ); + if (source.evidenceRefSha256 !== expectedReference) { + fail("Source evidence reference does not match its disclosed commitment fields"); + } + } + for (const reference of lifecycle.signal.sourceEvidenceRefs) { + const source = receipt.sourceEvidence.find((candidate) => candidate.evidenceRefSha256 === reference); + if (!source || source.role !== "signal") fail("Signal evidence reference is missing or has the wrong role"); + } + for (const bucketed of [ + lifecycle.signal.derivedEvidence.consensusVelocityBucketBps, + lifecycle.signal.derivedEvidence.polymarketVelocityBucketBps + ]) { + if (bucketed !== null && Math.abs(bucketed / 25 - Math.round(bucketed / 25)) > 1e-12) { + fail("TXLine-derived movement evidence must use 25-bps buckets"); + } + } + if (!lifecycle.signal.sourceEvidenceRefs.some((reference) => + receipt.sourceEvidence.some((source) => + source.evidenceRefSha256 === reference && source.source === "txline" && source.role === "signal" + ))) { + fail("Signal evidence must commit at least one TXLine source record"); + } + + const stages = receipt.agents.runs.map((run) => run.stage); + if (new Set(stages).size !== stages.length) fail("Agent stages must be unique"); + const expectedStages = lifecycle.thesis === null ? ["triage"] : ["triage", "analyst"]; + if (stableJson(stages) !== stableJson(expectedStages)) fail("Agent runs do not match the decision stages"); + for (const run of receipt.agents.runs) { + const expectedPricing = CLAUDE_PRICING[run.model]; + if ( + run.pricing.inputNanoUsdPerToken !== expectedPricing.inputNanoUsdPerToken || + run.pricing.outputNanoUsdPerToken !== expectedPricing.outputNanoUsdPerToken || + run.pricing.cacheWriteNanoUsdPerToken !== expectedPricing.cacheWriteNanoUsdPerToken || + run.pricing.cacheReadNanoUsdPerToken !== expectedPricing.cacheReadNanoUsdPerToken + ) fail(`Embedded pricing does not match the frozen schedule for ${run.stage}`); + if (run.actualCostNanoUsd !== cost(run)) fail(`Agent cost does not match embedded usage for ${run.stage}`); + if (receipt.provenance.synthetic && run.actualCostNanoUsd !== 0) { + fail("Synthetic stubs must have zero cost"); + } + } + const totalCost = receipt.agents.runs.reduce((sum, run) => sum + run.actualCostNanoUsd, 0); + if (receipt.agents.totalActualCostNanoUsd !== totalCost) fail("Total agent cost is inconsistent"); + const analyst = receipt.agents.runs.find((run) => run.stage === "analyst"); + if (lifecycle.thesis && analyst?.model !== lifecycle.thesis.analystModel) { + fail("Thesis model does not match analyst invocation evidence"); + } + + const expected = expectedKinds(lifecycle.finalStatus); + if (stableJson(lifecycle.orderedEventKinds) !== stableJson(expected)) { + fail("Lifecycle event ordering does not match final status"); + } + if (stableJson(ledger.caseEntries.map((entry) => entry.kind)) !== stableJson(expected)) { + fail("Ledger case commitments do not match lifecycle event ordering"); + } + if (ledger.caseEntries.length !== lifecycle.orderedEventKinds.length) { + fail("Ledger case commitment count does not match lifecycle"); + } + for (let index = 0; index < ledger.caseEntries.length; index += 1) { + const entry = ledger.caseEntries[index]!; + const prior = ledger.caseEntries[index - 1]; + if (prior && entry.sequence <= prior.sequence) fail("Ledger case sequences must increase"); + if (prior && entry.atTsMs < prior.atTsMs) fail("Lifecycle event timestamps must not move backward"); + if (prior && entry.sequence === prior.sequence + 1 && entry.previousHash !== prior.entryHash) { + fail("Contiguous ledger case commitments have a broken hash link"); + } + } + const finalCaseEntry = ledger.caseEntries.at(-1)!; + if (ledger.finalHeadSequence < finalCaseEntry.sequence) fail("Ledger head sequence precedes the case lifecycle"); + if (ledger.finalHeadSequence === finalCaseEntry.sequence && ledger.finalHeadHash !== finalCaseEntry.entryHash) { + fail("Final ledger head does not match the final case entry"); + } + + const lifecycleHashes = [ + lifecycle.signal.ledgerEntryHash, + lifecycle.triage.ledgerEntryHash, + lifecycle.thesis?.ledgerEntryHash, + lifecycle.analysis?.ledgerEntryHash, + lifecycle.risk?.ledgerEntryHash, + lifecycle.intent?.ledgerEntryHash, + lifecycle.execution?.ledgerEntryHash, + lifecycle.terminal.ledgerEntryHash, + lifecycle.position?.ledgerEntryHash, + lifecycle.close?.ledgerEntryHash, + lifecycle.settlement?.ledgerEntryHash + ].filter((value): value is string => value !== undefined); + if (stableJson(lifecycleHashes) !== stableJson(ledger.caseEntries.map((entry) => entry.entryHash))) { + fail("Lifecycle records do not bind to the ordered ledger entry hashes"); + } + + if (lifecycle.triage.decision === "drop") { + if (lifecycle.finalStatus !== "dropped" || lifecycle.thesis !== null) fail("Dropped lifecycle is inconsistent"); + } else if (lifecycle.thesis === null || lifecycle.analysis === null) { + fail("Escalated lifecycle requires a thesis and analysis evidence"); + } + if (lifecycle.thesis) { + if ( + lifecycle.thesis.signalRefSha256 !== lifecycle.signal.signalRefSha256 || + lifecycle.thesis.fixtureRefSha256 !== lifecycle.signal.fixtureRefSha256 || + lifecycle.thesis.marketRefSha256 !== lifecycle.signal.marketRefSha256 || + lifecycle.thesis.outcome !== lifecycle.signal.outcome || + lifecycle.thesis.direction !== lifecycle.signal.direction + ) fail("Thesis identity does not match the signal"); + if (lifecycle.thesis.expiresAtTsMs <= lifecycle.thesis.submittedAtTsMs) { + fail("Thesis expiry must be after submission"); + } + } + if (lifecycle.analysis) { + if ( + lifecycle.analysis.signalSourceTsMs !== lifecycle.signal.sourceTsMs || + lifecycle.analysis.signalObservedTsMs !== lifecycle.signal.observedAtTsMs || + lifecycle.analysis.readyAtTsMs !== lifecycle.signal.observedAtTsMs + lifecycle.analysis.decisionLatencyMs || + lifecycle.analysis.orderEligibleAtTsMs !== + lifecycle.analysis.readyAtTsMs + lifecycle.analysis.venuePlacementDelayMs + ) fail("Analysis readiness timing is inconsistent"); + if (lifecycle.thesis && lifecycle.analysis.recommendation !== lifecycle.thesis.recommendation) { + fail("Analysis recommendation does not match the thesis"); + } + } + + if (lifecycle.finalStatus === "no_trade") { + if (lifecycle.thesis?.recommendation !== "no_trade" || lifecycle.risk !== null) { + fail("No-trade lifecycle is inconsistent"); + } + } + if (lifecycle.finalStatus === "vetoed") { + if (lifecycle.risk?.decision !== "veto" || lifecycle.intent !== null || lifecycle.execution !== null) { + fail("Vetoed lifecycle is inconsistent"); + } + } + if (lifecycle.risk?.decision === "approve" && ( + lifecycle.risk.reasons.length !== 0 || + lifecycle.risk.stakeMicroUsd === null || lifecycle.risk.stakeMicroUsd <= 0 || + lifecycle.risk.limitPolicy !== "deterministic_private_fair_value_boundary" || + lifecycle.risk.realMoneyGate !== "closed" + )) fail("Deterministic approval fields are inconsistent"); + if (lifecycle.risk?.decision === "veto" && ( + lifecycle.risk.reasons.length === 0 || + lifecycle.risk.stakeMicroUsd !== null || + lifecycle.risk.limitPolicy !== null || + lifecycle.risk.realMoneyGate !== null + )) fail("Deterministic veto fields are inconsistent"); + const execution = lifecycle.execution; + if (execution) { + if (lifecycle.risk?.decision !== "approve" || lifecycle.intent === null || lifecycle.analysis === null) { + fail("Execution requires analysis, deterministic approval, and intent"); + } else { + if ( + lifecycle.intent.signalRefSha256 !== lifecycle.signal.signalRefSha256 || + lifecycle.intent.fixtureRefSha256 !== lifecycle.signal.fixtureRefSha256 || + lifecycle.intent.marketRefSha256 !== lifecycle.signal.marketRefSha256 || + lifecycle.intent.outcome !== lifecycle.signal.outcome || + lifecycle.intent.direction !== lifecycle.signal.direction || + lifecycle.intent.stakeMicroUsd !== lifecycle.risk.stakeMicroUsd || + lifecycle.intent.limitPolicy !== lifecycle.risk.limitPolicy + ) fail("Execution intent does not match signal identity or deterministic risk approval"); + if (execution.requestedStakeMicroUsd !== lifecycle.intent.stakeMicroUsd) { + fail("Paper execution stake does not match intent"); + } + if (execution.direction !== lifecycle.intent.direction) { + fail("Paper execution direction does not match intent"); + } + if ( + execution.bookObservedTsMs < lifecycle.analysis.orderEligibleAtTsMs || + execution.bookObservedTsMs < lifecycle.analysis.readyAtTsMs + ) fail("Execution evidence precedes analysis readiness or venue delay"); + } + const executionSource = receipt.sourceEvidence.find((source) => + source.evidenceRefSha256 === execution.bookEvidenceRefSha256 + ); + if ( + !executionSource || executionSource.source !== "polymarket" || executionSource.role !== "execution" || + executionSource.sourceTsMs !== execution.bookSourceTsMs || + executionSource.observedAtTsMs !== execution.bookObservedTsMs + ) fail("Execution book commitment is missing or inconsistent"); + } + + if (lifecycle.finalStatus === "no_fill") { + if (lifecycle.execution?.status !== "no_fill" || lifecycle.position !== null) { + fail("No-fill lifecycle is inconsistent"); + } + if (lifecycle.execution && ( + lifecycle.execution.filledShares !== 0 || + lifecycle.execution.grossMicroUsd !== 0 || + lifecycle.execution.feeMicroUsd !== 0 || + lifecycle.execution.netConsiderationMicroUsd !== 0 || + lifecycle.execution.averagePrice !== null || + lifecycle.execution.reason === null + )) fail("No-fill accounting is inconsistent"); + } + const positionStatus = lifecycle.finalStatus.startsWith("filled_") + ? "filled" + : lifecycle.finalStatus.startsWith("partial_") ? "partial" : null; + if (positionStatus) { + if (lifecycle.execution && ( + lifecycle.execution.filledShares <= 0 || + lifecycle.execution.averagePrice === null || + lifecycle.execution.bestPrice === null || + lifecycle.execution.halfSpreadBps === null || + lifecycle.execution.slippageProbabilityBps === null + )) fail("Filled paper execution lacks required market evidence"); + if (lifecycle.execution?.status !== positionStatus || lifecycle.position?.fillStatus !== positionStatus) { + fail("Position status does not match the paper fill"); + } + if ( + lifecycle.position?.signalRefSha256 !== lifecycle.signal.signalRefSha256 || + lifecycle.position.fixtureRefSha256 !== lifecycle.signal.fixtureRefSha256 || + lifecycle.position.marketRefSha256 !== lifecycle.signal.marketRefSha256 || + lifecycle.position.lineRefSha256 !== lifecycle.signal.lineRefSha256 || + lifecycle.position.openedAtTsMs < (lifecycle.execution?.bookObservedTsMs ?? Number.MAX_SAFE_INTEGER) + ) fail("Position identity or opening time is inconsistent"); + if (lifecycle.position && lifecycle.execution && ( + lifecycle.position.conditionRefSha256 !== lifecycle.execution.conditionRefSha256 || + lifecycle.position.assetRefSha256 !== lifecycle.execution.assetRefSha256 || + lifecycle.position.outcome !== lifecycle.signal.outcome || + lifecycle.position.filledShares !== lifecycle.execution.filledShares || + lifecycle.position.averageEntryPrice !== lifecycle.execution.averagePrice || + lifecycle.position.entryGrossMicroUsd !== lifecycle.execution.grossMicroUsd || + lifecycle.position.entryFeeMicroUsd !== lifecycle.execution.feeMicroUsd || + lifecycle.position.entryCostMicroUsd !== lifecycle.execution.netConsiderationMicroUsd || + lifecycle.position.entryHalfSpreadBps !== lifecycle.execution.halfSpreadBps || + lifecycle.position.entrySlippageBps !== lifecycle.execution.slippageProbabilityBps || + lifecycle.position.selectedDepthUsd !== lifecycle.execution.executableDepthUsd + )) fail("Position accounting does not match the paper fill"); + } + if (lifecycle.finalStatus.endsWith("_open") && (lifecycle.close !== null || lifecycle.settlement !== null)) { + fail("Open lifecycle cannot contain close or settlement evidence"); + } + if (lifecycle.finalStatus.endsWith("_marked") && (lifecycle.close === null || lifecycle.settlement !== null)) { + fail("Marked lifecycle must contain only close evidence"); + } + if (lifecycle.finalStatus.endsWith("_settled") && (lifecycle.close === null || lifecycle.settlement === null)) { + fail("Settled lifecycle requires close and settlement evidence"); + } + if (lifecycle.close) { + if ( + lifecycle.close.closeBid > lifecycle.close.closeAsk || + lifecycle.close.bookSourceTsMs > lifecycle.close.cutoffTsMs || + lifecycle.close.markedAtTsMs < lifecycle.close.bookObservedTsMs + ) fail("Close evidence has impossible price or timing relationships"); + const closeSource = receipt.sourceEvidence.find((source) => + source.evidenceRefSha256 === lifecycle.close?.bookEvidenceRefSha256 + ); + if ( + !closeSource || closeSource.source !== "polymarket" || closeSource.role !== "close" || + closeSource.sourceTsMs !== lifecycle.close.bookSourceTsMs || + closeSource.observedAtTsMs !== lifecycle.close.bookObservedTsMs + ) fail("Close book commitment is missing or inconsistent"); + if (lifecycle.position) { + const expectedMidpoint = (lifecycle.close.closeBid + lifecycle.close.closeAsk) / 2; + const unitCost = lifecycle.position.entryCostMicroUsd / 1_000_000 / lifecycle.position.filledShares; + const tolerance = 1e-8; + if ( + Math.abs(lifecycle.close.closeMidpoint - expectedMidpoint) > tolerance || + Math.abs( + lifecycle.close.grossMidpointClvBps - + (expectedMidpoint - lifecycle.position.averageEntryPrice) * 10_000 + ) > tolerance || + Math.abs(lifecycle.close.netMidpointClvBps - (expectedMidpoint - unitCost) * 10_000) > tolerance || + Math.abs(lifecycle.close.executableLiquidationClvBps - (lifecycle.close.closeBid - unitCost) * 10_000) > tolerance + ) fail("Close CLV arithmetic is inconsistent"); + } + } + if (lifecycle.settlement) { + if (lifecycle.close && lifecycle.settlement.settledAtTsMs < lifecycle.close.markedAtTsMs) { + fail("Settlement precedes the close mark"); + } + const settlementSource = receipt.sourceEvidence.find((source) => + source.evidenceRefSha256 === lifecycle.settlement?.resolutionEvidenceRefSha256 + ); + if ( + !settlementSource || settlementSource.source !== "polymarket" || + settlementSource.role !== "settlement" || + settlementSource.observedAtTsMs !== lifecycle.settlement.settledAtTsMs + ) fail("Settlement commitment is missing or inconsistent"); + if (lifecycle.position) { + const expectedPayout = lifecycle.settlement.won + ? Math.floor(lifecycle.position.filledShares * 1_000_000) + : 0; + const expectedPnl = expectedPayout - lifecycle.position.entryCostMicroUsd; + const expectedReturnBps = lifecycle.position.entryCostMicroUsd === 0 + ? 0 + : expectedPnl / lifecycle.position.entryCostMicroUsd * 10_000; + const expectedBrier = ( + lifecycle.position.averageEntryPrice - (lifecycle.settlement.won ? 1 : 0) + ) ** 2; + if ( + lifecycle.settlement.payoutMicroUsd !== expectedPayout || + lifecycle.settlement.pnlMicroUsd !== expectedPnl || + Math.abs(lifecycle.settlement.returnBps - expectedReturnBps) > 1e-8 || + Math.abs(lifecycle.settlement.entryBrier - expectedBrier) > 1e-12 + ) fail("Settlement accounting is inconsistent"); + } + } + + const expectedTerminalStatus = positionStatus ?? lifecycle.finalStatus; + if (lifecycle.terminal.status !== expectedTerminalStatus) { + fail("Terminal decision status does not match lifecycle status"); + } + if (receipt.solanaAnchor) { + if (receipt.solanaAnchor.committedHash !== ledger.finalHeadHash) { + fail("Solana anchor metadata does not commit the receipt ledger head"); + } + if ( + receipt.solanaAnchor.blockTimeTsMs !== null && + receipt.solanaAnchor.blockTimeTsMs < finalCaseEntry.atTsMs + ) fail("Solana anchor block time precedes the committed lifecycle"); + } + + if (issues.length > 0) throw new Error(issues.join("; ")); +} + +export type DecisionReceiptVerification = { + valid: true; + receiptHash: string; + committedLedgerHead: string; + lifecycleStatus: DecisionReceipt["lifecycle"]["finalStatus"]; + synthetic: boolean; + solanaAnchorMetadataPresent: boolean; + solanaNetworkVerificationPerformed: false; + assurance: [ + "receipt_schema_and_canonical_hash_verified", + "lifecycle_and_commitment_consistency_verified", + "source_payloads_and_local_ledger_not_replayed_by_offline_verifier" + ]; +}; + +/** Offline-only verification. It deliberately does not access Solana or source APIs. */ +export function verifyDecisionReceipt(value: unknown): DecisionReceiptVerification { + const receipt = decisionReceiptSchema.parse(value); + const { integrity: _integrity, ...bodyValue } = receipt; + const body = decisionReceiptBodySchema.parse(bodyValue); + const expectedHash = hashDecisionReceiptBody(body); + if (receipt.integrity.receiptHash !== expectedHash) { + throw new Error("Decision receipt canonical hash mismatch"); + } + assertDecisionReceiptSemantics(receipt); + return { + valid: true, + receiptHash: receipt.integrity.receiptHash, + committedLedgerHead: receipt.ledger.finalHeadHash, + lifecycleStatus: receipt.lifecycle.finalStatus, + synthetic: receipt.provenance.synthetic, + solanaAnchorMetadataPresent: receipt.solanaAnchor !== null, + solanaNetworkVerificationPerformed: false, + assurance: [ + "receipt_schema_and_canonical_hash_verified", + "lifecycle_and_commitment_consistency_verified", + "source_payloads_and_local_ledger_not_replayed_by_offline_verifier" + ] + }; +} diff --git a/src/proof/decision-receipt.ts b/src/proof/decision-receipt.ts new file mode 100644 index 0000000..ba57916 --- /dev/null +++ b/src/proof/decision-receipt.ts @@ -0,0 +1,624 @@ +import { z } from "zod"; +import { tradeThesisSchema, triageDecisionSchema } from "../agents/contracts.js"; +import { stableJson, type JsonValue } from "../domain/json.js"; +import { + DECISION_LEDGER_HASH_SCHEMA_V2, + type DecisionEventKind, + type DecisionLedger, + type DecisionLedgerEntry +} from "../store/decision-ledger.js"; +import { + DECISION_RECEIPT_CANONICALIZATION, + DECISION_RECEIPT_HASH_DOMAIN, + DECISION_RECEIPT_SCHEMA_VERSION, + decisionReceiptBodySchema, + decisionReceiptSchema, + hashDecisionReceiptBody, + receiptAgentRunSchema, + receiptReferenceHash, + receiptSolanaAnchorSchema, + receiptSourceEvidenceSchema, + sha256, + verifyDecisionReceipt, + type DecisionReceipt, + type ReceiptAgentRun, + type ReceiptSolanaAnchor, + type ReceiptSourceEvidence +} from "./decision-receipt-schema.js"; + +const timestampSchema = z.number().int().nonnegative().safe(); +const sha256TextSchema = z.string().regex(/^[0-9a-f]{64}$/); +const moneySchema = z.number().int().nonnegative().safe(); +const probabilitySchema = z.number().finite().min(0).max(1); + +const rawMarketSchema = z.object({ + family: z.enum(["match_result", "total_goals"]), + period: z.enum(["full_time", "first_half", "extra_time", "other"]), + lineMilli: z.number().int().safe().nullable(), + key: z.string().min(1) +}).strict(); + +const rawSignalSchema = z.object({ + signalId: z.string().min(1), + kind: z.enum(["CONSENSUS_MOVE", "XMARKET_DIVERGENCE", "FADER_CANDIDATE"]), + detectedAtTsMs: timestampSchema, + observedAtTsMs: timestampSchema, + fixtureId: z.string().min(1), + market: rawMarketSchema, + outcome: z.enum(["home", "draw", "away", "over", "under"]), + direction: z.enum(["buy", "sell"]), + eligibility: z.enum(["research_only", "pretrade_review_required"]), + reason: z.string().min(1), + evidence: z.object({ + consensusProbability: probabilitySchema, + polymarketProbability: probabilitySchema, + consensusVelocity: z.number().finite().nullable(), + consensusZScore: z.number().finite().nullable(), + polymarketVelocity: z.number().finite().nullable(), + polymarketZScore: z.number().finite().nullable(), + cusumUp: z.number().finite(), + cusumDown: z.number().finite(), + rawGap: z.number().finite(), + gapBasis: z.enum(["live_book", "sampled_history_proxy"]), + persistenceMs: z.number().int().nonnegative().safe(), + mappingStatus: z.string().min(1).nullable(), + scoreContextActions: z.array(z.string()) + }).strict() +}).strict(); + +const signalPayloadSchema = z.object({ + lane: z.enum(["bounty", "long_run"]), + signal: rawSignalSchema +}).strict(); + +const analysisPayloadSchema = z.object({ + signalSourceTsMs: timestampSchema, + signalObservedTsMs: timestampSchema, + decisionLatencyMs: z.number().int().positive().safe(), + readyAtTsMs: timestampSchema, + venuePlacementDelayMs: z.number().int().positive().safe(), + orderEligibleAtTsMs: timestampSchema, + recommendation: z.enum(["paper_trade", "no_trade"]) +}).strict(); + +const riskPayloadSchema = z.discriminatedUnion("decision", [ + z.object({ + decision: z.literal("veto"), + reasons: z.array(z.string().min(1)).min(1) + }).strict(), + z.object({ + decision: z.literal("approve"), + stakeMicroUsd: moneySchema, + limitProbability: probabilitySchema, + realMoneyGate: z.literal("closed") + }).strict() +]); + +const intentPayloadSchema = z.object({ + lane: z.enum(["bounty", "long_run"]), + caseId: z.string().min(1), + signalId: z.string().min(1), + fixtureId: z.string().min(1), + marketKey: z.string().min(1), + outcome: z.string().min(1), + direction: z.enum(["buy", "sell"]), + stakeMicroUsd: moneySchema, + limitProbability: probabilitySchema, + availableShares: z.number().finite().nonnegative() +}).strict(); + +const feePayloadSchema = z.object({ + source: z.literal("polymarket_clob_market_info"), + conditionId: z.string().min(1), + feesEnabled: z.boolean(), + takerFeeRate: z.number().finite().nonnegative(), + feeCurveExponent: z.number().finite().positive(), + takerOnly: z.boolean(), + minimumOrderSize: z.number().finite().positive(), + minimumTickSize: z.number().finite().positive(), + fetchedAtTsMs: timestampSchema +}).strict(); + +const fillPayloadSchema = z.object({ + adapter: z.literal("paper"), + status: z.enum(["filled", "partial", "no_fill"]), + reason: z.string().min(1).nullable(), + assetId: z.string().min(1), + conditionId: z.string().min(1), + direction: z.enum(["buy", "sell"]), + requestedStakeMicroUsd: moneySchema, + grossMicroUsd: moneySchema, + feeMicroUsd: moneySchema, + netConsiderationMicroUsd: moneySchema, + filledShares: z.number().finite().nonnegative(), + averagePrice: probabilitySchema.nullable(), + bestPrice: probabilitySchema.nullable(), + halfSpreadBps: z.number().finite().nonnegative().nullable(), + executableDepthUsd: z.number().finite().nonnegative(), + slippageProbabilityBps: z.number().finite().nonnegative().nullable(), + bookObservedTsMs: timestampSchema, + feeParameters: feePayloadSchema +}).strict(); + +const closePayloadSchema = z.object({ + cutoffTsMs: timestampSchema, + markedAtTsMs: timestampSchema, + bookSourceTsMs: timestampSchema, + bookObservedTsMs: timestampSchema, + closeBid: probabilitySchema, + closeAsk: probabilitySchema, + closeMidpoint: probabilitySchema, + grossMidpointClvBps: z.number().finite(), + netMidpointClvBps: z.number().finite(), + executableLiquidationClvBps: z.number().finite() +}).strict(); + +const settlementPayloadSchema = z.object({ + settledAtTsMs: timestampSchema, + won: z.boolean(), + payoutMicroUsd: moneySchema, + pnlMicroUsd: z.number().int().safe(), + returnBps: z.number().finite(), + entryBrier: z.number().finite().min(0).max(1) +}).strict(); + +const positionPayloadSchema = z.object({ + caseId: z.string().min(1), + lane: z.enum(["bounty", "long_run"]), + signalId: z.string().min(1), + fixtureId: z.string().min(1), + marketKey: z.string().min(1), + conditionId: z.string().min(1), + assetId: z.string().min(1), + outcome: z.string().min(1), + selectedLineMilli: z.number().int().safe(), + openedAtTsMs: timestampSchema, + filledShares: z.number().finite().positive(), + averageEntryPrice: probabilitySchema, + entryGrossMicroUsd: moneySchema, + entryFeeMicroUsd: moneySchema, + entryCostMicroUsd: moneySchema, + fillStatus: z.enum(["filled", "partial"]), + entryHalfSpreadBps: z.number().finite().nonnegative(), + entrySlippageBps: z.number().finite().nonnegative(), + selectedDepthUsd: z.number().finite().nonnegative(), + status: z.literal("open"), + closeMark: z.null(), + settlement: z.null() +}).strict(); + +const terminalPayloadSchema = z.object({ + status: z.enum(["dropped", "no_trade", "vetoed", "filled", "partial", "no_fill"]), + reason: z.string().min(1), + decisionLatencyMs: z.number().int().positive().safe().optional() +}).strict(); + +type BuildInput = { + codeVersion: string; + codeSha256: string; + configSha256: string; +}; + +type ProvenanceInput = { + evidenceClass: "synthetic_proving_fixture" | "captured_paper_case"; + synthetic: boolean; + performanceUse: "excluded_synthetic" | "excluded_unregistered" | "subject_to_registered_evaluation"; + label: string; +}; + +export type DecisionReceiptGenerationContext = { + generatedAtTsMs: number; + provenance: ProvenanceInput; + build: BuildInput; + sourceEvidence: ReceiptSourceEvidence[]; + agentRuns: ReceiptAgentRun[]; + executionBookEvidenceRefSha256?: string; + closeBookEvidenceRefSha256?: string; + settlementEvidenceRefSha256?: string; + solanaAnchor?: ReceiptSolanaAnchor | null; +}; + +const receiptKinds = new Set([ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "execution_intent", + "paper_execution", + "case_terminal", + "position_opened", + "position_closed", + "position_settled" +]); + +function one(entries: DecisionLedgerEntry[], kind: DecisionEventKind): DecisionLedgerEntry | null { + const matching = entries.filter((entry) => entry.kind === kind); + if (matching.length > 1) throw new Error(`Decision receipt lifecycle repeats ${kind}`); + return matching[0] ?? null; +} + +function required(entries: DecisionLedgerEntry[], kind: DecisionEventKind): DecisionLedgerEntry { + const entry = one(entries, kind); + if (!entry) throw new Error(`Decision receipt lifecycle is missing ${kind}`); + return entry; +} + +function reference(namespace: string, value: string | number): string { + return receiptReferenceHash(namespace, value); +} + +function bucketMovementBps(value: number): number { + const bucketed = Math.round(value / 25) * 25; + return Object.is(bucketed, -0) ? 0 : bucketed; +} + +function lifecycleStatus(input: { + triage: z.infer; + thesis: z.infer | null; + risk: z.infer | null; + fill: z.infer | null; + position: z.infer | null; + close: z.infer | null; + settlement: z.infer | null; +}): DecisionReceipt["lifecycle"]["finalStatus"] { + if (input.triage.decision === "drop") return "dropped"; + if (input.thesis?.recommendation === "no_trade") return "no_trade"; + if (input.risk?.decision === "veto") return "vetoed"; + if (input.fill?.status === "no_fill") return "no_fill"; + if (!input.fill || !input.position || !["filled", "partial"].includes(input.fill.status)) { + throw new Error("Executed decision receipt requires a fill/no-fill result and any filled position"); + } + const suffix = input.settlement ? "settled" : input.close ? "marked" : "open"; + return `${input.fill.status}_${suffix}` as DecisionReceipt["lifecycle"]["finalStatus"]; +} + +function entryCommitment(entry: DecisionLedgerEntry): DecisionReceipt["ledger"]["caseEntries"][number] { + if (entry.hashSchemaVersion !== DECISION_LEDGER_HASH_SCHEMA_V2) { + throw new Error(`Decision receipt refuses non-v2 ledger entry ${entry.entryId}`); + } + if (!receiptKinds.has(entry.kind)) throw new Error(`Unsupported receipt event kind: ${entry.kind}`); + return { + sequence: entry.sequence, + kind: entry.kind as DecisionReceipt["ledger"]["caseEntries"][number]["kind"], + atTsMs: entry.atTsMs, + insertedAtMs: entry.insertedAtMs, + entryIdSha256: reference("decision_entry_id", entry.entryId), + payloadSha256: sha256(stableJson(entry.payload)), + previousHash: entry.previousHash, + entryHash: entry.entryHash, + hashSchemaVersion: DECISION_LEDGER_HASH_SCHEMA_V2 + }; +} + +function selectedSource( + evidence: ReceiptSourceEvidence[], + evidenceRefSha256: string | undefined, + role: ReceiptSourceEvidence["role"] +): ReceiptSourceEvidence { + if (!evidenceRefSha256) throw new Error(`Decision receipt context lacks ${role} evidence reference`); + const source = evidence.find((candidate) => candidate.evidenceRefSha256 === evidenceRefSha256); + if (!source || source.role !== role) throw new Error(`Decision receipt ${role} evidence reference is invalid`); + return source; +} + +function json(value: unknown): JsonValue { + return JSON.parse(JSON.stringify(value)) as JsonValue; +} + +/** + * Generates a public, licence-safe receipt from a locally verified, all-v2 + * decision ledger. Raw source payloads never leave the ledger: only their + * caller-supplied SHA-256 commitments and derived signal metrics are exposed. + */ +export function generateDecisionReceipt(input: { + ledger: DecisionLedger; + caseId: string; + context: DecisionReceiptGenerationContext; +}): DecisionReceipt { + const verification = input.ledger.verifyChain(); + if ( + verification.rows === 0 || + verification.legacyV1Rows !== 0 || + verification.v2Rows !== verification.rows || + verification.hashSchemaVersions.length !== 1 || + verification.hashSchemaVersions[0] !== DECISION_LEDGER_HASH_SCHEMA_V2 + ) { + throw new Error("Decision receipts require a verified, entirely v2 decision ledger"); + } + const allEntries = input.ledger.entries(); + const studyEntries = allEntries.filter((entry) => entry.kind === "study_initialized"); + if (studyEntries.length !== 1 || studyEntries[0]!.hashSchemaVersion !== DECISION_LEDGER_HASH_SCHEMA_V2) { + throw new Error("Decision receipts require exactly one v2 study initialization commitment"); + } + const studyInitialization = z.object({ configHash: sha256TextSchema }).passthrough() + .parse(studyEntries[0]!.payload); + if (studyInitialization.configHash !== input.context.build.configSha256) { + throw new Error("Receipt config hash does not match the ledgered study initialization"); + } + const entries = input.ledger.entries(input.caseId); + if (entries.length === 0) throw new Error(`No decision lifecycle exists for ${input.caseId}`); + if (entries.some((entry) => !receiptKinds.has(entry.kind))) { + throw new Error("Decision receipt case contains unsupported lifecycle events"); + } + if (entries.some((entry) => entry.hashSchemaVersion !== DECISION_LEDGER_HASH_SCHEMA_V2)) { + throw new Error("Decision receipt lifecycle must use ledger hash schema v2 only"); + } + + const signalEntry = required(entries, "signal_received"); + const triageEntry = required(entries, "triage_decision"); + const terminalEntry = required(entries, "case_terminal"); + const signalPayload = signalPayloadSchema.parse(signalEntry.payload); + const signal = signalPayload.signal; + const triage = triageDecisionSchema.parse(triageEntry.payload); + const thesisEntry = one(entries, "thesis_submitted"); + const thesis = thesisEntry ? tradeThesisSchema.parse(thesisEntry.payload) : null; + const analysisEntry = one(entries, "analysis_completed"); + const analysis = analysisEntry ? analysisPayloadSchema.parse(analysisEntry.payload) : null; + const riskEntry = one(entries, "risk_verdict"); + const risk = riskEntry ? riskPayloadSchema.parse(riskEntry.payload) : null; + const intentEntry = one(entries, "execution_intent"); + const intent = intentEntry ? intentPayloadSchema.parse(intentEntry.payload) : null; + const executionEntry = one(entries, "paper_execution"); + const fill = executionEntry ? fillPayloadSchema.parse(executionEntry.payload) : null; + const terminal = terminalPayloadSchema.parse(terminalEntry.payload); + const positionEntry = one(entries, "position_opened"); + const position = positionEntry ? positionPayloadSchema.parse(positionEntry.payload) : null; + const closeEntry = one(entries, "position_closed"); + const close = closeEntry ? closePayloadSchema.parse(closeEntry.payload) : null; + const settlementEntry = one(entries, "position_settled"); + const settlement = settlementEntry ? settlementPayloadSchema.parse(settlementEntry.payload) : null; + + const status = lifecycleStatus({ triage, thesis, risk, fill, position, close, settlement }); + const signalRefSha256 = reference("signal", signal.signalId); + const fixtureRefSha256 = reference("fixture", signal.fixtureId); + const marketRefSha256 = reference("market", signal.market.key); + const lineRefSha256 = signal.market.lineMilli === null + ? null + : reference("market_line_milli", signal.market.lineMilli); + const sources = input.context.sourceEvidence.map((source) => receiptSourceEvidenceSchema.parse(source)); + const signalSourceRefs = sources + .filter((source) => source.role === "signal") + .map((source) => source.evidenceRefSha256); + const agentRuns = input.context.agentRuns + .map((run) => receiptAgentRunSchema.parse(run)) + .sort((left, right) => (left.stage === "triage" ? -1 : 1) - (right.stage === "triage" ? -1 : 1)); + const executionSource = fill + ? selectedSource(sources, input.context.executionBookEvidenceRefSha256, "execution") + : null; + const closeSource = close + ? selectedSource(sources, input.context.closeBookEvidenceRefSha256, "close") + : null; + const settlementSource = settlement + ? selectedSource(sources, input.context.settlementEvidenceRefSha256, "settlement") + : null; + + if (executionSource && executionSource.observedAtTsMs !== fill!.bookObservedTsMs) { + throw new Error("Execution evidence observation time does not match the ledgered paper fill"); + } + if (closeSource && ( + closeSource.sourceTsMs !== close!.bookSourceTsMs || + closeSource.observedAtTsMs !== close!.bookObservedTsMs + )) throw new Error("Close evidence timestamps do not match the ledgered close mark"); + if (settlementSource && settlementSource.observedAtTsMs !== settlement!.settledAtTsMs) { + throw new Error("Settlement evidence time does not match the ledgered settlement"); + } + + const ledgerHash = (entry: DecisionLedgerEntry | null): string | null => entry?.entryHash ?? null; + const orderedEntries = [...entries].sort((left, right) => left.sequence - right.sequence); + const bodyCandidate = { + schemaVersion: DECISION_RECEIPT_SCHEMA_VERSION, + receiptType: "samaritan_decision_receipt" as const, + receiptId: reference("decision_receipt", `${input.caseId}:${verification.headHash}`), + generatedAtTsMs: input.context.generatedAtTsMs, + provenance: input.context.provenance, + disclosure: { + policy: "hashes_and_derived_signals_only" as const, + rawTxlineFieldsIncluded: false as const + }, + build: input.context.build, + sourceEvidence: sources, + agents: { + runs: agentRuns, + totalActualCostNanoUsd: agentRuns.reduce((sum, run) => sum + run.actualCostNanoUsd, 0) + }, + lifecycle: { + finalStatus: status, + orderedEventKinds: orderedEntries.map((entry) => entry.kind), + signal: { + signalRefSha256, + fixtureRefSha256, + marketRefSha256, + lineRefSha256, + detector: signal.kind, + sourceTsMs: signal.detectedAtTsMs, + observedAtTsMs: signal.observedAtTsMs, + marketFamily: signal.market.family, + marketPeriod: signal.market.period, + outcome: signal.outcome, + direction: signal.direction, + eligibility: signal.eligibility, + derivedEvidence: { + txlineMovementBucketBps: 25 as const, + consensusVelocityBucketBps: signal.evidence.consensusVelocity === null + ? null + : bucketMovementBps(signal.evidence.consensusVelocity * 10_000), + consensusZScore: signal.evidence.consensusZScore, + polymarketVelocityBucketBps: signal.evidence.polymarketVelocity === null + ? null + : bucketMovementBps(signal.evidence.polymarketVelocity * 10_000), + polymarketZScore: signal.evidence.polymarketZScore, + cusumUpScore: signal.evidence.cusumUp, + cusumDownScore: signal.evidence.cusumDown, + relativeValueDirection: signal.evidence.rawGap > 0 + ? "consensus_above_venue" as const + : signal.evidence.rawGap < 0 ? "venue_above_consensus" as const : "aligned" as const, + gapBasis: signal.evidence.gapBasis, + persistenceMs: signal.evidence.persistenceMs, + mappingStatus: signal.evidence.mappingStatus, + scoreContextActionCount: signal.evidence.scoreContextActions.length + }, + sourceEvidenceRefs: signalSourceRefs, + ledgerEntryHash: signalEntry.entryHash + }, + triage: { ...triage, ledgerEntryHash: triageEntry.entryHash }, + thesis: thesis && thesisEntry ? { + schemaVersion: thesis.schemaVersion, + signalRefSha256: reference("signal", thesis.signalId), + fixtureRefSha256: reference("fixture", thesis.fixtureId), + marketRefSha256: reference("market", thesis.marketKey), + outcome: thesis.outcome, + direction: thesis.direction, + recommendation: thesis.recommendation, + disclosure: "private_payload_sha256_commitment" as const, + thesisPayloadSha256: sha256(stableJson(thesis)), + submittedAtTsMs: thesis.submittedAtTsMs, + expiresAtTsMs: thesis.expiresAtTsMs, + analystModel: thesis.analystModel, + ledgerEntryHash: thesisEntry.entryHash + } : null, + analysis: analysis && analysisEntry ? { + ...analysis, + ledgerEntryHash: analysisEntry.entryHash + } : null, + risk: risk && riskEntry ? risk.decision === "approve" ? { + decision: risk.decision, + reasons: [], + stakeMicroUsd: risk.stakeMicroUsd, + limitPolicy: "deterministic_private_fair_value_boundary" as const, + realMoneyGate: risk.realMoneyGate, + ledgerEntryHash: riskEntry.entryHash + } : { + decision: risk.decision, + reasons: risk.reasons, + stakeMicroUsd: null, + limitPolicy: null, + realMoneyGate: null, + ledgerEntryHash: riskEntry.entryHash + } : null, + intent: intent && intentEntry ? { + lane: intent.lane, + signalRefSha256: reference("signal", intent.signalId), + fixtureRefSha256: reference("fixture", intent.fixtureId), + marketRefSha256: reference("market", intent.marketKey), + outcome: intent.outcome, + direction: intent.direction, + stakeMicroUsd: intent.stakeMicroUsd, + limitPolicy: "deterministic_private_fair_value_boundary" as const, + availableShares: intent.availableShares, + ledgerEntryHash: intentEntry.entryHash + } : null, + execution: fill && executionEntry && executionSource ? { + adapter: fill.adapter, + status: fill.status, + reason: fill.reason, + assetRefSha256: reference("polymarket_asset", fill.assetId), + conditionRefSha256: reference("polymarket_condition", fill.conditionId), + direction: fill.direction, + requestedStakeMicroUsd: fill.requestedStakeMicroUsd, + grossMicroUsd: fill.grossMicroUsd, + feeMicroUsd: fill.feeMicroUsd, + netConsiderationMicroUsd: fill.netConsiderationMicroUsd, + filledShares: fill.filledShares, + averagePrice: fill.averagePrice, + bestPrice: fill.bestPrice, + halfSpreadBps: fill.halfSpreadBps, + executableDepthUsd: fill.executableDepthUsd, + slippageProbabilityBps: fill.slippageProbabilityBps, + bookSourceTsMs: executionSource.sourceTsMs, + bookObservedTsMs: fill.bookObservedTsMs, + bookEvidenceRefSha256: executionSource.evidenceRefSha256, + feeMetadataRefSha256: sha256(stableJson(fill.feeParameters)), + ledgerEntryHash: executionEntry.entryHash + } : null, + position: position && positionEntry ? { + lane: position.lane, + signalRefSha256: reference("signal", position.signalId), + fixtureRefSha256: reference("fixture", position.fixtureId), + marketRefSha256: reference("market", position.marketKey), + conditionRefSha256: reference("polymarket_condition", position.conditionId), + assetRefSha256: reference("polymarket_asset", position.assetId), + outcome: position.outcome, + lineRefSha256: reference("market_line_milli", position.selectedLineMilli), + openedAtTsMs: position.openedAtTsMs, + filledShares: position.filledShares, + averageEntryPrice: position.averageEntryPrice, + entryGrossMicroUsd: position.entryGrossMicroUsd, + entryFeeMicroUsd: position.entryFeeMicroUsd, + entryCostMicroUsd: position.entryCostMicroUsd, + fillStatus: position.fillStatus, + entryHalfSpreadBps: position.entryHalfSpreadBps, + entrySlippageBps: position.entrySlippageBps, + selectedDepthUsd: position.selectedDepthUsd, + ledgerEntryHash: positionEntry.entryHash + } : null, + close: close && closeEntry && closeSource ? { + ...close, + bookEvidenceRefSha256: closeSource.evidenceRefSha256, + ledgerEntryHash: closeEntry.entryHash + } : null, + settlement: settlement && settlementEntry && settlementSource ? { + ...settlement, + resolutionEvidenceRefSha256: settlementSource.evidenceRefSha256, + ledgerEntryHash: settlementEntry.entryHash + } : null, + terminal: { + status: terminal.status, + reason: terminal.reason, + decisionLatencyMs: terminal.decisionLatencyMs ?? null, + ledgerEntryHash: terminalEntry.entryHash + } + }, + ledger: { + verificationAtGeneration: "full_v2_chain_valid" as const, + payloadDisclosure: "sha256_commitments_only" as const, + caseIdSha256: reference("case", input.caseId), + rowsAtGeneration: verification.rows, + finalHeadSequence: allEntries.at(-1)!.sequence, + finalHeadHash: verification.headHash, + caseEntries: orderedEntries.map(entryCommitment) + }, + solanaAnchor: input.context.solanaAnchor === undefined || input.context.solanaAnchor === null + ? null + : receiptSolanaAnchorSchema.parse(input.context.solanaAnchor) + }; + const body = decisionReceiptBodySchema.parse(bodyCandidate); + const receipt = decisionReceiptSchema.parse({ + ...body, + integrity: { + algorithm: "sha256", + canonicalization: DECISION_RECEIPT_CANONICALIZATION, + domain: DECISION_RECEIPT_HASH_DOMAIN, + receiptHash: hashDecisionReceiptBody(body) + } + }); + verifyDecisionReceipt(receipt); + return receipt; +} + +/** Helper for callers committing an already-canonical source record. */ +export function sourceEvidenceCommitment(input: { + source: ReceiptSourceEvidence["source"]; + role: ReceiptSourceEvidence["role"]; + sourceTsMs: number; + observedAtTsMs: number; + payload: JsonValue; +}): ReceiptSourceEvidence { + const payloadSha256 = sha256(stableJson(input.payload)); + return receiptSourceEvidenceSchema.parse({ + evidenceRefSha256: reference( + `${input.source}_${input.role}_evidence`, + `${input.sourceTsMs}:${input.observedAtTsMs}:${payloadSha256}` + ), + source: input.source, + role: input.role, + sourceTsMs: input.sourceTsMs, + observedAtTsMs: input.observedAtTsMs, + payloadSha256, + disclosure: "hash_only" + }); +} + +export function hashJsonEvidence(value: unknown): string { + return sha256(stableJson(json(value))); +} diff --git a/src/proof/synthetic-decision-receipt.ts b/src/proof/synthetic-decision-receipt.ts new file mode 100644 index 0000000..aa2276c --- /dev/null +++ b/src/proof/synthetic-decision-receipt.ts @@ -0,0 +1,320 @@ +import { CLAUDE_MODEL, CLAUDE_PRICING } from "../agents/claude-pricing.js"; +import { DecisionLedger, type DecisionEventKind } from "../store/decision-ledger.js"; +import { + generateDecisionReceipt, + sourceEvidenceCommitment +} from "./decision-receipt.js"; +import { + receiptReferenceHash, + sha256, + type DecisionReceipt, + type ReceiptAgentRun +} from "./decision-receipt-schema.js"; + +const BASE_TS_MS = 1_700_000_000_000; +const CASE_ID = "synthetic-case-receipt-v1"; +const SIGNAL_ID = "synthetic-signal-receipt-v1"; +const FIXTURE_ID = "synthetic-fixture-receipt-v1"; +const MARKET_KEY = `${FIXTURE_ID}:total_goals:full_time:2500`; +const CONDITION_ID = "synthetic-condition-v1"; +const ASSET_ID = "synthetic-over-asset-v1"; + +function append( + ledger: DecisionLedger, + sequence: number, + kind: DecisionEventKind, + atTsMs: number, + payload: Parameters[0]["payload"] +): void { + ledger.append({ + entryId: `${CASE_ID}:${sequence}:${kind}`, + caseId: CASE_ID, + kind, + atTsMs, + insertedAtMs: atTsMs + 10, + payload + }); +} + +function syntheticRun(stage: "triage" | "analyst"): ReceiptAgentRun { + const model = stage === "triage" ? CLAUDE_MODEL.triage : CLAUDE_MODEL.analyst; + const pricing = CLAUDE_PRICING[model]; + return { + stage, + invocationClass: "synthetic_stub", + model, + promptVersion: stage === "triage" ? "triage-v1" : "analyst-v1", + promptSha256: sha256(`synthetic:${stage}:prompt`), + responseSha256: sha256(`synthetic:${stage}:response`), + billingEvidenceRefSha256: receiptReferenceHash("synthetic_billing", stage), + status: "success", + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + }, + pricing: { + pricingVersion: "anthropic-public-2026-07-12", + currency: "nano_usd", + inputNanoUsdPerToken: pricing.inputNanoUsdPerToken, + outputNanoUsdPerToken: pricing.outputNanoUsdPerToken, + cacheWriteNanoUsdPerToken: pricing.cacheWriteNanoUsdPerToken, + cacheReadNanoUsdPerToken: pricing.cacheReadNanoUsdPerToken + }, + actualCostNanoUsd: 0 + }; +} + +/** + * Deterministic proving fixture. It is synthetic, invokes no external model, + * venue, API, wallet, or chain, and is permanently excluded from performance. + */ +export function buildSyntheticDecisionReceipt(): DecisionReceipt { + const ledger = new DecisionLedger(":memory:"); + try { + ledger.append({ + entryId: "synthetic-study-initialized-v1", + caseId: "synthetic-study", + kind: "study_initialized", + atTsMs: BASE_TS_MS - 1_000, + insertedAtMs: BASE_TS_MS - 990, + payload: { + protocolVersion: "synthetic-proof-only-v1", + configHash: sha256("synthetic-config-v1"), + realMoneyGate: "closed", + performanceEligible: false + } + }); + + const averageEntryPrice = 2.93 / 5.5; + const unitCost = 3 / 5.5; + append(ledger, 1, "signal_received", BASE_TS_MS, { + lane: "bounty", + signal: { + signalId: SIGNAL_ID, + kind: "CONSENSUS_MOVE", + detectedAtTsMs: BASE_TS_MS, + observedAtTsMs: BASE_TS_MS, + fixtureId: FIXTURE_ID, + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: MARKET_KEY + }, + outcome: "over", + direction: "buy", + eligibility: "pretrade_review_required", + reason: "Synthetic proving signal; never a performance observation.", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.52, + consensusVelocity: 0.01, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.03, + gapBasis: "live_book", + persistenceMs: 60_000, + mappingStatus: "verified", + scoreContextActions: [] + } + } + }); + append(ledger, 2, "triage_decision", BASE_TS_MS + 100, { + decision: "escalate", + priority: "normal", + rationale: "Synthetic evidence satisfies the proving-fixture escalation path." + }); + append(ledger, 3, "thesis_submitted", BASE_TS_MS + 300, { + schemaVersion: 1, + signalId: SIGNAL_ID, + fixtureId: FIXTURE_ID, + marketKey: MARKET_KEY, + outcome: "over", + direction: "buy", + recommendation: "paper_trade", + fairProbability: 0.55, + thesisSummary: "Synthetic consensus movement remains ahead of the synthetic paper book.", + evidenceFor: ["The synthetic executable gap exceeds the frozen proving threshold."], + steelmanAgainst: "The synthetic movement could reverse before the synthetic cutoff.", + invalidationConditions: ["The synthetic ask reaches the deterministic limit."], + submittedAtTsMs: BASE_TS_MS + 300, + expiresAtTsMs: BASE_TS_MS + 900_300, + analystModel: CLAUDE_MODEL.analyst + }); + append(ledger, 4, "analysis_completed", BASE_TS_MS + 300, { + signalSourceTsMs: BASE_TS_MS, + signalObservedTsMs: BASE_TS_MS, + decisionLatencyMs: 300, + readyAtTsMs: BASE_TS_MS + 300, + venuePlacementDelayMs: 1_000, + orderEligibleAtTsMs: BASE_TS_MS + 1_300, + recommendation: "paper_trade" + }); + append(ledger, 5, "risk_verdict", BASE_TS_MS + 2_500, { + decision: "approve", + stakeMicroUsd: 3_000_000, + limitProbability: 0.54, + realMoneyGate: "closed" + }); + append(ledger, 6, "execution_intent", BASE_TS_MS + 2_500, { + lane: "bounty", + caseId: CASE_ID, + signalId: SIGNAL_ID, + fixtureId: FIXTURE_ID, + marketKey: MARKET_KEY, + outcome: "over", + direction: "buy", + stakeMicroUsd: 3_000_000, + limitProbability: 0.54, + availableShares: 0 + }); + append(ledger, 7, "paper_execution", BASE_TS_MS + 2_500, { + adapter: "paper", + status: "filled", + reason: null, + assetId: ASSET_ID, + conditionId: CONDITION_ID, + direction: "buy", + requestedStakeMicroUsd: 3_000_000, + grossMicroUsd: 2_930_000, + feeMicroUsd: 70_000, + netConsiderationMicroUsd: 3_000_000, + filledShares: 5.5, + averagePrice: averageEntryPrice, + bestPrice: 0.52, + halfSpreadBps: 100, + executableDepthUsd: 10, + slippageProbabilityBps: (averageEntryPrice - 0.52) * 10_000, + bookObservedTsMs: BASE_TS_MS + 2_500, + feeParameters: { + source: "polymarket_clob_market_info", + conditionId: CONDITION_ID, + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: BASE_TS_MS + 2_500 + } + }); + append(ledger, 8, "case_terminal", BASE_TS_MS + 2_500, { + status: "filled", + reason: "filled" + }); + append(ledger, 9, "position_opened", BASE_TS_MS + 2_500, { + caseId: CASE_ID, + lane: "bounty", + signalId: SIGNAL_ID, + fixtureId: FIXTURE_ID, + marketKey: MARKET_KEY, + conditionId: CONDITION_ID, + assetId: ASSET_ID, + outcome: "over", + selectedLineMilli: 2_500, + openedAtTsMs: BASE_TS_MS + 2_500, + filledShares: 5.5, + averageEntryPrice, + entryGrossMicroUsd: 2_930_000, + entryFeeMicroUsd: 70_000, + entryCostMicroUsd: 3_000_000, + fillStatus: "filled", + entryHalfSpreadBps: 100, + entrySlippageBps: (averageEntryPrice - 0.52) * 10_000, + selectedDepthUsd: 10, + status: "open", + closeMark: null, + settlement: null + }); + append(ledger, 10, "position_closed", BASE_TS_MS + 5_000, { + cutoffTsMs: BASE_TS_MS + 5_000, + markedAtTsMs: BASE_TS_MS + 5_000, + bookSourceTsMs: BASE_TS_MS + 4_930, + bookObservedTsMs: BASE_TS_MS + 5_000, + closeBid: 0.57, + closeAsk: 0.59, + closeMidpoint: 0.58, + grossMidpointClvBps: (0.58 - averageEntryPrice) * 10_000, + netMidpointClvBps: (0.58 - unitCost) * 10_000, + executableLiquidationClvBps: (0.57 - unitCost) * 10_000 + }); + append(ledger, 11, "position_settled", BASE_TS_MS + 10_000, { + settledAtTsMs: BASE_TS_MS + 10_000, + won: true, + payoutMicroUsd: 5_500_000, + pnlMicroUsd: 2_500_000, + returnBps: 2_500_000 / 3_000_000 * 10_000, + entryBrier: (averageEntryPrice - 1) ** 2 + }); + + const sourceEvidence = [ + sourceEvidenceCommitment({ + source: "txline", + role: "signal", + sourceTsMs: BASE_TS_MS - 64, + observedAtTsMs: BASE_TS_MS, + payload: { synthetic: true, record: "private-txline-signal-source" } + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "signal", + sourceTsMs: BASE_TS_MS - 25, + observedAtTsMs: BASE_TS_MS, + payload: { synthetic: true, record: "private-polymarket-signal-source" } + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "execution", + sourceTsMs: BASE_TS_MS + 2_436, + observedAtTsMs: BASE_TS_MS + 2_500, + payload: { synthetic: true, record: "private-execution-book" } + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "close", + sourceTsMs: BASE_TS_MS + 4_930, + observedAtTsMs: BASE_TS_MS + 5_000, + payload: { synthetic: true, record: "private-close-book" } + }), + sourceEvidenceCommitment({ + source: "polymarket", + role: "settlement", + sourceTsMs: BASE_TS_MS + 9_950, + observedAtTsMs: BASE_TS_MS + 10_000, + payload: { synthetic: true, record: "private-resolution-event" } + }) + ]; + + return generateDecisionReceipt({ + ledger, + caseId: CASE_ID, + context: { + generatedAtTsMs: BASE_TS_MS + 11_000, + provenance: { + evidenceClass: "synthetic_proving_fixture", + synthetic: true, + performanceUse: "excluded_synthetic", + label: "SYNTHETIC PROVING FIXTURE — no real match, feed, model call, order, fill, or performance evidence" + }, + build: { + codeVersion: "decision-receipt-v1-synthetic-fixture", + codeSha256: sha256("synthetic-code-bundle-v1"), + configSha256: sha256("synthetic-config-v1") + }, + sourceEvidence, + agentRuns: [syntheticRun("triage"), syntheticRun("analyst")], + executionBookEvidenceRefSha256: sourceEvidence[2]!.evidenceRefSha256, + closeBookEvidenceRefSha256: sourceEvidence[3]!.evidenceRefSha256, + settlementEvidenceRefSha256: sourceEvidence[4]!.evidenceRefSha256, + solanaAnchor: null + } + }); + } finally { + ledger.close(); + } +} diff --git a/src/proof/verify-decision-receipt.ts b/src/proof/verify-decision-receipt.ts new file mode 100644 index 0000000..f62128d --- /dev/null +++ b/src/proof/verify-decision-receipt.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { verifyDecisionReceipt } from "./decision-receipt-schema.js"; + +function usage(): never { + throw new Error("Usage: pnpm receipt:verify -- "); +} + +function main(): void { + const args = process.argv.slice(2).filter((argument) => argument !== "--"); + if (args.length !== 1 || !args[0]) usage(); + const path = resolve(args[0]); + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + const result = verifyDecisionReceipt(parsed); + process.stdout.write(`${JSON.stringify({ + file: path, + ...result, + note: "Offline verification only: no Solana RPC or source API was queried." + }, null, 2)}\n`); +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Decision receipt verification failed: ${message}\n`); + process.exitCode = 1; +} diff --git a/src/public/artifact-safety.ts b/src/public/artifact-safety.ts new file mode 100644 index 0000000..dfb4b16 --- /dev/null +++ b/src/public/artifact-safety.ts @@ -0,0 +1,865 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import { basename, extname, relative, resolve, sep } from "node:path"; + +export const DEFAULT_PUBLIC_ARTIFACT_MAX_FILE_BYTES = 5 * 1024 * 1024; +export const DEFAULT_PUBLIC_ARTIFACT_MAX_TOTAL_BYTES = 25 * 1024 * 1024; + +export type PublicArtifactAuditMode = "bundle" | "source"; + +export type PublicArtifactViolationCode = + | "NO_EXPLICIT_ALLOWLIST" + | "NO_ALLOWLISTED_BUNDLE" + | "EMPTY_ALLOWLISTED_BUNDLE" + | "ALLOWLIST_PATH_MISSING" + | "ALLOWLIST_PATH_UNREADABLE" + | "UNSUPPORTED_FILE_TYPE" + | "SYMLINK_NOT_ALLOWED" + | "SENSITIVE_FILENAME" + | "RAW_CAPTURE_FILENAME" + | "RAW_CAPTURE_EXTENSION" + | "FILE_TOO_LARGE" + | "BUNDLE_TOO_LARGE" + | "UNPARSEABLE_ARTIFACT_DATA" + | "OPAQUE_DATA_ARTIFACT" + | "RAW_TXLINE_KEY" + | "RAW_TXLINE_ENVELOPE" + | "EXACT_TXLINE_CONSENSUS" + | "RECONSTRUCTIVE_TXLINE_SHAPE" + | "SENSITIVE_DATA_KEY" + | "SECRET_VALUE_PATTERN" + | "PRIVATE_ABSOLUTE_PATH"; + +export type PublicArtifactViolation = { + code: PublicArtifactViolationCode; + path: string; + message: string; + jsonPath?: string; +}; + +export type PublicArtifactAuditOptions = { + /** + * The only files or directories the auditor may visit. There is deliberately + * no cwd/repository fallback: release callers must name the public surface. + */ + allowlistedPaths: readonly string[]; + mode?: PublicArtifactAuditMode; + cwd?: string; + maxFileBytes?: number; + maxTotalBytes?: number; +}; + +export type PublicArtifactAuditReport = { + ok: boolean; + mode: PublicArtifactAuditMode; + allowlistedPaths: string[]; + existingAllowlistedRoots: number; + filesScanned: number; + bytesScanned: number; + limits: { + maxFileBytes: number; + maxTotalBytes: number; + }; + violations: PublicArtifactViolation[]; +}; + +const RAW_CAPTURE_EXTENSIONS = [ + ".ndjson", + ".jsonl", + ".sqlite", + ".sqlite3", + ".duckdb", + ".parquet", + ".pcap", + ".pcapng", + ".har", + ".gz", + ".gzip", + ".zst", + ".tar", + ".tgz", + ".zip" +] as const; + +const DATA_EXTENSIONS = new Set([".json", ".yaml", ".yml", ".csv", ".tsv"]); +const SOURCE_EXTENSIONS = new Set([ + ".cjs", + ".css", + ".cts", + ".html", + ".js", + ".jsx", + ".mjs", + ".map", + ".mts", + ".scss", + ".ts", + ".tsx" +]); +const STATIC_BINARY_EXTENSIONS = new Set([ + ".avif", + ".eot", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".mp3", + ".mp4", + ".ogg", + ".otf", + ".pdf", + ".png", + ".ttf", + ".wav", + ".webm", + ".webp", + ".woff", + ".woff2" +]); + +const SENSITIVE_FILENAMES = [ + /^\.env(?:\.|$)/i, + /^\.npmrc$/i, + /^\.pypirc$/i, + /^credentials(?:\.|$)/i, + /^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)/i, + /(?:^|[-_.])(?:mnemonic|seed[-_.]?phrase|wallet[-_.]?secret)(?:[-_.]|$)/i, + /\.(?:key|keystore|pem|p12|pfx)$/i +] as const; + +const RAW_CAPTURE_FILENAME = + /(?:^|[-_.])(?:raw|capture|captured|sse|txline[-_.]?(?:odds|scores)?|odds[-_.]?(?:stream|snapshot|updates)|scores[-_.]?(?:stream|snapshot|updates))(?:[-_.]|$)/i; + +const RAW_CAPTURE_LIKE_EXTENSIONS = new Set([ + ".csv", + ".db", + ".duckdb", + ".har", + ".json", + ".jsonl", + ".ndjson", + ".parquet", + ".sqlite", + ".sqlite3", + ".tsv" +]); + +const SENSITIVE_NORMALIZED_KEYS = new Set([ + "anthropicapikey", + "apikey", + "apitoken", + "authorization", + "bearertoken", + "clientsecret", + "credential", + "credentials", + "jwt", + "mnemonic", + "password", + "privatekey", + "refreshtoken", + "secret", + "secretkey", + "seedphrase", + "txlineapitoken", + "walletprivatekey", + "walletsecret", + "walletsecretkey", + "xapitoken" +]); + +const EXACT_CONSENSUS_KEYS = new Set([ + "consensuslevel", + "consensuslevels", + "consensusprice", + "consensusprices", + "consensusprobabilities", + "consensusprobability", + "consensusprobabilityseries", + "consensusseries", + "consensusvalues", + "stableprice", + "stableprices", + "stablepriceprobabilities", + "stablepriceprobability", + "stablepriceseries", + "txlinefairprobability", + "txlinepct", + "txlineprobabilities", + "txlineprobability", + "txlineprobabilityseries", + "txlineprices", + "txlineseries" +]); + +const RAW_ENVELOPE_KEYS = new Set([ + "Action", + "Bookmaker", + "BookmakerId", + "Clock", + "FixtureId", + "GameState", + "InRunning", + "MarketParameters", + "MarketPeriod", + "MessageId", + "Pct", + "PossibleEvent", + "PriceNames", + "Prices", + "Stats", + "SuperOddsType", + "Ts" +]); + +const RAW_ENVELOPE_SIGNATURE_KEYS = new Set([ + "Action", + "Bookmaker", + "BookmakerId", + "GameState", + "InRunning", + "MessageId", + "Pct", + "PossibleEvent", + "PriceNames", + "Prices", + "Stats", + "SuperOddsType" +]); + +const RECONSTRUCTIVE_PRICE_KEYS = new Set([ + "polymarketmid", + "polymarketmidpoint", + "polymarketprice", + "polymarketprobability" +]); + +const RECONSTRUCTIVE_GAP_KEYS = new Set([ + "consensusedgebps", + "crossmarketgap", + "crossmarketgapbps", + "rawgap", + "txlinegap", + "txlinegapbps" +]); + +const SOURCE_LEVEL_KEYS = new Set([ + "level", + "levels", + "pct", + "price", + "prices", + "probabilities", + "probability", + "series", + "values" +]); + +const SECRET_TEXT_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [ + { + label: "private-key PEM block", + pattern: /-----BEGIN (?:EC |OPENSSH |PGP |RSA )?PRIVATE KEY-----/ + }, + { + label: "Anthropic/OpenAI-style API key", + pattern: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b/ + }, + { + label: "AWS access key", + pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ + }, + { + label: "JWT", + pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ + }, + { + label: "Authorization bearer credential", + pattern: /\bBearer[ \t]+[A-Za-z0-9._~+/=-]{20,}\b/i + }, + { + label: "assigned API token/key", + pattern: + /\b(?:ANTHROPIC_API_KEY|OPENAI_API_KEY|TXLINE_API_TOKEN|TXLINE_JWT|SOLANA_PRIVATE_KEY|WALLET_PRIVATE_KEY)\b[ \t]*[:=][ \t]*["']?(?!redacted\b|placeholder\b|example\b|<)[A-Za-z0-9._~+/=-]{12,}/i + } +]; + +const PRIVATE_PATH_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [ + { + label: "macOS user-home path", + pattern: /(?:^|[\s"'`=(])\/Users\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9 ._@%+,:=-]+)*/ + }, + { + label: "Linux user-home path", + pattern: /(?:^|[\s"'`=(])\/(?:home|root)\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9 ._@%+,:=-]+)*/ + }, + { + label: "Windows user-home path", + pattern: /\b[A-Za-z]:\\{1,2}Users\\{1,2}[^\\\s"']+(?:\\{1,2}[^\r\n"']+)*/ + }, + { + label: "file URI", + pattern: /\bfile:\/\/(?:\/Users\/|\/home\/|\/root\/|[A-Za-z]:\/Users\/)/i + } +]; + +type MutableAuditState = { + cwd: string; + mode: PublicArtifactAuditMode; + maxFileBytes: number; + maxTotalBytes: number; + existingAllowlistedRoots: number; + filesScanned: number; + bytesScanned: number; + filePathsSeen: Set; + violations: PublicArtifactViolation[]; +}; + +function normalizedKey(key: string): string { + return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase(); +} + +function displayPath(path: string, cwd: string): string { + const rel = relative(cwd, path); + if (rel === "") return "."; + if (rel !== ".." && !rel.startsWith(`..${sep}`)) return rel; + return path; +} + +function addViolation( + state: MutableAuditState, + code: PublicArtifactViolationCode, + path: string, + message: string, + jsonPath?: string +): void { + const violation: PublicArtifactViolation = { + code, + path: displayPath(path, state.cwd), + message + }; + if (jsonPath !== undefined) violation.jsonPath = jsonPath; + state.violations.push(violation); +} + +function safeIntegerLimit(value: number | undefined, fallback: number, name: string): number { + const selected = value ?? fallback; + if (!Number.isSafeInteger(selected) || selected <= 0) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return selected; +} + +function hasRawCaptureExtension(filename: string): boolean { + const lower = filename.toLowerCase(); + return RAW_CAPTURE_EXTENSIONS.some((extension) => lower.endsWith(extension)); +} + +function isSensitiveFilename(filename: string): boolean { + return SENSITIVE_FILENAMES.some((pattern) => pattern.test(filename)); +} + +function isRawCaptureFilename(filename: string): boolean { + const extension = extname(filename).toLowerCase(); + return RAW_CAPTURE_LIKE_EXTENSIONS.has(extension) && RAW_CAPTURE_FILENAME.test(filename); +} + +function jsonPathChild(parent: string, key: string): string { + return `${parent}[${JSON.stringify(key)}]`; +} + +function collectDescendantKeys(value: unknown, keys: Set): void { + if (Array.isArray(value)) { + for (const nested of value) collectDescendantKeys(nested, keys); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, nested] of Object.entries(value)) { + keys.add(normalizedKey(key)); + collectDescendantKeys(nested, keys); + } +} + +function isExactConsensusValue(key: string, value: unknown): boolean { + const normalized = normalizedKey(key); + if (EXACT_CONSENSUS_KEYS.has(normalized)) return true; + if (normalized !== "consensus") return false; + if (typeof value === "number") return true; + if (Array.isArray(value)) return value.some((entry) => typeof entry === "number" || typeof entry === "object"); + return value !== null && typeof value === "object"; +} + +function scanStructuredValue( + value: unknown, + path: string, + state: MutableAuditState, + filePath: string, + enforceTxlineDataPolicy: boolean +): void { + if (Array.isArray(value)) { + for (const [index, nested] of value.entries()) { + scanStructuredValue(nested, `${path}[${index}]`, state, filePath, enforceTxlineDataPolicy); + } + return; + } + if (value === null || typeof value !== "object") return; + + const entries = Object.entries(value); + const directKeys = new Set(entries.map(([key]) => key)); + const descendantKeys = new Set(); + collectDescendantKeys(value, descendantKeys); + + if (enforceTxlineDataPolicy) { + const rawEnvelopeKeyCount = [...directKeys].filter((key) => RAW_ENVELOPE_KEYS.has(key)).length; + const hasRawSignature = [...directKeys].some((key) => RAW_ENVELOPE_SIGNATURE_KEYS.has(key)); + if (rawEnvelopeKeyCount >= 3 && hasRawSignature) { + addViolation( + state, + "RAW_TXLINE_ENVELOPE", + filePath, + "Artifact data contains a TXLine-shaped raw odds/score envelope", + path + ); + } + + const hasReconstructivePrice = [...descendantKeys].some((key) => RECONSTRUCTIVE_PRICE_KEYS.has(key)); + const hasReconstructiveGap = [...descendantKeys].some((key) => RECONSTRUCTIVE_GAP_KEYS.has(key)); + const childAlreadyContainsPair = entries.some(([, nested]) => { + if (nested === null || typeof nested !== "object") return false; + const childKeys = new Set(); + collectDescendantKeys(nested, childKeys); + return ( + [...childKeys].some((key) => RECONSTRUCTIVE_PRICE_KEYS.has(key)) && + [...childKeys].some((key) => RECONSTRUCTIVE_GAP_KEYS.has(key)) + ); + }); + if (hasReconstructivePrice && hasReconstructiveGap && !childAlreadyContainsPair) { + addViolation( + state, + "RECONSTRUCTIVE_TXLINE_SHAPE", + filePath, + "Exact Polymarket level plus a consensus/TXLine gap can reconstruct the licensed consensus level", + path + ); + } + + const sourceEntry = entries.find(([key]) => normalizedKey(key) === "source"); + if (sourceEntry && typeof sourceEntry[1] === "string" && /^txline$/i.test(sourceEntry[1])) { + const sourceDataKeys = new Set(); + collectDescendantKeys(value, sourceDataKeys); + if ([...sourceDataKeys].some((key) => SOURCE_LEVEL_KEYS.has(key))) { + addViolation( + state, + "EXACT_TXLINE_CONSENSUS", + filePath, + "A TXLine-labelled data object exposes exact levels/series instead of hash-only or derived evidence", + path + ); + } + } + } + + for (const [key, nested] of entries) { + const nestedPath = jsonPathChild(path, key); + const normalized = normalizedKey(key); + + if (SENSITIVE_NORMALIZED_KEYS.has(normalized)) { + addViolation( + state, + "SENSITIVE_DATA_KEY", + filePath, + `Artifact data contains sensitive credential field ${JSON.stringify(key)}`, + nestedPath + ); + } + + if (enforceTxlineDataPolicy) { + if (key === "Pct" || key === "Prices" || key === "Bookmaker" || key === "BookmakerId") { + addViolation( + state, + "RAW_TXLINE_KEY", + filePath, + `Artifact data exposes forbidden raw TXLine field ${JSON.stringify(key)}`, + nestedPath + ); + } + if (isExactConsensusValue(key, nested)) { + addViolation( + state, + "EXACT_TXLINE_CONSENSUS", + filePath, + `Artifact data exposes exact consensus/TXLine level field ${JSON.stringify(key)}`, + nestedPath + ); + } + if (typeof nested === "string" && nested === "TXLineStablePriceDemargined") { + addViolation( + state, + "EXACT_TXLINE_CONSENSUS", + filePath, + "Artifact data exposes the internal raw TXLine consensus source label", + nestedPath + ); + } + } + + scanStructuredValue(nested, nestedPath, state, filePath, enforceTxlineDataPolicy); + } +} + +function scanTextSafety(text: string, state: MutableAuditState, filePath: string): void { + for (const { label, pattern } of SECRET_TEXT_PATTERNS) { + if (pattern.test(text)) { + addViolation(state, "SECRET_VALUE_PATTERN", filePath, `File contains a ${label}`); + } + } + for (const { label, pattern } of PRIVATE_PATH_PATTERNS) { + if (pattern.test(text)) { + addViolation(state, "PRIVATE_ABSOLUTE_PATH", filePath, `File contains a private ${label}`); + } + } +} + +function scanConcreteSourceDataLiterals(text: string, state: MutableAuditState, filePath: string): void { + const rawFieldLiteral = /(?:["'](?:Pct|Prices|Bookmaker|BookmakerId)["']|\b(?:Pct|Prices|Bookmaker|BookmakerId))\s*:\s*(?:\[|["']|[-+]?\d)/u; + if (rawFieldLiteral.test(text)) { + addViolation( + state, + "RAW_TXLINE_KEY", + filePath, + "Source-like artifact embeds a concrete raw TXLine field value (not merely an implementation identifier)" + ); + } + + const exactConsensusLiteral = + /(?:["'](?:consensusProbability|consensusSeries|stablePrice|stablePriceSeries|txlineProbability|txlineSeries)["']|\b(?:consensusProbability|consensusSeries|stablePrice|stablePriceSeries|txlineProbability|txlineSeries))\s*:\s*(?:\[|[-+]?\d)/u; + if (exactConsensusLiteral.test(text)) { + addViolation( + state, + "EXACT_TXLINE_CONSENSUS", + filePath, + "Source-like artifact embeds a concrete exact consensus/TXLine level or series" + ); + } +} + +function scanDelimitedHeader( + text: string, + delimiter: "," | "\t", + state: MutableAuditState, + filePath: string, + enforceTxlineDataPolicy: boolean +): void { + const header = text.split(/\r?\n/u).find((line) => line.trim().length > 0); + if (!header) return; + const keys = header.split(delimiter).map((key) => key.trim().replace(/^['"]|['"]$/g, "")); + const shape = Object.fromEntries(keys.map((key) => [key, null])); + scanStructuredValue(shape, "$header", state, filePath, enforceTxlineDataPolicy); +} + +function scanYamlKeys( + text: string, + state: MutableAuditState, + filePath: string, + enforceTxlineDataPolicy: boolean +): void { + const keys = [...text.matchAll(/^\s*(?:-\s*)?([A-Za-z_][A-Za-z0-9_.-]*)\s*:/gmu)].map((match) => match[1]!); + const shape = Object.fromEntries(keys.map((key) => [key, null])); + scanStructuredValue(shape, "$yaml", state, filePath, enforceTxlineDataPolicy); +} + +function scanEmbeddedJson( + text: string, + state: MutableAuditState, + filePath: string, + enforceTxlineDataPolicy: boolean +): void { + const embeddedPattern = /]*\btype=["']application\/(?:ld\+)?json["'][^>]*>([\s\S]*?)<\/script>/giu; + let index = 0; + for (const match of text.matchAll(embeddedPattern)) { + const body = match[1]?.trim(); + if (!body) continue; + try { + scanStructuredValue(JSON.parse(body) as unknown, `$embedded[${index}]`, state, filePath, enforceTxlineDataPolicy); + } catch { + addViolation( + state, + "UNPARSEABLE_ARTIFACT_DATA", + filePath, + "HTML contains an invalid application/json data block", + `$embedded[${index}]` + ); + } + index += 1; + } +} + +function appearsBinary(buffer: Buffer): boolean { + const sampleLength = Math.min(buffer.byteLength, 8_192); + for (let index = 0; index < sampleLength; index += 1) { + if (buffer[index] === 0) return true; + } + return false; +} + +async function scanFile(filePath: string, state: MutableAuditState): Promise { + if (state.filePathsSeen.has(filePath)) return; + state.filePathsSeen.add(filePath); + + const filename = basename(filePath); + const extension = extname(filename).toLowerCase(); + let info; + try { + info = await lstat(filePath); + } catch (error) { + addViolation( + state, + "ALLOWLIST_PATH_UNREADABLE", + filePath, + `Cannot inspect file: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + state.filesScanned += 1; + state.bytesScanned += info.size; + + if (isSensitiveFilename(filename)) { + addViolation(state, "SENSITIVE_FILENAME", filePath, "Sensitive credential/key filename is forbidden"); + } + if (state.mode === "bundle" && hasRawCaptureExtension(filename)) { + addViolation(state, "RAW_CAPTURE_EXTENSION", filePath, "Raw/archive capture extension is forbidden in a public bundle"); + } + if (state.mode === "bundle" && isRawCaptureFilename(filename)) { + addViolation(state, "RAW_CAPTURE_FILENAME", filePath, "Raw TXLine/capture-style data filename is forbidden"); + } + if (info.size > state.maxFileBytes) { + addViolation( + state, + "FILE_TOO_LARGE", + filePath, + `File is ${info.size} bytes; the public-artifact limit is ${state.maxFileBytes}` + ); + return; + } + + let buffer: Buffer; + try { + buffer = await readFile(filePath); + } catch (error) { + addViolation( + state, + "ALLOWLIST_PATH_UNREADABLE", + filePath, + `Cannot read file: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + // Even approved static binaries get a narrow printable-string scan for key, + // token, and private-path leaks. Their opaque payload is not parsed as data. + const text = buffer.toString("utf8"); + scanTextSafety(text, state, filePath); + if (STATIC_BINARY_EXTENSIONS.has(extension)) return; + + const binary = appearsBinary(buffer); + if (binary) { + addViolation(state, "OPAQUE_DATA_ARTIFACT", filePath, "Unapproved opaque binary artifact cannot be safety-inspected"); + return; + } + + const enforceTxlineDataPolicy = state.mode === "bundle"; + if (enforceTxlineDataPolicy && SOURCE_EXTENSIONS.has(extension)) { + scanConcreteSourceDataLiterals(text, state, filePath); + } + if (extension === ".json") { + try { + scanStructuredValue(JSON.parse(text) as unknown, "$", state, filePath, enforceTxlineDataPolicy); + } catch { + addViolation(state, "UNPARSEABLE_ARTIFACT_DATA", filePath, "Public JSON artifact is not valid JSON"); + } + return; + } + if (extension === ".csv" || extension === ".tsv") { + scanDelimitedHeader(text, extension === ".csv" ? "," : "\t", state, filePath, enforceTxlineDataPolicy); + return; + } + if (extension === ".yaml" || extension === ".yml") { + scanYamlKeys(text, state, filePath, enforceTxlineDataPolicy); + return; + } + if (extension === ".html") { + scanEmbeddedJson(text, state, filePath, enforceTxlineDataPolicy); + return; + } + const trimmed = text.trimStart(); + if ( + enforceTxlineDataPolicy && + !SOURCE_EXTENSIONS.has(extension) && + (trimmed.startsWith("{") || trimmed.startsWith("[")) + ) { + try { + scanStructuredValue(JSON.parse(text) as unknown, "$", state, filePath, true); + } catch { + // An unknown text file that merely begins with punctuation is not + // automatically data. Known data extensions fail closed above. + } + } +} + +async function walk(path: string, state: MutableAuditState): Promise { + let info; + try { + info = await lstat(path); + } catch (error) { + addViolation( + state, + "ALLOWLIST_PATH_MISSING", + path, + `Allowlisted path does not exist or cannot be inspected: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + if (info.isSymbolicLink()) { + addViolation(state, "SYMLINK_NOT_ALLOWED", path, "Symlinks are forbidden because they can escape the explicit allowlist"); + return; + } + if (info.isFile()) { + await scanFile(path, state); + return; + } + if (!info.isDirectory()) { + addViolation(state, "UNSUPPORTED_FILE_TYPE", path, "Only regular files and directories may be audited"); + return; + } + + let children; + try { + children = await readdir(path, { withFileTypes: true }); + } catch (error) { + addViolation( + state, + "ALLOWLIST_PATH_UNREADABLE", + path, + `Cannot enumerate allowlisted directory: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + children.sort((left, right) => left.name.localeCompare(right.name)); + for (const child of children) { + await walk(resolve(path, child.name), state); + } +} + +function deduplicateAndSort(violations: PublicArtifactViolation[]): PublicArtifactViolation[] { + const byIdentity = new Map(); + for (const violation of violations) { + const identity = `${violation.code}\u0000${violation.path}\u0000${violation.jsonPath ?? ""}\u0000${violation.message}`; + byIdentity.set(identity, violation); + } + return [...byIdentity.values()].sort((left, right) => { + return ( + left.path.localeCompare(right.path) || + left.code.localeCompare(right.code) || + (left.jsonPath ?? "").localeCompare(right.jsonPath ?? "") || + left.message.localeCompare(right.message) + ); + }); +} + +export async function auditPublicArtifacts(options: PublicArtifactAuditOptions): Promise { + const cwd = resolve(options.cwd ?? process.cwd()); + const mode = options.mode ?? "bundle"; + const maxFileBytes = safeIntegerLimit( + options.maxFileBytes, + DEFAULT_PUBLIC_ARTIFACT_MAX_FILE_BYTES, + "maxFileBytes" + ); + const maxTotalBytes = safeIntegerLimit( + options.maxTotalBytes, + DEFAULT_PUBLIC_ARTIFACT_MAX_TOTAL_BYTES, + "maxTotalBytes" + ); + const allowlistedPaths = [...new Set(options.allowlistedPaths.map((path) => path.trim()).filter(Boolean))].map((path) => + resolve(cwd, path) + ); + const state: MutableAuditState = { + cwd, + mode, + maxFileBytes, + maxTotalBytes, + existingAllowlistedRoots: 0, + filesScanned: 0, + bytesScanned: 0, + filePathsSeen: new Set(), + violations: [] + }; + + if (allowlistedPaths.length === 0) { + addViolation( + state, + "NO_EXPLICIT_ALLOWLIST", + cwd, + mode === "bundle" + ? "Bundle audit requires at least one explicit allowlisted bundle path" + : "Source audit requires at least one explicit allowlisted repository path" + ); + } else { + for (const path of allowlistedPaths) { + try { + await lstat(path); + state.existingAllowlistedRoots += 1; + } catch { + // walk emits the path-specific failure below. + } + await walk(path, state); + } + } + + if (mode === "bundle" && state.existingAllowlistedRoots === 0) { + addViolation( + state, + "NO_ALLOWLISTED_BUNDLE", + cwd, + "No explicit allowlisted public bundle exists; use --source-audit only for an intentional repository-source audit" + ); + } + if (mode === "bundle" && state.existingAllowlistedRoots > 0 && state.filesScanned === 0) { + addViolation( + state, + "EMPTY_ALLOWLISTED_BUNDLE", + cwd, + "The allowlisted public bundle contains no regular files, so there is nothing to approve" + ); + } + if (state.bytesScanned > maxTotalBytes) { + addViolation( + state, + "BUNDLE_TOO_LARGE", + cwd, + `Allowlisted artifacts total ${state.bytesScanned} bytes; the limit is ${maxTotalBytes}` + ); + } + + const violations = deduplicateAndSort(state.violations); + return { + ok: violations.length === 0, + mode, + allowlistedPaths, + existingAllowlistedRoots: state.existingAllowlistedRoots, + filesScanned: state.filesScanned, + bytesScanned: state.bytesScanned, + limits: { maxFileBytes, maxTotalBytes }, + violations + }; +} + +export function assertPublicArtifactsSafe(report: PublicArtifactAuditReport): void { + if (report.ok) return; + const summary = report.violations + .map((violation) => { + const location = violation.jsonPath ? `${violation.path}${violation.jsonPath}` : violation.path; + return `[${violation.code}] ${location}: ${violation.message}`; + }) + .join("\n"); + throw new Error(`Public artifact safety audit failed (${report.violations.length} violation(s)):\n${summary}`); +} diff --git a/src/public/audit-public-artifacts.ts b/src/public/audit-public-artifacts.ts new file mode 100644 index 0000000..9011c9a --- /dev/null +++ b/src/public/audit-public-artifacts.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import { pathToFileURL } from "node:url"; +import { + auditPublicArtifacts, + DEFAULT_PUBLIC_ARTIFACT_MAX_FILE_BYTES, + DEFAULT_PUBLIC_ARTIFACT_MAX_TOTAL_BYTES, + type PublicArtifactAuditMode, + type PublicArtifactAuditReport +} from "./artifact-safety.js"; + +type CliOptions = { + mode: PublicArtifactAuditMode; + allowlistedPaths: string[]; + maxFileBytes: number; + maxTotalBytes: number; + json: boolean; + help: boolean; +}; + +const USAGE = `Usage: + pnpm exec tsx src/public/audit-public-artifacts.ts --bundle [--bundle ...] + pnpm exec tsx src/public/audit-public-artifacts.ts --source-audit --path [--path ...] + +Options: + --bundle Explicit allowlisted frozen public bundle path (repeatable) + --source-audit Audit explicitly named repository source paths; never implies cwd + --path Explicit allowlisted path for --source-audit (repeatable) + --max-file-bytes Per-file ceiling (default ${DEFAULT_PUBLIC_ARTIFACT_MAX_FILE_BYTES}) + --max-total-bytes Total allowlisted byte ceiling (default ${DEFAULT_PUBLIC_ARTIFACT_MAX_TOTAL_BYTES}) + --json Emit the complete machine-readable report + --help Show this usage text + +The command has no implicit scan root. Bundle mode fails closed when no existing +--bundle path is supplied. Source mode must be explicitly selected and still +requires one or more --path allowlist entries. +`; + +function parsePositiveInteger(raw: string | undefined, flag: string): number { + if (!raw || !/^[1-9][0-9]*$/u.test(raw)) throw new Error(`${flag} requires a positive integer`); + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) throw new Error(`${flag} exceeds the safe integer range`); + return parsed; +} + +export function parsePublicArtifactAuditArgs(args: readonly string[]): CliOptions { + const bundles: string[] = []; + const sourcePaths: string[] = []; + let sourceAudit = false; + let json = false; + let help = false; + let maxFileBytes = DEFAULT_PUBLIC_ARTIFACT_MAX_FILE_BYTES; + let maxTotalBytes = DEFAULT_PUBLIC_ARTIFACT_MAX_TOTAL_BYTES; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--bundle") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--bundle requires a path"); + bundles.push(value); + index += 1; + continue; + } + if (argument === "--path") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--path requires a path"); + sourcePaths.push(value); + index += 1; + continue; + } + if (argument === "--source-audit") { + sourceAudit = true; + continue; + } + if (argument === "--max-file-bytes") { + maxFileBytes = parsePositiveInteger(args[index + 1], argument); + index += 1; + continue; + } + if (argument === "--max-total-bytes") { + maxTotalBytes = parsePositiveInteger(args[index + 1], argument); + index += 1; + continue; + } + if (argument === "--json") { + json = true; + continue; + } + if (argument === "--help" || argument === "-h") { + help = true; + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + + if (sourceAudit && bundles.length > 0) { + throw new Error("--bundle cannot be combined with --source-audit; use explicit --path entries"); + } + if (!sourceAudit && sourcePaths.length > 0) { + throw new Error("--path is only valid with explicit --source-audit"); + } + + return { + mode: sourceAudit ? "source" : "bundle", + allowlistedPaths: sourceAudit ? sourcePaths : bundles, + maxFileBytes, + maxTotalBytes, + json, + help + }; +} + +function humanReport(report: PublicArtifactAuditReport): string { + const status = report.ok ? "PASS" : "FAIL"; + const lines = [ + `Public artifact safety audit: ${status}`, + `mode=${report.mode} roots=${report.existingAllowlistedRoots}/${report.allowlistedPaths.length} files=${report.filesScanned} bytes=${report.bytesScanned}`, + `limits: file=${report.limits.maxFileBytes} bytes total=${report.limits.maxTotalBytes} bytes` + ]; + for (const violation of report.violations) { + const location = violation.jsonPath ? `${violation.path}${violation.jsonPath}` : violation.path; + lines.push(`[${violation.code}] ${location}: ${violation.message}`); + } + return `${lines.join("\n")}\n`; +} + +export async function runPublicArtifactAuditCli(args: readonly string[]): Promise { + const options = parsePublicArtifactAuditArgs(args); + if (options.help) { + process.stdout.write(USAGE); + return 0; + } + const report = await auditPublicArtifacts({ + allowlistedPaths: options.allowlistedPaths, + mode: options.mode, + maxFileBytes: options.maxFileBytes, + maxTotalBytes: options.maxTotalBytes + }); + process.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : humanReport(report)); + return report.ok ? 0 : 1; +} + +async function main(): Promise { + try { + process.exitCode = await runPublicArtifactAuditCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`Public artifact audit refused to run: ${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write(USAGE); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/src/replay/merge.ts b/src/replay/merge.ts index dcdc6cb..1a5251f 100644 --- a/src/replay/merge.ts +++ b/src/replay/merge.ts @@ -46,6 +46,15 @@ function compareHeads(left: Head, right: Head): number { ); } +function compareObservedHeads(left: Head, right: Head): number { + return ( + left.event.observedTsMs - right.event.observedTsMs || + left.event.sourceTsMs - right.event.sourceTsMs || + left.event.eventId.localeCompare(right.event.eventId) || + left.index - right.index + ); +} + export async function* mergeReplaySources( sources: AsyncIterable[], options: MergeReplayOptions @@ -98,3 +107,56 @@ export async function* mergeReplaySources( ); } } + +export async function* mergeCapturedSourcesByObservedTime( + sources: AsyncIterable[], + options: MergeReplayOptions +): AsyncGenerator { + if (sources.length === 0) return; + if (!(options.speed > 0) && options.speed !== Number.POSITIVE_INFINITY) { + throw new RangeError("Replay speed must be positive or Infinity"); + } + const clock = options.clock ?? defaultClock; + const iterators = sources.map((source) => source[Symbol.asyncIterator]()); + const lastObservedTs = new Array(sources.length).fill(null); + const heads: Head[] = []; + let firstEventTsMs: number | null = null; + let firstWallTsMs: number | null = null; + + try { + const initial = await Promise.all(iterators.map((iterator) => iterator.next())); + for (const [index, result] of initial.entries()) { + if (!result.done) heads.push({ index, event: result.value }); + } + while (heads.length > 0 && !isAborted(options.signal)) { + heads.sort(compareObservedHeads); + const head = heads.shift()!; + const previous = lastObservedTs[head.index] ?? null; + if (previous !== null && head.event.observedTsMs < previous) { + throw new Error( + `Captured replay source ${head.index} observation moved backward from ${previous} to ${head.event.observedTsMs}` + ); + } + lastObservedTs[head.index] = head.event.observedTsMs; + if (firstEventTsMs === null) { + firstEventTsMs = head.event.observedTsMs; + firstWallTsMs = clock.nowMs(); + } + if (options.speed !== Number.POSITIVE_INFINITY) { + const targetElapsed = (head.event.observedTsMs - firstEventTsMs) / options.speed; + const actualElapsed = clock.nowMs() - firstWallTsMs!; + await clock.sleep(Math.max(0, targetElapsed - actualElapsed), options.signal); + if (isAborted(options.signal)) break; + } + yield head.event; + const next = await iterators[head.index]!.next(); + if (!next.done) heads.push({ index: head.index, event: next.value }); + } + } finally { + await Promise.all( + iterators.map(async (iterator) => { + if (iterator.return) await iterator.return().catch(() => undefined); + }) + ); + } +} diff --git a/src/research/analyze-paired-capture.ts b/src/research/analyze-paired-capture.ts new file mode 100644 index 0000000..159ef0f --- /dev/null +++ b/src/research/analyze-paired-capture.ts @@ -0,0 +1,52 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { analyzePairedLiveCapture, renderPairedLiveStudyMarkdown } from "./paired-live-study.js"; + +type CaptureConfig = { + captureId?: string; + txline?: { fixtureId?: string }; +}; + +function argument(name: string, fallback?: string): string { + const index = process.argv.indexOf(`--${name}`); + const value = index >= 0 ? process.argv[index + 1] : fallback; + if (!value) throw new Error(`Missing --${name}`); + return value; +} + +const captureConfigPath = resolve(argument("capture-config", "config/captures/spain-belgium-2026-07-10.json")); +const captureConfig = JSON.parse(await readFile(captureConfigPath, "utf8")) as CaptureConfig; +const runId = captureConfig.captureId; +const fixtureId = captureConfig.txline?.fixtureId; +if (!runId || !fixtureId) throw new Error("Capture config requires captureId and txline.fixtureId"); +const mappingPath = resolve(argument("mappings", "data/research/mappings/world-cup-candidates.json")); +const polymarketDirectory = resolve(argument("polymarket-dir", `samples/polymarket-live/${runId}`)); +const txlineDirectory = resolve(argument("txline-dir", `samples/odds-sse/mainnet/${runId}`)); +const outputPath = resolve(argument("output", `data/research/${runId}-live-lane.json`)); +const reportPath = resolve(argument("report", `docs/research/${runId}-live-lane.md`)); +const materialMoveBps = Number(argument("material-move-bps", "50")); +if (!Number.isFinite(materialMoveBps) || materialMoveBps <= 0) { + throw new RangeError("--material-move-bps must be positive"); +} + +const study = await analyzePairedLiveCapture({ + fixtureId, + mappingPath, + polymarketMessagesPath: resolve(polymarketDirectory, "messages.ndjson"), + polymarketReconnectsPath: resolve(polymarketDirectory, "reconnects.ndjson"), + txlineOddsPath: resolve(txlineDirectory, "odds.frames.ndjson"), + txlineScoresPath: resolve(txlineDirectory, "scores.frames.ndjson"), + materialMoveProbability: materialMoveBps / 10_000 +}); +await Promise.all([mkdir(dirname(outputPath), { recursive: true }), mkdir(dirname(reportPath), { recursive: true })]); +await Promise.all([ + writeFile(outputPath, `${JSON.stringify(study, null, 2)}\n`), + writeFile(reportPath, renderPairedLiveStudyMarkdown(study)) +]); +console.log(JSON.stringify({ + outputPath, + reportPath, + goals: study.goals.length, + marketGroups: study.mapping.groups, + normalizedEvents: study.analysisWindow.normalizedEvents +}, null, 2)); diff --git a/src/research/build-total-line-evidence.ts b/src/research/build-total-line-evidence.ts index 23cc028..bce00db 100644 --- a/src/research/build-total-line-evidence.ts +++ b/src/research/build-total-line-evidence.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { mkdir } from "node:fs/promises"; @@ -5,18 +6,11 @@ import { DuckDBInstance } from "@duckdb/node-api"; import { marketKey } from "../bus/events.js"; import { probability } from "../domain/probability.js"; import { MappingRegistry } from "../mapping/registry.js"; -import { readJsonArray } from "../replay/files.js"; +import { PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS } from "../config/paper-study.js"; import type { TotalLineEvidence } from "./main-total-selector.js"; +import { buildTotalLineHistoryEvidenceQuery } from "./total-line-evidence-query.js"; type CandidateFile = { records?: unknown[] }; -type GammaMarket = { - id?: string | number; - volume?: string | number; - volumeNum?: number; - liquidity?: string | number; - liquidityNum?: number; -}; -type GammaEvent = { markets?: GammaMarket[] }; type Target = { fixtureId: string; marketId: string; @@ -25,7 +19,7 @@ type Target = { mappingStatus: "candidate" | "verified" | "rejected"; txlineMarketObserved: boolean; overAssetId: string; - cutoffTsMs: number; + selectorCutoffTsMs: number; }; function argument(name: string, fallback: string): string { @@ -33,22 +27,12 @@ function argument(name: string, fallback: string): string { return resolve(index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback); } -function sqlString(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function finiteMetadata(value: unknown): number { - const parsed = Number(value ?? 0); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; -} - const databasePath = argument("db", "data/research/samaritan-research-v1.duckdb"); const candidatesPath = argument("candidates", "data/research/mappings/world-cup-candidates.json"); -const eventsPath = argument( - "events", - "samples/polymarket-history/world-cup-2026-v1/world-cup-events.json" -); -const outputPath = argument("output", "data/research/main-total-line-evidence.json"); +const outputPath = argument("output", "data/research/main-total-line-evidence-causal-v2.json"); +if (existsSync(outputPath)) { + throw new Error(`Refusing to replace existing total-line evidence: ${outputPath}`); +} const candidateFile = JSON.parse(await readFile(candidatesPath, "utf8")) as CandidateFile; const registry = new MappingRegistry(candidateFile.records ?? []); const targets: Target[] = []; @@ -80,54 +64,23 @@ for (const record of registry.records()) { mappingStatus: record.status, txlineMarketObserved: condition.evidence?.txlineMarketObserved === true, overAssetId: over.assetId, - cutoffTsMs: record.kickoff.polymarketTsMs - 5 * 60_000 - }); - } -} - -const metadata = new Map(); -for await (const event of readJsonArray(eventsPath)) { - for (const market of event.markets ?? []) { - const id = String(market.id ?? ""); - if (!targetIds.has(id) || metadata.has(id)) continue; - metadata.set(id, { - volume: finiteMetadata(market.volumeNum ?? market.volume), - liquidity: finiteMetadata(market.liquidityNum ?? market.liquidity) + selectorCutoffTsMs: + record.kickoff.txlineTsMs - PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS }); } - if (metadata.size === targetIds.size) break; -} -const missingMetadata = targets.filter((target) => !metadata.has(target.marketId)); -if (missingMetadata.length > 0) { - throw new Error( - `Gamma metadata missing for ${missingMetadata.length} total markets; first ${missingMetadata[0]!.marketId}` - ); } -const valuesSql = targets - .map( - (target) => - `(${sqlString(target.marketId)}, ${sqlString(target.overAssetId)}, ${target.cutoffTsMs})` - ) - .join(",\n"); const instance = await DuckDBInstance.create(databasePath, { access_mode: "READ_ONLY" }); const connection = await instance.connect(); let historyRows: Record[]; try { - const result = await connection.runAndReadAll(` - WITH targets(market_id, asset_id, cutoff_ts_ms) AS (VALUES ${valuesSql}) - SELECT - targets.market_id, - COUNT(history.event_id) AS coverage_points, - arg_max(history.price, history.source_ts_ms) - FILTER (WHERE history.source_ts_ms <= targets.cutoff_ts_ms) AS pre_kickoff_probability, - MAX(history.source_ts_ms) - FILTER (WHERE history.source_ts_ms <= targets.cutoff_ts_ms) AS pre_kickoff_point_ts_ms - FROM targets - LEFT JOIN polymarket_history history ON history.asset_id = targets.asset_id - GROUP BY targets.market_id - ORDER BY targets.market_id - `); + const result = await connection.runAndReadAll(buildTotalLineHistoryEvidenceQuery( + targets.map((target) => ({ + marketId: target.marketId, + assetId: target.overAssetId, + selectorCutoffTsMs: target.selectorCutoffTsMs + })) + )); historyRows = result.getRowObjectsJson() as Record[]; } finally { connection.closeSync(); @@ -135,7 +88,6 @@ try { } const history = new Map(historyRows.map((row) => [String(row["market_id"]), row])); const evidence: TotalLineEvidence[] = targets.map((target) => { - const marketMetadata = metadata.get(target.marketId)!; const historyRow = history.get(target.marketId); const priceValue = historyRow?.["pre_kickoff_probability"]; return { @@ -145,6 +97,7 @@ const evidence: TotalLineEvidence[] = targets.map((target) => { lineMilli: target.lineMilli, mappingStatus: target.mappingStatus, txlineMarketObserved: target.txlineMarketObserved, + selectorCutoffTsMs: target.selectorCutoffTsMs, preKickoffOverProbability: priceValue === null || priceValue === undefined ? null : probability(Number(priceValue)), preKickoffPointTsMs: @@ -152,19 +105,37 @@ const evidence: TotalLineEvidence[] = targets.map((target) => { historyRow?.["pre_kickoff_point_ts_ms"] === undefined ? null : Number(historyRow["pre_kickoff_point_ts_ms"]), - volume: marketMetadata.volume, - liquidity: marketMetadata.liquidity, + coverageFirstPointTsMs: + historyRow?.["coverage_first_point_ts_ms"] === null || + historyRow?.["coverage_first_point_ts_ms"] === undefined + ? null + : Number(historyRow["coverage_first_point_ts_ms"]), + coverageLastPointTsMs: + historyRow?.["coverage_last_point_ts_ms"] === null || + historyRow?.["coverage_last_point_ts_ms"] === undefined + ? null + : Number(historyRow["coverage_last_point_ts_ms"]), + // The captured Gamma volume/liquidity snapshot is post-close and has no + // historical timestamp. It is deliberately excluded from causal evidence. + volume: 0, + liquidity: 0, coveragePoints: Number(historyRow?.["coverage_points"] ?? 0) }; }); const payload = { - schemaVersion: 1, + schemaVersion: 2, generatedAt: new Date().toISOString(), status: "research_evidence_only", selectionRuleFrozen: false, - preKickoffCutoffMinutes: 5, - sourcePaths: { databasePath, candidatesPath, eventsPath }, + selectorEvidence: { + asOfBeforeKickoffMs: PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS, + kickoffBasis: "txline_kickoff_ts_ms", + probabilityRule: "source_ts_ms_lte_selector_cutoff", + coverageRule: "source_ts_ms_lte_selector_cutoff", + volumeAndLiquidity: "unavailable_zeroed_no_timestamped_as_of_evidence" + }, + sourcePaths: { databasePath, candidatesPath }, counts: { fixtures: new Set(evidence.map((row) => row.fixtureId)).size, lines: evidence.length, diff --git a/src/research/historical-gate-study.ts b/src/research/historical-gate-study.ts new file mode 100644 index 0000000..9ed2cad --- /dev/null +++ b/src/research/historical-gate-study.ts @@ -0,0 +1,1229 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { basename } from "node:path"; +import { readFile, stat } from "node:fs/promises"; +import type { DetectorBankConfig } from "../detectors/bank.js"; +import { DetectorBank } from "../detectors/bank.js"; +import { normalizeExecutableEconomicCases } from "../detectors/economic-cases.js"; +import type { DetectorSignal, SignalKind } from "../detectors/types.js"; +import type { CanonicalOutcome } from "../bus/events.js"; +import { stableJson } from "../domain/json.js"; +import { + PAPER_STUDY_FEATURE_CONFIG, + PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS, + PAPER_STUDY_TOTAL_SELECTOR_CONFIG +} from "../config/paper-study.js"; +import type { FeatureEngineConfig, FeatureSnapshot } from "../features/engine.js"; +import type { ClassificationSummary } from "../metrics/classification.js"; +import { ForwardOutcomeLabeler, type ForwardLabelConfig } from "../metrics/forward-labels.js"; +import { StreamingDetectorStudy } from "../metrics/replay-study.js"; +import { + DetectorThresholdGrid, + expandDetectorThresholdGrid, + type ThresholdGridResult +} from "../metrics/threshold-grid.js"; +import type { MappingRecord } from "../mapping/registry.js"; +import { extractResearchFeatures, type ResearchFeatureExtractionSummary } from "./extract-features.js"; +import { + assertCausalTotalSelectorConfig, + selectMainTotalLine, + type TotalLineEvidence, + type TotalLineSelection, + type TotalLineSelectorConfig +} from "./main-total-selector.js"; + +const DETECTORS: SignalKind[] = ["XMARKET_DIVERGENCE", "CONSENSUS_MOVE", "FADER_CANDIDATE"]; +export const HISTORICAL_GATE_PROTOCOL_ID = "historical-gate-causal-economic-v4-2026-07-14" as const; +const HISTORICAL_GATE_BOOTSTRAP_ITERATIONS = 10_000; +const HISTORICAL_GATE_BOOTSTRAP_SEED = 20_260_714; + +type MappingFile = { records?: MappingRecord[] }; +type TotalEvidenceFile = { + schemaVersion?: unknown; + selectorEvidence?: { + asOfBeforeKickoffMs?: unknown; + kickoffBasis?: unknown; + probabilityRule?: unknown; + coverageRule?: unknown; + }; + evidence?: TotalLineEvidence[]; +}; + +export function assertCausalTotalEvidenceFile(value: TotalEvidenceFile): void { + if (value.schemaVersion !== 2) { + throw new Error("Historical gate requires causal total-line evidence schemaVersion 2"); + } + if ( + value.selectorEvidence?.asOfBeforeKickoffMs !== + PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS + ) { + throw new Error("Historical gate total-line evidence has the wrong selector as-of boundary"); + } + if (value.selectorEvidence.kickoffBasis !== "txline_kickoff_ts_ms") { + throw new Error("Historical gate total-line evidence must use the TXLine kickoff boundary"); + } + if ( + value.selectorEvidence.probabilityRule !== "source_ts_ms_lte_selector_cutoff" || + value.selectorEvidence.coverageRule !== "source_ts_ms_lte_selector_cutoff" + ) { + throw new Error("Historical gate total-line probability and coverage must share the causal cutoff"); + } + if (!Array.isArray(value.evidence)) { + throw new Error("Historical gate total-line evidence is missing its evidence rows"); + } +} + +export type HistoricalFixture = { + fixtureId: string; + kickoffTsMs: number; + home: string; + away: string; + matchResultMarketKey: string; + selectedTotalMarketKey: string; + selectedTotalLineMilli: number; +}; + +export type ChronologicalSplit = { + train: T[]; + test: T[]; + cutoffTsMs: number; +}; + +export function chronologicalFixtureSplit( + fixtures: readonly T[], + trainFraction: number +): ChronologicalSplit { + if (!(trainFraction > 0 && trainFraction < 1)) throw new RangeError("Train fraction must be between zero and one"); + if (fixtures.length < 2) throw new Error("Chronological split requires at least two fixtures"); + const sorted = [...fixtures].sort( + (left, right) => left.kickoffTsMs - right.kickoffTsMs || left.fixtureId.localeCompare(right.fixtureId) + ); + const targetIndex = Math.max(0, Math.min(sorted.length - 2, Math.floor(sorted.length * trainFraction) - 1)); + const cutoffTsMs = sorted[targetIndex]!.kickoffTsMs; + const train = sorted.filter((fixture) => fixture.kickoffTsMs <= cutoffTsMs); + const test = sorted.filter((fixture) => fixture.kickoffTsMs > cutoffTsMs); + if (train.length === 0 || test.length === 0) throw new Error("Chronological split produced an empty partition"); + return { train, test, cutoffTsMs }; +} + +type ConfusionCounts = { + cases: number; + truePositive: number; + falsePositive: number; + trueNegative: number; + falseNegative: number; +}; + +export type AggregatedThresholdResult = { + configId: string; + config: DetectorBankConfig; + snapshots: number; + rawEmissions: number; + /** Normalized economic cases; this is the only count used by sample gates. */ + signals: number; + classification: ClassificationSummary; +}; + +type AggregateEntry = { + configId: string; + config: DetectorBankConfig; + snapshots: number; + rawEmissions: number; + signals: number; + confusion: ConfusionCounts; +}; + +function emptyConfusion(): ConfusionCounts { + return { cases: 0, truePositive: 0, falsePositive: 0, trueNegative: 0, falseNegative: 0 }; +} + +function classification(detector: SignalKind, counts: ConfusionCounts): ClassificationSummary { + const predictedPositive = counts.truePositive + counts.falsePositive; + const actualPositive = counts.truePositive + counts.falseNegative; + const actualNegative = counts.trueNegative + counts.falsePositive; + return { + detector, + ...counts, + precision: predictedPositive === 0 ? null : counts.truePositive / predictedPositive, + recall: actualPositive === 0 ? null : counts.truePositive / actualPositive, + falsePositiveRate: actualNegative === 0 ? null : counts.falsePositive / actualNegative + }; +} + +export class ThresholdResultAggregator { + readonly #entries = new Map(); + + constructor(readonly detector: SignalKind) {} + + add(results: readonly ThresholdGridResult[]): void { + for (const result of results) { + const summary = result.classification.find((item) => item.detector === this.detector); + const entry = this.#entries.get(result.configId) ?? { + configId: result.configId, + config: { ...result.config }, + snapshots: 0, + rawEmissions: 0, + signals: 0, + confusion: emptyConfusion() + }; + entry.snapshots += result.snapshots; + entry.rawEmissions += result.rawSignalCounts[this.detector]; + entry.signals += result.signalCounts[this.detector]; + if (summary) { + entry.confusion.cases += summary.cases; + entry.confusion.truePositive += summary.truePositive; + entry.confusion.falsePositive += summary.falsePositive; + entry.confusion.trueNegative += summary.trueNegative; + entry.confusion.falseNegative += summary.falseNegative; + } + this.#entries.set(result.configId, entry); + } + } + + results(): AggregatedThresholdResult[] { + return [...this.#entries.values()].map((entry) => ({ + configId: entry.configId, + config: { ...entry.config }, + snapshots: entry.snapshots, + rawEmissions: entry.rawEmissions, + signals: entry.signals, + classification: classification(this.detector, entry.confusion) + })); + } +} + +export type RankedTrainingConfiguration = AggregatedThresholdResult & { + /** Backward-compatible alias for normalizedEconomicCases. */ + predictedPositive: number; + normalizedEconomicCases: number; + f1: number | null; + meetsMinimumSignals: boolean; + meetsCostFloor: boolean; +}; + +function f1(summary: ClassificationSummary): number | null { + if (summary.precision === null || summary.recall === null || summary.precision + summary.recall === 0) return null; + return (2 * summary.precision * summary.recall) / (summary.precision + summary.recall); +} + +export function rankTrainingConfigurations( + results: readonly AggregatedThresholdResult[], + minimumSignals: number, + detector: SignalKind, + costFloorProbability: number +): RankedTrainingConfiguration[] { + if (!Number.isInteger(minimumSignals) || minimumSignals < 1) throw new RangeError("Minimum signals must be positive"); + if (!Number.isFinite(costFloorProbability) || costFloorProbability < 0) { + throw new RangeError("Cost floor must be finite and non-negative"); + } + return results + .map((result) => { + const predictedPositive = result.signals; + const configuredGap = detector === "XMARKET_DIVERGENCE" + ? result.config.xmarketMinimumRawGap + : detector === "CONSENSUS_MOVE" + ? result.config.consensusMinimumRawGap + : result.config.faderMinimumRawGap; + return { + ...result, + predictedPositive, + normalizedEconomicCases: predictedPositive, + f1: f1(result.classification), + meetsMinimumSignals: predictedPositive >= minimumSignals, + meetsCostFloor: configuredGap >= costFloorProbability + }; + }) + .sort((left, right) => + Number(right.meetsCostFloor) - Number(left.meetsCostFloor) || + Number(right.meetsMinimumSignals) - Number(left.meetsMinimumSignals) || + (right.f1 ?? -1) - (left.f1 ?? -1) || + (right.classification.precision ?? -1) - (left.classification.precision ?? -1) || + right.predictedPositive - left.predictedPositive || + left.configId.localeCompare(right.configId) + ); +} + +export function selectEligibleTrainingConfiguration( + detector: SignalKind, + ranked: readonly RankedTrainingConfiguration[] +): RankedTrainingConfiguration { + const selected = ranked.find((candidate) => candidate.meetsMinimumSignals && candidate.meetsCostFloor); + if (!selected) { + throw new Error(`Training sweep produced no sample- and cost-eligible configuration for ${detector}`); + } + return selected; +} + +type Sweep = { + detector: SignalKind; + configs: DetectorBankConfig[]; +}; + +type GroupCoverage = ResearchFeatureExtractionSummary & { + fixtureId: string; + marketKey: string; +}; + +type SignalClv = { + detector: SignalKind; + fixtureId: string; + marketKey: string; + outcome: CanonicalOutcome; + direction: "buy" | "sell"; + detectedAtTsMs: number; + entryProbability: number; + closingProbability: number; + directionalClv: number; + netAfterCostProxy: number; +}; + +export type ClusteredNetClvInput = Pick; + +export type ClvSliceSummary = { + signals: number; + withClosingProbability: number; + uniqueFixtures: number; + meanDirectionalClv: number | null; + medianDirectionalClv: number | null; + p25DirectionalClv: number | null; + p75DirectionalClv: number | null; + positiveClvRate: number | null; + meanNetAfterCostProxy: number | null; + matchClusteredNetClv95: { + iterations: number; + seed: number; + fixtures: number; + signals: number; + low: number; + median: number; + high: number; + } | null; + meanNetByCostProxyBps: Record; +}; + +export type ClvSummary = ClvSliceSummary & { + byMarketFamily: { + matchResult: ClvSliceSummary; + totalGoals: ClvSliceSummary; + }; +}; + +export type DetectorEmissionAudit = { + rawEmissions: number; + normalizedEconomicCases: number; + executableTotalGoalsCases: number; + nonBinaryMarketSignalsPassedThrough: number; + duplicateExecutableBuysCollapsed: number; + complementarySellsCollapsed: number; + sellOnlySignalsDropped: number; + unsupportedTotalGoalsSignalsDropped: number; +}; + +function emptyEmissionAudit(): DetectorEmissionAudit { + return { + rawEmissions: 0, + normalizedEconomicCases: 0, + executableTotalGoalsCases: 0, + nonBinaryMarketSignalsPassedThrough: 0, + duplicateExecutableBuysCollapsed: 0, + complementarySellsCollapsed: 0, + sellOnlySignalsDropped: 0, + unsupportedTotalGoalsSignalsDropped: 0 + }; +} + +function quantile(sorted: readonly number[], probability: number): number | null { + if (sorted.length === 0) return null; + const index = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * probability))); + return sorted[index]!; +} + +function mulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ value >>> 15, value | 1); + value ^= value + Math.imul(value ^ value >>> 7, value | 61); + return ((value ^ value >>> 14) >>> 0) / 4_294_967_296; + }; +} + +export function clusteredNetClvInterval( + signals: readonly ClusteredNetClvInput[] +): ClvSliceSummary["matchClusteredNetClv95"] { + const byFixture = new Map(); + for (const signal of signals) { + const values = byFixture.get(signal.fixtureId) ?? []; + values.push(signal.netAfterCostProxy); + byFixture.set(signal.fixtureId, values); + } + const fixtures = [...byFixture.values()]; + if (fixtures.length === 0) return null; + const random = mulberry32(HISTORICAL_GATE_BOOTSTRAP_SEED); + const distribution: number[] = []; + for (let iteration = 0; iteration < HISTORICAL_GATE_BOOTSTRAP_ITERATIONS; iteration += 1) { + let sum = 0; + let count = 0; + for (let index = 0; index < fixtures.length; index += 1) { + const sampled = fixtures[Math.floor(random() * fixtures.length)]!; + for (const value of sampled) { + sum += value; + count += 1; + } + } + distribution.push(sum / count); + } + distribution.sort((left, right) => left - right); + return { + iterations: HISTORICAL_GATE_BOOTSTRAP_ITERATIONS, + seed: HISTORICAL_GATE_BOOTSTRAP_SEED, + fixtures: fixtures.length, + signals: signals.length, + low: quantile(distribution, 0.025)!, + median: quantile(distribution, 0.5)!, + high: quantile(distribution, 0.975)! + }; +} + +function summarizeClvSlice( + signals: readonly SignalClv[], + emittedSignals: number, + costSensitivityBps: readonly number[] +): ClvSliceSummary { + const values = signals.map((signal) => signal.directionalClv).sort((left, right) => left - right); + const net = signals.map((signal) => signal.netAfterCostProxy); + const mean = values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length; + return { + signals: emittedSignals, + withClosingProbability: values.length, + uniqueFixtures: new Set(signals.map((signal) => signal.fixtureId)).size, + meanDirectionalClv: mean, + medianDirectionalClv: quantile(values, 0.5), + p25DirectionalClv: quantile(values, 0.25), + p75DirectionalClv: quantile(values, 0.75), + positiveClvRate: values.length === 0 ? null : values.filter((value) => value > 0).length / values.length, + meanNetAfterCostProxy: net.length === 0 ? null : net.reduce((sum, value) => sum + value, 0) / net.length, + matchClusteredNetClv95: clusteredNetClvInterval(signals), + meanNetByCostProxyBps: Object.fromEntries( + costSensitivityBps.map((costBps) => [ + String(costBps), + mean === null ? null : mean - costBps / 10_000 + ]) + ) + }; +} + +function summarizeClv( + signals: readonly SignalClv[], + emittedSignals: number, + costSensitivityBps: readonly number[] +): ClvSummary { + const matchResult = signals.filter((signal) => signal.marketKey.includes(":match_result:")); + const totalGoals = signals.filter((signal) => signal.marketKey.includes(":total_goals:")); + return { + ...summarizeClvSlice(signals, emittedSignals, costSensitivityBps), + byMarketFamily: { + matchResult: summarizeClvSlice(matchResult, matchResult.length, costSensitivityBps), + totalGoals: summarizeClvSlice(totalGoals, totalGoals.length, costSensitivityBps) + } + }; +} + +export type HistoricalGateStudyOptions = { + archivePath: string; + mappingsPath: string; + totalEvidencePath: string; + trainFraction: number; + windowBeforeKickoffMs: number; + minimumTrainingSignals: number; + costProxyProbability: number; +}; + +export type DetectorGateResult = { + detector: SignalKind; + training: { + selected: RankedTrainingConfiguration; + selectedTotalGoalsAudit: AggregatedThresholdResult; + topConfigurations: RankedTrainingConfiguration[]; + }; + test: { + result: AggregatedThresholdResult; + emissions: DetectorEmissionAudit; + clv: ClvSummary; + necessaryEvidenceStatus: + | "human_review_required" + | "insufficient_test_signals" + | "nonpositive_net_signal_clv" + | "clustered_interval_not_positive" + | "missing_classification"; + }; +}; + +export type HistoricalGateStudy = { + schemaVersion: 4; + protocolId: typeof HISTORICAL_GATE_PROTOCOL_ID; + configurationHash: string; + generatedAt: string; + status: "research_evidence_only"; + auditStatus: "causal_selector_and_economic_case_normalization_repaired_pending_human_review"; + realMoneyGate: "closed"; + sourceHashes: { + algorithm: "sha256"; + archive: { name: string; bytes: number; sha256: string }; + mappings: { name: string; bytes: number; sha256: string }; + totalEvidence: { name: string; bytes: number; sha256: string }; + }; + configuration: Omit & { + feature: FeatureEngineConfig; + labels: ForwardLabelConfig; + totalSelector: TotalLineSelectorConfig; + totalSelectorAsOfBeforeKickoffMs: number; + costSensitivityBps: number[]; + bootstrap: { iterations: number; seed: number; cluster: "fixture" }; + economicCaseNormalization: "binary_totals_executable_buy_v1"; + replayEndsAtKickoff: true; + }; + causalDiagnostics: { + evidenceRows: number; + probabilityAfterCutoffViolations: 0; + coverageAfterCutoffViolations: 0; + selectorAfterEvaluationStartViolations: 0; + nonzeroUntimestampedVolumeLiquidityRows: 0; + }; + operatingCost: { + modelCalls: 0; + modelCostMicroUsd: 0; + note: "historical_detector_replay_has_no_model_calls"; + }; + forwardPaperCandidate: { + detector: "CONSENSUS_MOVE"; + marketFamily: "total_goals"; + configurationSelection: "training_only_before_heldout_totals_review"; + trainingNormalizedCases: number; + heldoutNormalizedCases: number; + heldoutFixtures: number; + meanNetAfterCostProxy: number | null; + matchClusteredNetClv95: ClvSliceSummary["matchClusteredNetClv95"]; + status: + | "historical_signal_candidate_for_forward_paper_review" + | "insufficient_historical_signal_evidence"; + executionEvidence: "not_established_sampled_prices_only"; + registration: "engineering_candidate_unregistered"; + }; + split: { + cutoffTsMs: number; + cutoffIso: string; + trainFixtures: number; + testFixtures: number; + trainKickoffRange: [string, string]; + testKickoffRange: [string, string]; + }; + totalSelector: { + status: "candidate_pending_human_gate_review"; + selectedFixtures: number; + noEligibleFixtures: number; + criteriaDisagreeFixtures: number; + lineDistribution: Record; + }; + coverage: { + train: { + groupsAttempted: number; + groupsWithBothSources: number; + events: number; + snapshots: number; + missingGroups: Array<{ fixtureId: string; marketKey: string; txlineEvents: number; polymarketEvents: number }>; + }; + test: { + groupsAttempted: number; + groupsWithBothSources: number; + events: number; + snapshots: number; + missingGroups: Array<{ fixtureId: string; marketKey: string; txlineEvents: number; polymarketEvents: number }>; + }; + }; + detectors: DetectorGateResult[]; +}; + +const BASE_DETECTOR_CONFIG: DetectorBankConfig = { + velocityWindowMs: 300_000, + consensusMoveAbsZ: 1.5, + consensusCusumThreshold: 0.0025, + consensusMinimumUpdates: 3, + consensusMinimumRawGap: 0.005, + consensusStableAbsZ: 1, + xmarketMinimumRawGap: 0.005, + xmarketPersistenceMs: 60_000, + faderPolymarketAbsZ: 1.5, + faderMinimumRawGap: 0.005, + faderPersistenceMs: 60_000 +}; + +const LABEL_CONFIG: ForwardLabelConfig = { + horizonMs: 900_000, + maximumResolutionDelayMs: 120_000, + velocityWindowMs: 300_000, + minimumGapClosure: 0.0025, + minimumPolymarketReversion: 0.0025, + maximumConsensusMoveForFader: 0.0025, + minimumPolymarketFollow: 0.0025, + maximumConsensusReversal: 0.0025 +}; + +function sweeps(): Sweep[] { + return [ + { + detector: "XMARKET_DIVERGENCE", + configs: expandDetectorThresholdGrid(BASE_DETECTOR_CONFIG, { + consensusStableAbsZ: [0.5, 1], + xmarketMinimumRawGap: [0.005, 0.0075, 0.01, 0.015, 0.02], + xmarketPersistenceMs: [0, 60_000, 120_000] + }) + }, + { + detector: "CONSENSUS_MOVE", + configs: expandDetectorThresholdGrid(BASE_DETECTOR_CONFIG, { + consensusMoveAbsZ: [1, 1.5, 2], + consensusCusumThreshold: [0.001, 0.0025, 0.005], + consensusMinimumUpdates: [3, 5], + consensusMinimumRawGap: [0.005, 0.01, 0.015, 0.02] + }) + }, + { + detector: "FADER_CANDIDATE", + configs: expandDetectorThresholdGrid(BASE_DETECTOR_CONFIG, { + consensusStableAbsZ: [0.5, 1], + faderPolymarketAbsZ: [1, 1.5, 2], + faderMinimumRawGap: [0.005, 0.01, 0.015, 0.02], + faderPersistenceMs: [0, 60_000] + }) + } + ]; +} + +function buildFixtures( + records: readonly MappingRecord[], + evidence: readonly TotalLineEvidence[], + windowBeforeKickoffMs: number +): { fixtures: HistoricalFixture[]; selections: TotalLineSelection[] } { + const byFixture = new Map(); + for (const record of records) { + const rows = byFixture.get(record.txlineFixtureId) ?? []; + rows.push(record); + byFixture.set(record.txlineFixtureId, rows); + } + const evidenceByFixture = new Map(); + for (const row of evidence) { + const rows = evidenceByFixture.get(row.fixtureId) ?? []; + rows.push(row); + evidenceByFixture.set(row.fixtureId, rows); + } + const fixtures: HistoricalFixture[] = []; + const selections: TotalLineSelection[] = []; + for (const [fixtureId, fixtureRecords] of byFixture) { + const kickoffTsMs = Math.min(...fixtureRecords.map((item) => item.kickoff.txlineTsMs)); + const detectorEvaluationStartTsMs = kickoffTsMs - windowBeforeKickoffMs; + const selection = selectMainTotalLine( + fixtureId, + evidenceByFixture.get(fixtureId) ?? [], + PAPER_STUDY_TOTAL_SELECTOR_CONFIG, + detectorEvaluationStartTsMs + ); + selections.push(selection); + if (selection.selected === null) continue; + const record = fixtureRecords[0]!; + fixtures.push({ + fixtureId, + kickoffTsMs, + home: record.teams.home.canonical, + away: record.teams.away.canonical, + matchResultMarketKey: `${fixtureId}:match_result:full_time:none`, + selectedTotalMarketKey: selection.selected.marketKey, + selectedTotalLineMilli: selection.selected.lineMilli + }); + } + return { fixtures, selections }; +} + +function coverageTotals(rows: readonly GroupCoverage[]): HistoricalGateStudy["coverage"]["train"] { + return { + groupsAttempted: rows.length, + groupsWithBothSources: rows.filter((row) => row.txlineEvents > 0 && row.polymarketEvents > 0).length, + events: rows.reduce((sum, row) => sum + row.events, 0), + snapshots: rows.reduce((sum, row) => sum + row.snapshots, 0), + missingGroups: rows + .filter((row) => row.txlineEvents === 0 || row.polymarketEvents === 0) + .map((row) => ({ + fixtureId: row.fixtureId, + marketKey: row.marketKey, + txlineEvents: row.txlineEvents, + polymarketEvents: row.polymarketEvents + })) + }; +} + +async function runTrainingGroup( + options: HistoricalGateStudyOptions, + fixture: HistoricalFixture, + marketKey: string, + definitions: readonly Sweep[], + aggregators: ReadonlyMap, + totalGoalsAggregators: ReadonlyMap +): Promise { + const studies = definitions.map((definition) => ({ + definition, + study: new StreamingDetectorStudy( + new DetectorThresholdGrid(definition.configs), + new ForwardOutcomeLabeler(LABEL_CONFIG) + ) + })); + const summary = await extractResearchFeatures({ + archivePath: options.archivePath, + query: { + fixtureId: fixture.fixtureId, + marketKey, + fromTsMs: fixture.kickoffTsMs - options.windowBeforeKickoffMs, + toTsMs: fixture.kickoffTsMs, + includeInRunning: false + }, + featureConfig: PAPER_STUDY_FEATURE_CONFIG, + onSnapshot: (snapshot) => { + for (const item of studies) item.study.ingest(snapshot); + } + }); + for (const item of studies) { + const results = item.study.finish(); + aggregators.get(item.definition.detector)!.add(results); + if (marketKey.includes(":total_goals:")) { + totalGoalsAggregators.get(item.definition.detector)!.add(results); + } + } + return { fixtureId: fixture.fixtureId, marketKey, ...summary }; +} + +type TestRunner = { + detector: SignalKind; + config: DetectorBankConfig; + aggregator: ThresholdResultAggregator; +}; + +async function runTestGroup( + options: HistoricalGateStudyOptions, + fixture: HistoricalFixture, + marketKey: string, + runners: readonly TestRunner[], + clvSignals: Map, + emissionAudits: Map +): Promise { + const studies = runners.map((runner) => ({ + runner, + study: new StreamingDetectorStudy( + new DetectorThresholdGrid([runner.config]), + new ForwardOutcomeLabeler(LABEL_CONFIG) + ), + bank: new DetectorBank(runner.config) + })); + const signals: DetectorSignal[] = []; + const closing = new Map(); + const summary = await extractResearchFeatures({ + archivePath: options.archivePath, + query: { + fixtureId: fixture.fixtureId, + marketKey, + fromTsMs: fixture.kickoffTsMs - options.windowBeforeKickoffMs, + toTsMs: fixture.kickoffTsMs, + includeInRunning: false + }, + featureConfig: PAPER_STUDY_FEATURE_CONFIG, + onSnapshot: (snapshot: FeatureSnapshot) => { + if (snapshot.consensus.probability !== null) { + closing.set(`${snapshot.market.key}:${snapshot.outcome}`, snapshot.consensus.probability); + } + for (const item of studies) { + item.study.ingest(snapshot); + for (const signal of item.bank.ingest(snapshot)) { + if ( + signal.kind === item.runner.detector && + signal.detectedAtTsMs <= fixture.kickoffTsMs - LABEL_CONFIG.horizonMs + ) { + signals.push(signal); + } + } + } + } + }); + for (const item of studies) item.runner.aggregator.add(item.study.finish()); + const normalization = normalizeExecutableEconomicCases(signals); + for (const [index, signal] of signals.entries()) { + const disposition = normalization.dispositions[index]!; + const audit = emissionAudits.get(signal.kind)!; + audit.rawEmissions += 1; + if (disposition.disposition === "retained") audit.normalizedEconomicCases += 1; + if (disposition.reason === "executable_buy_retained") audit.executableTotalGoalsCases += 1; + else if (disposition.reason === "non_binary_market_passthrough") { + audit.nonBinaryMarketSignalsPassedThrough += 1; + } else if (disposition.reason === "duplicate_executable_buy_collapsed") { + audit.duplicateExecutableBuysCollapsed += 1; + } else if (disposition.reason === "complementary_sell_collapsed_into_executable_buy") { + audit.complementarySellsCollapsed += 1; + } else if (disposition.reason === "sell_only_unproven_executable_ask") { + audit.sellOnlySignalsDropped += 1; + } else if (disposition.reason === "unsupported_total_goals_outcome") { + audit.unsupportedTotalGoalsSignalsDropped += 1; + } + } + for (const signal of normalization.signals) { + const closingProbability = closing.get(`${signal.market.key}:${signal.outcome}`); + if (closingProbability === undefined) continue; + const entryProbability = signal.evidence.polymarketProbability; + const directionalClv = signal.direction === "buy" + ? closingProbability - entryProbability + : entryProbability - closingProbability; + clvSignals.get(signal.kind)!.push({ + detector: signal.kind, + fixtureId: fixture.fixtureId, + marketKey: signal.market.key, + outcome: signal.outcome, + direction: signal.direction, + detectedAtTsMs: signal.detectedAtTsMs, + entryProbability, + closingProbability, + directionalClv, + netAfterCostProxy: directionalClv - options.costProxyProbability + }); + } + return { fixtureId: fixture.fixtureId, marketKey, ...summary }; +} + +function kickoffRange(fixtures: readonly HistoricalFixture[]): [string, string] { + const values = fixtures.map((fixture) => fixture.kickoffTsMs).sort((left, right) => left - right); + return [new Date(values[0]!).toISOString(), new Date(values.at(-1)!).toISOString()]; +} + +function sha256Text(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function sha256File(path: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer); + return hash.digest("hex"); +} + +function causalDiagnostics( + evidence: readonly TotalLineEvidence[], + records: readonly MappingRecord[], + windowBeforeKickoffMs: number +): HistoricalGateStudy["causalDiagnostics"] { + const kickoffByFixture = new Map(); + for (const record of records) { + const current = kickoffByFixture.get(record.txlineFixtureId); + kickoffByFixture.set( + record.txlineFixtureId, + current === undefined ? record.kickoff.txlineTsMs : Math.min(current, record.kickoff.txlineTsMs) + ); + } + const probabilityAfterCutoffViolations = evidence.filter((row) => + row.preKickoffPointTsMs !== null && row.preKickoffPointTsMs > row.selectorCutoffTsMs + ).length; + const coverageAfterCutoffViolations = evidence.filter((row) => + row.coverageLastPointTsMs !== null && row.coverageLastPointTsMs > row.selectorCutoffTsMs + ).length; + const selectorAfterEvaluationStartViolations = evidence.filter((row) => { + const kickoff = kickoffByFixture.get(row.fixtureId); + return kickoff === undefined || row.selectorCutoffTsMs > kickoff - windowBeforeKickoffMs; + }).length; + const nonzeroUntimestampedVolumeLiquidityRows = evidence.filter((row) => + row.volume !== 0 || row.liquidity !== 0 + ).length; + if ( + probabilityAfterCutoffViolations !== 0 || + coverageAfterCutoffViolations !== 0 || + selectorAfterEvaluationStartViolations !== 0 || + nonzeroUntimestampedVolumeLiquidityRows !== 0 + ) { + throw new Error("Historical gate causal diagnostics found future or untimestamped selector inputs"); + } + return { + evidenceRows: evidence.length, + probabilityAfterCutoffViolations: 0, + coverageAfterCutoffViolations: 0, + selectorAfterEvaluationStartViolations: 0, + nonzeroUntimestampedVolumeLiquidityRows: 0 + }; +} + +export async function runHistoricalGateStudy( + options: HistoricalGateStudyOptions, + onProgress?: (message: string) => void +): Promise { + if (!Number.isFinite(options.costProxyProbability) || options.costProxyProbability < 0) { + throw new RangeError("Cost proxy probability must be non-negative"); + } + if (!Number.isSafeInteger(options.windowBeforeKickoffMs) || options.windowBeforeKickoffMs <= 0) { + throw new RangeError("Historical replay window must be a positive safe-integer duration"); + } + if (options.windowBeforeKickoffMs > PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS) { + throw new Error("Historical detector evaluation cannot begin before the frozen total-line selector cutoff"); + } + const archiveBefore = await stat(options.archivePath); + const [mappingText, evidenceText, archiveSha256] = await Promise.all([ + readFile(options.mappingsPath, "utf8"), + readFile(options.totalEvidencePath, "utf8"), + sha256File(options.archivePath) + ]); + const mappingFile = JSON.parse(mappingText) as MappingFile; + const evidenceFile = JSON.parse(evidenceText) as TotalEvidenceFile; + assertCausalTotalEvidenceFile(evidenceFile); + assertCausalTotalSelectorConfig(PAPER_STUDY_TOTAL_SELECTOR_CONFIG); + const costSensitivityBps = [50, 100, 150, 200]; + const records = mappingFile.records ?? []; + const evidence = evidenceFile.evidence ?? []; + const diagnostics = causalDiagnostics(evidence, records, options.windowBeforeKickoffMs); + const { fixtures, selections } = buildFixtures( + records, + evidence, + options.windowBeforeKickoffMs + ); + const split = chronologicalFixtureSplit(fixtures, options.trainFraction); + const definitions = sweeps(); + const configuration: HistoricalGateStudy["configuration"] = { + trainFraction: options.trainFraction, + windowBeforeKickoffMs: options.windowBeforeKickoffMs, + minimumTrainingSignals: options.minimumTrainingSignals, + costProxyProbability: options.costProxyProbability, + feature: PAPER_STUDY_FEATURE_CONFIG, + labels: LABEL_CONFIG, + totalSelector: PAPER_STUDY_TOTAL_SELECTOR_CONFIG, + totalSelectorAsOfBeforeKickoffMs: PAPER_STUDY_TOTAL_SELECTOR_AS_OF_BEFORE_KICKOFF_MS, + costSensitivityBps, + bootstrap: { + iterations: HISTORICAL_GATE_BOOTSTRAP_ITERATIONS, + seed: HISTORICAL_GATE_BOOTSTRAP_SEED, + cluster: "fixture" + }, + economicCaseNormalization: "binary_totals_executable_buy_v1", + replayEndsAtKickoff: true + }; + const configurationHash = sha256Text(stableJson({ + protocolId: HISTORICAL_GATE_PROTOCOL_ID, + configuration, + detectorSweeps: definitions + })); + const trainingAggregators = new Map( + DETECTORS.map((detector) => [detector, new ThresholdResultAggregator(detector)]) + ); + const trainingTotalGoalsAggregators = new Map( + DETECTORS.map((detector) => [detector, new ThresholdResultAggregator(detector)]) + ); + const trainCoverage: GroupCoverage[] = []; + for (const [index, fixture] of split.train.entries()) { + for (const marketKey of [fixture.matchResultMarketKey, fixture.selectedTotalMarketKey]) { + trainCoverage.push(await runTrainingGroup( + options, + fixture, + marketKey, + definitions, + trainingAggregators, + trainingTotalGoalsAggregators + )); + } + if ((index + 1) % 10 === 0 || index + 1 === split.train.length) { + onProgress?.(`training ${index + 1}/${split.train.length} fixtures`); + } + } + + const ranked = new Map(); + for (const detector of DETECTORS) { + ranked.set( + detector, + rankTrainingConfigurations( + trainingAggregators.get(detector)!.results(), + options.minimumTrainingSignals, + detector, + options.costProxyProbability + ) + ); + } + const selected = new Map( + DETECTORS.map((detector) => [detector, selectEligibleTrainingConfiguration(detector, ranked.get(detector) ?? [])]) + ); + const selectedTotalGoalsAudit = new Map(DETECTORS.map((detector) => { + const selectedConfig = selected.get(detector)!; + const audit = trainingTotalGoalsAggregators + .get(detector)! + .results() + .find((candidate) => candidate.configId === selectedConfig.configId); + if (!audit) throw new Error(`Training Total Goals audit is missing selected config for ${detector}`); + return [detector, audit] as const; + })); + const testRunners: TestRunner[] = DETECTORS.map((detector) => { + return { + detector, + config: selected.get(detector)!.config, + aggregator: new ThresholdResultAggregator(detector) + }; + }); + const clvSignals = new Map(DETECTORS.map((detector) => [detector, [] as SignalClv[]])); + const emissionAudits = new Map(DETECTORS.map((detector) => [detector, emptyEmissionAudit()])); + const testCoverage: GroupCoverage[] = []; + for (const [index, fixture] of split.test.entries()) { + for (const marketKey of [fixture.matchResultMarketKey, fixture.selectedTotalMarketKey]) { + testCoverage.push(await runTestGroup( + options, + fixture, + marketKey, + testRunners, + clvSignals, + emissionAudits + )); + } + if ((index + 1) % 5 === 0 || index + 1 === split.test.length) { + onProgress?.(`test ${index + 1}/${split.test.length} fixtures`); + } + } + + const detectorResults: DetectorGateResult[] = DETECTORS.map((detector) => { + const selectedConfiguration = selected.get(detector)!; + const result = testRunners.find((runner) => runner.detector === detector)!.aggregator.results()[0]; + if (!result) throw new Error(`Held-out study produced no result for ${detector}`); + const clv = summarizeClv( + clvSignals.get(detector)!, + emissionAudits.get(detector)!.normalizedEconomicCases, + costSensitivityBps + ); + let necessaryEvidenceStatus: DetectorGateResult["test"]["necessaryEvidenceStatus"]; + if (result.classification.precision === null) necessaryEvidenceStatus = "missing_classification"; + else if (clv.withClosingProbability < 30) necessaryEvidenceStatus = "insufficient_test_signals"; + else if (clv.meanNetAfterCostProxy === null || clv.meanNetAfterCostProxy <= 0) { + necessaryEvidenceStatus = "nonpositive_net_signal_clv"; + } else if (clv.matchClusteredNetClv95 === null || clv.matchClusteredNetClv95.low <= 0) { + necessaryEvidenceStatus = "clustered_interval_not_positive"; + } else necessaryEvidenceStatus = "human_review_required"; + return { + detector, + training: { + selected: selectedConfiguration, + selectedTotalGoalsAudit: selectedTotalGoalsAudit.get(detector)!, + topConfigurations: ranked.get(detector)!.slice(0, 5) + }, + test: { result, emissions: { ...emissionAudits.get(detector)! }, clv, necessaryEvidenceStatus } + }; + }); + const lineDistribution: Record = {}; + for (const fixture of fixtures) { + const line = (fixture.selectedTotalLineMilli / 1_000).toFixed(1); + lineDistribution[line] = (lineDistribution[line] ?? 0) + 1; + } + const archiveAfter = await stat(options.archivePath); + if (archiveAfter.size !== archiveBefore.size || archiveAfter.mtimeMs !== archiveBefore.mtimeMs) { + throw new Error("Historical source archive changed during the gate run"); + } + const consensus = detectorResults.find((result) => result.detector === "CONSENSUS_MOVE")!; + const consensusTotals = consensus.test.clv.byMarketFamily.totalGoals; + const consensusTotalsInterval = consensusTotals.matchClusteredNetClv95; + const forwardCandidatePasses = + consensus.training.selectedTotalGoalsAudit.signals >= options.minimumTrainingSignals && + consensusTotals.withClosingProbability >= 30 && + consensusTotals.meanNetAfterCostProxy !== null && + consensusTotals.meanNetAfterCostProxy > 0 && + consensusTotalsInterval !== null && + consensusTotalsInterval.low > 0; + return { + schemaVersion: 4, + protocolId: HISTORICAL_GATE_PROTOCOL_ID, + configurationHash, + generatedAt: new Date().toISOString(), + status: "research_evidence_only", + auditStatus: "causal_selector_and_economic_case_normalization_repaired_pending_human_review", + realMoneyGate: "closed", + sourceHashes: { + algorithm: "sha256", + archive: { + name: basename(options.archivePath), + bytes: archiveBefore.size, + sha256: archiveSha256 + }, + mappings: { + name: basename(options.mappingsPath), + bytes: Buffer.byteLength(mappingText), + sha256: sha256Text(mappingText) + }, + totalEvidence: { + name: basename(options.totalEvidencePath), + bytes: Buffer.byteLength(evidenceText), + sha256: sha256Text(evidenceText) + } + }, + configuration, + causalDiagnostics: diagnostics, + operatingCost: { + modelCalls: 0, + modelCostMicroUsd: 0, + note: "historical_detector_replay_has_no_model_calls" + }, + forwardPaperCandidate: { + detector: "CONSENSUS_MOVE", + marketFamily: "total_goals", + configurationSelection: "training_only_before_heldout_totals_review", + trainingNormalizedCases: consensus.training.selectedTotalGoalsAudit.signals, + heldoutNormalizedCases: consensusTotals.withClosingProbability, + heldoutFixtures: consensusTotals.uniqueFixtures, + meanNetAfterCostProxy: consensusTotals.meanNetAfterCostProxy, + matchClusteredNetClv95: consensusTotalsInterval, + status: forwardCandidatePasses + ? "historical_signal_candidate_for_forward_paper_review" + : "insufficient_historical_signal_evidence", + executionEvidence: "not_established_sampled_prices_only", + registration: "engineering_candidate_unregistered" + }, + split: { + cutoffTsMs: split.cutoffTsMs, + cutoffIso: new Date(split.cutoffTsMs).toISOString(), + trainFixtures: split.train.length, + testFixtures: split.test.length, + trainKickoffRange: kickoffRange(split.train), + testKickoffRange: kickoffRange(split.test) + }, + totalSelector: { + status: "candidate_pending_human_gate_review", + selectedFixtures: fixtures.length, + noEligibleFixtures: selections.filter((selection) => selection.selected === null).length, + criteriaDisagreeFixtures: selections.filter((selection) => selection.criteriaDisagree).length, + lineDistribution + }, + coverage: { train: coverageTotals(trainCoverage), test: coverageTotals(testCoverage) }, + detectors: detectorResults + }; +} + +function percent(value: number | null): string { + return value === null ? "n/a" : `${(value * 100).toFixed(1)}%`; +} + +function probabilityBps(value: number | null): string { + return value === null ? "n/a" : (value * 10_000).toFixed(1); +} + +function selectedParameters(detector: SignalKind, config: DetectorBankConfig): string { + if (detector === "XMARKET_DIVERGENCE") { + return `gap=${probabilityBps(config.xmarketMinimumRawGap)}bps, persist=${config.xmarketPersistenceMs / 1_000}s, stableZ=${config.consensusStableAbsZ}`; + } + if (detector === "CONSENSUS_MOVE") { + return `z=${config.consensusMoveAbsZ}, cusum=${probabilityBps(config.consensusCusumThreshold)}bps, gap=${probabilityBps(config.consensusMinimumRawGap)}bps, updates=${config.consensusMinimumUpdates}`; + } + return `pmZ=${config.faderPolymarketAbsZ}, gap=${probabilityBps(config.faderMinimumRawGap)}bps, persist=${config.faderPersistenceMs / 1_000}s, stableZ=${config.consensusStableAbsZ}`; +} + +export function renderHistoricalGateStudyMarkdown(study: HistoricalGateStudy): string { + const detectorRows = study.detectors.map((row) => { + const train = row.training.selected.classification; + const test = row.test.result.classification; + const interval = row.test.clv.matchClusteredNetClv95; + const intervalText = interval === null + ? "n/a" + : `[${probabilityBps(interval.low)}, ${probabilityBps(interval.high)}]`; + return `| ${row.detector} | ${selectedParameters(row.detector, row.training.selected.config)} | ${row.training.selected.rawEmissions} | ${row.training.selected.normalizedEconomicCases} | ${percent(train.precision)} | ${percent(train.recall)} | ${row.test.emissions.rawEmissions} | ${row.test.emissions.normalizedEconomicCases} | ${row.test.clv.withClosingProbability} | ${row.test.clv.uniqueFixtures} | ${percent(test.precision)} | ${percent(test.recall)} | ${probabilityBps(row.test.clv.meanDirectionalClv)} | ${probabilityBps(row.test.clv.meanNetAfterCostProxy)} | ${intervalText} | ${probabilityBps(row.test.clv.byMarketFamily.matchResult.meanNetAfterCostProxy)} | ${probabilityBps(row.test.clv.byMarketFamily.totalGoals.meanNetAfterCostProxy)} | ${row.test.necessaryEvidenceStatus} |`; + }); + const sensitivityRows = study.detectors.map((detector) => + `| ${detector.detector} | ${study.configuration.costSensitivityBps.map((cost) => probabilityBps(detector.test.clv.meanNetByCostProxyBps[String(cost)] ?? null)).join(" | ")} |` + ); + const normalizationRows = study.detectors.map((detector) => { + const audit = detector.test.emissions; + return `| ${detector.detector} | ${audit.rawEmissions} | ${audit.normalizedEconomicCases} | ${audit.executableTotalGoalsCases} | ${audit.nonBinaryMarketSignalsPassedThrough} | ${audit.complementarySellsCollapsed} | ${audit.duplicateExecutableBuysCollapsed} | ${audit.sellOnlySignalsDropped} | ${audit.unsupportedTotalGoalsSignalsDropped} |`; + }); + const topRows = study.detectors.flatMap((detector) => detector.training.topConfigurations.map((row, index) => + `| ${detector.detector} | ${index + 1} | ${selectedParameters(detector.detector, row.config)} | ${row.rawEmissions} | ${row.normalizedEconomicCases} | ${percent(row.classification.precision)} | ${percent(row.classification.recall)} | ${row.f1 === null ? "n/a" : row.f1.toFixed(3)} | ${row.meetsMinimumSignals ? "yes" : "no"} | ${row.meetsCostFloor ? "yes" : "no"} |` + )); + return [ + "# Historical Detector Gate Study", + "", + `Generated: ${study.generatedAt}`, + `Protocol: \`${study.protocolId}\``, + `Configuration SHA-256: \`${study.configurationHash}\``, + "", + "> Research evidence only. Historical Polymarket points are sampled prices without bid/ask or depth. Results below are signal research, not executable-fill proof. The real-money gate remains closed.", + "> Audit status: the causal selector and binary Total Goals economic-case normalization are repaired. The result still requires Deborah's human gate review and must not be described as executable alpha.", + `> Source SHA-256: archive \`${study.sourceHashes.archive.sha256}\`; mappings \`${study.sourceHashes.mappings.sha256}\`; causal selector evidence \`${study.sourceHashes.totalEvidence.sha256}\`.`, + "", + "## Design", + "", + `- Chronological train/test split: ${study.split.trainFixtures} / ${study.split.testFixtures} fixtures`, + `- Training range: ${study.split.trainKickoffRange[0]} to ${study.split.trainKickoffRange[1]}`, + `- Held-out range: ${study.split.testKickoffRange[0]} to ${study.split.testKickoffRange[1]}`, + `- Split cutoff: ${study.split.cutoffIso}; identical kickoff timestamps never cross partitions`, + `- Replay window: final ${(study.configuration.windowBeforeKickoffMs / 3_600_000).toFixed(1)} hours through kickoff; in-running TXLine rows excluded`, + `- Forward label: ${(study.configuration.labels.horizonMs / 60_000).toFixed(0)} minutes with ${probabilityBps(study.configuration.labels.minimumGapClosure)} bps minimum gap closure`, + `- Historical cost proxy: ${probabilityBps(study.configuration.costProxyProbability)} probability bps per signal`, + `- Training selection: labeled snapshot F1, guarded by at least ${study.configuration.minimumTrainingSignals} normalized economic cases; raw emissions cannot satisfy the minimum-n gate`, + `- Uncertainty: ${study.configuration.bootstrap.iterations.toLocaleString("en-US")} fixture-clustered bootstrap iterations, seed ${study.configuration.bootstrap.seed}`, + `- Model operating cost: $0.00 (${study.operatingCost.modelCalls} model calls; deterministic historical replay only)`, + "", + "## Coverage", + "", + `- Training groups with both sources: ${study.coverage.train.groupsWithBothSources}/${study.coverage.train.groupsAttempted}; ${study.coverage.train.events.toLocaleString("en-US")} events; ${study.coverage.train.snapshots.toLocaleString("en-US")} snapshots`, + `- Held-out groups with both sources: ${study.coverage.test.groupsWithBothSources}/${study.coverage.test.groupsAttempted}; ${study.coverage.test.events.toLocaleString("en-US")} events; ${study.coverage.test.snapshots.toLocaleString("en-US")} snapshots`, + `- Causal diagnostics across ${study.causalDiagnostics.evidenceRows.toLocaleString("en-US")} selector rows: ${study.causalDiagnostics.probabilityAfterCutoffViolations} future probabilities; ${study.causalDiagnostics.coverageAfterCutoffViolations} future coverage rows; ${study.causalDiagnostics.selectorAfterEvaluationStartViolations} late selector cutoffs; ${study.causalDiagnostics.nonzeroUntimestampedVolumeLiquidityRows} nonzero untimestamped liquidity/volume rows`, + ...(study.coverage.test.missingGroups.length === 0 + ? [] + : [`- Held-out missing groups: ${study.coverage.test.missingGroups.map((group) => `\`${group.marketKey}\``).join(", ")}`]), + "", + "## Dynamic Total Candidate", + "", + `The candidate rule freezes each exact mapped full-time total ${(study.configuration.totalSelectorAsOfBeforeKickoffMs / 3_600_000).toFixed(1)} hours before TXLine kickoff—before the first detector snapshot—then selects the line closest to 50/50. Both probability and coverage are bounded by that same as-of timestamp. It requires at least ${study.configuration.totalSelector.minimumCoveragePoints.toLocaleString("en-US")} as-of history points and fails closed beyond ${probabilityBps(study.configuration.totalSelector.maximumDistanceFromEven)} bps from even. It selected ${study.totalSelector.selectedFixtures} fixtures with ${study.totalSelector.noEligibleFixtures} failures. Distribution: ${Object.entries(study.totalSelector.lineDistribution).map(([line, count]) => `O/U ${line}: ${count}`).join(", ")}.`, + "", + `Status: \`${study.totalSelector.status}\`. Volume and liquidity are zeroed and forbidden as selector inputs because no timestamped historical as-of evidence exists for them.`, + "", + "## Forward Paper Candidate Assessment", + "", + `The prespecified paper family is **Total Goals only**. Its frozen \`CONSENSUS_MOVE\` configuration was chosen from training data before held-out Total Goals results were inspected. A family-specific training audit found ${study.forwardPaperCandidate.trainingNormalizedCases} normalized Total Goals cases, so the training minimum was not borrowed from Match Result.`, + "", + study.forwardPaperCandidate.matchClusteredNetClv95 === null + ? "The held-out Total Goals slice has no estimable fixture-clustered interval." + : `Held-out Total Goals: ${study.forwardPaperCandidate.heldoutNormalizedCases} normalized buy cases across ${study.forwardPaperCandidate.heldoutFixtures} fixtures; mean after the ${probabilityBps(study.configuration.costProxyProbability)} bps cost proxy ${probabilityBps(study.forwardPaperCandidate.meanNetAfterCostProxy)} bps; fixture-clustered 95% CI [${probabilityBps(study.forwardPaperCandidate.matchClusteredNetClv95.low)}, ${probabilityBps(study.forwardPaperCandidate.matchClusteredNetClv95.high)}] bps.`, + "", + `Status: \`${study.forwardPaperCandidate.status}\`. This is a **historical signal candidate for a fresh forward paper review**, not alpha, profitability, or executable-fill evidence. Inputs are sampled Polymarket prices without bid/ask depth; the v2 paper protocol remains \`${study.forwardPaperCandidate.registration}\` and cannot admit observations or spend Claude tokens before Deborah registers it.`, + "", + "## Held-Out Results", + "", + "| Detector | Train-selected parameters | Train raw emissions | Train normalized cases | Train snapshot precision | Train snapshot recall | Test raw emissions | Test normalized cases | Test CLV n | Fixtures | Test snapshot precision | Test snapshot recall | Mean signal CLV bps | Mean after-cost bps | Fixture-clustered 95% CI bps | Match Result net bps | Total net bps | Necessary evidence status |", + "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---:|---:|---|", + ...detectorRows, + "", + "`human_review_required` means only that the predeclared sample, after-cost point estimate, and fixture-clustered interval checks passed. `clustered_interval_not_positive` means the point estimate was not strong enough to exclude zero after fixture clustering. Neither status is permission to trade or a substitute for Deborah's gate decision, settlement verification, risk caps, or executable-book evidence.", + "", + "For binary totals, `buy Over + sell Under` is one Over exposure and `buy Under + sell Over` is one Under exposure. An actual BUY is retained; duplicates and complementary sell expressions collapse into it. Sell-only totals are excluded because detector inputs do not prove an executable complementary-token ask. Three-way Match Result signals are not economically normalized.", + "", + "### Held-Out Normalization Audit", + "", + "| Detector | Raw | Normalized | Executable total buys | Match Result pass-through | Complementary sells collapsed | Duplicate buys collapsed | Sell-only totals dropped | Invalid total outcomes dropped |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|", + ...normalizationRows, + "", + "## Cost Sensitivity", + "", + `| Detector | ${study.configuration.costSensitivityBps.map((cost) => `${cost} bps proxy`).join(" | ")} |`, + `|---|${study.configuration.costSensitivityBps.map(() => "---:").join("|")}|`, + ...sensitivityRows, + "", + "Values are mean directional signal CLV after subtracting each proxy. This is a sensitivity check, not a claim about actual historical execution costs.", + "", + "## Training Leaderboard", + "", + "| Detector | Rank | Parameters | Raw emissions | Normalized cases | Snapshot precision | Snapshot recall | F1 | Minimum n met | Cost floor met |", + "|---|---:|---|---:|---:|---:|---:|---:|---|---|", + ...topRows, + "", + "## Guardrails", + "", + "- No held-out result influenced configuration selection.", + "- Total-line selection uses only prices and coverage observable no later than the first detector snapshot.", + "- Training and held-out minimum-sample checks use normalized economic cases, never raw detector emissions.", + "- Sell-only Total Goals cases fail closed; sampled history cannot prove a complementary token was buyable at that moment.", + "- Sampled Polymarket history cannot establish executable spread, slippage, or fill probability; the cost proxy is deliberately conservative but remains a proxy.", + "- Candidate mappings remain non-tradeable and no settlement review is inferred from research alignment.", + "- Thresholds in this report are exploratory until the human gate review explicitly freezes or rejects them.", + "- STALE_QUOTE remains disabled based on the synchronized live-lane result; this historical study does not evaluate it.", + "" + ].join("\n"); +} diff --git a/src/research/main-total-selector.ts b/src/research/main-total-selector.ts index 43726c7..c5f22b0 100644 --- a/src/research/main-total-selector.ts +++ b/src/research/main-total-selector.ts @@ -8,8 +8,11 @@ export type TotalLineEvidence = { lineMilli: number; mappingStatus: MappingStatus; txlineMarketObserved: boolean; + selectorCutoffTsMs: number; preKickoffOverProbability: Probability | null; preKickoffPointTsMs: number | null; + coverageFirstPointTsMs: number | null; + coverageLastPointTsMs: number | null; volume: number; liquidity: number; coveragePoints: number; @@ -46,6 +49,7 @@ export type RankedTotalLine = TotalLineEvidence & { export type TotalLineSelection = { fixtureId: string; + latestAllowedSelectorCutoffTsMs: number | null; status: "selected" | "no_eligible_line"; selected: RankedTotalLine | null; ranked: RankedTotalLine[]; @@ -74,6 +78,20 @@ function validateConfig(config: TotalLineSelectorConfig): void { } } +export function assertCausalTotalSelectorConfig(config: TotalLineSelectorConfig): void { + validateConfig(config); + if ( + config.minimumVolume !== 0 || + config.minimumLiquidity !== 0 || + config.weights.volume !== 0 || + config.weights.liquidity !== 0 + ) { + throw new Error( + "Causal total selector cannot use volume or liquidity without timestamped as-of evidence" + ); + } +} + function normalizedLog(value: number, maximum: number): number { return maximum <= 0 ? 0 : Math.log1p(value) / Math.log1p(maximum); } @@ -107,10 +125,17 @@ function winners(rows: TotalLineEvidence[]): { export function selectMainTotalLine( fixtureId: string, evidence: readonly TotalLineEvidence[], - config: TotalLineSelectorConfig + config: TotalLineSelectorConfig, + latestAllowedSelectorCutoffTsMs?: number ): TotalLineSelection { validateConfig(config); if (fixtureId.trim() === "") throw new Error("Total-line selection requires a fixtureId"); + if ( + latestAllowedSelectorCutoffTsMs !== undefined && + !Number.isSafeInteger(latestAllowedSelectorCutoffTsMs) + ) { + throw new RangeError("Latest allowed selector cutoff must be a safe-integer timestamp"); + } const excluded: Array<{ marketId: string; reasons: string[] }> = []; const eligible: TotalLineEvidence[] = []; for (const row of evidence) { @@ -131,14 +156,44 @@ export function selectMainTotalLine( if (row.preKickoffPointTsMs !== null && !Number.isSafeInteger(row.preKickoffPointTsMs)) { throw new Error(`Total line ${row.marketId} has invalid pre-kickoff point timestamp`); } + if (row.coverageFirstPointTsMs !== null && !Number.isSafeInteger(row.coverageFirstPointTsMs)) { + throw new Error(`Total line ${row.marketId} has invalid first coverage timestamp`); + } + if (row.coverageLastPointTsMs !== null && !Number.isSafeInteger(row.coverageLastPointTsMs)) { + throw new Error(`Total line ${row.marketId} has invalid last coverage timestamp`); + } if (row.preKickoffOverProbability !== null) probability(row.preKickoffOverProbability); const reasons: string[] = []; if (row.mappingStatus === "rejected") reasons.push("mapping_rejected"); if (!row.txlineMarketObserved) reasons.push("txline_line_unobserved"); + const hasValidSelectorCutoff = Number.isSafeInteger(row.selectorCutoffTsMs); + if (!hasValidSelectorCutoff) reasons.push("missing_selector_cutoff"); + else if ( + latestAllowedSelectorCutoffTsMs !== undefined && + row.selectorCutoffTsMs > latestAllowedSelectorCutoffTsMs + ) { + reasons.push("selector_cutoff_after_evaluation_start"); + } if (row.preKickoffOverProbability === null) reasons.push("missing_pre_kickoff_probability"); + else if (row.preKickoffPointTsMs === null) reasons.push("missing_pre_kickoff_probability_timestamp"); + else if (hasValidSelectorCutoff && row.preKickoffPointTsMs > row.selectorCutoffTsMs) { + reasons.push("probability_after_selector_cutoff"); + } else if (Math.abs(row.preKickoffOverProbability - 0.5) > config.maximumDistanceFromEven) { reasons.push("too_far_from_even"); } + if ( + row.coveragePoints > 0 && + (row.coverageFirstPointTsMs === null || row.coverageLastPointTsMs === null) + ) { + reasons.push("missing_coverage_cutoff_evidence"); + } else if ( + hasValidSelectorCutoff && + row.coverageLastPointTsMs !== null && + row.coverageLastPointTsMs > row.selectorCutoffTsMs + ) { + reasons.push("coverage_after_selector_cutoff"); + } if (row.coveragePoints < config.minimumCoveragePoints) reasons.push("insufficient_coverage"); if (row.volume < config.minimumVolume) reasons.push("insufficient_volume"); if (row.liquidity < config.minimumLiquidity) reasons.push("insufficient_liquidity"); @@ -149,6 +204,7 @@ export function selectMainTotalLine( if (eligible.length === 0) { return { fixtureId, + latestAllowedSelectorCutoffTsMs: latestAllowedSelectorCutoffTsMs ?? null, status: "no_eligible_line", selected: null, ranked: [], @@ -203,6 +259,7 @@ export function selectMainTotalLine( ); return { fixtureId, + latestAllowedSelectorCutoffTsMs: latestAllowedSelectorCutoffTsMs ?? null, status: "selected", selected: ranked[0]!, ranked, diff --git a/src/research/paired-live-study.ts b/src/research/paired-live-study.ts new file mode 100644 index 0000000..ace132f --- /dev/null +++ b/src/research/paired-live-study.ts @@ -0,0 +1,871 @@ +import { readFile } from "node:fs/promises"; +import type { + CanonicalOutcome, + OddsQuoteEvent, + PolymarketBookEvent, + PolymarketPriceEvent, + ScoreEvent +} from "../bus/events.js"; +import { + normalizePolymarketPayload +} from "../ingest/polymarket/normalizer.js"; +import { replayCapturedTxLineFrames } from "../ingest/txline/replay.js"; +import { MappingRegistry, type MappedAsset, type MappingRecord } from "../mapping/registry.js"; +import { readNdjson } from "../replay/files.js"; + +type CandidateFile = { records?: unknown[] }; + +type CapturedPolymarketMessage = { + receivedAt: string; + eventType: string; + assetIds?: string[]; + rawPayload: string; + parseError?: string | null; +}; + +export type ReconnectRecord = { + at?: string; + action?: string; + connectionIndex?: number; + code?: number; + clean?: boolean; +}; + +export type FeedOutage = { + connectionIndex: number | null; + startedAt: string; + endedAt: string | null; + durationMs: number | null; + closeCode: number | null; +}; + +export type FeedOutageSummary = { + outages: FeedOutage[]; + totalDowntimeMs: number; + maximumDowntimeMs: number; + unresolvedOutages: number; +}; + +export type LatencySummary = { + count: number; + minimumMs: number | null; + maximumMs: number | null; + meanMs: number | null; + p50Ms: number | null; + p90Ms: number | null; + p99Ms: number | null; + histogramResolutionMs: 1; + belowHistogramRange: number; + aboveHistogramRange: number; +}; + +export class MillisecondLatencyHistogram { + readonly #bins: Uint32Array; + #count = 0; + #sum = 0; + #minimum = Number.POSITIVE_INFINITY; + #maximum = Number.NEGATIVE_INFINITY; + #below = 0; + #above = 0; + + constructor( + readonly minimumBinMs = -60_000, + readonly maximumBinMs = 60_000 + ) { + if (!Number.isInteger(minimumBinMs) || !Number.isInteger(maximumBinMs) || minimumBinMs >= maximumBinMs) { + throw new RangeError("Latency histogram requires increasing integer millisecond bounds"); + } + this.#bins = new Uint32Array(maximumBinMs - minimumBinMs + 1); + } + + add(valueMs: number): void { + if (!Number.isFinite(valueMs)) throw new RangeError("Latency must be finite"); + const rounded = Math.round(valueMs); + this.#count += 1; + this.#sum += valueMs; + this.#minimum = Math.min(this.#minimum, valueMs); + this.#maximum = Math.max(this.#maximum, valueMs); + if (rounded < this.minimumBinMs) this.#below += 1; + else if (rounded > this.maximumBinMs) this.#above += 1; + else { + const index = rounded - this.minimumBinMs; + this.#bins[index] = this.#bins[index]! + 1; + } + } + + summary(): LatencySummary { + return { + count: this.#count, + minimumMs: this.#count === 0 ? null : this.#minimum, + maximumMs: this.#count === 0 ? null : this.#maximum, + meanMs: this.#count === 0 ? null : this.#sum / this.#count, + p50Ms: this.#quantile(0.5), + p90Ms: this.#quantile(0.9), + p99Ms: this.#quantile(0.99), + histogramResolutionMs: 1, + belowHistogramRange: this.#below, + aboveHistogramRange: this.#above + }; + } + + #quantile(probability: number): number | null { + if (this.#count === 0) return null; + const target = Math.max(1, Math.ceil(this.#count * probability)); + if (target <= this.#below) return Math.round(this.#minimum); + let cumulative = this.#below; + for (let index = 0; index < this.#bins.length; index += 1) { + cumulative += this.#bins[index]!; + if (cumulative >= target) return this.minimumBinMs + index; + } + return Math.round(this.#maximum); + } +} + +export function summarizeFeedOutages(records: readonly ReconnectRecord[]): FeedOutageSummary { + const outages: FeedOutage[] = []; + let pending: { atMs: number; record: ReconnectRecord } | null = null; + for (const record of records) { + if (record.action === "disconnect") { + if (record.code === 1000 && record.clean === true) continue; + const atMs = Date.parse(record.at ?? ""); + if (!Number.isFinite(atMs)) throw new Error(`Invalid reconnect timestamp: ${String(record.at)}`); + pending = { atMs, record }; + continue; + } + if (record.action !== "open-and-resubscribe" || pending === null) continue; + const endedAtMs = Date.parse(record.at ?? ""); + if (!Number.isFinite(endedAtMs) || endedAtMs < pending.atMs) { + throw new Error(`Invalid reconnect completion timestamp: ${String(record.at)}`); + } + outages.push({ + connectionIndex: pending.record.connectionIndex ?? null, + startedAt: new Date(pending.atMs).toISOString(), + endedAt: new Date(endedAtMs).toISOString(), + durationMs: endedAtMs - pending.atMs, + closeCode: pending.record.code ?? null + }); + pending = null; + } + if (pending !== null) { + outages.push({ + connectionIndex: pending.record.connectionIndex ?? null, + startedAt: new Date(pending.atMs).toISOString(), + endedAt: null, + durationMs: null, + closeCode: pending.record.code ?? null + }); + } + const durations = outages.flatMap((outage) => outage.durationMs === null ? [] : [outage.durationMs]); + return { + outages, + totalDowntimeMs: durations.reduce((sum, duration) => sum + duration, 0), + maximumDowntimeMs: durations.length === 0 ? 0 : Math.max(...durations), + unresolvedOutages: outages.filter((outage) => outage.durationMs === null).length + }; +} + +type BookState = { + bids: Map; + asks: Map; + bestBid: number | null; + bestAsk: number | null; + observedTsMs: number | null; + sourceTsMs: number | null; +}; + +type GroupDefinition = { + marketKey: string; + family: "match_result" | "total_goals"; + lineMilli: number | null; + mappingStatus: "candidate" | "verified" | "rejected"; + assets: MappedAsset[]; +}; + +type QuotePoint = { observedTsMs: number; event: OddsQuoteEvent }; + +export type OutcomeBookSnapshot = { + outcome: CanonicalOutcome; + assetId: string; + consensusProbability: number | null; + bestBid: number | null; + bestAsk: number | null; + midpoint: number | null; + spread: number | null; + bestBidSize: number | null; + bestAskSize: number | null; + quoteAgeMs: number | null; + executableBuyGap: number | null; +}; + +export type GroupBookSnapshot = { + marketKey: string; + family: "match_result" | "total_goals"; + lineMilli: number | null; + mappingStatus: "candidate" | "verified" | "rejected"; + atTsMs: number; + probabilitySum: number | null; + maximumSpread: number | null; + outcomes: OutcomeBookSnapshot[]; +}; + +type GoalPoint = { + actionId: number; + participant: number | null; + clockSeconds: number | null; + firstSeenSourceTsMs: number; + firstSeenObservedTsMs: number; + firstSeenConfirmed: boolean | null; + firstConfirmedSourceTsMs: number | null; + firstConfirmedObservedTsMs: number | null; +}; + +type GoalTracker = { + goal: GoalPoint; + snapshots: Map; + firstMarketEventMs: Map; + firstQuoteUpdateMs: Map; + firstMaterialMoveMs: Map; +}; + +export type RepricingEvidence = { + marketKey: string; + family: "match_result" | "total_goals"; + lineMilli: number | null; + firstMarketEventLatencyMs: number | null; + firstQuoteUpdateLatencyMs: number | null; + firstMaterialMoveLatencyMs: number | null; + preTriggerFiveSecondMove: number | null; + classification: + | "polymarket_moved_before_txline" + | "post_txline_reprice_observed" + | "no_material_reprice_in_window" + | "insufficient_book_state"; + outageAtTrigger: boolean; + outageBeforeMaterialMove: boolean; + snapshots: Array<{ horizonMs: number; book: GroupBookSnapshot }>; +}; + +export type GoalStudy = GoalPoint & { + txlineFirstSeenLatencyMs: number; + confirmationDelayMs: number | null; + markets: RepricingEvidence[]; +}; + +export type PairedLiveStudyOptions = { + fixtureId: string; + mappingPath: string; + polymarketMessagesPath: string; + polymarketReconnectsPath: string; + txlineOddsPath: string; + txlineScoresPath: string; + materialMoveProbability: number; + snapshotHorizonsMs?: readonly number[]; +}; + +export type PairedLiveStudy = { + schemaVersion: 1; + generatedAt: string; + status: "research_evidence_only"; + tradeable: false; + fixtureId: string; + configuration: { + materialMoveProbability: number; + materialMoveProbabilityBps: number; + reconnectRecoveryExclusionMs: number; + snapshotHorizonsMs: number[]; + }; + sourcePaths: Omit; + mapping: { + records: number; + groups: number; + canonicalAssets: number; + statuses: string[]; + }; + analysisWindow: { + firstMessageAt: string | null; + lastMessageAt: string | null; + stoppedAt: string; + messagesScanned: number; + targetMessages: number; + normalizedEvents: number; + eventTypes: Record; + }; + feedHealth: { + polymarketOutages: FeedOutageSummary; + txlineOddsLatency: LatencySummary; + txlineScoreLatency: LatencySummary; + polymarketVenueTimestampAgeAllCanonicalEvents: LatencySummary; + polymarketVenueTimestampAgeNonBookOutsideRecovery: LatencySummary; + polymarketVenueTimestampAgeByObservation: Record; + }; + goals: GoalStudy[]; + gateReadout: { + marketEventCases: number; + polymarketMovedBeforeTxline: number; + postTxlineRepriceObserved: number; + noMaterialRepriceInWindow: number; + insufficientBookState: number; + staleQuoteHypothesis: "not_supported_by_this_match" | "requires_more_evidence"; + }; +}; + +function midpoint(state: BookState): number | null { + return state.bestBid === null || state.bestAsk === null + ? null + : (state.bestBid + state.bestAsk) / 2; +} + +function bestLevel(levels: Map, side: "bid" | "ask"): number | null { + if (levels.size === 0) return null; + const prices = [...levels.keys()]; + return side === "bid" ? Math.max(...prices) : Math.min(...prices); +} + +function updateBookState( + state: BookState, + event: PolymarketBookEvent | PolymarketPriceEvent +): boolean { + let quoteUpdated = false; + if (event.kind === "polymarket.book") { + state.bids = new Map(event.bids.map((level) => [level.price, Number(level.size)])); + state.asks = new Map(event.asks.map((level) => [level.price, Number(level.size)])); + state.bestBid = bestLevel(state.bids, "bid"); + state.bestAsk = bestLevel(state.asks, "ask"); + quoteUpdated = true; + } else { + if (event.observation === "price_change" && event.price !== null && event.size !== null && event.side !== null) { + const levels = event.side === "BUY" ? state.bids : state.asks; + const size = Number(event.size); + if (Number.isFinite(size) && size > 0) levels.set(event.price, size); + else levels.delete(event.price); + quoteUpdated = true; + } + if (event.bestBid !== null) { + state.bestBid = event.bestBid; + quoteUpdated = true; + } else if (event.observation === "price_change") state.bestBid = bestLevel(state.bids, "bid"); + if (event.bestAsk !== null) { + state.bestAsk = event.bestAsk; + quoteUpdated = true; + } else if (event.observation === "price_change") state.bestAsk = bestLevel(state.asks, "ask"); + } + state.observedTsMs = event.observedTsMs; + state.sourceTsMs = event.sourceTsMs; + return quoteUpdated; +} + +function latestQuote(points: readonly QuotePoint[], atTsMs: number): OddsQuoteEvent | null { + let low = 0; + let high = points.length - 1; + let found: OddsQuoteEvent | null = null; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const point = points[middle]!; + if (point.observedTsMs <= atTsMs) { + found = point.event; + low = middle + 1; + } else high = middle - 1; + } + return found; +} + +function snapshotGroup( + group: GroupDefinition, + states: ReadonlyMap, + quoteHistory: ReadonlyMap, + atTsMs: number +): GroupBookSnapshot { + const consensus = latestQuote(quoteHistory.get(group.marketKey) ?? [], atTsMs); + const outcomes = group.assets.map((asset): OutcomeBookSnapshot => { + const state = states.get(asset.assetId); + const fair = consensus?.outcomes.find((outcome) => outcome.outcome === asset.outcome)?.fairProbability ?? null; + const bid = state?.bestBid ?? null; + const ask = state?.bestAsk ?? null; + const mid = state ? midpoint(state) : null; + return { + outcome: asset.outcome, + assetId: asset.assetId, + consensusProbability: fair, + bestBid: bid, + bestAsk: ask, + midpoint: mid, + spread: bid === null || ask === null ? null : ask - bid, + bestBidSize: bid === null ? null : (state?.bids.get(bid) ?? null), + bestAskSize: ask === null ? null : (state?.asks.get(ask) ?? null), + quoteAgeMs: state?.observedTsMs === null || state?.observedTsMs === undefined + ? null + : atTsMs - state.observedTsMs, + executableBuyGap: fair === null || ask === null ? null : fair - ask + }; + }); + const mids = outcomes.flatMap((outcome) => outcome.midpoint === null ? [] : [outcome.midpoint]); + const spreads = outcomes.flatMap((outcome) => outcome.spread === null ? [] : [outcome.spread]); + return { + marketKey: group.marketKey, + family: group.family, + lineMilli: group.lineMilli, + mappingStatus: group.mappingStatus, + atTsMs, + probabilitySum: mids.length === outcomes.length ? mids.reduce((sum, value) => sum + value, 0) : null, + maximumSpread: spreads.length === 0 ? null : Math.max(...spreads), + outcomes + }; +} + +function maximumMidpointMove(left: GroupBookSnapshot, right: GroupBookSnapshot): number | null { + const changes: number[] = []; + for (const outcome of left.outcomes) { + const other = right.outcomes.find((candidate) => candidate.assetId === outcome.assetId); + if (outcome.midpoint !== null && other?.midpoint !== null && other?.midpoint !== undefined) { + changes.push(Math.abs(other.midpoint - outcome.midpoint)); + } + } + return changes.length === 0 ? null : Math.max(...changes); +} + +function targetPayload(value: unknown, assetIds: ReadonlySet): unknown | null { + if (Array.isArray(value)) { + const filtered = value.flatMap((item) => { + const target = targetPayload(item, assetIds); + return target === null ? [] : [target]; + }); + return filtered.length === 0 ? null : filtered; + } + if (value === null || typeof value !== "object") return null; + const row = value as Record; + if (row["event_type"] === "price_change" && Array.isArray(row["price_changes"])) { + const changes = row["price_changes"].filter((change) => { + if (change === null || typeof change !== "object") return false; + return assetIds.has(String((change as Record)["asset_id"] ?? "")); + }); + return changes.length === 0 ? null : { ...row, price_changes: changes }; + } + const assetId = String(row["asset_id"] ?? ""); + return assetIds.has(assetId) ? row : null; +} + +function groupDefinitions(registry: MappingRegistry): GroupDefinition[] { + const groups = new Map(); + for (const assetId of registry.assetIds()) { + const asset = registry.resolveAsset(assetId); + if (asset.tokenRole !== "canonical") continue; + const group = groups.get(asset.market.key) ?? { + marketKey: asset.market.key, + family: asset.market.family, + lineMilli: asset.market.lineMilli, + mappingStatus: asset.mappingStatus, + assets: [] + }; + group.assets.push(asset); + groups.set(asset.market.key, group); + } + return [...groups.values()] + .map((group) => ({ ...group, assets: group.assets.sort((a, b) => a.outcome.localeCompare(b.outcome)) })) + .sort((left, right) => + left.family.localeCompare(right.family) || + (left.lineMilli ?? -1) - (right.lineMilli ?? -1) + ); +} + +async function targetRegistry(path: string, fixtureId: string): Promise<{ registry: MappingRegistry; records: MappingRecord[] }> { + const file = JSON.parse(await readFile(path, "utf8")) as CandidateFile; + const all = new MappingRegistry(file.records ?? []); + const records = all.records().filter((record) => record.txlineFixtureId === fixtureId); + if (records.length === 0) throw new Error(`No mapping records found for fixture ${fixtureId}`); + return { registry: new MappingRegistry(records), records: [...records] }; +} + +function outageContains(outages: readonly FeedOutage[], timestampMs: number): boolean { + return outages.some((outage) => { + const start = Date.parse(outage.startedAt); + const end = outage.endedAt === null ? Number.POSITIVE_INFINITY : Date.parse(outage.endedAt); + return timestampMs >= start && timestampMs <= end; + }); +} + +function outageOverlaps(outages: readonly FeedOutage[], fromMs: number, toMs: number): boolean { + return outages.some((outage) => { + const start = Date.parse(outage.startedAt); + const end = outage.endedAt === null ? Number.POSITIVE_INFINITY : Date.parse(outage.endedAt); + return start <= toMs && end >= fromMs; + }); +} + +function reconnectRecoveryContains( + outages: readonly FeedOutage[], + timestampMs: number, + recoveryExclusionMs: number +): boolean { + return outages.some((outage) => { + if (outage.endedAt === null) return false; + const end = Date.parse(outage.endedAt); + return timestampMs >= end && timestampMs <= end + recoveryExclusionMs; + }); +} + +function trackerSnapshots( + tracker: GoalTracker, + groups: readonly GroupDefinition[], + states: ReadonlyMap, + quotes: ReadonlyMap, + horizons: readonly number[], + nextObservedTsMs: number +): void { + for (const horizon of horizons) { + if (tracker.snapshots.has(horizon)) continue; + const boundary = tracker.goal.firstSeenObservedTsMs + horizon; + if (nextObservedTsMs < boundary) continue; + tracker.snapshots.set( + horizon, + groups.map((group) => snapshotGroup(group, states, quotes, boundary)) + ); + } +} + +function groupSnapshot(tracker: GoalTracker, horizon: number, marketKey: string): GroupBookSnapshot | null { + return tracker.snapshots.get(horizon)?.find((snapshot) => snapshot.marketKey === marketKey) ?? null; +} + +export async function analyzePairedLiveCapture(options: PairedLiveStudyOptions): Promise { + if (!(options.materialMoveProbability > 0 && options.materialMoveProbability < 1)) { + throw new RangeError("Material move probability must be between zero and one"); + } + const horizons = [...new Set(options.snapshotHorizonsMs ?? [-5_000, -1_000, 0, 250, 500, 1_000, 2_000, 5_000, 10_000, 30_000])] + .sort((left, right) => left - right); + if (!horizons.includes(0) || horizons[0]! >= 0 || horizons.at(-1)! <= 0) { + throw new Error("Snapshot horizons must include negative, zero, and positive offsets"); + } + + const { registry, records } = await targetRegistry(options.mappingPath, options.fixtureId); + const groups = groupDefinitions(registry); + const canonicalAssets = new Set(groups.flatMap((group) => group.assets.map((asset) => asset.assetId))); + const quoteHistory = new Map(); + const oddsLatency = new MillisecondLatencyHistogram(); + const scoreLatency = new MillisecondLatencyHistogram(); + const goalsById = new Map(); + + for await (const event of replayCapturedTxLineFrames(options.txlineScoresPath)) { + if (event.kind !== "score.update" || event.fixtureId !== options.fixtureId) continue; + scoreLatency.add(event.observedTsMs - event.sourceTsMs); + if (event.action !== "goal") continue; + const existing = goalsById.get(event.actionId); + if (!existing) goalsById.set(event.actionId, { first: event, confirmed: event.confirmed === true ? event : null }); + else if (event.confirmed === true && existing.confirmed === null) existing.confirmed = event; + } + const goals: GoalPoint[] = [...goalsById.values()] + .map(({ first, confirmed }) => ({ + actionId: first.actionId, + participant: first.participant, + clockSeconds: first.clock?.seconds ?? null, + firstSeenSourceTsMs: first.sourceTsMs, + firstSeenObservedTsMs: first.observedTsMs, + firstSeenConfirmed: first.confirmed, + firstConfirmedSourceTsMs: confirmed?.sourceTsMs ?? null, + firstConfirmedObservedTsMs: confirmed?.observedTsMs ?? null + })) + .sort((left, right) => left.firstSeenObservedTsMs - right.firstSeenObservedTsMs); + if (goals.length === 0) throw new Error(`No goal events found for fixture ${options.fixtureId}`); + + const groupKeys = new Set(groups.map((group) => group.marketKey)); + for await (const event of replayCapturedTxLineFrames(options.txlineOddsPath)) { + if (event.kind !== "odds.quote" || event.fixtureId !== options.fixtureId || !groupKeys.has(event.market.key)) continue; + oddsLatency.add(event.observedTsMs - event.sourceTsMs); + const points = quoteHistory.get(event.market.key) ?? []; + points.push({ observedTsMs: event.observedTsMs, event }); + quoteHistory.set(event.market.key, points); + } + + const reconnectRecords: ReconnectRecord[] = []; + for await (const record of readNdjson(options.polymarketReconnectsPath)) { + reconnectRecords.push(record); + } + const outageSummary = summarizeFeedOutages(reconnectRecords); + const trackers: GoalTracker[] = goals.map((goal) => ({ + goal, + snapshots: new Map(), + firstMarketEventMs: new Map(), + firstQuoteUpdateMs: new Map(), + firstMaterialMoveMs: new Map() + })); + const states = new Map(); + for (const assetId of canonicalAssets) { + states.set(assetId, { + bids: new Map(), + asks: new Map(), + bestBid: null, + bestAsk: null, + observedTsMs: null, + sourceTsMs: null + }); + } + + const polymarketLatency = new MillisecondLatencyHistogram(); + const polymarketSteadyStateLatency = new MillisecondLatencyHistogram(); + const polymarketLatencyByObservation = new Map(); + const reconnectRecoveryExclusionMs = 5_000; + const eventTypes: Record = {}; + const analysisStopMs = goals.at(-1)!.firstSeenObservedTsMs + horizons.at(-1)!; + let firstMessageMs: number | null = null; + let lastMessageMs: number | null = null; + let messagesScanned = 0; + let targetMessages = 0; + let normalizedEvents = 0; + + for await (const message of readNdjson(options.polymarketMessagesPath)) { + const observedTsMs = Date.parse(message.receivedAt); + if (!Number.isFinite(observedTsMs)) throw new Error(`Invalid captured Polymarket timestamp: ${message.receivedAt}`); + firstMessageMs ??= observedTsMs; + lastMessageMs = observedTsMs; + messagesScanned += 1; + for (const tracker of trackers) { + trackerSnapshots(tracker, groups, states, quoteHistory, horizons, observedTsMs); + } + if (observedTsMs > analysisStopMs) break; + if (message.parseError) throw new Error(`Captured Polymarket parse error: ${message.parseError}`); + if (message.assetIds && !message.assetIds.some((assetId) => canonicalAssets.has(assetId))) continue; + const payload = targetPayload(JSON.parse(message.rawPayload) as unknown, canonicalAssets); + if (payload === null) continue; + targetMessages += 1; + eventTypes[message.eventType] = (eventTypes[message.eventType] ?? 0) + 1; + const events = normalizePolymarketPayload(payload, observedTsMs, registry); + for (const event of events) { + if (event.kind !== "polymarket.book" && event.kind !== "polymarket.price") continue; + if (!canonicalAssets.has(event.assetId)) continue; + normalizedEvents += 1; + const sourceAgeMs = event.observedTsMs - event.sourceTsMs; + const observation = event.kind === "polymarket.book" ? "book" : event.observation; + const observationHistogram = polymarketLatencyByObservation.get(observation) ?? new MillisecondLatencyHistogram(); + observationHistogram.add(sourceAgeMs); + polymarketLatencyByObservation.set(observation, observationHistogram); + polymarketLatency.add(sourceAgeMs); + if (event.kind !== "polymarket.book" && + event.observedTsMs > firstMessageMs + reconnectRecoveryExclusionMs && + !reconnectRecoveryContains(outageSummary.outages, event.observedTsMs, reconnectRecoveryExclusionMs) + ) { + polymarketSteadyStateLatency.add(sourceAgeMs); + } + const state = states.get(event.assetId)!; + const quoteUpdated = updateBookState(state, event); + for (const tracker of trackers) { + if (event.observedTsMs < tracker.goal.firstSeenObservedTsMs) continue; + const marketKey = event.market.key; + if (!tracker.firstMarketEventMs.has(marketKey)) { + tracker.firstMarketEventMs.set(marketKey, event.observedTsMs); + } + if (quoteUpdated && !tracker.firstQuoteUpdateMs.has(marketKey)) { + tracker.firstQuoteUpdateMs.set(marketKey, event.observedTsMs); + } + if (tracker.firstMaterialMoveMs.has(marketKey)) continue; + const baseline = groupSnapshot(tracker, 0, marketKey); + const group = groups.find((candidate) => candidate.marketKey === marketKey); + if (!baseline || !group) continue; + const current = snapshotGroup(group, states, quoteHistory, event.observedTsMs); + const movement = maximumMidpointMove(baseline, current); + if (movement !== null && movement + Number.EPSILON >= options.materialMoveProbability) { + tracker.firstMaterialMoveMs.set(marketKey, event.observedTsMs); + } + } + } + } + for (const tracker of trackers) { + trackerSnapshots(tracker, groups, states, quoteHistory, horizons, Number.POSITIVE_INFINITY); + } + + const goalStudies: GoalStudy[] = trackers.map((tracker) => { + const trigger = tracker.goal.firstSeenObservedTsMs; + const markets = groups.map((group): RepricingEvidence => { + const before = groupSnapshot(tracker, -5_000, group.marketKey); + const baseline = groupSnapshot(tracker, 0, group.marketKey); + const preMove = before && baseline ? maximumMidpointMove(before, baseline) : null; + const firstMaterialAt = tracker.firstMaterialMoveMs.get(group.marketKey) ?? null; + let classification: RepricingEvidence["classification"]; + if (preMove === null || baseline === null) classification = "insufficient_book_state"; + else if (preMove + Number.EPSILON >= options.materialMoveProbability) classification = "polymarket_moved_before_txline"; + else if (firstMaterialAt !== null) classification = "post_txline_reprice_observed"; + else classification = "no_material_reprice_in_window"; + return { + marketKey: group.marketKey, + family: group.family, + lineMilli: group.lineMilli, + firstMarketEventLatencyMs: tracker.firstMarketEventMs.has(group.marketKey) + ? tracker.firstMarketEventMs.get(group.marketKey)! - trigger + : null, + firstQuoteUpdateLatencyMs: tracker.firstQuoteUpdateMs.has(group.marketKey) + ? tracker.firstQuoteUpdateMs.get(group.marketKey)! - trigger + : null, + firstMaterialMoveLatencyMs: firstMaterialAt === null ? null : firstMaterialAt - trigger, + preTriggerFiveSecondMove: preMove, + classification, + outageAtTrigger: outageContains(outageSummary.outages, trigger), + outageBeforeMaterialMove: firstMaterialAt === null + ? false + : outageOverlaps(outageSummary.outages, trigger, firstMaterialAt), + snapshots: horizons.map((horizon) => ({ + horizonMs: horizon, + book: groupSnapshot(tracker, horizon, group.marketKey)! + })) + }; + }); + return { + ...tracker.goal, + txlineFirstSeenLatencyMs: tracker.goal.firstSeenObservedTsMs - tracker.goal.firstSeenSourceTsMs, + confirmationDelayMs: tracker.goal.firstConfirmedObservedTsMs === null + ? null + : tracker.goal.firstConfirmedObservedTsMs - tracker.goal.firstSeenObservedTsMs, + markets + }; + }); + const classifications = goalStudies.flatMap((goal) => goal.markets.map((market) => market.classification)); + const movedBefore = classifications.filter((value) => value === "polymarket_moved_before_txline").length; + const postTxline = classifications.filter((value) => value === "post_txline_reprice_observed").length; + const noMaterial = classifications.filter((value) => value === "no_material_reprice_in_window").length; + const insufficient = classifications.filter((value) => value === "insufficient_book_state").length; + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + status: "research_evidence_only", + tradeable: false, + fixtureId: options.fixtureId, + configuration: { + materialMoveProbability: options.materialMoveProbability, + materialMoveProbabilityBps: options.materialMoveProbability * 10_000, + reconnectRecoveryExclusionMs, + snapshotHorizonsMs: horizons + }, + sourcePaths: { + mappingPath: options.mappingPath, + polymarketMessagesPath: options.polymarketMessagesPath, + polymarketReconnectsPath: options.polymarketReconnectsPath, + txlineOddsPath: options.txlineOddsPath, + txlineScoresPath: options.txlineScoresPath + }, + mapping: { + records: records.length, + groups: groups.length, + canonicalAssets: canonicalAssets.size, + statuses: [...new Set(records.map((record) => record.status))].sort() + }, + analysisWindow: { + firstMessageAt: firstMessageMs === null ? null : new Date(firstMessageMs).toISOString(), + lastMessageAt: lastMessageMs === null ? null : new Date(lastMessageMs).toISOString(), + stoppedAt: new Date(analysisStopMs).toISOString(), + messagesScanned, + targetMessages, + normalizedEvents, + eventTypes + }, + feedHealth: { + polymarketOutages: outageSummary, + txlineOddsLatency: oddsLatency.summary(), + txlineScoreLatency: scoreLatency.summary(), + polymarketVenueTimestampAgeAllCanonicalEvents: polymarketLatency.summary(), + polymarketVenueTimestampAgeNonBookOutsideRecovery: polymarketSteadyStateLatency.summary(), + polymarketVenueTimestampAgeByObservation: Object.fromEntries( + [...polymarketLatencyByObservation.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([observation, histogram]) => [observation, histogram.summary()]) + ) + }, + goals: goalStudies, + gateReadout: { + marketEventCases: classifications.length, + polymarketMovedBeforeTxline: movedBefore, + postTxlineRepriceObserved: postTxline, + noMaterialRepriceInWindow: noMaterial, + insufficientBookState: insufficient, + staleQuoteHypothesis: postTxline === 0 && movedBefore > 0 + ? "not_supported_by_this_match" + : "requires_more_evidence" + } + }; +} + +function milliseconds(value: number | null): string { + return value === null ? "n/a" : value.toFixed(0); +} + +function probabilityPoints(value: number | null): string { + return value === null ? "n/a" : (value * 100).toFixed(3); +} + +export function renderPairedLiveStudyMarkdown(study: PairedLiveStudy): string { + const rows = study.goals.flatMap((goal, goalIndex) => goal.markets.map((market) => + `| ${goalIndex + 1} | ${goal.clockSeconds ?? ""} | ${market.family}${market.lineMilli === null ? "" : ` ${(market.lineMilli / 1_000).toFixed(1)}`} | ${milliseconds(market.firstQuoteUpdateLatencyMs)} | ${milliseconds(market.firstMaterialMoveLatencyMs)} | ${probabilityPoints(market.preTriggerFiveSecondMove)} | ${market.classification} | ${market.outageBeforeMaterialMove ? "yes" : "no"} |` + )); + const outageRows = study.feedHealth.polymarketOutages.outages.map((outage) => + `| ${outage.startedAt} | ${outage.endedAt ?? "unresolved"} | ${milliseconds(outage.durationMs)} | ${outage.closeCode ?? ""} |` + ); + const goalRows = study.goals.map((goal, index) => + `| ${index + 1} | ${new Date(goal.firstSeenObservedTsMs).toISOString()} | ${goal.participant ?? ""} | ${goal.clockSeconds ?? ""} | ${milliseconds(goal.txlineFirstSeenLatencyMs)} | ${milliseconds(goal.confirmationDelayMs)} |` + ); + const polymarketAgeRows = Object.entries(study.feedHealth.polymarketVenueTimestampAgeByObservation) + .map(([observation, summary]) => + `| ${observation} | ${summary.count.toLocaleString("en-US")} | ${milliseconds(summary.p50Ms)} | ${milliseconds(summary.p90Ms)} | ${milliseconds(summary.p99Ms)} |` + ); + return [ + "# Spain-Belgium Paired Live-Lane Evidence", + "", + `Generated: ${study.generatedAt}`, + "", + "> Research evidence only. The mapping is capture-confirmed but not settlement-verified, every in-play result remains paper-only, and the material-move threshold is exploratory rather than an approved detector threshold.", + "", + "## Scope", + "", + `- Fixture: \`${study.fixtureId}\``, + `- Canonical target assets: ${study.mapping.canonicalAssets} across ${study.mapping.groups} exact market groups`, + `- Material midpoint move: ${study.configuration.materialMoveProbabilityBps.toFixed(0)} probability bps`, + `- Polymarket messages scanned through the last response window: ${study.analysisWindow.messagesScanned.toLocaleString("en-US")}`, + `- Canonical Polymarket events normalized: ${study.analysisWindow.normalizedEvents.toLocaleString("en-US")}`, + "", + "## Feed Health", + "", + `- TXLine odds receive latency p50/p90/p99: ${milliseconds(study.feedHealth.txlineOddsLatency.p50Ms)} / ${milliseconds(study.feedHealth.txlineOddsLatency.p90Ms)} / ${milliseconds(study.feedHealth.txlineOddsLatency.p99Ms)} ms`, + `- TXLine score receive latency p50/p90/p99: ${milliseconds(study.feedHealth.txlineScoreLatency.p50Ms)} / ${milliseconds(study.feedHealth.txlineScoreLatency.p90Ms)} / ${milliseconds(study.feedHealth.txlineScoreLatency.p99Ms)} ms`, + `- Polymarket non-book venue-timestamp age outside reconnect recovery p50/p90/p99: ${milliseconds(study.feedHealth.polymarketVenueTimestampAgeNonBookOutsideRecovery.p50Ms)} / ${milliseconds(study.feedHealth.polymarketVenueTimestampAgeNonBookOutsideRecovery.p90Ms)} / ${milliseconds(study.feedHealth.polymarketVenueTimestampAgeNonBookOutsideRecovery.p99Ms)} ms`, + `- Polymarket all-event venue-timestamp age p50/p90/p99: ${milliseconds(study.feedHealth.polymarketVenueTimestampAgeAllCanonicalEvents.p50Ms)} / ${milliseconds(study.feedHealth.polymarketVenueTimestampAgeAllCanonicalEvents.p90Ms)} / ${milliseconds(study.feedHealth.polymarketVenueTimestampAgeAllCanonicalEvents.p99Ms)} ms`, + "- Polymarket payload timestamps show event age, not a pure network-latency clock; stale/repeated venue timestamps remain visible outside reconnect windows and must be treated as feed-health evidence rather than transport timing alone.", + `- Public WebSocket outages: ${study.feedHealth.polymarketOutages.outages.length}; total ${milliseconds(study.feedHealth.polymarketOutages.totalDowntimeMs)} ms; max ${milliseconds(study.feedHealth.polymarketOutages.maximumDowntimeMs)} ms`, + "", + "| Outage start | Reconnected | Duration ms | Code |", + "|---|---|---:|---:|", + ...(outageRows.length === 0 ? ["| none | | | |"] : outageRows), + "", + "| Polymarket observation | Events | Age p50 ms | Age p90 ms | Age p99 ms |", + "|---|---:|---:|---:|---:|", + ...polymarketAgeRows, + "", + "## Goal Delivery", + "", + "| Goal | First seen | Participant | Match clock sec | TXLine receive latency ms | Confirmation delay ms |", + "|---:|---|---:|---:|---:|---:|", + ...goalRows, + "", + "## Repricing Evidence", + "", + "A pre-trigger move compares the book at T-5s with the last state before TXLine delivered the goal. Material movement before T0 means the venue had already reacted before Samaritan received the score event; that is evidence against a post-TXLine stale-quote window for that market instance.", + "", + "| Goal | Clock sec | Market | First quote update ms | First material move ms | Pre-trigger move pp | Classification | Outage overlap |", + "|---:|---:|---|---:|---:|---:|---|---|", + ...rows, + "", + "## Gate Readout", + "", + `- Market-event cases measured: ${study.gateReadout.marketEventCases}`, + `- Polymarket moved at least the exploratory threshold before TXLine first delivery: ${study.gateReadout.polymarketMovedBeforeTxline}`, + `- Material post-TXLine repricing observed without a prior move: ${study.gateReadout.postTxlineRepriceObserved}`, + `- No material move inside the 30-second response window: ${study.gateReadout.noMaterialRepriceInWindow}`, + `- Result for STALE_QUOTE: \`${study.gateReadout.staleQuoteHypothesis}\``, + "", + "For this match, Polymarket was already repricing before the first TXLine goal event arrived whenever a material move was visible. The capture therefore does not support a post-TXLine stale-order edge. STALE_QUOTE remains disabled and paper-only; more synchronized matches may add evidence but cannot reverse this result by assumption.", + "", + "## Interpretation Guardrails", + "", + "- These are three event instances from one match, not a fitted latency distribution.", + "- A code-1006 reconnect interval is unavailable data, never evidence that a quote stayed unchanged.", + "- Match Result is grouped from the three canonical Yes tokens; complement No tokens are not double-counted.", + "- Every mapped totals line is reported. No O/U line is hard-coded as the production main total.", + "- No trading, fill simulation, or real-money claim is authorized by this report.", + "" + ].join("\n"); +} diff --git a/src/research/run-historical-gate.ts b/src/research/run-historical-gate.ts new file mode 100644 index 0000000..c861612 --- /dev/null +++ b/src/research/run-historical-gate.ts @@ -0,0 +1,45 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS } from "../config/paper-study.js"; +import { + renderHistoricalGateStudyMarkdown, + runHistoricalGateStudy +} from "./historical-gate-study.js"; + +function argument(name: string, fallback: string): string { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; +} + +const outputPath = resolve(argument("output", "data/research/historical-gate-study-causal-economic-v4.json")); +const reportPath = resolve(argument("report", "docs/research/historical-gate-study-causal-economic-v4.md")); +for (const path of [outputPath, reportPath]) { + if (existsSync(path)) throw new Error(`Refusing to replace existing corrected gate artifact: ${path}`); +} +const study = await runHistoricalGateStudy({ + archivePath: resolve(argument("db", "data/research/samaritan-research-v1.duckdb")), + mappingsPath: resolve(argument("mappings", "data/research/mappings/world-cup-candidates.json")), + totalEvidencePath: resolve(argument("total-evidence", "data/research/main-total-line-evidence-causal-v2.json")), + trainFraction: Number(argument("train-fraction", "0.7")), + windowBeforeKickoffMs: + Number(argument("window-hours", String(PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS / 3_600_000))) * + 3_600_000, + minimumTrainingSignals: Number(argument("minimum-training-signals", "30")), + costProxyProbability: Number(argument("cost-proxy-bps", "100")) / 10_000 +}, (message) => console.log(message)); + +await Promise.all([mkdir(dirname(outputPath), { recursive: true }), mkdir(dirname(reportPath), { recursive: true })]); +await Promise.all([ + writeFile(outputPath, `${JSON.stringify(study, null, 2)}\n`), + writeFile(reportPath, renderHistoricalGateStudyMarkdown(study)) +]); +console.log(JSON.stringify({ + outputPath, + reportPath, + split: study.split, + detectorStatuses: Object.fromEntries( + study.detectors.map((detector) => [detector.detector, detector.test.necessaryEvidenceStatus]) + ), + forwardPaperCandidate: study.forwardPaperCandidate +}, null, 2)); diff --git a/src/research/total-line-evidence-query.ts b/src/research/total-line-evidence-query.ts new file mode 100644 index 0000000..f47e9c1 --- /dev/null +++ b/src/research/total-line-evidence-query.ts @@ -0,0 +1,60 @@ +export type TotalLineHistoryTarget = { + marketId: string; + assetId: string; + selectorCutoffTsMs: number; +}; + +function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function validateTarget(target: TotalLineHistoryTarget): void { + if (target.marketId.trim() === "") throw new Error("Total-line history target requires a marketId"); + if (target.assetId.trim() === "") throw new Error("Total-line history target requires an assetId"); + if (!Number.isSafeInteger(target.selectorCutoffTsMs) || target.selectorCutoffTsMs <= 0) { + throw new RangeError("Total-line selector cutoff must be a positive safe-integer timestamp"); + } +} + +/** + * Builds the causal evidence query used by the historical main-total selector. + * Probability and every coverage statistic are bounded by the same per-market + * selector cutoff. Rows observed after that cutoff cannot affect the result. + */ +export function buildTotalLineHistoryEvidenceQuery( + targets: readonly TotalLineHistoryTarget[] +): string { + if (targets.length === 0) throw new Error("Total-line history evidence requires at least one target"); + for (const target of targets) validateTarget(target); + const duplicateMarketIds = targets + .map((target) => target.marketId) + .filter((marketId, index, marketIds) => marketIds.indexOf(marketId) !== index); + if (duplicateMarketIds.length > 0) { + throw new Error(`Duplicate total-line history target: ${duplicateMarketIds[0]}`); + } + const valuesSql = targets + .map((target) => + `(${sqlString(target.marketId)}, ${sqlString(target.assetId)}, ${target.selectorCutoffTsMs})` + ) + .join(",\n"); + return ` + WITH targets(market_id, asset_id, selector_cutoff_ts_ms) AS (VALUES ${valuesSql}) + SELECT + targets.market_id, + targets.selector_cutoff_ts_ms, + COUNT(history.event_id) + FILTER (WHERE history.source_ts_ms <= targets.selector_cutoff_ts_ms) AS coverage_points, + MIN(history.source_ts_ms) + FILTER (WHERE history.source_ts_ms <= targets.selector_cutoff_ts_ms) AS coverage_first_point_ts_ms, + MAX(history.source_ts_ms) + FILTER (WHERE history.source_ts_ms <= targets.selector_cutoff_ts_ms) AS coverage_last_point_ts_ms, + arg_max(history.price, history.source_ts_ms) + FILTER (WHERE history.source_ts_ms <= targets.selector_cutoff_ts_ms) AS pre_kickoff_probability, + MAX(history.source_ts_ms) + FILTER (WHERE history.source_ts_ms <= targets.selector_cutoff_ts_ms) AS pre_kickoff_point_ts_ms + FROM targets + LEFT JOIN polymarket_history history ON history.asset_id = targets.asset_id + GROUP BY targets.market_id, targets.selector_cutoff_ts_ms + ORDER BY targets.market_id + `; +} diff --git a/src/risk/paper.ts b/src/risk/paper.ts new file mode 100644 index 0000000..44191f1 --- /dev/null +++ b/src/risk/paper.ts @@ -0,0 +1,218 @@ +import type { PolymarketBookEvent } from "../bus/events.js"; +import type { DetectorSignal } from "../detectors/types.js"; +import { microUsd, nonnegativeMicroUsd, type MicroUsd } from "../domain/money.js"; +import { probability, type Probability } from "../domain/probability.js"; +import type { TradeThesis } from "../agents/contracts.js"; +import { + POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS, + takerFeePerShare, + type PolymarketFeeParameters +} from "../exec/paper.js"; + +export type PaperRiskConfig = { + bankrollMicroUsd: MicroUsd; + perTradeStakeMicroUsd: MicroUsd; + aggregateExposureMicroUsd: MicroUsd; + drawdownStopMicroUsd: MicroUsd; + minimumRawGap: number; + maxBookAgeMs: number; + maxFeeMetadataAgeMs: number; + realMoneyGate: "closed"; +}; + +export const APPROVED_PAPER_RISK_CONFIG: PaperRiskConfig = Object.freeze({ + bankrollMicroUsd: microUsd(50_000_000), + perTradeStakeMicroUsd: microUsd(3_000_000), + aggregateExposureMicroUsd: microUsd(15_000_000), + drawdownStopMicroUsd: microUsd(20_000_000), + minimumRawGap: 0.01, + maxBookAgeMs: 5_000, + maxFeeMetadataAgeMs: 300_000, + realMoneyGate: "closed" +}); + +export type PaperRiskState = { + openExposureMicroUsd: MicroUsd; + currentDrawdownMicroUsd: MicroUsd; + halted: boolean; +}; + +export function paperRiskState(input: { + openExposureMicroUsd?: number; + currentDrawdownMicroUsd?: number; + halted?: boolean; +} = {}): PaperRiskState { + return { + openExposureMicroUsd: nonnegativeMicroUsd(input.openExposureMicroUsd ?? 0), + currentDrawdownMicroUsd: nonnegativeMicroUsd(input.currentDrawdownMicroUsd ?? 0), + halted: input.halted ?? false + }; +} + +export type PaperRiskVerdict = + | { decision: "veto"; reasons: string[] } + | { + decision: "approve"; + stakeMicroUsd: MicroUsd; + limitProbability: Probability; + realMoneyGate: "closed"; + }; + +function bestAsk(book: PolymarketBookEvent): number | null { + return book.asks.length === 0 ? null : Math.min(...book.asks.map((level) => level.price)); +} + +function bestBid(book: PolymarketBookEvent): number | null { + return book.bids.length === 0 ? null : Math.max(...book.bids.map((level) => level.price)); +} + +export function reviewPaperRisk(input: { + config: PaperRiskConfig; + state: PaperRiskState; + signal: DetectorSignal; + thesis: TradeThesis; + book: PolymarketBookEvent; + fees: PolymarketFeeParameters; + asOfTsMs: number; + feeValidationTsMs?: number; + executionLatencyMs: number; + availableShares?: number; +}): PaperRiskVerdict { + const { config, state, signal, thesis, book, fees } = input; + const reasons: string[] = []; + if (config.realMoneyGate !== "closed") reasons.push("real_money_gate_not_closed"); + if ( + config.bankrollMicroUsd <= 0 || + config.perTradeStakeMicroUsd <= 0 || + config.aggregateExposureMicroUsd <= 0 || + config.drawdownStopMicroUsd <= 0 || + config.perTradeStakeMicroUsd > config.aggregateExposureMicroUsd || + config.aggregateExposureMicroUsd > config.bankrollMicroUsd || + config.drawdownStopMicroUsd > config.bankrollMicroUsd || + !(config.minimumRawGap > 0) || + !(config.maxBookAgeMs > 0) || + !(config.maxFeeMetadataAgeMs > 0) + ) { + reasons.push("invalid_risk_configuration"); + } + if (state.halted) reasons.push("paper_lane_halted"); + if (state.currentDrawdownMicroUsd >= config.drawdownStopMicroUsd) reasons.push("drawdown_stop_reached"); + if (state.openExposureMicroUsd + config.perTradeStakeMicroUsd > config.aggregateExposureMicroUsd) { + reasons.push("aggregate_exposure_cap"); + } + if (signal.kind !== "CONSENSUS_MOVE") reasons.push("detector_not_approved"); + if (signal.eligibility !== "pretrade_review_required") reasons.push("signal_research_only"); + if (signal.market.family !== "total_goals" || signal.market.period !== "full_time") { + reasons.push("market_not_approved"); + } + if (signal.evidence.gapBasis !== "live_book") reasons.push("non_executable_signal_basis"); + if (signal.evidence.rawGap < config.minimumRawGap) reasons.push("edge_below_locked_gap"); + if (signal.evidence.consensusZScore === null || Math.abs(signal.evidence.consensusZScore) < 1) { + reasons.push("consensus_z_below_locked_threshold"); + } + const directionalCusum = signal.direction === "buy" + ? signal.evidence.cusumUp + : signal.evidence.cusumDown; + if (directionalCusum < 0.001) reasons.push("cusum_below_locked_threshold"); + if (signal.evidence.scoreContextActions.length > 0) reasons.push("score_context_present"); + if (thesis.recommendation !== "paper_trade") reasons.push("analyst_no_trade"); + if (thesis.expiresAtTsMs < input.asOfTsMs) reasons.push("thesis_expired"); + const asOfTimestampValid = Number.isSafeInteger(input.asOfTsMs) && input.asOfTsMs >= 0; + if (!asOfTimestampValid) reasons.push("invalid_as_of_timestamp"); + const executionLatencyValid = + Number.isSafeInteger(input.executionLatencyMs) && input.executionLatencyMs > 0; + if (!executionLatencyValid) { + reasons.push("invalid_execution_latency"); + } + const signalTimestampValid = + Number.isSafeInteger(signal.detectedAtTsMs) && + Number.isSafeInteger(signal.observedAtTsMs) && + signal.detectedAtTsMs >= 0 && + signal.observedAtTsMs >= 0; + if (!signalTimestampValid) reasons.push("invalid_signal_timestamp"); + if ( + book.fixtureId !== signal.fixtureId || + book.market.key !== signal.market.key || + book.outcome !== signal.outcome || + book.tokenRole !== "canonical" + ) { + reasons.push("book_identity_mismatch"); + } + if (book.mappingStatus === "rejected") reasons.push("mapping_rejected"); + const bookTimestampValid = + Number.isSafeInteger(book.sourceTsMs) && + Number.isSafeInteger(book.observedTsMs) && + book.sourceTsMs >= 0 && + book.observedTsMs >= 0; + if (!bookTimestampValid) reasons.push("invalid_book_timestamp"); + if (signalTimestampValid && executionLatencyValid) { + const decisionReadyAtTsMs = signal.observedAtTsMs + input.executionLatencyMs; + const orderEligibleAtTsMs = decisionReadyAtTsMs + POLYMARKET_SPORTS_MARKETABLE_ORDER_DELAY_MS; + if (!Number.isSafeInteger(decisionReadyAtTsMs) || !Number.isSafeInteger(orderEligibleAtTsMs)) { + reasons.push("invalid_execution_ready_timestamp"); + } else { + if (thesis.submittedAtTsMs !== decisionReadyAtTsMs) reasons.push("thesis_timestamp_mismatch"); + if (bookTimestampValid && book.observedTsMs < decisionReadyAtTsMs) { + reasons.push("book_precedes_execution_latency"); + } else if (bookTimestampValid && book.observedTsMs < orderEligibleAtTsMs) { + reasons.push("book_precedes_venue_placement_delay"); + } + } + } + if ( + !bookTimestampValid || + !asOfTimestampValid || + book.observedTsMs > input.asOfTsMs || + input.asOfTsMs - book.observedTsMs > config.maxBookAgeMs + ) { + reasons.push("book_stale_or_from_future"); + } + const feeValidationTsMs = input.feeValidationTsMs ?? input.asOfTsMs; + if ( + fees.conditionId !== book.conditionId || + !Number.isSafeInteger(fees.fetchedAtTsMs) || + !Number.isSafeInteger(feeValidationTsMs) || + Math.abs(feeValidationTsMs - fees.fetchedAtTsMs) > config.maxFeeMetadataAgeMs + ) { + reasons.push("fee_metadata_invalid"); + } + if (!fees.feesEnabled && fees.takerFeeRate !== 0) reasons.push("disabled_fee_has_nonzero_rate"); + if (fees.feesEnabled && (!(fees.takerFeeRate > 0) || !Number.isFinite(fees.takerFeeRate))) { + reasons.push("enabled_fee_missing_rate"); + } + if ( + fees.feeCurveExponent !== 1 || + !fees.takerOnly || + !(fees.minimumOrderSize > 0) || + !(fees.minimumTickSize > 0) + ) { + reasons.push("unsupported_market_execution_parameters"); + } + if (signal.direction === "sell" && (input.availableShares ?? 0) <= 0) { + reasons.push("sell_requires_owned_inventory"); + } + + const quote = signal.direction === "buy" ? bestAsk(book) : bestBid(book); + if (quote === null) reasons.push("empty_executable_book_side"); + else { + const executableGap = signal.direction === "buy" + ? signal.evidence.consensusProbability - quote + : quote - signal.evidence.consensusProbability; + if (executableGap < config.minimumRawGap) reasons.push("live_edge_below_locked_gap"); + const totalPerShare = quote + takerFeePerShare(quote, fees); + if (config.perTradeStakeMicroUsd / 1_000_000 / totalPerShare < fees.minimumOrderSize) { + reasons.push("stake_below_minimum_order_size"); + } + } + + if (reasons.length > 0) return { decision: "veto", reasons }; + const deterministicLimit = signal.direction === "buy" + ? signal.evidence.consensusProbability - config.minimumRawGap + : signal.evidence.consensusProbability + config.minimumRawGap; + return { + decision: "approve", + stakeMicroUsd: config.perTradeStakeMicroUsd, + limitProbability: probability(deterministicLimit), + realMoneyGate: "closed" + }; +} diff --git a/src/store/decision-ledger.ts b/src/store/decision-ledger.ts new file mode 100644 index 0000000..945c32c --- /dev/null +++ b/src/store/decision-ledger.ts @@ -0,0 +1,346 @@ +import { createHash } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import Database from "better-sqlite3"; +import { stableJson, type JsonValue } from "../domain/json.js"; + +const GENESIS_HASH = "0".repeat(64); +const V2_HASH_DOMAIN = "samaritan.decision-ledger.entry/v2"; + +export const DECISION_LEDGER_HASH_SCHEMA_V1 = 1 as const; +export const DECISION_LEDGER_HASH_SCHEMA_V2 = 2 as const; + +export type DecisionLedgerHashSchemaVersion = + | typeof DECISION_LEDGER_HASH_SCHEMA_V1 + | typeof DECISION_LEDGER_HASH_SCHEMA_V2; + +export type DecisionEventKind = + | "study_initialized" + | "signal_received" + | "triage_decision" + | "thesis_submitted" + | "analysis_completed" + | "risk_verdict" + | "execution_intent" + | "paper_execution" + | "position_opened" + | "position_closed" + | "position_settled" + | "case_terminal"; + +export type DecisionLedgerEntry = { + sequence: number; + entryId: string; + caseId: string; + kind: DecisionEventKind; + atTsMs: number; + insertedAtMs: number; + payload: JsonValue; + previousHash: string; + entryHash: string; + hashSchemaVersion: DecisionLedgerHashSchemaVersion; +}; + +export type DecisionLedgerV2HashEnvelope = { + schemaVersion: typeof DECISION_LEDGER_HASH_SCHEMA_V2; + sequence: number; + entryId: string; + caseId: string; + kind: DecisionEventKind; + atTsMs: number; + insertedAtMs: number; + payload: JsonValue; + previousHash: string; +}; + +export type DecisionLedgerVerification = { + valid: true; + rows: number; + headHash: string; + hashSchemaVersions: DecisionLedgerHashSchemaVersion[]; + legacyV1Rows: number; + v2Rows: number; +}; + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +/** + * Returns the domain-separated hash of the canonical v2 proof envelope. + * Exported so an independent verifier can reproduce a decision receipt. + */ +export function hashDecisionLedgerV2Entry( + input: Omit +): string { + const envelope: DecisionLedgerV2HashEnvelope = { + schemaVersion: DECISION_LEDGER_HASH_SCHEMA_V2, + sequence: input.sequence, + entryId: input.entryId, + caseId: input.caseId, + kind: input.kind, + atTsMs: input.atTsMs, + insertedAtMs: input.insertedAtMs, + payload: input.payload, + previousHash: input.previousHash + }; + return sha256(`${V2_HASH_DOMAIN}\n${stableJson(envelope)}`); +} + +function legacyV1Hash(previousHash: string, entryId: string, payloadJson: string): string { + return sha256(`${previousHash}\n${entryId}\n${payloadJson}`); +} + +function readHashSchemaVersion(value: number | null): DecisionLedgerHashSchemaVersion { + // The original schema had no version column. ALTER TABLE leaves those rows + // NULL so their weaker v1 proof remains explicit instead of being relabelled. + if (value === null || value === DECISION_LEDGER_HASH_SCHEMA_V1) { + return DECISION_LEDGER_HASH_SCHEMA_V1; + } + if (value === DECISION_LEDGER_HASH_SCHEMA_V2) return DECISION_LEDGER_HASH_SCHEMA_V2; + throw new Error(`Unsupported decision hash schema version: ${value}`); +} + +function assertTimestamp(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } +} + +export class DecisionLedger { + readonly #db: Database.Database; + readonly #now: () => number; + + constructor(path: string, options: { now?: () => number } = {}) { + this.#now = options.now ?? Date.now; + if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); + this.#db = new Database(path); + this.#db.pragma("journal_mode = WAL"); + this.#db.pragma("synchronous = FULL"); + this.#db.exec(` + CREATE TABLE IF NOT EXISTS decision_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id TEXT NOT NULL UNIQUE, + case_id TEXT NOT NULL, + kind TEXT NOT NULL, + at_ts_ms INTEGER NOT NULL, + payload_json TEXT NOT NULL, + previous_hash TEXT NOT NULL, + entry_hash TEXT NOT NULL UNIQUE, + inserted_at_ms INTEGER NOT NULL, + hash_schema_version INTEGER NOT NULL CHECK (hash_schema_version >= 1) + ); + CREATE TRIGGER IF NOT EXISTS decision_events_no_update + BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision ledger is append-only'); END; + CREATE TRIGGER IF NOT EXISTS decision_events_no_delete + BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision ledger is append-only'); END; + `); + this.#db.transaction(() => { + const columns = this.#db.prepare("PRAGMA table_info(decision_events)").all() as Array<{ + name: string; + }>; + if (!columns.some((column) => column.name === "hash_schema_version")) { + // Non-destructive evolution: legacy rows retain NULL, which is read and + // reported as v1. No historical append-only row is rewritten. + this.#db.exec("ALTER TABLE decision_events ADD COLUMN hash_schema_version INTEGER"); + } + }).immediate(); + } + + append(input: { + entryId: string; + caseId: string; + kind: DecisionEventKind; + atTsMs: number; + payload: JsonValue; + insertedAtMs?: number; + }): DecisionLedgerEntry { + return this.#db.transaction(() => { + assertTimestamp(input.atTsMs, "Decision event timestamp"); + const payloadJson = stableJson(input.payload); + const existing = this.#db.prepare( + `SELECT sequence, case_id, kind, at_ts_ms, payload_json, previous_hash, entry_hash, + inserted_at_ms, hash_schema_version + FROM decision_events WHERE entry_id = ?` + ).get(input.entryId) as { + sequence: number; + case_id: string; + kind: DecisionEventKind; + at_ts_ms: number; + payload_json: string; + previous_hash: string; + entry_hash: string; + inserted_at_ms: number; + hash_schema_version: number | null; + } | undefined; + if (existing) { + if ( + existing.case_id !== input.caseId || + existing.kind !== input.kind || + existing.at_ts_ms !== input.atTsMs || + existing.payload_json !== payloadJson || + (input.insertedAtMs !== undefined && existing.inserted_at_ms !== input.insertedAtMs) + ) { + throw new Error(`Decision entry ID collision with different content: ${input.entryId}`); + } + return { + sequence: existing.sequence, + entryId: input.entryId, + caseId: input.caseId, + kind: input.kind, + atTsMs: input.atTsMs, + insertedAtMs: existing.inserted_at_ms, + payload: input.payload, + previousHash: existing.previous_hash, + entryHash: existing.entry_hash, + hashSchemaVersion: readHashSchemaVersion(existing.hash_schema_version) + }; + } + const insertedAtMs = input.insertedAtMs ?? this.#now(); + assertTimestamp(insertedAtMs, "Decision insertion timestamp"); + const next = this.#db.prepare( + "SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM decision_events" + ).get() as { sequence: number }; + const sequence = next.sequence; + const previous = this.#db.prepare( + "SELECT entry_hash FROM decision_events ORDER BY sequence DESC LIMIT 1" + ).get() as { entry_hash: string } | undefined; + const previousHash = previous?.entry_hash ?? GENESIS_HASH; + const entryHash = hashDecisionLedgerV2Entry({ + sequence, + entryId: input.entryId, + caseId: input.caseId, + kind: input.kind, + atTsMs: input.atTsMs, + insertedAtMs, + payload: input.payload, + previousHash + }); + this.#db.prepare( + `INSERT INTO decision_events + (sequence, entry_id, case_id, kind, at_ts_ms, payload_json, previous_hash, entry_hash, + inserted_at_ms, hash_schema_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + sequence, + input.entryId, + input.caseId, + input.kind, + input.atTsMs, + payloadJson, + previousHash, + entryHash, + insertedAtMs, + DECISION_LEDGER_HASH_SCHEMA_V2 + ); + return { + sequence, + entryId: input.entryId, + caseId: input.caseId, + kind: input.kind, + atTsMs: input.atTsMs, + insertedAtMs, + payload: input.payload, + previousHash, + entryHash, + hashSchemaVersion: DECISION_LEDGER_HASH_SCHEMA_V2 + }; + }).immediate(); + } + + entries(caseId?: string): DecisionLedgerEntry[] { + const rows = (caseId === undefined + ? this.#db.prepare("SELECT * FROM decision_events ORDER BY sequence").all() + : this.#db.prepare("SELECT * FROM decision_events WHERE case_id = ? ORDER BY sequence").all(caseId) + ) as Array<{ + sequence: number; + entry_id: string; + case_id: string; + kind: DecisionEventKind; + at_ts_ms: number; + payload_json: string; + previous_hash: string; + entry_hash: string; + inserted_at_ms: number; + hash_schema_version: number | null; + }>; + return rows.map((row) => ({ + sequence: row.sequence, + entryId: row.entry_id, + caseId: row.case_id, + kind: row.kind, + atTsMs: row.at_ts_ms, + insertedAtMs: row.inserted_at_ms, + payload: JSON.parse(row.payload_json) as JsonValue, + previousHash: row.previous_hash, + entryHash: row.entry_hash, + hashSchemaVersion: readHashSchemaVersion(row.hash_schema_version) + })); + } + + verifyChain(): DecisionLedgerVerification { + let previousHash = GENESIS_HASH; + let rows = 0; + let legacyV1Rows = 0; + let v2Rows = 0; + let reachedV2 = false; + for (const row of this.#db.prepare( + `SELECT sequence, entry_id, case_id, kind, at_ts_ms, payload_json, previous_hash, + entry_hash, inserted_at_ms, hash_schema_version + FROM decision_events ORDER BY sequence` + ).iterate() as Iterable<{ + sequence: number; + entry_id: string; + case_id: string; + kind: DecisionEventKind; + at_ts_ms: number; + payload_json: string; + previous_hash: string; + entry_hash: string; + inserted_at_ms: number; + hash_schema_version: number | null; + }>) { + if (row.previous_hash !== previousHash) throw new Error(`Broken decision link at ${row.entry_id}`); + const hashSchemaVersion = readHashSchemaVersion(row.hash_schema_version); + if (reachedV2 && hashSchemaVersion === DECISION_LEDGER_HASH_SCHEMA_V1) { + throw new Error(`Decision hash schema downgrade at ${row.entry_id}`); + } + let expected: string; + if (hashSchemaVersion === DECISION_LEDGER_HASH_SCHEMA_V1) { + expected = legacyV1Hash(previousHash, row.entry_id, row.payload_json); + legacyV1Rows += 1; + } else { + reachedV2 = true; + expected = hashDecisionLedgerV2Entry({ + sequence: row.sequence, + entryId: row.entry_id, + caseId: row.case_id, + kind: row.kind, + atTsMs: row.at_ts_ms, + insertedAtMs: row.inserted_at_ms, + payload: JSON.parse(row.payload_json) as JsonValue, + previousHash + }); + v2Rows += 1; + } + if (row.entry_hash !== expected) throw new Error(`Broken decision hash at ${row.entry_id}`); + previousHash = row.entry_hash; + rows += 1; + } + return { + valid: true, + rows, + headHash: previousHash, + hashSchemaVersions: [ + ...(legacyV1Rows > 0 ? [DECISION_LEDGER_HASH_SCHEMA_V1] : []), + ...(v2Rows > 0 ? [DECISION_LEDGER_HASH_SCHEMA_V2] : []) + ], + legacyV1Rows, + v2Rows + }; + } + + close(): void { + this.#db.close(); + } +} diff --git a/test/capture-config.test.ts b/test/capture-config.test.ts new file mode 100644 index 0000000..43318f4 --- /dev/null +++ b/test/capture-config.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { validateCaptureConfig } from "../src/harness/capture-config.js"; + +const repoRoot = "/tmp/samaritan-portable-clone"; +const kickoff = "2026-07-14T19:00:00.000Z"; +const config = { + captureId: "paired-france-spain-2026-07-14", + status: "pending_human_confirmation", + tradeable: false, + txline: { fixtureId: "123", home: "France", away: "Spain", kickoffUtc: kickoff }, + polymarket: { + eventId: "1", + eventSlug: "fifwc-fra-esp-2026-07-14", + totalsEventId: "2", + totalsEventSlug: "fifwc-fra-esp-2026-07-14-more-markets", + home: "France", + away: "Spain", + kickoffUtc: kickoff, + rulesPeriod: "first_90_minutes_plus_stoppage_time" + }, + capture: { + scheduledStartUtc: "2026-07-14T16:00:00.000Z", + durationMinutes: 360, + runLabel: "paired-france-spain-2026-07-14" + }, + evidence: { txlineFixtures: "fixtures", polymarketEvents: "events", readinessReport: "report" }, + note: "Capture only; no trading." +}; +const txlineFixtures = [{ FixtureId: 123, Participant1: "France", Participant2: "Spain", StartTime: Date.parse(kickoff) }]; +const polymarketEvents = [ + { + id: "1", + slug: "fifwc-fra-esp-2026-07-14", + teams: [{ name: "France" }, { name: "Spain" }], + startTime: kickoff, + markets: [{ sportsMarketType: "moneyline", gameStartTime: kickoff }] + }, + { + id: "2", + slug: "fifwc-fra-esp-2026-07-14-more-markets", + teams: [{ name: "France" }, { name: "Spain" }], + startTime: kickoff, + markets: [{ + sportsMarketType: "totals", + gameStartTime: kickoff, + description: "This market refers only to the outcome within the first 90 minutes of regular play plus stoppage time." + }] + } +]; + +describe("paired capture config validation", () => { + it("validates evidence but withholds launch until human confirmation", () => { + const result = validateCaptureConfig({ repoRoot, config, txlineFixtures, polymarketEvents }); + expect(result).toMatchObject({ + evidenceValid: true, + readyToSchedule: false, + reason: "human_confirmation_required", + launch: null + }); + }); + + it("emits an exact capture command only after Deborah confirmation", () => { + const result = validateCaptureConfig({ + config: { ...config, status: "human_confirmed_for_capture_only", confirmedBy: "Deborah", confirmedAt: "2026-07-12" }, + repoRoot, + txlineFixtures, + polymarketEvents + }); + expect(result.readyToSchedule).toBe(true); + expect(result.launch?.command).toContain("--txline-fixture-id 123 --duration-minutes 360"); + expect(result.launch).toMatchObject({ + cwd: `${repoRoot}/phase0`, + logPath: `${repoRoot}/samples/_logs/paired-france-spain-2026-07-14.log`, + pidPath: `${repoRoot}/samples/_logs/paired-france-spain-2026-07-14.pid` + }); + expect(JSON.stringify(result.launch)).not.toContain("/Users/"); + }); + + it("fails closed when kickoff evidence changes", () => { + expect(() => validateCaptureConfig({ + config, + repoRoot, + txlineFixtures: [{ ...txlineFixtures[0], StartTime: Date.parse(kickoff) + 60_000 }], + polymarketEvents + })).toThrow(/changed/); + }); +}); diff --git a/test/capture-readiness.test.ts b/test/capture-readiness.test.ts new file mode 100644 index 0000000..3afd82b --- /dev/null +++ b/test/capture-readiness.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { buildCaptureReadiness } from "../src/harness/capture-readiness.js"; + +const kickoff = 20_000_000; +const gammaEvents = [ + { + id: "event-main", + slug: "fifwc-fra-esp-2026-07-14", + title: "France vs. Spain", + teams: [{ name: "France" }, { name: "Spain" }], + markets: [{ gameStartTime: new Date(kickoff).toISOString() }] + }, + { + id: "event-more", + slug: "fifwc-fra-esp-2026-07-14-more-markets", + title: "France vs. Spain - More Markets", + teams: [{ name: "France" }, { name: "Spain" }], + markets: [{ gameStartTime: new Date(kickoff).toISOString() }] + } +]; + +describe("paired capture readiness", () => { + it("blocks a public event family without an exact TXLine fixture", () => { + const report = buildCaptureReadiness({ + generatedAt: "2026-07-12T00:00:00.000Z", + nowTsMs: 1_000, + requiredLeadMs: 10_000, + signalCutoffMs: 5_000, + gammaEvents, + txlineFixtures: [] + }); + expect(report.fixtures).toEqual([expect.objectContaining({ + home: "France", + away: "Spain", + eventSlugs: ["fifwc-fra-esp-2026-07-14", "fifwc-fra-esp-2026-07-14-more-markets"], + txlineFixtureId: null, + status: "blocked_missing_txline_fixture", + recommendedCaptureStartTsMs: kickoff - 10_000, + signalCutoffTsMs: kickoff - 5_000 + })]); + }); + + it("advances only an exact team and kickoff match to human confirmation", () => { + const report = buildCaptureReadiness({ + generatedAt: "2026-07-12T00:00:00.000Z", + nowTsMs: 1_000, + requiredLeadMs: 10_000, + signalCutoffMs: 5_000, + gammaEvents, + txlineFixtures: [{ + FixtureId: 123, + Participant1: "Spain", + Participant2: "France", + StartTime: kickoff + }] + }); + expect(report.fixtures[0]).toMatchObject({ + txlineFixtureId: "123", + status: "ready_for_human_confirmation" + }); + }); +}); diff --git a/test/claude-agents.test.ts b/test/claude-agents.test.ts new file mode 100644 index 0000000..c9ff3e9 --- /dev/null +++ b/test/claude-agents.test.ts @@ -0,0 +1,300 @@ +import type { + Message, + MessageCreateParamsNonStreaming +} from "@anthropic-ai/sdk/resources/messages/messages"; +import Anthropic from "@anthropic-ai/sdk"; +import { describe, expect, it, vi } from "vitest"; +import { + ClaudeAnalystAgent, + ClaudeTriageAgent, + type ClaudeMessagesClient +} from "../src/agents/claude.js"; +import { CLAUDE_MODEL } from "../src/agents/claude-pricing.js"; +import { ClaudeSpendLedger } from "../src/agents/claude-spend-ledger.js"; +import type { TradeThesis } from "../src/agents/contracts.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; + +const nowTsMs = 1_000_000; + +function signal(): DetectorSignal { + return { + signalId: "signal-claude-1", + kind: "CONSENSUS_MOVE", + detectedAtTsMs: nowTsMs - 1_000, + observedAtTsMs: nowTsMs - 1_000, + fixtureId: "fixture-1", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }, + outcome: "over", + direction: "buy", + eligibility: "research_only", + reason: "Frozen totals candidate", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.51, + consensusVelocity: 0.02, + consensusZScore: 1.2, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.001, + cusumDown: 0, + rawGap: 0.04, + gapBasis: "live_book", + persistenceMs: 5_000, + mappingStatus: "verified", + scoreContextActions: [] + } + }; +} + +function thesis(overrides: Partial = {}): TradeThesis { + return { + schemaVersion: 1, + signalId: signal().signalId, + fixtureId: signal().fixtureId, + marketKey: signal().market.key, + outcome: signal().outcome, + direction: signal().direction, + recommendation: "paper_trade", + fairProbability: 0.55, + thesisSummary: "The executable total has not caught up to the consensus move.", + evidenceFor: ["The move persisted while the live-book gap remained open."], + steelmanAgainst: "The move may be noise or reverse before kickoff.", + invalidationConditions: ["The executable ask reaches fair value."], + submittedAtTsMs: nowTsMs, + expiresAtTsMs: nowTsMs + 300_000, + analystModel: CLAUDE_MODEL.analyst, + ...overrides + }; +} + +function message(input: { + content: Message["content"]; + stopReason?: Message["stop_reason"]; + model?: string; +}): Message { + return { + id: "msg_test", + type: "message", + role: "assistant", + model: input.model ?? CLAUDE_MODEL.triage, + content: input.content, + stop_reason: input.stopReason ?? "tool_use", + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + cache_creation: null, + inference_geo: null, + output_tokens_details: null, + service_tier: "standard" + } + } as Message; +} + +function clientReturning(response: Message): { + client: ClaudeMessagesClient; + create: ReturnType; +} { + const create = vi.fn(async (_params: MessageCreateParamsNonStreaming) => response); + return { client: { create }, create }; +} + +describe("bounded Claude agents", () => { + it("forces Haiku through one strict triage submission tool", async () => { + const ledger = new ClaudeSpendLedger(":memory:"); + const fake = clientReturning(message({ + content: [{ + type: "tool_use", + id: "tool-1", + name: "submit_triage", + input: { decision: "escalate", priority: "normal", rationale: "Coherent executable gap." }, + caller: { type: "direct" } + }] + })); + const agent = new ClaudeTriageAgent({ + client: fake.client, + spendLedger: ledger, + requestId: () => "request-triage-1" + }); + + await expect(agent.triage({ caseId: "case-1", signal: signal() })).resolves.toEqual({ + decision: "escalate", + priority: "normal", + rationale: "Coherent executable gap." + }); + const params = fake.create.mock.calls[0]?.[0] as MessageCreateParamsNonStreaming; + expect(params.model).toBe(CLAUDE_MODEL.triage); + expect(params.tool_choice).toEqual({ + type: "tool", + name: "submit_triage", + disable_parallel_tool_use: true + }); + expect(params.tools?.[0]).toMatchObject({ name: "submit_triage", strict: true }); + expect(ledger.summary()).toMatchObject({ + actualCostNanoUsd: 200_000, + outstandingReservedNanoUsd: 0 + }); + expect(ledger.verifyChain()).toMatchObject({ valid: true, rows: 2 }); + ledger.close(); + }); + + it.each([ + { + name: "text instead of a tool", + response: message({ content: [{ type: "text", text: "escalate", citations: null }], stopReason: "end_turn" }) + }, + { + name: "duplicate tool calls", + response: message({ content: [ + { type: "tool_use", id: "tool-1", name: "submit_triage", input: { decision: "drop", priority: "low", rationale: "Noise." }, caller: { type: "direct" } }, + { type: "tool_use", id: "tool-2", name: "submit_triage", input: { decision: "escalate", priority: "high", rationale: "Maybe." }, caller: { type: "direct" } } + ] }) + }, + { + name: "token truncation", + response: message({ content: [], stopReason: "max_tokens" }) + } + ])("fails closed on $name", async ({ response }) => { + const ledger = new ClaudeSpendLedger(":memory:"); + const fake = clientReturning(response); + const agent = new ClaudeTriageAgent({ + client: fake.client, + spendLedger: ledger, + requestId: () => "request-invalid" + }); + await expect(agent.triage({ caseId: "case-1", signal: signal() })).rejects.toThrow(); + expect(ledger.summary()).toMatchObject({ + actualCostNanoUsd: 200_000, + outstandingReservedNanoUsd: 0 + }); + expect(ledger.verifyChain()).toMatchObject({ valid: true, rows: 2 }); + ledger.close(); + }); + + it("uses Opus adaptive thinking but accepts only submit_thesis", async () => { + const ledger = new ClaudeSpendLedger(":memory:"); + const fake = clientReturning(message({ + model: CLAUDE_MODEL.analyst, + content: [ + { type: "thinking", thinking: "", signature: "signature" }, + { type: "tool_use", id: "tool-thesis", name: "submit_thesis", input: thesis(), caller: { type: "direct" } } + ] + })); + const agent = new ClaudeAnalystAgent({ + client: fake.client, + spendLedger: ledger, + requestId: () => "request-analyst-1" + }); + + await expect(agent.investigate({ + caseId: "case-1", + signal: signal(), + asOfTsMs: nowTsMs, + triage: { decision: "escalate", priority: "normal", rationale: "Review it." } + })).resolves.toEqual(thesis()); + const params = fake.create.mock.calls[0]?.[0] as MessageCreateParamsNonStreaming; + expect(params).toMatchObject({ + model: CLAUDE_MODEL.analyst, + thinking: { type: "adaptive", display: "omitted" }, + output_config: { effort: "medium" }, + tool_choice: { type: "auto", disable_parallel_tool_use: true } + }); + expect(JSON.stringify(params.tools?.[0])).not.toMatch(/"minimum"|"maximum"|"minLength"|"maxLength"/); + ledger.close(); + }); + + it("rejects analyst attempts to add sizing fields", async () => { + const ledger = new ClaudeSpendLedger(":memory:"); + const fake = clientReturning(message({ + model: CLAUDE_MODEL.analyst, + content: [{ + type: "tool_use", + id: "tool-thesis", + name: "submit_thesis", + input: { ...thesis(), stakeMicroUsd: 3_000_000 }, + caller: { type: "direct" } + }] + })); + const agent = new ClaudeAnalystAgent({ + client: fake.client, + spendLedger: ledger, + requestId: () => "request-analyst-invalid" + }); + await expect(agent.investigate({ + caseId: "case-1", + signal: signal(), + asOfTsMs: nowTsMs, + triage: { decision: "escalate", priority: "normal", rationale: "Review it." } + })).rejects.toThrow(); + ledger.close(); + }); + + it("charges the full reservation when billing is unknown after a client failure", async () => { + const ledger = new ClaudeSpendLedger(":memory:"); + const client: ClaudeMessagesClient = { + create: vi.fn(async () => { throw new Error("network details must not escape"); }) + }; + const agent = new ClaudeTriageAgent({ + client, + spendLedger: ledger, + requestId: () => "request-network-failure" + }); + await expect(agent.triage({ caseId: "case-1", signal: signal() })).rejects.toThrow( + "triage Claude request failed" + ); + const summary = ledger.summary(); + expect(summary.actualCostNanoUsd).toBeGreaterThan(0); + expect(summary.outstandingReservedNanoUsd).toBe(0); + ledger.close(); + }); + + it("settles a rejected API request at zero without exposing credentials", async () => { + const ledger = new ClaudeSpendLedger(":memory:"); + const client: ClaudeMessagesClient = { + create: vi.fn(async () => { + throw Anthropic.APIError.generate( + 400, + { type: "error", error: { type: "invalid_request_error", message: "bad sk-ant-secret" } }, + "bad sk-ant-secret", + new Headers() + ); + }) + }; + const agent = new ClaudeTriageAgent({ + client, + spendLedger: ledger, + requestId: () => "request-rejected" + }); + const rejection = agent.triage({ caseId: "case-1", signal: signal() }); + await expect(rejection).rejects.toThrow(/\[redacted\]/); + await expect(rejection).rejects.not.toThrow(/sk-ant-secret/); + expect(ledger.summary()).toMatchObject({ + actualCostNanoUsd: 0, + outstandingReservedNanoUsd: 0 + }); + ledger.close(); + }); + + it("rejects at the hard ceiling before calling Anthropic", async () => { + const ledger = new ClaudeSpendLedger(":memory:", 1); + const create = vi.fn(async () => message({ content: [] })); + const agent = new ClaudeTriageAgent({ + client: { create }, + spendLedger: ledger, + requestId: () => "request-over-budget" + }); + await expect(agent.triage({ caseId: "case-1", signal: signal() })).rejects.toThrow( + /hard spend ceiling/ + ); + expect(create).not.toHaveBeenCalled(); + expect(ledger.verifyChain()).toMatchObject({ valid: true, rows: 0 }); + ledger.close(); + }); +}); diff --git a/test/claude-paper-study.test.ts b/test/claude-paper-study.test.ts new file mode 100644 index 0000000..c466daa --- /dev/null +++ b/test/claude-paper-study.test.ts @@ -0,0 +1,228 @@ +import type { Message } from "@anthropic-ai/sdk/resources/messages/messages"; +import { describe, expect, it, vi } from "vitest"; +import type { ClaudeMessagesClient } from "../src/agents/claude.js"; +import { ClaudeSpendLedger } from "../src/agents/claude-spend-ledger.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import { createPersistentClaudePaperStudy } from "../src/harness/claude-paper-study.js"; +import type { PaperFixtureUniverse } from "../src/harness/paper-fixture-universe.js"; +import { initializePaperStudyLedger } from "../src/harness/paper-study-ledger.js"; + +const fixtureId = "future-fixture"; +const marketKey = `${fixtureId}:total_goals:full_time:2500`; + +function universe(): PaperFixtureUniverse { + return { + generatedAt: "2026-07-12T00:00:00.000Z", + laneStartTsMs: 1_000, + selectorConfig: { + minimumCoveragePoints: 1_000, + minimumVolume: 0, + minimumLiquidity: 0, + maximumDistanceFromEven: 0.15, + weights: { balance: 1, volume: 0, liquidity: 0, coverage: 0 } + }, + fixtures: [{ + fixtureId, + home: "Home", + away: "Away", + kickoffTsMs: 2_000_000, + eventSlugs: ["future-event"], + mappingStatus: "verified", + selectedTotal: { + marketId: "market-1", + marketKey, + conditionId: "condition-1", + lineMilli: 2_500, + preKickoffOverProbability: 0.5, + preKickoffPointTsMs: 10_000, + coveragePoints: 2_000, + assetIds: ["over", "under"] + }, + evidenceGrade: "paired_order_books", + capabilities: { + signalResearchReplay: true, + executablePaperReplay: true, + kickoffCloseReplay: true, + publicResolutionReplay: true + }, + bountyLane: { + mode: "executable_book_replay", + exploratory: true, + reason: "test" + }, + longRunLane: { eligible: true, reason: null }, + pairedCapture: null + }], + summary: { + fixtures: 1, + pairedBookReplays: 1, + executableBookReplays: 1, + bookLifecycleReplays: 0, + signalResearchOnly: 0, + unavailable: 0, + longRunEligible: 1 + } + }; +} + +function signal( + id: string, + fixture = fixtureId, + eligibility: DetectorSignal["eligibility"] = "pretrade_review_required" +): DetectorSignal { + return { + signalId: id, + kind: "CONSENSUS_MOVE", + detectedAtTsMs: 10_000, + observedAtTsMs: 10_000, + fixtureId: fixture, + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: fixture === fixtureId ? marketKey : `${fixture}:total_goals:full_time:2500` + }, + outcome: "over", + direction: "buy", + eligibility, + reason: "test", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.51, + consensusVelocity: 0.01, + consensusZScore: 1.2, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.001, + cusumDown: 0, + rawGap: 0.04, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "verified", + scoreContextActions: [] + } + }; +} + +function triageDropMessage(): Message { + return { + id: "msg-triage", + type: "message", + role: "assistant", + model: "claude-haiku-4-5", + content: [{ + type: "tool_use", + id: "tool-triage", + name: "submit_triage", + input: { decision: "drop", priority: "low", rationale: "Test drop." }, + caller: { type: "direct" } + }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + cache_creation: null, + inference_geo: null, + output_tokens_details: null, + service_tier: "standard" + } + } as Message; +} + +describe("persistent Claude paper study composition", () => { + it("refuses an API key while the corrected protocol is unregistered", () => { + const bounty = initializePaperStudyLedger({ path: ":memory:", lane: "bounty", startedAtTsMs: 1_000 }); + const longRun = initializePaperStudyLedger({ path: ":memory:", lane: "long_run", startedAtTsMs: 1_000 }); + const spendLedger = new ClaudeSpendLedger(":memory:"); + try { + expect(() => createPersistentClaudePaperStudy({ + apiKey: "not-a-real-key", + spendLedger, + bounty, + longRun, + universe: universe(), + feeResolver: async () => { throw new Error("unused"); }, + maximumPendingMs: 5_000 + })).toThrow(/API use is disabled.*engineering_candidate_unregistered/); + expect(spendLedger.verifyChain()).toMatchObject({ valid: true, rows: 0 }); + } finally { + bounty.ledger.close(); + longRun.ledger.close(); + spendLedger.close(); + } + }); + + it("shares one bounded client and spend chain across isolated paper lanes", async () => { + const bounty = initializePaperStudyLedger({ path: ":memory:", lane: "bounty", startedAtTsMs: 1_000 }); + const longRun = initializePaperStudyLedger({ path: ":memory:", lane: "long_run", startedAtTsMs: 1_000 }); + const spendLedger = new ClaudeSpendLedger(":memory:"); + const create = vi.fn(async () => triageDropMessage()); + const client: ClaudeMessagesClient = { create }; + const study = createPersistentClaudePaperStudy({ + client, + spendLedger, + bounty, + longRun, + universe: universe(), + feeResolver: async () => { throw new Error("unused"); }, + maximumPendingMs: 5_000 + }); + try { + expect(study.bounty.scheduler.dependencies.pipeline.dependencies.triageAgent).toBe(study.agents.triage); + expect(study.longRun.scheduler.dependencies.pipeline.dependencies.analystAgent).toBe(study.agents.analyst); + + await expect(study.bounty.scheduler.enqueue(signal("ineligible", "other"))).resolves.toBe(false); + await expect(study.bounty.scheduler.enqueue( + signal("research-only", fixtureId, "research_only") + )).resolves.toBe(false); + expect(create).not.toHaveBeenCalled(); + expect(spendLedger.verifyChain()).toMatchObject({ valid: true, rows: 0 }); + + await expect(study.bounty.scheduler.enqueue(signal("bounty-signal"))).resolves.toBe(true); + await expect(study.longRun.scheduler.enqueue(signal("long-run-signal"))).resolves.toBe(true); + expect(create).toHaveBeenCalledTimes(2); + expect(spendLedger.verifyChain()).toMatchObject({ valid: true, rows: 4 }); + expect(spendLedger.summary().outstandingReservedNanoUsd).toBe(0); + expect(bounty.ledger.entries().map((entry) => entry.kind)).toEqual([ + "study_initialized", + "signal_received", + "triage_decision", + "case_terminal" + ]); + expect(longRun.ledger.entries().map((entry) => entry.kind)).toEqual([ + "study_initialized", + "signal_received", + "triage_decision", + "case_terminal" + ]); + } finally { + bounty.ledger.close(); + longRun.ledger.close(); + spendLedger.close(); + } + }); + + it("rejects a spend ledger configured above the locked project ceiling", () => { + const bounty = initializePaperStudyLedger({ path: ":memory:", lane: "bounty", startedAtTsMs: 1_000 }); + const longRun = initializePaperStudyLedger({ path: ":memory:", lane: "long_run", startedAtTsMs: 1_000 }); + const spendLedger = new ClaudeSpendLedger(":memory:", 300_000_000_001); + try { + expect(() => createPersistentClaudePaperStudy({ + client: { create: vi.fn() }, + spendLedger, + bounty, + longRun, + universe: universe(), + feeResolver: async () => { throw new Error("unused"); }, + maximumPendingMs: 5_000 + })).toThrow(/locked project maximum/); + } finally { + bounty.ledger.close(); + longRun.ledger.close(); + spendLedger.close(); + } + }); +}); diff --git a/test/dashboard-projection.test.ts b/test/dashboard-projection.test.ts new file mode 100644 index 0000000..3784e69 --- /dev/null +++ b/test/dashboard-projection.test.ts @@ -0,0 +1,248 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CASEBOOK_API_PATH, COMMAND_API_PATH, SPAIN_BELGIUM_API_PATH, STUDY_API_PATH, handleDashboardApi } from "../src/dash/api.js"; +import { buildCasebookSnapshot } from "../src/dash/casebook-projection.js"; +import { buildCommandSnapshot } from "../src/dash/command-projection.js"; +import { buildSpainBelgiumMatchroomSnapshot } from "../src/dash/projection.js"; +import { buildStudySnapshot } from "../src/dash/study-projection.js"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const privateProjectionEvidenceAvailable = process.env.SAMARITAN_TEST_NO_PRIVATE_DATA !== "1" && [ + "data/paper/reports/current.json", + "data/research/paper-fixture-universe.json", + "data/research/historical-gate-study-causal-economic-v4.json", + "data/research/paired-spain-belgium-2026-07-10-live-lane.json", + "data/live/paired-spain-belgium-2026-07-10/analysis-manifest.json", + "samples/fixtures/mainnet-world-cup-fixtures.json" +].every((path) => existsSync(resolve(repoRoot, path))); + +function publicKeys(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap(publicKeys); + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, nested]) => [key, ...publicKeys(nested)]); +} + +describe.runIf(privateProjectionEvidenceAvailable)("public dashboard private projection", () => { + it("builds Command from confirmed fixtures, sealed study evidence, and the verified replay", async () => { + const snapshot = await buildCommandSnapshot(repoRoot, Date.parse("2026-07-13T12:00:00.000Z")); + expect(snapshot).toMatchObject({ + schemaVersion: 2, + mode: "offline_artifact", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + system: { posture: "standing_by", label: "Standing by for paired capture" }, + featuredCase: { + caseId: "ESP-BEL-01", + disposition: "no_trade", + consensusMoveFromBaselineBps: 25, + preTriggerMarketMoveBps: 775, + identityParity: true, + canonicalEvents: 2_470_342 + }, + study: { + protocolStatus: "invalidated_suspended", + status: "suspended", + filledMatches: 0, + requiredFilledMatches: 20, + fills: 0, + requiredFills: 40, + stoppingRuleMet: false + }, + proof: { + replayIdentityParity: true, + bountyLedgerValid: true, + longRunLedgerValid: true, + evidenceFixtures: 3, + pairedBookReplays: 1, + signalResearchOnly: 2 + } + }); + expect(snapshot.fixtureSchedule).toEqual([ + expect.objectContaining({ fixtureId: "18237038", home: { name: "France", code: "FRA" }, away: { name: "Spain", code: "ESP" }, phase: "scheduled", captureOnly: true, tradeable: false }), + expect.objectContaining({ fixtureId: "18241006", home: { name: "England", code: "ENG" }, away: { name: "Argentina", code: "ARG" }, phase: "scheduled", captureOnly: true, tradeable: false }) + ]); + expect(snapshot.recentCases).toHaveLength(1); + }); + + it("projects verified Spain-Belgium evidence without inventing an executable edge", async () => { + const snapshot = await buildSpainBelgiumMatchroomSnapshot(repoRoot); + expect(snapshot).toMatchObject({ + schemaVersion: 2, + mode: "captured_replay", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + match: { + fixtureId: "18218149", + eventSlug: "fifwc-esp-bel-2026-07-10", + home: { name: "Spain", code: "ESP" }, + away: { name: "Belgium", code: "BEL" }, + scoreAtCursor: { home: 1, away: 0 }, + clockLabel: "29:21" + }, + decision: { + disposition: "no_trade", + semanticStatus: "disciplined_pass", + capitalMovedMicros: 0, + ordersPlaced: 0, + walletAccessed: false + } + }); + expect(snapshot.replay.states).toEqual([ + expect.objectContaining({ id: "pre", consensusMoveFromBaselineBps: 0, bestBid: 0.245, bestAsk: 0.26 }), + expect.objectContaining({ id: "goal", consensusMoveFromBaselineBps: 25, bestBid: 0.17, bestAsk: 0.18 }), + expect.objectContaining({ id: "post", consensusMoveFromBaselineBps: 25, bestBid: 0.16, bestAsk: 0.1625 }) + ]); + expect(snapshot.replay.preTriggerMarketMoveBps).toBe(775); + expect(snapshot.replay.states.every((state) => state.consensusMoveFromBaselineBps % snapshot.publicDataPolicy.txlineMovementBucketBps === 0)).toBe(true); + expect(snapshot.publicDataPolicy).toMatchObject({ + derivedOnly: true, + txlineProbabilityDisplay: "bucketed_movement_only", + txlineMovementBucketBps: 25 + }); + expect(snapshot.replay.firstMaterialMoveLatencyMs).toBe(228); + expect(snapshot.proof).toMatchObject({ + identityParity: true, + canonicalEvents: 2_470_342, + feedOutageCount: 8, + feedDowntimeMs: 91_171, + gateCases: 18, + movedBeforeTxlineCases: 12, + noMaterialRepriceCases: 6, + cleanStaleWindows: 0 + }); + }); + + it("builds Casebook from the complete verified refusal record", async () => { + const snapshot = await buildCasebookSnapshot(repoRoot); + expect(snapshot).toMatchObject({ + schemaVersion: 2, + mode: "offline_artifact", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + statistics: { + totalCases: 1, + noTradeCases: 1, + executedCases: 0, + verifiedCases: 1, + capitalMovedMicros: 0 + }, + selectedCase: { + summary: { + caseId: "ESP-BEL-01", + detector: "STALE_QUOTE_FEASIBILITY", + disposition: "No trade", + executionOutcome: "Not executed", + evidenceLane: "Research only", + source: "Captured replay" + }, + decision: { ordersPlaced: 0, walletAccessed: false }, + evidenceReadout: { consensusMoveFromBaselineBps: 25, preTriggerMarketMoveBps: 775 }, + analysis: { thesisStatus: "not_requested", costStatus: "not_applicable", costMicros: 0 }, + proof: { identityParity: true, canonicalEvents: 2_470_342 } + } + }); + expect(snapshot.cases).toHaveLength(1); + expect(snapshot.selectedCase.lifecycle).toHaveLength(4); + expect(snapshot.selectedCase.lifecycle[0]).toMatchObject({ + label: "Goal observed", + detail: "STALE_QUOTE feasibility · research only" + }); + expect(snapshot.selectedCase.evidence).toHaveLength(3); + }); + + it("keeps long-run Study endpoints sealed while exposing the frozen protocol", async () => { + const snapshot = await buildStudySnapshot(repoRoot); + expect(snapshot).toMatchObject({ + mode: "offline_artifact", + executionMode: "paper", + realMoneyGate: "closed", + tradeable: false, + protocol: { + evaluation: { minimumFilledMatches: 20, minimumFills: 40, bootstrapIterations: 10_000 }, + risk: { bankrollMicroUsd: 50_000_000, perTradeStakeMicroUsd: 3_000_000, aggregateExposureMicroUsd: 15_000_000, drawdownStopMicroUsd: 20_000_000 }, + guardrailThresholds: { minimumFillRate: 0.6, maximumMeanSlippageBps: 100 } + }, + lanes: { + bounty: { status: "exploratory", canSatisfyGate: false }, + longRun: { status: "sealed", stoppingRuleMet: false, canSatisfyGate: false } + }, + results: { visibility: "sealed", rows: null, endpoints: null, guardrails: null }, + correctedHistoricalCandidate: { + protocolId: "historical-gate-causal-economic-v4-2026-07-14", + trainingNormalizedCases: 135, + heldoutNormalizedCases: 38, + heldoutFixtures: 18, + costProxyBps: 100, + meanNetAfterCostProxyBps: 132.7, + matchClustered95Bps: { iterations: 10_000, cluster: "fixture", low: 14.3, high: 243.9 }, + registration: "engineering_candidate_unregistered", + activeStudy: false, + executable: false + }, + syntheticProof: { + lifecycleStatus: "filled_settled", + offlineVerified: true, + performanceUse: "excluded_synthetic", + externalCalls: 0, + solanaAnchorStatus: "not_submitted" + }, + fixtureUniverse: { evidenceFixtures: 3, pairedBookReplays: 1, executableBookReplays: 0, longRunEligible: 0 } + }); + }); + + it("exposes bucketed TXLine movement without exact levels, reconstructive gaps, or raw fields", async () => { + const projections = { + matchroom: await buildSpainBelgiumMatchroomSnapshot(repoRoot), + command: await buildCommandSnapshot(repoRoot, Date.parse("2026-07-13T12:00:00.000Z")), + casebook: await buildCasebookSnapshot(repoRoot), + study: await buildStudySnapshot(repoRoot) + }; + const serialized = JSON.stringify(projections); + const keys = publicKeys(projections); + + for (const forbiddenKey of ["fairProbability", "consensusProbability", "Pct", "executableGap"]) { + expect(keys).not.toContain(forbiddenKey); + } + expect(keys.filter((key) => key.toLowerCase().startsWith("raw"))).toEqual([]); + expect(serialized).not.toContain("0.26681"); + expect(serialized).not.toContain("0.2681"); + expect(projections.matchroom.replay.states.map((state) => state.consensusMoveFromBaselineBps)).toEqual([0, 25, 25]); + expect(projections.command.featuredCase.consensusMoveFromBaselineBps).toBe(25); + expect(projections.casebook.selectedCase.evidenceReadout.consensusMoveFromBaselineBps).toBe(25); + + for (const forbidden of [ + "sourcePaths", + "assetId", + "rawPayload", + "odds.frames.ndjson", + "scores.frames.ndjson", + "/Users/", + "privateKey", + "apiKey", + "tokenId" + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + + it("serves only the explicit read-only public API routes", async () => { + const command = await handleDashboardApi(COMMAND_API_PATH, repoRoot); + expect(command).toMatchObject({ status: 200, headers: { "cache-control": "no-store" } }); + expect(JSON.parse(command!.body)).toHaveProperty("data.study.status", "suspended"); + const match = await handleDashboardApi(SPAIN_BELGIUM_API_PATH, repoRoot); + expect(match).toMatchObject({ status: 200, headers: { "cache-control": "no-store" } }); + expect(JSON.parse(match!.body)).toHaveProperty("data.publicDataPolicy.txlineProbabilityDisplay", "bucketed_movement_only"); + const casebook = await handleDashboardApi(CASEBOOK_API_PATH, repoRoot); + expect(casebook).toMatchObject({ status: 200, headers: { "cache-control": "no-store" } }); + expect(JSON.parse(casebook!.body)).toHaveProperty("data.selectedCase.analysis.thesisStatus", "not_requested"); + const study = await handleDashboardApi(STUDY_API_PATH, repoRoot); + expect(study).toMatchObject({ status: 200, headers: { "cache-control": "no-store" } }); + expect(JSON.parse(study!.body)).toMatchObject({ data: { results: { visibility: "sealed", endpoints: null }, realMoneyGate: "closed" } }); + expect(await handleDashboardApi("/api/v1/wallet", repoRoot)).toMatchObject({ status: 404 }); + expect(await handleDashboardApi("/matchroom", repoRoot)).toBeNull(); + }); +}); diff --git a/test/decision-ledger.test.ts b/test/decision-ledger.test.ts new file mode 100644 index 0000000..98e6d0f --- /dev/null +++ b/test/decision-ledger.test.ts @@ -0,0 +1,284 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { afterEach, describe, expect, it } from "vitest"; +import { stableJson, type JsonValue } from "../src/domain/json.js"; +import { + DECISION_LEDGER_HASH_SCHEMA_V1, + DECISION_LEDGER_HASH_SCHEMA_V2, + DecisionLedger, + hashDecisionLedgerV2Entry +} from "../src/store/decision-ledger.js"; + +const GENESIS_HASH = "0".repeat(64); +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function ledgerPath(): string { + const directory = mkdtempSync(join(tmpdir(), "samaritan-decision-ledger-")); + directories.push(directory); + return join(directory, "decision-ledger.sqlite"); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function legacyV1Hash(previousHash: string, entryId: string, payloadJson: string): string { + return sha256(`${previousHash}\n${entryId}\n${payloadJson}`); +} + +function createLegacyV1Ledger(path: string): void { + const db = new Database(path); + const payloadJson = stableJson({ status: "initialized" }); + db.exec(` + CREATE TABLE decision_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id TEXT NOT NULL UNIQUE, + case_id TEXT NOT NULL, + kind TEXT NOT NULL, + at_ts_ms INTEGER NOT NULL, + payload_json TEXT NOT NULL, + previous_hash TEXT NOT NULL, + entry_hash TEXT NOT NULL UNIQUE, + inserted_at_ms INTEGER NOT NULL + ); + CREATE TRIGGER decision_events_no_update + BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision ledger is append-only'); END; + CREATE TRIGGER decision_events_no_delete + BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision ledger is append-only'); END; + `); + db.prepare( + `INSERT INTO decision_events + (sequence, entry_id, case_id, kind, at_ts_ms, payload_json, previous_hash, entry_hash, inserted_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 1, + "legacy-entry", + "legacy-case", + "study_initialized", + 1_000, + payloadJson, + GENESIS_HASH, + legacyV1Hash(GENESIS_HASH, "legacy-entry", payloadJson), + 1_001 + ); + db.close(); +} + +function appendV2Fixture(path: string, count = 1): void { + const ledger = new DecisionLedger(path); + for (let index = 1; index <= count; index += 1) { + ledger.append({ + entryId: `entry-${index}`, + caseId: "case-1", + kind: index === 1 ? "signal_received" : "triage_decision", + atTsMs: 1_000 + index, + insertedAtMs: 2_000 + index, + payload: index === 1 ? { z: 2, a: { y: 1, x: 0 } } : { decision: "drop" } + }); + } + ledger.close(); +} + +function mutate(path: string, sql: string, parameters: unknown[] = []): void { + const db = new Database(path); + db.exec("DROP TRIGGER IF EXISTS decision_events_no_update"); + db.exec("DROP TRIGGER IF EXISTS decision_events_no_delete"); + db.prepare(sql).run(...parameters); + db.close(); +} + +describe("decision-ledger v2 proof envelope", () => { + it("commits every proof field through a canonical, domain-separated v2 hash", () => { + const path = ledgerPath(); + const ledger = new DecisionLedger(path); + const payload: JsonValue = { z: 2, a: { y: 1, x: 0 } }; + const entry = ledger.append({ + entryId: "entry-1", + caseId: "case-1", + kind: "signal_received", + atTsMs: 1_001, + insertedAtMs: 2_001, + payload + }); + + expect(entry).toMatchObject({ + sequence: 1, + insertedAtMs: 2_001, + hashSchemaVersion: DECISION_LEDGER_HASH_SCHEMA_V2 + }); + expect(entry.entryHash).toBe(hashDecisionLedgerV2Entry({ + sequence: 1, + entryId: "entry-1", + caseId: "case-1", + kind: "signal_received", + atTsMs: 1_001, + insertedAtMs: 2_001, + payload: { a: { x: 0, y: 1 }, z: 2 }, + previousHash: GENESIS_HASH + })); + expect(ledger.verifyChain()).toMatchObject({ + valid: true, + rows: 1, + hashSchemaVersions: [DECISION_LEDGER_HASH_SCHEMA_V2], + legacyV1Rows: 0, + v2Rows: 1 + }); + ledger.close(); + }); + + it.each([ + ["entry ID", "entry_id", "tampered-entry"], + ["case ID", "case_id", "tampered-case"], + ["event kind", "kind", "risk_verdict"], + ["event timestamp", "at_ts_ms", 9_001], + ["insertion timestamp", "inserted_at_ms", 9_002] + ])("rejects tampered %s metadata", (_label, column, value) => { + const path = ledgerPath(); + appendV2Fixture(path); + mutate(path, `UPDATE decision_events SET ${column} = ? WHERE sequence = 1`, [value]); + + const ledger = new DecisionLedger(path); + expect(() => ledger.verifyChain()).toThrow(/Broken decision hash/); + ledger.close(); + }); + + it("rejects a tampered canonical payload", () => { + const path = ledgerPath(); + appendV2Fixture(path); + mutate(path, "UPDATE decision_events SET payload_json = ? WHERE sequence = 1", [ + stableJson({ a: { x: 0, y: 999 }, z: 2 }) + ]); + + const ledger = new DecisionLedger(path); + expect(() => ledger.verifyChain()).toThrow(/Broken decision hash/); + ledger.close(); + }); + + it("rejects a relabelled hash schema and a replaced previous link", () => { + const relabelledPath = ledgerPath(); + appendV2Fixture(relabelledPath); + mutate(relabelledPath, "UPDATE decision_events SET hash_schema_version = 1 WHERE sequence = 1"); + const relabelled = new DecisionLedger(relabelledPath); + expect(() => relabelled.verifyChain()).toThrow(/Broken decision hash/); + relabelled.close(); + + const relinkedPath = ledgerPath(); + appendV2Fixture(relinkedPath); + mutate(relinkedPath, "UPDATE decision_events SET previous_hash = ? WHERE sequence = 1", [ + "1".repeat(64) + ]); + const relinked = new DecisionLedger(relinkedPath); + expect(() => relinked.verifyChain()).toThrow(/Broken decision link/); + relinked.close(); + }); + + it("rejects reordered rows because sequence is committed", () => { + const path = ledgerPath(); + appendV2Fixture(path, 2); + const db = new Database(path); + db.exec("DROP TRIGGER IF EXISTS decision_events_no_update"); + db.exec("UPDATE decision_events SET sequence = -1 WHERE sequence = 1"); + db.exec("UPDATE decision_events SET sequence = 1 WHERE sequence = 2"); + db.exec("UPDATE decision_events SET sequence = 2 WHERE sequence = -1"); + db.close(); + + const ledger = new DecisionLedger(path); + expect(() => ledger.verifyChain()).toThrow(/Broken decision (link|hash)/); + ledger.close(); + }); +}); + +describe("decision-ledger hash-schema compatibility", () => { + it("preserves legacy rows as identifiable v1 and appends a linked v2 suffix", () => { + const path = ledgerPath(); + createLegacyV1Ledger(path); + + const ledger = new DecisionLedger(path); + expect(ledger.entries()[0]).toMatchObject({ + entryId: "legacy-entry", + insertedAtMs: 1_001, + hashSchemaVersion: DECISION_LEDGER_HASH_SCHEMA_V1 + }); + expect(ledger.verifyChain()).toMatchObject({ + valid: true, + rows: 1, + hashSchemaVersions: [DECISION_LEDGER_HASH_SCHEMA_V1], + legacyV1Rows: 1, + v2Rows: 0 + }); + + const appended = ledger.append({ + entryId: "v2-entry", + caseId: "legacy-case", + kind: "signal_received", + atTsMs: 2_000, + insertedAtMs: 2_001, + payload: { status: "received" } + }); + expect(appended).toMatchObject({ sequence: 2, hashSchemaVersion: DECISION_LEDGER_HASH_SCHEMA_V2 }); + expect(ledger.verifyChain()).toMatchObject({ + valid: true, + rows: 2, + hashSchemaVersions: [ + DECISION_LEDGER_HASH_SCHEMA_V1, + DECISION_LEDGER_HASH_SCHEMA_V2 + ], + legacyV1Rows: 1, + v2Rows: 1 + }); + ledger.close(); + + const db = new Database(path, { readonly: true }); + const versions = db.prepare( + "SELECT hash_schema_version AS version FROM decision_events ORDER BY sequence" + ).all() as Array<{ version: number | null }>; + expect(versions).toEqual([{ version: null }, { version: DECISION_LEDGER_HASH_SCHEMA_V2 }]); + db.close(); + }); + + it("rejects a legacy writer downgrade after the ledger has transitioned to v2", () => { + const path = ledgerPath(); + createLegacyV1Ledger(path); + const ledger = new DecisionLedger(path); + const v2 = ledger.append({ + entryId: "v2-entry", + caseId: "legacy-case", + kind: "signal_received", + atTsMs: 2_000, + insertedAtMs: 2_001, + payload: { status: "received" } + }); + ledger.close(); + + const payloadJson = stableJson({ decision: "drop" }); + const db = new Database(path); + db.prepare( + `INSERT INTO decision_events + (sequence, entry_id, case_id, kind, at_ts_ms, payload_json, previous_hash, entry_hash, + inserted_at_ms, hash_schema_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)` + ).run( + 3, + "downgraded-entry", + "legacy-case", + "triage_decision", + 3_000, + payloadJson, + v2.entryHash, + legacyV1Hash(v2.entryHash, "downgraded-entry", payloadJson), + 3_001 + ); + db.close(); + + const reopened = new DecisionLedger(path); + expect(() => reopened.verifyChain()).toThrow(/hash schema downgrade/); + reopened.close(); + }); +}); diff --git a/test/decision-receipt.test.ts b/test/decision-receipt.test.ts new file mode 100644 index 0000000..3ebaef1 --- /dev/null +++ b/test/decision-receipt.test.ts @@ -0,0 +1,181 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { stableJson } from "../src/domain/json.js"; +import { + decisionReceiptBodySchema, + hashDecisionReceiptBody, + verifyDecisionReceipt, + type DecisionReceipt +} from "../src/proof/decision-receipt-schema.js"; +import { buildSyntheticDecisionReceipt } from "../src/proof/synthetic-decision-receipt.js"; + +function clone(): DecisionReceipt { + return structuredClone(buildSyntheticDecisionReceipt()); +} + +function rehash(receipt: DecisionReceipt): void { + const { integrity: _integrity, ...bodyValue } = receipt; + const body = decisionReceiptBodySchema.parse(bodyValue); + receipt.integrity.receiptHash = hashDecisionReceiptBody(body); +} + +function recursiveKeys(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap(recursiveKeys); + if (value === null || typeof value !== "object") return []; + return Object.entries(value as Record).flatMap(([key, child]) => [ + key, + ...recursiveKeys(child) + ]); +} + +describe("decision receipt v1", () => { + it("verifies the frozen synthetic proving fixture without claiming real evidence", () => { + const built = buildSyntheticDecisionReceipt(); + const frozen = JSON.parse(readFileSync( + join(process.cwd(), "proof/fixtures/decision-receipt.synthetic.v1.json"), + "utf8" + )) as unknown; + + expect(stableJson(frozen)).toBe(stableJson(built)); + expect(verifyDecisionReceipt(frozen)).toEqual({ + valid: true, + receiptHash: built.integrity.receiptHash, + committedLedgerHead: built.ledger.finalHeadHash, + lifecycleStatus: "filled_settled", + synthetic: true, + solanaAnchorMetadataPresent: false, + solanaNetworkVerificationPerformed: false, + assurance: [ + "receipt_schema_and_canonical_hash_verified", + "lifecycle_and_commitment_consistency_verified", + "source_payloads_and_local_ledger_not_replayed_by_offline_verifier" + ] + }); + expect(built.provenance).toMatchObject({ + evidenceClass: "synthetic_proving_fixture", + synthetic: true, + performanceUse: "excluded_synthetic" + }); + expect(built.agents.runs.every((run) => + run.invocationClass === "synthetic_stub" && run.actualCostNanoUsd === 0 + )).toBe(true); + }); + + it("recursively excludes exact TXLine levels, reconstructive gaps, and raw fields", () => { + const receipt = buildSyntheticDecisionReceipt(); + const receiptJson = JSON.stringify(receipt); + const keys = recursiveKeys(receipt); + for (const forbiddenKey of [ + "fairProbability", + "consensusProbability", + "polymarketProbability", + "marketProbability", + "limitProbability", + "Pct", + "Prices", + "rawGap", + "crossMarketGapBps", + "executableGap", + "exactGap", + "gapBps" + ]) expect(keys).not.toContain(forbiddenKey); + expect(receiptJson).not.toContain("private-txline-signal-source"); + expect(receiptJson).toContain("payloadSha256"); + expect(receipt.lifecycle.signal.derivedEvidence).toMatchObject({ + txlineMovementBucketBps: 25, + relativeValueDirection: "consensus_above_venue" + }); + for (const movement of [ + receipt.lifecycle.signal.derivedEvidence.consensusVelocityBucketBps, + receipt.lifecycle.signal.derivedEvidence.polymarketVelocityBucketBps + ]) { + if (movement !== null) expect(movement % 25).toBe(0); + } + }); + + it.each([ + ["code hash", (receipt: DecisionReceipt) => { receipt.build.codeSha256 = "1".repeat(64); }], + ["config hash", (receipt: DecisionReceipt) => { receipt.build.configSha256 = "2".repeat(64); }], + ["source payload", (receipt: DecisionReceipt) => { receipt.sourceEvidence[0]!.payloadSha256 = "3".repeat(64); }], + ["signal evidence", (receipt: DecisionReceipt) => { receipt.lifecycle.signal.derivedEvidence.consensusVelocityBucketBps! += 25; }], + ["prompt version", (receipt: DecisionReceipt) => { receipt.agents.runs[0]!.promptVersion = "tampered"; }], + ["agent cost", (receipt: DecisionReceipt) => { receipt.agents.runs[0]!.actualCostNanoUsd += 1; }], + ["thesis", (receipt: DecisionReceipt) => { receipt.lifecycle.thesis!.thesisPayloadSha256 = "6".repeat(64); }], + ["risk verdict", (receipt: DecisionReceipt) => { receipt.lifecycle.risk!.stakeMicroUsd! -= 1; }], + ["execution intent", (receipt: DecisionReceipt) => { receipt.lifecycle.intent!.stakeMicroUsd -= 1; }], + ["post-readiness evidence", (receipt: DecisionReceipt) => { receipt.lifecycle.execution!.bookObservedTsMs += 1; }], + ["fill", (receipt: DecisionReceipt) => { receipt.lifecycle.execution!.grossMicroUsd -= 1; }], + ["close", (receipt: DecisionReceipt) => { receipt.lifecycle.close!.closeBid -= 0.01; }], + ["settlement", (receipt: DecisionReceipt) => { receipt.lifecycle.settlement!.pnlMicroUsd -= 1; }], + ["ledger head", (receipt: DecisionReceipt) => { receipt.ledger.finalHeadHash = "4".repeat(64); }] + ])("detects %s tampering through the canonical receipt hash", (_label, mutate) => { + const receipt = clone(); + mutate(receipt); + expect(() => verifyDecisionReceipt(receipt)).toThrow(/canonical hash mismatch|Invalid input/); + }); + + it("rejects reordered lifecycle commitments even when an attacker recomputes the receipt hash", () => { + const receipt = clone(); + [receipt.lifecycle.orderedEventKinds[4], receipt.lifecycle.orderedEventKinds[5]] = [ + receipt.lifecycle.orderedEventKinds[5]!, + receipt.lifecycle.orderedEventKinds[4]! + ]; + [receipt.ledger.caseEntries[4], receipt.ledger.caseEntries[5]] = [ + receipt.ledger.caseEntries[5]!, + receipt.ledger.caseEntries[4]! + ]; + rehash(receipt); + expect(() => verifyDecisionReceipt(receipt)).toThrow(/ordering|sequences|hashes/); + }); + + it("rejects post-readiness look-ahead even when all disclosed timestamps are rehashed", () => { + const receipt = clone(); + const ineligibleTsMs = receipt.lifecycle.analysis!.orderEligibleAtTsMs - 1; + const execution = receipt.lifecycle.execution!; + execution.bookObservedTsMs = ineligibleTsMs; + const source = receipt.sourceEvidence.find((item) => + item.evidenceRefSha256 === execution.bookEvidenceRefSha256 + )!; + source.observedAtTsMs = ineligibleTsMs; + rehash(receipt); + expect(() => verifyDecisionReceipt(receipt)).toThrow(/precedes analysis readiness or venue delay/); + }); + + it("rejects inconsistent model cost even when the receipt is rehashed", () => { + const receipt = clone(); + receipt.agents.runs[0]!.usage.inputTokens = 1; + receipt.agents.totalActualCostNanoUsd = 0; + rehash(receipt); + expect(() => verifyDecisionReceipt(receipt)).toThrow(/Agent cost/); + }); + + it("validates optional anchor metadata but explicitly performs no Solana network check", () => { + const receipt = clone(); + receipt.solanaAnchor = { + network: "devnet", + transactionSignature: "1".repeat(64), + slot: 123, + blockTimeTsMs: receipt.generatedAtTsMs + 1, + memoProgramId: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + commitmentType: "decision_ledger_head_sha256", + committedHash: receipt.ledger.finalHeadHash, + networkVerification: "not_performed_by_offline_verifier" + }; + rehash(receipt); + expect(verifyDecisionReceipt(receipt)).toMatchObject({ + solanaAnchorMetadataPresent: true, + solanaNetworkVerificationPerformed: false + }); + + receipt.solanaAnchor.committedHash = "5".repeat(64); + rehash(receipt); + expect(() => verifyDecisionReceipt(receipt)).toThrow(/does not commit the receipt ledger head/); + }); + + it("rejects unknown fields under the strict public schema", () => { + const receipt = clone() as DecisionReceipt & { rawTxlinePayload?: unknown }; + receipt.rawTxlinePayload = { Pct: [55, 45] }; + expect(() => verifyDecisionReceipt(receipt)).toThrow(/Unrecognized key/); + }); +}); diff --git a/test/detectors.test.ts b/test/detectors.test.ts index bf5b6e7..5fdc841 100644 --- a/test/detectors.test.ts +++ b/test/detectors.test.ts @@ -33,6 +33,7 @@ function snapshot(overrides: Partial = {}): FeatureSnapshot { triggerEventId: "trigger-1", triggerSource: "polymarket", asOfTsMs: 10_000, + observedAtTsMs: 10_000, fixtureId: "fixture-1", market: { family: "match_result", diff --git a/test/economic-cases.test.ts b/test/economic-cases.test.ts new file mode 100644 index 0000000..8cb2bca --- /dev/null +++ b/test/economic-cases.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { normalizeExecutableEconomicCases } from "../src/detectors/economic-cases.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import { PaperStudyRuntime } from "../src/harness/paper-runtime.js"; +import type { PaperCaseScheduler } from "../src/harness/paper-scheduler.js"; + +function totalSignal(overrides: Partial = {}): DetectorSignal { + const outcome = overrides.outcome ?? "over"; + const direction = overrides.direction ?? "buy"; + return { + signalId: overrides.signalId ?? `${direction}-${outcome}`, + kind: "XMARKET_DIVERGENCE", + detectedAtTsMs: 1_000, + observedAtTsMs: 1_005, + fixtureId: "fixture-1", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }, + outcome, + direction, + eligibility: "research_only", + reason: "test", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.5, + consensusVelocity: 0, + consensusZScore: 0, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0, + cusumDown: 0, + rawGap: 0.05, + gapBasis: "sampled_history_proxy", + persistenceMs: 0, + mappingStatus: "candidate", + scoreContextActions: [] + }, + ...overrides + }; +} + +describe("executable economic-case normalization", () => { + it("retains buy Over and collapses its sell Under complement", () => { + const sellUnder = totalSignal({ signalId: "sell-under", outcome: "under", direction: "sell" }); + const buyOver = totalSignal({ signalId: "buy-over", outcome: "over", direction: "buy" }); + const result = normalizeExecutableEconomicCases([sellUnder, buyOver]); + + expect(result.signals).toEqual([buyOver]); + expect(result.dispositions).toEqual([ + { + signalId: "sell-under", + disposition: "collapsed", + reason: "complementary_sell_collapsed_into_executable_buy", + economicOutcome: "over" + }, + { + signalId: "buy-over", + disposition: "retained", + reason: "executable_buy_retained", + economicOutcome: "over" + } + ]); + expect(result.summary).toMatchObject({ + rawEmissions: 2, + normalizedCases: 1, + executableTotalGoalsCases: 1, + complementarySellsCollapsed: 1, + sellOnlySignalsDropped: 0 + }); + }); + + it("retains buy Under and collapses its sell Over complement", () => { + const buyUnder = totalSignal({ signalId: "buy-under", outcome: "under", direction: "buy" }); + const sellOver = totalSignal({ signalId: "sell-over", outcome: "over", direction: "sell" }); + const result = normalizeExecutableEconomicCases([buyUnder, sellOver]); + + expect(result.signals).toEqual([buyUnder]); + expect(result.summary.complementarySellsCollapsed).toBe(1); + expect(result.dispositions.map((item) => item.economicOutcome)).toEqual(["under", "under"]); + }); + + it("prefers the first actual executable buy over duplicate buys and complements", () => { + const firstBuy = totalSignal({ signalId: "buy-a" }); + const duplicateBuy = totalSignal({ signalId: "buy-b" }); + const complement = totalSignal({ signalId: "sell-under", outcome: "under", direction: "sell" }); + const result = normalizeExecutableEconomicCases([firstBuy, complement, duplicateBuy]); + + expect(result.signals).toEqual([firstBuy]); + expect(result.summary).toMatchObject({ + rawEmissions: 3, + normalizedCases: 1, + duplicateExecutableBuysCollapsed: 1, + complementarySellsCollapsed: 1 + }); + }); + + it("fails closed for sell-only total cases because signals prove no complementary ask", () => { + const sellUnder = totalSignal({ signalId: "sell-under", outcome: "under", direction: "sell" }); + const sellOver = totalSignal({ signalId: "sell-over", outcome: "over", direction: "sell" }); + const result = normalizeExecutableEconomicCases([sellUnder, sellOver]); + + expect(result.signals).toEqual([]); + expect(result.summary.sellOnlySignalsDropped).toBe(2); + expect(result.dispositions.every((item) => + item.reason === "sell_only_unproven_executable_ask" && item.disposition === "dropped" + )).toBe(true); + }); + + it("keeps distinct detector, source-time, and knowledge-time economic cases", () => { + const signals = [ + totalSignal({ signalId: "base" }), + totalSignal({ signalId: "detector", kind: "CONSENSUS_MOVE" }), + totalSignal({ signalId: "source-time", detectedAtTsMs: 1_001 }), + totalSignal({ signalId: "knowledge-time", observedAtTsMs: 1_006 }) + ]; + const result = normalizeExecutableEconomicCases(signals); + + expect(result.signals).toEqual(signals); + expect(result.summary.normalizedCases).toBe(4); + }); + + it("does not normalize or deduplicate three-way Match Result signals", () => { + const market = { + family: "match_result" as const, + period: "full_time" as const, + lineMilli: null, + key: "fixture-1:match_result:full_time:none" + }; + const buyHome = totalSignal({ signalId: "buy-home", market, outcome: "home", direction: "buy" }); + const duplicateBuyHome = totalSignal({ signalId: "buy-home-duplicate", market, outcome: "home", direction: "buy" }); + const sellAway = totalSignal({ signalId: "sell-away", market, outcome: "away", direction: "sell" }); + const raw = [buyHome, duplicateBuyHome, sellAway]; + const result = normalizeExecutableEconomicCases(raw); + + expect(result.signals).toEqual(raw); + expect(result.signals[0]).toBe(buyHome); + expect(result.signals[2]).toBe(sellAway); + expect(result.summary).toMatchObject({ + rawEmissions: 3, + normalizedCases: 3, + nonBinaryMarketSignalsPassedThrough: 3, + duplicateExecutableBuysCollapsed: 0, + sellOnlySignalsDropped: 0 + }); + }); + + it("drops an impossible Total Goals outcome instead of guessing a complement", () => { + const malformed = totalSignal({ signalId: "bad-total", outcome: "home" }); + const result = normalizeExecutableEconomicCases([malformed]); + + expect(result.signals).toEqual([]); + expect(result.summary.unsupportedTotalGoalsSignalsDropped).toBe(1); + expect(result.dispositions[0]?.reason).toBe("unsupported_total_goals_outcome"); + }); + + it("routes the normalized buy, not raw complementary emissions, in the forward runtime", async () => { + const buyOver = totalSignal({ signalId: "buy-over" }); + const sellUnder = totalSignal({ signalId: "sell-under", outcome: "under", direction: "sell" }); + const routed: DetectorSignal[] = []; + let detectorCall = 0; + const scheduler = { + ingest: async () => [], + enqueue: async (candidate: DetectorSignal) => { + routed.push(candidate); + return true; + } + } as unknown as PaperCaseScheduler; + const runtime = new PaperStudyRuntime({ + featureProcessor: { ingest: () => [{} as never, {} as never] }, + detectorProcessor: { + ingest: () => detectorCall++ === 0 ? [sellUnder] : [buyOver] + }, + scheduler + }); + + const batch = await runtime.ingest({ kind: "feed.heartbeat" } as never); + + expect(batch.rawSignals).toEqual([sellUnder, buyOver]); + expect(batch.signals).toEqual([buyOver]); + expect(batch.routedSignalIds).toEqual(["buy-over"]); + expect(routed).toEqual([buyOver]); + expect(batch.economicCaseNormalization).toMatchObject({ + rawEmissions: 2, + normalizedCases: 1, + complementarySellsCollapsed: 1 + }); + }); +}); diff --git a/test/features.test.ts b/test/features.test.ts index 0917404..b6957b0 100644 --- a/test/features.test.ts +++ b/test/features.test.ts @@ -47,7 +47,7 @@ function quote(tsMs: number, home: number): OddsQuoteEvent { }; } -function book(tsMs: number): PolymarketBookEvent { +function book(tsMs: number, bid = 0.45, ask = 0.47): PolymarketBookEvent { return { schemaVersion: CANONICAL_SCHEMA_VERSION, kind: "polymarket.book", @@ -64,11 +64,11 @@ function book(tsMs: number): PolymarketBookEvent { tokenRole: "canonical", bids: [ { price: probability(0.44), size: "10" }, - { price: probability(0.45), size: "5" } + { price: probability(bid), size: "5" } ], asks: [ { price: probability(0.48), size: "10" }, - { price: probability(0.47), size: "5" } + { price: probability(ask), size: "5" } ], lastTradePrice: probability(0.46), tickSize: "0.01" @@ -157,4 +157,49 @@ describe("feature engine", () => { }; expect(engine.ingest(complement)).toEqual([]); }); + + it("drops regressing TXLine source observations before they mutate feature state", () => { + const engine = new FeatureEngine(config); + engine.ingest(quote(10_000, 0.5)); + engine.ingest(book(10_500)); + const before = engine.ingest(quote(11_000, 0.51)); + expect(engine.ingest({ ...quote(10_750, 0.9), observedTsMs: 11_020 })).toEqual([]); + + const after = engine.ingest(book(11_500)).find((snapshot) => snapshot.outcome === "home"); + const homeBefore = before.find((snapshot) => snapshot.outcome === "home"); + expect(after?.consensus.probability).toBe(homeBefore?.consensus.probability); + expect(after?.consensus.updateCount).toBe(homeBefore?.consensus.updateCount); + expect(after?.consensus.cusumUp).toBe(homeBefore?.consensus.cusumUp); + expect(engine.diagnostics().rejectedSourceRegressions.txline).toBe(3); + }); + + it("drops regressing Polymarket books before they overwrite the current quote", () => { + const engine = new FeatureEngine(config); + engine.ingest(quote(10_000, 0.5)); + const [current] = engine.ingest(book(11_000, 0.45, 0.47)); + expect(engine.ingest({ ...book(10_900, 0.1, 0.2), observedTsMs: 11_025 })).toEqual([]); + + const [after] = engine.ingest(quote(11_500, 0.51)); + expect(after?.polymarket.bestBid).toBe(current?.polymarket.bestBid); + expect(after?.polymarket.bestAsk).toBe(current?.polymarket.bestAsk); + expect(after?.polymarket.probability).toBe(current?.polymarket.probability); + expect(engine.diagnostics().rejectedSourceRegressions.polymarket).toBe(1); + }); + + it("accepts small source-clock lead while preserving the observed delta diagnostic", () => { + const engine = new FeatureEngine(config); + const sourceAhead = { ...quote(10_000, 0.5), observedTsMs: 9_936 }; + expect(engine.ingest(sourceAhead)).toHaveLength(3); + expect(engine.diagnostics().sourceObservationDeltaMs.txline).toEqual({ + minimum: -64, + maximum: -64 + }); + }); + + it("fails closed on regressing observed knowledge time", () => { + const engine = new FeatureEngine(config); + engine.ingest(quote(10_000, 0.5)); + expect(engine.ingest({ ...quote(11_000, 0.51), observedTsMs: 10_009 })).toEqual([]); + expect(engine.diagnostics().rejectedObservedRegressions.txline).toBe(1); + }); }); diff --git a/test/forward-labels.test.ts b/test/forward-labels.test.ts index 6b82a7a..52da84f 100644 --- a/test/forward-labels.test.ts +++ b/test/forward-labels.test.ts @@ -36,6 +36,7 @@ function snapshot(tsMs: number, consensus: number, polymarket: number): FeatureS triggerEventId: `event-${tsMs}`, triggerSource: "polymarket", asOfTsMs: tsMs, + observedAtTsMs: tsMs, fixtureId: "fixture-1", market: { family: "match_result", diff --git a/test/historical-gate-study.test.ts b/test/historical-gate-study.test.ts new file mode 100644 index 0000000..7e1c271 --- /dev/null +++ b/test/historical-gate-study.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import type { DetectorBankConfig } from "../src/detectors/bank.js"; +import { assertCausalTotalSelectorConfig } from "../src/research/main-total-selector.js"; +import { + ThresholdResultAggregator, + assertCausalTotalEvidenceFile, + clusteredNetClvInterval, + chronologicalFixtureSplit, + rankTrainingConfigurations, + selectEligibleTrainingConfiguration +} from "../src/research/historical-gate-study.js"; + +const config: DetectorBankConfig = { + velocityWindowMs: 300_000, + consensusMoveAbsZ: 1.5, + consensusCusumThreshold: 0.0025, + consensusMinimumUpdates: 3, + consensusMinimumRawGap: 0.005, + consensusStableAbsZ: 1, + xmarketMinimumRawGap: 0.005, + xmarketPersistenceMs: 60_000, + faderPolymarketAbsZ: 1.5, + faderMinimumRawGap: 0.005, + faderPersistenceMs: 60_000 +}; + +function normalization(rawEmissions: number, normalizedCases: number) { + return { + rawEmissions, + normalizedCases, + executableTotalGoalsCases: normalizedCases, + nonBinaryMarketSignalsPassedThrough: 0, + duplicateExecutableBuysCollapsed: rawEmissions - normalizedCases, + complementarySellsCollapsed: 0, + sellOnlySignalsDropped: 0, + unsupportedTotalGoalsSignalsDropped: 0 + }; +} + +describe("historical gate study primitives", () => { + it("computes a deterministic fixture-clustered interval instead of treating signals as independent", () => { + const inputs = [ + { fixtureId: "one", netAfterCostProxy: 0.01 }, + { fixtureId: "one", netAfterCostProxy: -0.005 } + ]; + const first = clusteredNetClvInterval(inputs); + const second = clusteredNetClvInterval(inputs); + expect(first).toEqual(second); + expect(first).toMatchObject({ + iterations: 10_000, + seed: 20_260_714, + fixtures: 1, + signals: 2, + low: 0.0025, + median: 0.0025, + high: 0.0025 + }); + }); + + it("rejects legacy or non-causal total-line evidence artifacts", () => { + expect(() => assertCausalTotalEvidenceFile({ schemaVersion: 1, evidence: [] })).toThrow( + /schemaVersion 2/ + ); + expect(() => assertCausalTotalEvidenceFile({ + schemaVersion: 2, + selectorEvidence: { + asOfBeforeKickoffMs: 180 * 60_000, + kickoffBasis: "txline_kickoff_ts_ms", + probabilityRule: "source_ts_ms_lte_selector_cutoff", + coverageRule: "unbounded" + }, + evidence: [] + })).toThrow(/share the causal cutoff/); + }); + + it("rejects unbounded volume or liquidity as causal selection inputs", () => { + expect(() => assertCausalTotalSelectorConfig({ + minimumCoveragePoints: 1, + minimumVolume: 0, + minimumLiquidity: 0, + maximumDistanceFromEven: 0.15, + weights: { balance: 1, volume: 1, liquidity: 0, coverage: 0 } + })).toThrow(/cannot use volume or liquidity/); + }); + + it("keeps fixtures with identical kickoff timestamps in one chronological partition", () => { + const split = chronologicalFixtureSplit([ + { fixtureId: "a", kickoffTsMs: 1 }, + { fixtureId: "b", kickoffTsMs: 2 }, + { fixtureId: "c", kickoffTsMs: 2 }, + { fixtureId: "d", kickoffTsMs: 3 } + ], 0.5); + expect(split.train.map((fixture) => fixture.fixtureId)).toEqual(["a", "b", "c"]); + expect(split.test.map((fixture) => fixture.fixtureId)).toEqual(["d"]); + }); + + it("aggregates confusion matrices before ranking configurations", () => { + const aggregator = new ThresholdResultAggregator("XMARKET_DIVERGENCE"); + aggregator.add([{ + configId: "config-a", + config, + snapshots: 10, + signalCounts: { + XMARKET_DIVERGENCE: 3, + CONSENSUS_MOVE: 0, + FADER_CANDIDATE: 0 + }, + rawSignalCounts: { + XMARKET_DIVERGENCE: 6, + CONSENSUS_MOVE: 0, + FADER_CANDIDATE: 0 + }, + economicCaseNormalization: normalization(6, 3), + classification: [{ + detector: "XMARKET_DIVERGENCE", + cases: 4, + truePositive: 2, + falsePositive: 1, + trueNegative: 1, + falseNegative: 0, + precision: 2 / 3, + recall: 1, + falsePositiveRate: 0.5 + }] + }]); + aggregator.add([{ + configId: "config-a", + config, + snapshots: 8, + signalCounts: { + XMARKET_DIVERGENCE: 2, + CONSENSUS_MOVE: 0, + FADER_CANDIDATE: 0 + }, + rawSignalCounts: { + XMARKET_DIVERGENCE: 4, + CONSENSUS_MOVE: 0, + FADER_CANDIDATE: 0 + }, + economicCaseNormalization: normalization(4, 2), + classification: [{ + detector: "XMARKET_DIVERGENCE", + cases: 3, + truePositive: 1, + falsePositive: 0, + trueNegative: 1, + falseNegative: 1, + precision: 1, + recall: 0.5, + falsePositiveRate: 0 + }] + }]); + const [result] = rankTrainingConfigurations( + aggregator.results(), + 4, + "XMARKET_DIVERGENCE", + 0.005 + ); + expect(result).toMatchObject({ + snapshots: 18, + rawEmissions: 10, + signals: 5, + predictedPositive: 5, + meetsMinimumSignals: true, + meetsCostFloor: true, + classification: { + cases: 7, + truePositive: 3, + falsePositive: 1, + trueNegative: 2, + falseNegative: 1, + precision: 0.75, + recall: 0.75 + } + }); + expect(result?.f1).toBe(0.75); + }); + + it("fails closed when training produces no sample- and cost-eligible configuration", () => { + const candidate = { + configId: "under-cost-floor", + config, + snapshots: 10, + rawEmissions: 5, + signals: 5, + classification: { + detector: "XMARKET_DIVERGENCE" as const, + cases: 10, + truePositive: 3, + falsePositive: 1, + trueNegative: 5, + falseNegative: 1, + precision: 0.75, + recall: 0.75, + falsePositiveRate: 1 / 6 + } + }; + const ranked = rankTrainingConfigurations( + [candidate], + 4, + "XMARKET_DIVERGENCE", + 0.01 + ); + expect(ranked[0]?.meetsMinimumSignals).toBe(true); + expect(ranked[0]?.meetsCostFloor).toBe(false); + expect(() => selectEligibleTrainingConfiguration("XMARKET_DIVERGENCE", ranked)).toThrow( + /no sample- and cost-eligible configuration/ + ); + }); + + it("uses normalized cases rather than inflated raw predictions for the training minimum", () => { + const [ranked] = rankTrainingConfigurations([{ + configId: "complement-inflated", + config, + snapshots: 100, + rawEmissions: 20, + signals: 3, + classification: { + detector: "XMARKET_DIVERGENCE", + cases: 100, + truePositive: 8, + falsePositive: 2, + trueNegative: 88, + falseNegative: 2, + precision: 0.8, + recall: 0.8, + falsePositiveRate: 2 / 90 + } + }], 5, "XMARKET_DIVERGENCE", 0.005); + + expect(ranked).toMatchObject({ + rawEmissions: 20, + predictedPositive: 3, + meetsMinimumSignals: false + }); + }); +}); diff --git a/test/main-total-selector.test.ts b/test/main-total-selector.test.ts index fe893aa..1bd1b3f 100644 --- a/test/main-total-selector.test.ts +++ b/test/main-total-selector.test.ts @@ -21,8 +21,11 @@ function row( lineMilli, mappingStatus: "candidate", txlineMarketObserved: true, + selectorCutoffTsMs: 10_000, preKickoffOverProbability: probability(price), preKickoffPointTsMs: 10_000, + coverageFirstPointTsMs: 1_000, + coverageLastPointTsMs: 10_000, volume, liquidity, coveragePoints @@ -75,4 +78,39 @@ describe("dynamic main total selector", () => { "insufficient_liquidity" ]); }); + + it("fails closed when probability or coverage crosses the selector cutoff", () => { + const probabilityFromFuture = { + ...row("future-price", 2_500, 0.5, 0, 0, 10), + preKickoffPointTsMs: 10_001 + }; + const coverageFromFuture = { + ...row("future-coverage", 3_500, 0.5, 0, 0, 10), + coverageLastPointTsMs: 10_001 + }; + const selected = selectMainTotalLine( + "fixture-1", + [probabilityFromFuture, coverageFromFuture], + baseConfig, + 10_000 + ); + expect(selected.status).toBe("no_eligible_line"); + expect(selected.excluded).toEqual([ + { marketId: "future-price", reasons: ["probability_after_selector_cutoff"] }, + { marketId: "future-coverage", reasons: ["coverage_after_selector_cutoff"] } + ]); + }); + + it("fails closed when the selector cutoff is later than detector evaluation start", () => { + const selected = selectMainTotalLine( + "fixture-1", + [row("late-selector", 2_500, 0.5, 0, 0, 10)], + baseConfig, + 9_999 + ); + expect(selected.status).toBe("no_eligible_line"); + expect(selected.excluded[0]?.reasons).toEqual([ + "selector_cutoff_after_evaluation_start" + ]); + }); }); diff --git a/test/mapping-polymarket.test.ts b/test/mapping-polymarket.test.ts index 0e3a038..632618a 100644 --- a/test/mapping-polymarket.test.ts +++ b/test/mapping-polymarket.test.ts @@ -119,4 +119,29 @@ describe("evidence-bearing mappings and Polymarket normalization", () => { expect(book.outcome).toBe("under"); expect(book.bids[0]).toEqual({ price: 0.37, size: "10" }); }); + + it("normalizes public market resolution by winning asset without inferring from price", () => { + const registry = new MappingRegistry([mapping()]); + const [resolution] = normalizePolymarketPayload( + { + market: "0xcondition", + assets_ids: ["over-token", "under-token"], + winning_asset_id: "under-token", + winning_outcome: "Under", + timestamp: "1783717968900", + event_type: "market_resolved" + }, + 1783717968929, + registry + ); + expect(resolution).toMatchObject({ + kind: "polymarket.resolution", + fixtureId: "18218149", + conditionId: "0xcondition", + winningAssetId: "under-token", + winningOutcomeLabel: "Under", + sourceTsMs: 1783717968900, + observedTsMs: 1783717968929 + }); + }); }); diff --git a/test/paired-live-study.test.ts b/test/paired-live-study.test.ts new file mode 100644 index 0000000..9e63dfc --- /dev/null +++ b/test/paired-live-study.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + MillisecondLatencyHistogram, + summarizeFeedOutages +} from "../src/research/paired-live-study.js"; + +describe("paired live-lane metrics", () => { + it("summarizes bounded latency distributions without retaining observations", () => { + const histogram = new MillisecondLatencyHistogram(-10, 10); + for (const value of [-20, -5, 0, 5, 20]) histogram.add(value); + expect(histogram.summary()).toEqual({ + count: 5, + minimumMs: -20, + maximumMs: 20, + meanMs: 0, + p50Ms: 0, + p90Ms: 20, + p99Ms: 20, + histogramResolutionMs: 1, + belowHistogramRange: 1, + aboveHistogramRange: 1 + }); + }); + + it("pairs abnormal disconnects with successful resubscriptions and ignores the clean deadline close", () => { + const summary = summarizeFeedOutages([ + { at: "2026-07-10T20:00:00.000Z", action: "disconnect", connectionIndex: 1, code: 1006 }, + { at: "2026-07-10T20:00:02.500Z", action: "open-and-resubscribe", connectionIndex: 2 }, + { at: "2026-07-11T00:00:00.000Z", action: "disconnect", connectionIndex: 2, code: 1000, clean: true } + ]); + expect(summary).toEqual({ + outages: [{ + connectionIndex: 1, + startedAt: "2026-07-10T20:00:00.000Z", + endedAt: "2026-07-10T20:00:02.500Z", + durationMs: 2_500, + closeCode: 1006 + }], + totalDowntimeMs: 2_500, + maximumDowntimeMs: 2_500, + unresolvedOutages: 0 + }); + }); +}); diff --git a/test/paper-close-scheduler.test.ts b/test/paper-close-scheduler.test.ts new file mode 100644 index 0000000..b86369e --- /dev/null +++ b/test/paper-close-scheduler.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import type { PolymarketBookEvent } from "../src/bus/events.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import { microUsd } from "../src/domain/money.js"; +import { probability } from "../src/domain/probability.js"; +import type { PaperFill } from "../src/exec/paper.js"; +import { PaperCloseScheduler } from "../src/portfolio/paper-close-scheduler.js"; +import { PaperPortfolio } from "../src/portfolio/paper.js"; +import { DecisionLedger } from "../src/store/decision-ledger.js"; + +const market = { + family: "total_goals" as const, + period: "full_time" as const, + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" +}; + +function signal(): DetectorSignal { + return { + signalId: "signal-1", + kind: "CONSENSUS_MOVE", + detectedAtTsMs: 1_000, + observedAtTsMs: 1_000, + fixtureId: "fixture-1", + market, + outcome: "over", + direction: "buy", + eligibility: "research_only", + reason: "test", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.52, + consensusVelocity: 0.01, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.03, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "candidate", + scoreContextActions: [] + } + }; +} + +function fill(): PaperFill { + return { + adapter: "paper", + status: "filled", + reason: null, + assetId: "asset-over", + conditionId: "condition-1", + direction: "buy", + requestedStakeMicroUsd: microUsd(3_000_000), + grossMicroUsd: microUsd(2_990_000), + feeMicroUsd: microUsd(10_000), + netConsiderationMicroUsd: microUsd(3_000_000), + filledShares: 5.5, + averagePrice: 2.99 / 5.5, + bestPrice: 0.54, + halfSpreadBps: 100, + executableDepthUsd: 54, + slippageProbabilityBps: 36.36, + bookObservedTsMs: 1_500, + feeParameters: { + source: "polymarket_clob_market_info", + conditionId: "condition-1", + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: 1_400 + } + }; +} + +function book(sourceTsMs: number, observedTsMs: number, bid: number, ask: number): PolymarketBookEvent { + return { + schemaVersion: 1, + kind: "polymarket.book", + eventId: `book-${sourceTsMs}-${observedTsMs}`, + source: "polymarket", + sourceTsMs, + observedTsMs, + fixtureId: "fixture-1", + market, + mappingStatus: "candidate", + conditionId: "condition-1", + assetId: "asset-over", + outcome: "over", + tokenRole: "canonical", + bids: [{ price: probability(bid), size: "100" }], + asks: [{ price: probability(ask), size: "100" }], + lastTradePrice: probability((bid + ask) / 2), + tickSize: "0.01" + }; +} + +function setup() { + const ledger = new DecisionLedger(":memory:"); + const portfolio = new PaperPortfolio({ + lane: "long_run", + bankrollMicroUsd: microUsd(50_000_000), + drawdownStopMicroUsd: microUsd(20_000_000), + ledger + }); + portfolio.open({ caseId: "case-1", signal: signal(), fill: fill(), openedAtTsMs: 1_500 }); + const scheduler = new PaperCloseScheduler({ + portfolio, + kickoffByFixtureId: new Map([["fixture-1", 5_000]]) + }); + return { ledger, portfolio, scheduler }; +} + +describe("paper close scheduler", () => { + it("marks from the latest canonical source book at or before kickoff", () => { + const { ledger, portfolio, scheduler } = setup(); + expect(scheduler.ingest(book(4_000, 4_010, 0.55, 0.57))).toEqual([]); + expect(scheduler.ingest(book(4_900, 4_920, 0.57, 0.59))).toEqual([]); + const [result] = scheduler.ingest(book(5_100, 5_120, 0.6, 0.62)); + expect(result?.mark).toMatchObject({ + cutoffTsMs: 5_000, + markedAtTsMs: 5_120, + bookSourceTsMs: 4_900, + bookObservedTsMs: 4_920, + closeMidpoint: 0.58 + }); + expect(portfolio.positions()[0]?.status).toBe("marked"); + ledger.close(); + }); + + it("does not let a regressing capture-order book replace newer close evidence", () => { + const { ledger, scheduler } = setup(); + scheduler.ingest(book(4_900, 4_910, 0.57, 0.59)); + scheduler.ingest(book(4_800, 4_950, 0.1, 0.2)); + const [result] = scheduler.ingest(book(5_100, 5_110, 0.6, 0.62)); + expect(result?.mark.bookSourceTsMs).toBe(4_900); + expect(result?.mark.closeMidpoint).toBeCloseTo(0.58); + ledger.close(); + }); + + it("fails closed when no pre-kickoff book was captured", () => { + const { ledger, portfolio, scheduler } = setup(); + expect(scheduler.ingest(book(5_100, 5_110, 0.6, 0.62))).toEqual([]); + expect(portfolio.positions()[0]?.status).toBe("open"); + ledger.close(); + }); +}); diff --git a/test/paper-fixture-universe.test.ts b/test/paper-fixture-universe.test.ts new file mode 100644 index 0000000..8fe4b8b --- /dev/null +++ b/test/paper-fixture-universe.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { + PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS, + PAPER_STUDY_TOTAL_SELECTOR_CONFIG +} from "../src/config/paper-study.js"; +import { buildPaperFixtureUniverse } from "../src/harness/paper-fixture-universe.js"; +import { sha256, type MappingRecord } from "../src/mapping/registry.js"; +import type { TotalLineEvidence } from "../src/research/main-total-selector.js"; + +function mapping(fixtureId: string, slug: string, kickoffTsMs: number): MappingRecord { + const rulesText = "Full-time total, regulation plus stoppage time only."; + return { + mappingId: `${fixtureId}:totals`, + status: "candidate", + txlineFixtureId: fixtureId, + teams: { + home: { canonical: `Home ${fixtureId}`, aliases: [] }, + away: { canonical: `Away ${fixtureId}`, aliases: [] } + }, + kickoff: { txlineTsMs: kickoffTsMs, polymarketTsMs: kickoffTsMs }, + polymarketEventId: `event-${fixtureId}`, + polymarketEventSlug: slug, + conditions: [{ + polymarketMarketId: `market-${fixtureId}`, + conditionId: `condition-${fixtureId}`, + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + rulesText, + rulesSha256: sha256(rulesText), + tokens: [ + { assetId: `over-${fixtureId}`, outcome: "over", role: "canonical" }, + { assetId: `under-${fixtureId}`, outcome: "under", role: "canonical" } + ] + }] + }; +} + +function evidence(fixtureId: string, kickoffTsMs: number): TotalLineEvidence { + const selectorCutoffTsMs = kickoffTsMs - PAPER_STUDY_REPLAY_WINDOW_BEFORE_KICKOFF_MS; + return { + fixtureId, + marketId: `market-${fixtureId}`, + marketKey: `${fixtureId}:total_goals:full_time:2500`, + lineMilli: 2_500, + mappingStatus: "candidate", + txlineMarketObserved: true, + selectorCutoffTsMs, + preKickoffOverProbability: 0.52 as never, + preKickoffPointTsMs: selectorCutoffTsMs, + coverageFirstPointTsMs: selectorCutoffTsMs - 1_000, + coverageLastPointTsMs: selectorCutoffTsMs, + volume: 0, + liquidity: 0, + coveragePoints: 2_000 + }; +} + +describe("paper fixture evidence universe", () => { + it("separates executable paired capture from sampled-history-only fixtures", () => { + const paired = mapping("paired", "paired-slug", 2_000_000); + const sampled = mapping("sampled", "sampled-slug", 3_000_000); + const universe = buildPaperFixtureUniverse({ + generatedAt: "2026-07-12T00:00:00.000Z", + laneStartTsMs: 4_000_000, + mappings: [paired, sampled], + totalEvidence: [evidence("paired", 2_000_000), evidence("sampled", 3_000_000)], + pairedCaptures: [{ + runId: "paired-run", + status: "verified", + fixtureId: "paired", + eventSlug: "paired-slug", + logComplete: true, + mappingConfirmed: true, + identityParity: true, + replayMode: "capture-order-per-source", + rows: 100, + firstObservedTsMs: 1_000 + }], + sampledHistoryAssetIds: new Set(["over-paired", "under-paired", "over-sampled", "under-sampled"]), + selectorConfig: PAPER_STUDY_TOTAL_SELECTOR_CONFIG + }); + expect(universe.summary).toEqual({ + fixtures: 2, + pairedBookReplays: 1, + executableBookReplays: 1, + bookLifecycleReplays: 0, + signalResearchOnly: 1, + unavailable: 0, + longRunEligible: 0 + }); + expect(universe.fixtures.find((fixture) => fixture.fixtureId === "paired")).toMatchObject({ + evidenceGrade: "paired_order_books", + bountyLane: { mode: "executable_book_replay", exploratory: true }, + capabilities: { executablePaperReplay: true, kickoffCloseReplay: true } + }); + expect(universe.fixtures.find((fixture) => fixture.fixtureId === "sampled")).toMatchObject({ + evidenceGrade: "sampled_price_history", + bountyLane: { mode: "signal_research_only", exploratory: true }, + capabilities: { executablePaperReplay: false, signalResearchReplay: true } + }); + expect(universe.fixtures.every((fixture) => !fixture.longRunLane.eligible)).toBe(true); + }); + + it("fails closed on a capture slug that does not belong to the fixture", () => { + expect(() => buildPaperFixtureUniverse({ + generatedAt: "2026-07-12T00:00:00.000Z", + laneStartTsMs: 4_000_000, + mappings: [mapping("fixture", "expected-slug", 2_000_000)], + totalEvidence: [evidence("fixture", 2_000_000)], + pairedCaptures: [{ + runId: "bad-run", + status: "verified", + fixtureId: "fixture", + eventSlug: "wrong-slug", + logComplete: true, + mappingConfirmed: true, + identityParity: true, + replayMode: "capture-order-per-source", + rows: 100, + firstObservedTsMs: 1_000 + }], + sampledHistoryAssetIds: new Set(["over-fixture", "under-fixture"]), + selectorConfig: PAPER_STUDY_TOTAL_SELECTOR_CONFIG + })).toThrow(/slug/); + }); + + it("fails closed when frozen total evidence is later than detector evaluation start", () => { + const kickoffTsMs = 20_000_000; + const lateEvidence = evidence("late", kickoffTsMs); + lateEvidence.selectorCutoffTsMs += 1; + lateEvidence.preKickoffPointTsMs = lateEvidence.selectorCutoffTsMs; + lateEvidence.coverageLastPointTsMs = lateEvidence.selectorCutoffTsMs; + expect(() => buildPaperFixtureUniverse({ + generatedAt: "2026-07-12T00:00:00.000Z", + laneStartTsMs: 1_000, + mappings: [mapping("late", "late-slug", kickoffTsMs)], + totalEvidence: [lateEvidence], + pairedCaptures: [], + sampledHistoryAssetIds: new Set(["over-late", "under-late"]), + selectorConfig: PAPER_STUDY_TOTAL_SELECTOR_CONFIG + })).toThrow(/no eligible frozen total selection/); + }); + + it("requires post-start timing, verified mapping, and pre-match paired books for long-run admission", () => { + const record = mapping("future", "future-slug", 2_000_000); + record.status = "verified"; + const universe = buildPaperFixtureUniverse({ + generatedAt: "2026-07-12T00:00:00.000Z", + laneStartTsMs: 1_000_000, + mappings: [record], + totalEvidence: [evidence("future", 2_000_000)], + pairedCaptures: [{ + runId: "future-run", + status: "verified", + fixtureId: "future", + eventSlug: "future-slug", + logComplete: true, + mappingConfirmed: true, + identityParity: true, + replayMode: "capture-order-per-source", + rows: 100, + firstObservedTsMs: 1_000_000 + }], + sampledHistoryAssetIds: new Set(), + selectorConfig: PAPER_STUDY_TOTAL_SELECTOR_CONFIG + }); + expect(universe.fixtures[0]?.longRunLane).toEqual({ eligible: true, reason: null }); + expect(universe.summary.longRunEligible).toBe(1); + }); +}); diff --git a/test/paper-lane-runtime.test.ts b/test/paper-lane-runtime.test.ts new file mode 100644 index 0000000..63f848a --- /dev/null +++ b/test/paper-lane-runtime.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import type { PaperFixtureUniverse } from "../src/harness/paper-fixture-universe.js"; +import { + applyPaperFixtureAdmission, + createPersistentPaperLaneRuntime, + planPaperFixtureAdmission +} from "../src/harness/paper-lane-runtime.js"; +import { initializePaperStudyLedger } from "../src/harness/paper-study-ledger.js"; + +function fixture(input: { + fixtureId: string; + kickoffTsMs: number; + bountyMode: "executable_book_replay" | "book_lifecycle_replay" | "signal_research_only"; + longRunEligible?: boolean; +}): PaperFixtureUniverse["fixtures"][number] { + return { + fixtureId: input.fixtureId, + home: "Home", + away: "Away", + kickoffTsMs: input.kickoffTsMs, + eventSlugs: [`event-${input.fixtureId}`], + mappingStatus: "verified", + selectedTotal: { + marketId: `market-${input.fixtureId}`, + marketKey: `${input.fixtureId}:total_goals:full_time:2500`, + conditionId: `condition-${input.fixtureId}`, + lineMilli: 2_500, + preKickoffOverProbability: 0.5, + preKickoffPointTsMs: input.kickoffTsMs - 600_000, + coveragePoints: 2_000, + assetIds: [`over-${input.fixtureId}`, `under-${input.fixtureId}`] + }, + evidenceGrade: input.bountyMode === "signal_research_only" ? "sampled_price_history" : "paired_order_books", + capabilities: { + signalResearchReplay: true, + executablePaperReplay: input.bountyMode === "executable_book_replay", + kickoffCloseReplay: input.bountyMode !== "signal_research_only", + publicResolutionReplay: input.bountyMode !== "signal_research_only" + }, + bountyLane: { + mode: input.bountyMode, + exploratory: true, + reason: "test" + }, + longRunLane: { + eligible: input.longRunEligible ?? false, + reason: input.longRunEligible ? null : "predates_long_run_lane_start" + }, + pairedCapture: null + }; +} + +function universe(fixtures: PaperFixtureUniverse["fixtures"]): PaperFixtureUniverse { + return { + generatedAt: "2026-07-12T00:00:00.000Z", + laneStartTsMs: 10_000, + selectorConfig: { + minimumCoveragePoints: 1_000, + minimumVolume: 0, + minimumLiquidity: 0, + maximumDistanceFromEven: 0.15, + weights: { balance: 1, volume: 0, liquidity: 0, coverage: 0 } + }, + fixtures, + summary: { + fixtures: fixtures.length, + pairedBookReplays: fixtures.filter((item) => item.evidenceGrade === "paired_order_books").length, + executableBookReplays: fixtures.filter((item) => item.bountyLane.mode === "executable_book_replay").length, + bookLifecycleReplays: fixtures.filter((item) => item.bountyLane.mode === "book_lifecycle_replay").length, + signalResearchOnly: fixtures.filter((item) => item.bountyLane.mode === "signal_research_only").length, + unavailable: 0, + longRunEligible: 0 + } + }; +} + +describe("persistent paper lane runtime", () => { + it("physically excludes sampled-history fixtures from the bounty executor", () => { + const handle = initializePaperStudyLedger({ path: ":memory:", lane: "bounty", startedAtTsMs: 10_000 }); + const runtime = createPersistentPaperLaneRuntime({ + lane: "bounty", + initialization: handle.initialization, + universe: universe([ + fixture({ fixtureId: "paired", kickoffTsMs: 20_000, bountyMode: "executable_book_replay" }), + fixture({ fixtureId: "sampled", kickoffTsMs: 30_000, bountyMode: "signal_research_only" }) + ]), + ledger: handle.ledger, + triageAgent: { triage: async () => ({ decision: "drop", priority: "normal", rationale: "test" }) }, + analystAgent: { investigate: async () => { throw new Error("unused"); } }, + feeResolver: async () => { throw new Error("unused"); }, + executionLatencyMs: 500, + maximumPendingMs: 5_000 + }); + expect(runtime.fixtures.map((item) => item.fixtureId)).toEqual(["paired"]); + expect(runtime.scheduler.dependencies.config.eligibleMarketKeys).toEqual( + new Set(["paired:total_goals:full_time:2500"]) + ); + handle.ledger.close(); + }); + + it("starts a long-run runtime empty when no post-start fixture is eligible", () => { + const handle = initializePaperStudyLedger({ path: ":memory:", lane: "long_run", startedAtTsMs: 10_000 }); + const runtime = createPersistentPaperLaneRuntime({ + lane: "long_run", + initialization: handle.initialization, + universe: universe([fixture({ fixtureId: "past", kickoffTsMs: 9_000, bountyMode: "executable_book_replay" })]), + ledger: handle.ledger, + triageAgent: { triage: async () => ({ decision: "drop", priority: "normal", rationale: "test" }) }, + analystAgent: { investigate: async () => { throw new Error("unused"); } }, + feeResolver: async () => { throw new Error("unused"); }, + executionLatencyMs: 500, + maximumPendingMs: 5_000 + }); + expect(runtime.fixtures).toEqual([]); + expect(runtime.scheduler.dependencies.config.eligibleMarketKeys.size).toBe(0); + handle.ledger.close(); + }); + + it("admits a newly verified executable fixture without changing prior identities", () => { + const handle = initializePaperStudyLedger({ path: ":memory:", lane: "long_run", startedAtTsMs: 10_000 }); + const runtime = createPersistentPaperLaneRuntime({ + lane: "long_run", + initialization: handle.initialization, + universe: universe([]), + ledger: handle.ledger, + triageAgent: { triage: async () => ({ decision: "drop", priority: "normal", rationale: "test" }) }, + analystAgent: { investigate: async () => { throw new Error("unused"); } }, + feeResolver: async () => { throw new Error("unused"); }, + executionLatencyMs: 500, + maximumPendingMs: 5_000 + }); + const admitted = fixture({ + fixtureId: "future", + kickoffTsMs: 20_000, + bountyMode: "executable_book_replay", + longRunEligible: true + }); + const refreshed = universe([admitted]); + const plan = planPaperFixtureAdmission(runtime, refreshed); + expect(applyPaperFixtureAdmission(runtime, plan)).toEqual(["future"]); + expect(runtime.eligibleMarketKeys).toEqual(new Set(["future:total_goals:full_time:2500"])); + expect(runtime.kickoffByFixtureId).toEqual(new Map([["future", 20_000]])); + expect(planPaperFixtureAdmission(runtime, refreshed).fixtures).toEqual([]); + + const changed = structuredClone(refreshed); + changed.fixtures[0]!.selectedTotal.conditionId = "changed-condition"; + expect(() => planPaperFixtureAdmission(runtime, changed)).toThrow(/changed admitted fixture/); + handle.ledger.close(); + }); + + it("rehydrates committed seen cases before admitting any repeated model work", async () => { + const handle = initializePaperStudyLedger({ path: ":memory:", lane: "bounty", startedAtTsMs: 10_000 }); + const admitted = fixture({ + fixtureId: "restart", + kickoffTsMs: 20_000_000, + bountyMode: "executable_book_replay" + }); + const fixtureUniverse = universe([admitted]); + const candidate: DetectorSignal = { + signalId: "lane-restart-signal", + kind: "CONSENSUS_MOVE", + detectedAtTsMs: 11_000, + observedAtTsMs: 11_000, + fixtureId: "restart", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: admitted.selectedTotal.marketKey + }, + outcome: "over", + direction: "buy", + eligibility: "pretrade_review_required", + reason: "restart test", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.52, + consensusVelocity: 0.01, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.03, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "verified", + scoreContextActions: [] + } + }; + const first = createPersistentPaperLaneRuntime({ + lane: "bounty", + initialization: handle.initialization, + universe: fixtureUniverse, + ledger: handle.ledger, + triageAgent: { triage: async () => ({ decision: "drop", priority: "normal", rationale: "test drop" }) }, + analystAgent: { investigate: async () => { throw new Error("unused"); } }, + feeResolver: async () => { throw new Error("unused"); }, + executionLatencyMs: 500, + maximumPendingMs: 5_000 + }); + expect(await first.scheduler.enqueue(candidate)).toBe(true); + let triageCalls = 0; + const restarted = createPersistentPaperLaneRuntime({ + lane: "bounty", + initialization: handle.initialization, + universe: fixtureUniverse, + ledger: handle.ledger, + triageAgent: { + triage: async () => { + triageCalls += 1; + return { decision: "drop", priority: "normal", rationale: "should not run" }; + } + }, + analystAgent: { investigate: async () => { throw new Error("unused"); } }, + feeResolver: async () => { throw new Error("unused"); }, + executionLatencyMs: 500, + maximumPendingMs: 5_000 + }); + expect(restarted.rehydratedState.seenSignalIds).toEqual([candidate.signalId]); + expect(restarted.scheduler.seenCount()).toBe(1); + expect(await restarted.scheduler.enqueue(candidate)).toBe(false); + expect(triageCalls).toBe(0); + handle.ledger.close(); + }); +}); diff --git a/test/paper-pipeline.test.ts b/test/paper-pipeline.test.ts new file mode 100644 index 0000000..43e995a --- /dev/null +++ b/test/paper-pipeline.test.ts @@ -0,0 +1,691 @@ +import { describe, expect, it } from "vitest"; +import { tradeThesisSchema, type TradeThesis } from "../src/agents/contracts.js"; +import type { PolymarketBookEvent } from "../src/bus/events.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import { probability } from "../src/domain/probability.js"; +import { + OrderBookPaperExecutor, + type PaperExecutor, + type PolymarketFeeParameters +} from "../src/exec/paper.js"; +import { PaperCasePipeline } from "../src/harness/paper-pipeline.js"; +import { PaperStudyRuntime } from "../src/harness/paper-runtime.js"; +import { PaperCaseScheduler } from "../src/harness/paper-scheduler.js"; +import { + APPROVED_PAPER_RISK_CONFIG, + paperRiskState, + reviewPaperRisk +} from "../src/risk/paper.js"; +import { DecisionLedger } from "../src/store/decision-ledger.js"; + +const detectedAtTsMs = 10_000; + +function signal(overrides: Partial = {}): DetectorSignal { + return { + signalId: "signal-1", + kind: "CONSENSUS_MOVE", + detectedAtTsMs, + observedAtTsMs: detectedAtTsMs, + fixtureId: "fixture-1", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }, + outcome: "over", + direction: "buy", + eligibility: "pretrade_review_required", + reason: "locked paper candidate", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.515, + consensusVelocity: 0.02, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.03, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "candidate", + scoreContextActions: [] + }, + ...overrides + }; +} + +function book(overrides: Partial = {}): PolymarketBookEvent { + return { + schemaVersion: 1, + kind: "polymarket.book", + eventId: "book-1", + source: "polymarket", + sourceTsMs: 11_500, + observedTsMs: 11_500, + fixtureId: "fixture-1", + market: signal().market, + mappingStatus: "candidate", + conditionId: "condition-1", + assetId: "asset-over", + outcome: "over", + tokenRole: "canonical", + bids: [{ price: probability(0.51), size: "20" }], + asks: [ + { price: probability(0.52), size: "2" }, + { price: probability(0.53), size: "20" } + ], + lastTradePrice: probability(0.52), + tickSize: "0.01", + ...overrides + }; +} + +const fees: PolymarketFeeParameters = { + source: "polymarket_clob_market_info", + conditionId: "condition-1", + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: 10_400 +}; + +function thesis(overrides: Partial = {}): TradeThesis { + return { + schemaVersion: 1, + signalId: "signal-1", + fixtureId: "fixture-1", + marketKey: "fixture-1:total_goals:full_time:2500", + outcome: "over", + direction: "buy", + recommendation: "paper_trade", + fairProbability: 0.55, + thesisSummary: "Consensus moved while the executable total remained behind.", + evidenceFor: ["Live ask remains below locked fair-value boundary."], + steelmanAgainst: "The consensus move may reverse before kickoff.", + invalidationConditions: ["Live ask reaches the deterministic limit."], + submittedAtTsMs: 10_500, + expiresAtTsMs: 20_000, + analystModel: "test-analyst", + ...overrides + }; +} + +function timingHarness(executionLatencyMs = 500): { + ledger: DecisionLedger; + scheduler: PaperCaseScheduler; +} { + const ledger = new DecisionLedger(":memory:"); + const pipeline = new PaperCasePipeline({ + triageAgent: { + triage: async () => ({ + decision: "escalate", + priority: "normal", + rationale: "Eligible timing test signal." + }) + }, + analystAgent: { + investigate: async ({ signal: candidate }) => thesis({ + signalId: candidate.signalId, + fixtureId: candidate.fixtureId, + marketKey: candidate.market.key, + outcome: candidate.outcome, + direction: candidate.direction + }) + }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: "bounty", + executionLatencyMs, + maximumPendingMs: 10_000, + minimumSignalToKickoffMs: 900_000, + eligibleMarketKeys: new Set([signal().market.key]), + kickoffByFixtureId: new Map([["fixture-1", 2_000_000]]) + }, + pipeline, + feeResolver: async (executionBook, asOfTsMs) => ({ + ...fees, + conditionId: executionBook.conditionId, + fetchedAtTsMs: asOfTsMs + }) + }); + return { ledger, scheduler }; +} + +describe("paper decision pipeline", () => { + it("rejects thesis fields that could let an analyst size or construct an order", () => { + expect(() => tradeThesisSchema.parse({ + ...thesis(), + stakeMicroUsd: 50_000_000 + })).toThrow(); + }); + + it("walks real book depth and applies the documented per-market taker fee", async () => { + const executor = new OrderBookPaperExecutor(); + const fill = await executor.execute({ + lane: "bounty", + caseId: "case-1", + signalId: "signal-1", + fixtureId: "fixture-1", + marketKey: signal().market.key, + outcome: "over", + direction: "buy", + stakeMicroUsd: APPROVED_PAPER_RISK_CONFIG.perTradeStakeMicroUsd, + limitProbability: probability(0.54), + availableShares: 0 + }, book(), fees); + expect(fill.status).toBe("filled"); + expect(fill.filledShares).toBeGreaterThan(5); + expect(fill.averagePrice).toBeGreaterThan(0.52); + expect(fill.feeMicroUsd).toBeGreaterThan(0); + expect(fill.feeMicroUsd % 10).toBe(0); + expect(fill.netConsiderationMicroUsd).toBeLessThanOrEqual(3_000_000); + expect(fill.slippageProbabilityBps).toBeGreaterThan(0); + }); + + it("makes the paper adapter fail closed on malformed fee metadata", async () => { + const executor = new OrderBookPaperExecutor(); + await expect(executor.execute({ + lane: "bounty", + caseId: "case-1", + signalId: "signal-1", + fixtureId: "fixture-1", + marketKey: signal().market.key, + outcome: "over", + direction: "buy", + stakeMicroUsd: APPROVED_PAPER_RISK_CONFIG.perTradeStakeMicroUsd, + limitProbability: probability(0.54), + availableShares: 0 + }, book(), { ...fees, feesEnabled: false })).rejects.toThrow(/non-zero taker rate/); + }); + + it("records no fill when the fixed stake cannot satisfy the market minimum shares", async () => { + const executor = new OrderBookPaperExecutor(); + const fillResult = await executor.execute({ + lane: "bounty", + caseId: "case-minimum", + signalId: "signal-minimum", + fixtureId: "fixture-1", + marketKey: signal().market.key, + outcome: "over", + direction: "buy", + stakeMicroUsd: APPROVED_PAPER_RISK_CONFIG.perTradeStakeMicroUsd, + limitProbability: probability(0.8), + availableShares: 0 + }, book({ + bids: [{ price: probability(0.69), size: "100" }], + asks: [{ price: probability(0.7), size: "100" }], + lastTradePrice: probability(0.7) + }), fees); + expect(fillResult).toMatchObject({ + status: "no_fill", + reason: "below_minimum_order_size", + filledShares: 0 + }); + }); + + it("vetoes the lane at the locked drawdown stop", () => { + const verdict = reviewPaperRisk({ + config: APPROVED_PAPER_RISK_CONFIG, + state: paperRiskState({ currentDrawdownMicroUsd: 20_000_000 }), + signal: signal(), + thesis: thesis(), + book: book(), + fees, + asOfTsMs: 11_600, + executionLatencyMs: 500 + }); + expect(verdict).toEqual({ decision: "veto", reasons: ["drawdown_stop_reached"] }); + }); + + it("vetoes research-only signals even if they bypass scheduler fixture filtering", () => { + const verdict = reviewPaperRisk({ + config: APPROVED_PAPER_RISK_CONFIG, + state: paperRiskState(), + signal: signal({ eligibility: "research_only" }), + thesis: thesis(), + book: book(), + fees, + asOfTsMs: 11_600, + executionLatencyMs: 500 + }); + expect(verdict).toMatchObject({ decision: "veto" }); + if (verdict.decision === "veto") expect(verdict.reasons).toContain("signal_research_only"); + }); + + it("validates replay fee freshness on processing time without rewriting event time", () => { + const verdict = reviewPaperRisk({ + config: APPROVED_PAPER_RISK_CONFIG, + state: paperRiskState(), + signal: signal(), + thesis: thesis(), + book: book(), + fees: { ...fees, fetchedAtTsMs: 1_000_000 }, + asOfTsMs: 11_600, + feeValidationTsMs: 1_000_100, + executionLatencyMs: 500 + }); + expect(verdict).toMatchObject({ decision: "approve" }); + }); + + it("ledgers every decision and the execution intent before a paper fill", async () => { + const ledger = new DecisionLedger(":memory:"); + const delegate = new OrderBookPaperExecutor(); + const executor: PaperExecutor = { + execute: async (intent, executionBook, executionFees) => { + expect(ledger.entries().map((entry) => entry.kind)).toEqual([ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "execution_intent" + ]); + return delegate.execute(intent, executionBook, executionFees); + } + }; + const pipeline = new PaperCasePipeline({ + triageAgent: { + triage: async () => ({ + decision: "escalate", + priority: "normal", + rationale: "Locked candidate with a live executable gap." + }) + }, + analystAgent: { investigate: async () => thesis() }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor, + ledger + }); + const result = await pipeline.run({ + lane: "bounty", + signal: signal(), + book: book(), + fees, + riskState: paperRiskState(), + asOfTsMs: 11_600, + executionLatencyMs: 500 + }); + expect(result.status).toBe("filled"); + expect(ledger.entries().map((entry) => entry.kind)).toEqual([ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "risk_verdict", + "execution_intent", + "paper_execution", + "case_terminal" + ]); + expect(ledger.verifyChain()).toMatchObject({ valid: true, rows: 8 }); + ledger.close(); + }); + + it("records at detection and waits for the first post-latency canonical book", async () => { + const ledger = new DecisionLedger(":memory:"); + let wallNowMs = 1_000; + const pipeline = new PaperCasePipeline({ + triageAgent: { + triage: async () => { + wallNowMs += 100; + return { + decision: "escalate", + priority: "normal", + rationale: "Eligible locked paper signal." + }; + } + }, + analystAgent: { + investigate: async () => { + wallNowMs += 300; + return thesis(); + } + }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger, + now: () => wallNowMs + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: "bounty", + executionLatencyMs: 1, + maximumPendingMs: 5_000, + minimumSignalToKickoffMs: 900_000, + eligibleMarketKeys: new Set([signal().market.key]), + kickoffByFixtureId: new Map([["fixture-1", 1_000_000]]) + }, + pipeline, + feeResolver: async (executionBook, asOfTsMs) => ({ + ...fees, + conditionId: executionBook.conditionId, + fetchedAtTsMs: asOfTsMs + }) + }); + expect(await scheduler.enqueue(signal())).toBe(true); + expect(await scheduler.enqueue(signal({ + signalId: "late-signal", + detectedAtTsMs: 100_001, + observedAtTsMs: 100_001 + }))).toBe(false); + expect(ledger.entries().map((entry) => entry.kind)).toEqual([ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed" + ]); + + expect(await scheduler.ingest(book({ + eventId: "early-book", + sourceTsMs: 10_399, + observedTsMs: 10_399 + }))).toEqual([]); + expect(scheduler.pendingCount()).toBe(1); + + const [result] = await scheduler.ingest(book()); + expect(result?.status).toBe("filled"); + expect(scheduler.pendingCount()).toBe(0); + expect(scheduler.riskState().openExposureMicroUsd).toBeGreaterThan(0); + expect(ledger.entries().find((entry) => entry.kind === "analysis_completed")?.payload).toEqual({ + signalSourceTsMs: 10_000, + signalObservedTsMs: 10_000, + decisionLatencyMs: 400, + readyAtTsMs: 10_400, + venuePlacementDelayMs: 1_000, + orderEligibleAtTsMs: 11_400, + recommendation: "paper_trade" + }); + expect(ledger.verifyChain()).toMatchObject({ valid: true, rows: 8 }); + ledger.close(); + }); + + it("starts analysis readiness from delayed observation time, never the source timestamp", async () => { + const { ledger, scheduler } = timingHarness(); + const delayedSignal = signal({ + detectedAtTsMs: 10_000, + observedAtTsMs: 20_000 + }); + expect(await scheduler.enqueue(delayedSignal)).toBe(true); + expect(ledger.entries()[0]).toMatchObject({ + kind: "signal_received", + atTsMs: 20_000, + payload: { + signal: { detectedAtTsMs: 10_000, observedAtTsMs: 20_000 } + } + }); + + expect(await scheduler.ingest(book({ + eventId: "retroactive-source-book", + sourceTsMs: 10_500, + observedTsMs: 21_499 + }))).toEqual([]); + expect(scheduler.pendingCount()).toBe(1); + + const [result] = await scheduler.ingest(book({ + eventId: "first-knowledge-eligible-book", + sourceTsMs: 21_564, + observedTsMs: 21_500 + })); + expect(result?.status).toBe("filled"); + expect(ledger.entries().find((entry) => entry.kind === "analysis_completed")?.payload).toMatchObject({ + signalSourceTsMs: 10_000, + signalObservedTsMs: 20_000, + decisionLatencyMs: 500, + readyAtTsMs: 20_500, + venuePlacementDelayMs: 1_000, + orderEligibleAtTsMs: 21_500 + }); + ledger.close(); + }); + + it("rejects reconnect catch-up books without rejecting small venue clock lead", async () => { + const { ledger, scheduler } = timingHarness(); + const skewedSignal = signal({ + detectedAtTsMs: 10_064, + observedAtTsMs: 10_000 + }); + await scheduler.ingest(book({ + eventId: "detection-book", + sourceTsMs: 10_064, + observedTsMs: 10_000 + })); + expect(await scheduler.enqueue(skewedSignal)).toBe(true); + + expect(await scheduler.ingest(book({ + eventId: "reconnect-catch-up-book", + sourceTsMs: 9_500, + observedTsMs: 11_500 + }))).toEqual([]); + expect(scheduler.pendingCount()).toBe(1); + + const [result] = await scheduler.ingest(book({ + eventId: "fresh-post-reconnect-book", + sourceTsMs: 11_564, + observedTsMs: 11_500 + })); + expect(result?.status).toBe("filled"); + ledger.close(); + }); + + it("does not fill a favorable book observed inside the sports placement delay", async () => { + const { ledger, scheduler } = timingHarness(); + expect(await scheduler.enqueue(signal())).toBe(true); + + expect(await scheduler.ingest(book({ + eventId: "favorable-inside-delay", + sourceTsMs: 11_000, + observedTsMs: 11_000, + asks: [{ price: probability(0.52), size: "100" }] + }))).toEqual([]); + expect(scheduler.pendingCount()).toBe(1); + + const [result] = await scheduler.ingest(book({ + eventId: "unfavorable-after-delay", + sourceTsMs: 11_500, + observedTsMs: 11_500, + bids: [{ price: probability(0.53), size: "100" }], + asks: [{ price: probability(0.55), size: "100" }], + lastTradePrice: probability(0.55) + })); + expect(result?.status).toBe("vetoed"); + expect(result?.reason).toContain("live_edge_below_locked_gap"); + expect(scheduler.riskState().openExposureMicroUsd).toBe(0); + expect(ledger.entries().some((entry) => entry.kind === "paper_execution")).toBe(false); + ledger.close(); + }); + + it("fails closed on malformed or regressing observed knowledge timestamps", async () => { + const { ledger, scheduler } = timingHarness(); + expect(await scheduler.enqueue(signal({ + signalId: "invalid-observed-signal", + observedAtTsMs: Number.NaN + }))).toBe(false); + expect(ledger.entries()).toEqual([]); + + await scheduler.ingest(book({ + eventId: "clock-primer", + sourceTsMs: 10_064, + observedTsMs: 10_000 + })); + expect(await scheduler.enqueue(signal({ + signalId: "valid-after-primer", + detectedAtTsMs: 10_064, + observedAtTsMs: 10_000 + }))).toBe(true); + expect(await scheduler.ingest(book({ + eventId: "regressing-observation", + sourceTsMs: 10_500, + observedTsMs: 9_999 + }))).toEqual([]); + expect(scheduler.pendingCount()).toBe(1); + expect(ledger.entries().some((entry) => entry.kind === "paper_execution")).toBe(false); + ledger.close(); + }); + + it("persists the signal before a slow triage request resolves", async () => { + const ledger = new DecisionLedger(":memory:"); + let releaseTriage!: (value: unknown) => void; + const triageResult = new Promise((resolve) => { releaseTriage = resolve; }); + const pipeline = new PaperCasePipeline({ + triageAgent: { triage: async () => triageResult }, + analystAgent: { investigate: async () => thesis() }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: "bounty", + executionLatencyMs: 1, + maximumPendingMs: 5_000, + minimumSignalToKickoffMs: 900_000, + eligibleMarketKeys: new Set([signal().market.key]), + kickoffByFixtureId: new Map([["fixture-1", 1_000_000]]) + }, + pipeline, + feeResolver: async () => fees + }); + + const enqueue = scheduler.enqueue(signal()); + expect(ledger.entries().map((entry) => entry.kind)).toEqual(["signal_received"]); + releaseTriage({ decision: "drop", priority: "low", rationale: "Slow test drop." }); + await expect(enqueue).resolves.toBe(true); + expect(ledger.entries().map((entry) => entry.kind)).toEqual([ + "signal_received", + "triage_decision", + "case_terminal" + ]); + ledger.close(); + }); + + it("cannot convert a pre-match signal into an in-play entry when analysis finishes late", async () => { + const ledger = new DecisionLedger(":memory:"); + const pipeline = new PaperCasePipeline({ + triageAgent: { + triage: async () => ({ decision: "escalate", priority: "normal", rationale: "test" }) + }, + analystAgent: { investigate: async () => thesis() }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: "bounty", + executionLatencyMs: 1_000_000, + maximumPendingMs: 5_000, + minimumSignalToKickoffMs: 900_000, + eligibleMarketKeys: new Set([signal().market.key]), + kickoffByFixtureId: new Map([["fixture-1", 1_000_000]]) + }, + pipeline, + feeResolver: async () => fees + }); + + await expect(scheduler.enqueue(signal())).resolves.toBe(true); + expect(scheduler.pendingCount()).toBe(0); + expect(ledger.entries().at(-1)?.payload).toEqual({ + status: "failed", + reason: "order_became_eligible_at_or_after_kickoff" + }); + ledger.close(); + }); + + it("expires a recorded signal when no post-latency executable book arrives", async () => { + const ledger = new DecisionLedger(":memory:"); + const pipeline = new PaperCasePipeline({ + triageAgent: { + triage: async () => ({ decision: "escalate", priority: "normal", rationale: "test" }) + }, + analystAgent: { investigate: async () => thesis() }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: "long_run", + executionLatencyMs: 500, + maximumPendingMs: 1_000, + minimumSignalToKickoffMs: 900_000, + eligibleMarketKeys: new Set([signal().market.key]), + kickoffByFixtureId: new Map([["fixture-1", 1_000_000]]) + }, + pipeline, + feeResolver: async () => fees + }); + await scheduler.enqueue(signal()); + await scheduler.ingest(book({ + eventId: "unrelated-book", + observedTsMs: 12_500, + sourceTsMs: 12_500, + outcome: "under" + })); + expect(scheduler.pendingCount()).toBe(0); + expect(ledger.entries().map((entry) => entry.kind)).toEqual([ + "signal_received", + "triage_decision", + "thesis_submitted", + "analysis_completed", + "case_terminal" + ]); + ledger.close(); + }); + + it("routes canonical events through detector output without reprocessing a seen signal", async () => { + const ledger = new DecisionLedger(":memory:"); + const pipeline = new PaperCasePipeline({ + triageAgent: { + triage: async () => ({ decision: "escalate", priority: "normal", rationale: "Eligible signal." }) + }, + analystAgent: { investigate: async () => thesis() }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger + }); + const scheduler = new PaperCaseScheduler({ + config: { + lane: "bounty", + executionLatencyMs: 500, + maximumPendingMs: 5_000, + minimumSignalToKickoffMs: 900_000, + eligibleMarketKeys: new Set([signal().market.key]), + kickoffByFixtureId: new Map([["fixture-1", 1_000_000]]) + }, + pipeline, + feeResolver: async (executionBook, asOfTsMs) => ({ + ...fees, + conditionId: executionBook.conditionId, + fetchedAtTsMs: asOfTsMs + }) + }); + const runtime = new PaperStudyRuntime({ + featureProcessor: { ingest: () => [{} as never] }, + detectorProcessor: { ingest: () => [signal()] }, + scheduler + }); + + const detected = await runtime.ingest(book({ + eventId: "detection-book", + sourceTsMs: 10_000, + observedTsMs: 10_000 + })); + expect(detected.routedSignalIds).toEqual(["signal-1"]); + expect(detected.caseResults).toEqual([]); + + const executed = await runtime.ingest(book()); + expect(executed.caseResults[0]?.status).toBe("filled"); + expect(executed.routedSignalIds).toEqual([]); + expect(scheduler.seenCount()).toBe(1); + expect(ledger.verifyChain()).toMatchObject({ valid: true, rows: 8 }); + ledger.close(); + }); +}); diff --git a/test/paper-portfolio.test.ts b/test/paper-portfolio.test.ts new file mode 100644 index 0000000..3439f39 --- /dev/null +++ b/test/paper-portfolio.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import type { PolymarketBookEvent } from "../src/bus/events.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import { microUsd } from "../src/domain/money.js"; +import { probability } from "../src/domain/probability.js"; +import type { PaperFill } from "../src/exec/paper.js"; +import { PaperPortfolio } from "../src/portfolio/paper.js"; +import { DecisionLedger } from "../src/store/decision-ledger.js"; + +function signal(): DetectorSignal { + return { + signalId: "portfolio-signal", + kind: "CONSENSUS_MOVE", + detectedAtTsMs: 1_000, + observedAtTsMs: 1_000, + fixtureId: "fixture-1", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }, + outcome: "over", + direction: "buy", + eligibility: "research_only", + reason: "paper candidate", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.52, + consensusVelocity: 0.01, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.03, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "candidate", + scoreContextActions: [] + } + }; +} + +function fill(): PaperFill { + return { + adapter: "paper", + status: "filled", + reason: null, + assetId: "asset-over", + conditionId: "condition-1", + direction: "buy", + requestedStakeMicroUsd: microUsd(3_000_000), + grossMicroUsd: microUsd(2_930_000), + feeMicroUsd: microUsd(70_000), + netConsiderationMicroUsd: microUsd(3_000_000), + filledShares: 5.5, + averagePrice: 2.93 / 5.5, + bestPrice: 0.52, + halfSpreadBps: 100, + executableDepthUsd: 10, + slippageProbabilityBps: 127.2727, + bookObservedTsMs: 1_500, + feeParameters: { + source: "polymarket_clob_market_info", + conditionId: "condition-1", + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: 1_400 + } + }; +} + +function closingBook(): PolymarketBookEvent { + return { + schemaVersion: 1, + kind: "polymarket.book", + eventId: "closing-book", + source: "polymarket", + sourceTsMs: 5_000, + observedTsMs: 5_000, + fixtureId: "fixture-1", + market: signal().market, + mappingStatus: "candidate", + conditionId: "condition-1", + assetId: "asset-over", + outcome: "over", + tokenRole: "canonical", + bids: [{ price: probability(0.57), size: "100" }], + asks: [{ price: probability(0.59), size: "100" }], + lastTradePrice: probability(0.58), + tickSize: "0.01" + }; +} + +describe("paper portfolio evidence", () => { + it("separates midpoint CLV, executable liquidation CLV, and settlement P&L", () => { + const ledger = new DecisionLedger(":memory:"); + const portfolio = new PaperPortfolio({ + lane: "long_run", + bankrollMicroUsd: microUsd(50_000_000), + drawdownStopMicroUsd: microUsd(20_000_000), + ledger + }); + portfolio.open({ caseId: "case-1", signal: signal(), fill: fill(), openedAtTsMs: 1_500 }); + expect(portfolio.riskState().openExposureMicroUsd).toBe(3_000_000); + + const mark = portfolio.markAtClose({ + caseId: "case-1", + book: closingBook(), + cutoffTsMs: 5_000, + markedAtTsMs: 5_000 + }); + expect(mark.closeMidpoint).toBeCloseTo(0.58); + expect(mark.grossMidpointClvBps).toBeGreaterThan(mark.netMidpointClvBps); + expect(mark.netMidpointClvBps).toBeGreaterThan(mark.executableLiquidationClvBps); + + const settlement = portfolio.settle({ caseId: "case-1", won: true, settledAtTsMs: 10_000 }); + expect(settlement.payoutMicroUsd).toBe(5_500_000); + expect(settlement.pnlMicroUsd).toBe(2_500_000); + expect(portfolio.riskState().openExposureMicroUsd).toBe(0); + expect(portfolio.summary()).toMatchObject({ + positions: 1, + openPositions: 0, + settledPositions: 1, + realizedPnlMicroUsd: 2_500_000, + currentDrawdownMicroUsd: 0 + }); + expect(ledger.entries().map((entry) => entry.kind)).toEqual([ + "position_opened", + "position_closed", + "position_settled" + ]); + expect(ledger.verifyChain()).toMatchObject({ valid: true, rows: 3 }); + ledger.close(); + }); + + it("updates drawdown from settled losses and exposes the risk halt state", () => { + const ledger = new DecisionLedger(":memory:"); + const portfolio = new PaperPortfolio({ + lane: "bounty", + bankrollMicroUsd: microUsd(50_000_000), + drawdownStopMicroUsd: microUsd(3_000_000), + ledger + }); + portfolio.open({ caseId: "case-loss", signal: signal(), fill: fill(), openedAtTsMs: 1_500 }); + portfolio.markAtClose({ + caseId: "case-loss", + book: closingBook(), + cutoffTsMs: 5_000, + markedAtTsMs: 5_000 + }); + portfolio.settle({ caseId: "case-loss", won: false, settledAtTsMs: 10_000 }); + expect(portfolio.riskState()).toMatchObject({ + openExposureMicroUsd: 0, + currentDrawdownMicroUsd: 3_000_000, + halted: true + }); + ledger.close(); + }); +}); diff --git a/test/paper-settlement-scheduler.test.ts b/test/paper-settlement-scheduler.test.ts new file mode 100644 index 0000000..6005cdf --- /dev/null +++ b/test/paper-settlement-scheduler.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import type { PolymarketResolutionEvent } from "../src/bus/events.js"; +import { microUsd } from "../src/domain/money.js"; +import { PaperSettlementScheduler } from "../src/portfolio/paper-settlement-scheduler.js"; +import { PaperPortfolio } from "../src/portfolio/paper.js"; +import { DecisionLedger } from "../src/store/decision-ledger.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import type { PaperFill } from "../src/exec/paper.js"; +import type { PolymarketBookEvent } from "../src/bus/events.js"; +import { probability } from "../src/domain/probability.js"; + +const market = { + family: "total_goals" as const, + period: "full_time" as const, + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" +}; + +const signal: DetectorSignal = { + signalId: "signal-1", + kind: "CONSENSUS_MOVE", + detectedAtTsMs: 1_000, + observedAtTsMs: 1_000, + fixtureId: "fixture-1", + market, + outcome: "over", + direction: "buy", + eligibility: "research_only", + reason: "test", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.52, + consensusVelocity: 0.01, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.03, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "candidate", + scoreContextActions: [] + } +}; + +const fill: PaperFill = { + adapter: "paper", + status: "filled", + reason: null, + assetId: "asset-over", + conditionId: "condition-1", + direction: "buy", + requestedStakeMicroUsd: microUsd(3_000_000), + grossMicroUsd: microUsd(2_990_000), + feeMicroUsd: microUsd(10_000), + netConsiderationMicroUsd: microUsd(3_000_000), + filledShares: 5.5, + averagePrice: 2.99 / 5.5, + bestPrice: 0.54, + halfSpreadBps: 100, + executableDepthUsd: 54, + slippageProbabilityBps: 36.36, + bookObservedTsMs: 1_500, + feeParameters: { + source: "polymarket_clob_market_info", + conditionId: "condition-1", + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: 1_400 + } +}; + +const closeBook: PolymarketBookEvent = { + schemaVersion: 1, + kind: "polymarket.book", + eventId: "close", + source: "polymarket", + sourceTsMs: 4_900, + observedTsMs: 4_920, + fixtureId: "fixture-1", + market, + mappingStatus: "candidate", + conditionId: "condition-1", + assetId: "asset-over", + outcome: "over", + tokenRole: "canonical", + bids: [{ price: probability(0.57), size: "100" }], + asks: [{ price: probability(0.59), size: "100" }], + lastTradePrice: probability(0.58), + tickSize: "0.01" +}; + +function resolution(winningAssetId: string): PolymarketResolutionEvent { + return { + schemaVersion: 1, + kind: "polymarket.resolution", + eventId: "resolution", + source: "polymarket", + sourceTsMs: 10_000, + observedTsMs: 10_010, + fixtureId: "fixture-1", + market, + mappingStatus: "candidate", + conditionId: "condition-1", + assetIds: ["asset-over", "asset-under"], + winningAssetId, + winningOutcomeLabel: winningAssetId === "asset-over" ? "Over" : "Under" + }; +} + +describe("paper settlement scheduler", () => { + it("settles a marked position from the public winning asset ID", () => { + const ledger = new DecisionLedger(":memory:"); + const portfolio = new PaperPortfolio({ + lane: "long_run", + bankrollMicroUsd: microUsd(50_000_000), + drawdownStopMicroUsd: microUsd(20_000_000), + ledger + }); + portfolio.open({ caseId: "case-1", signal, fill, openedAtTsMs: 1_500 }); + portfolio.markAtClose({ caseId: "case-1", book: closeBook, cutoffTsMs: 5_000, markedAtTsMs: 5_000 }); + const scheduler = new PaperSettlementScheduler(portfolio); + const [result] = scheduler.ingest(resolution("asset-under")); + expect(result).toMatchObject({ caseId: "case-1", won: false }); + expect(portfolio.positions()[0]?.status).toBe("settled"); + expect(portfolio.summary().realizedPnlMicroUsd).toBe(-3_000_000); + expect(ledger.entries().map((entry) => entry.kind)).toEqual([ + "position_opened", + "position_closed", + "position_settled" + ]); + ledger.close(); + }); + + it("does not settle before the registered kickoff close exists", () => { + const ledger = new DecisionLedger(":memory:"); + const portfolio = new PaperPortfolio({ + lane: "long_run", + bankrollMicroUsd: microUsd(50_000_000), + drawdownStopMicroUsd: microUsd(20_000_000), + ledger + }); + portfolio.open({ caseId: "case-1", signal, fill, openedAtTsMs: 1_500 }); + expect(new PaperSettlementScheduler(portfolio).ingest(resolution("asset-over"))).toEqual([]); + expect(portfolio.positions()[0]?.status).toBe("open"); + ledger.close(); + }); +}); diff --git a/test/paper-state-rehydrator.test.ts b/test/paper-state-rehydrator.test.ts new file mode 100644 index 0000000..9e10ae5 --- /dev/null +++ b/test/paper-state-rehydrator.test.ts @@ -0,0 +1,405 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { afterEach, describe, expect, it } from "vitest"; +import type { TradeThesis } from "../src/agents/contracts.js"; +import type { CanonicalEvent, PolymarketBookEvent } from "../src/bus/events.js"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import { microUsd } from "../src/domain/money.js"; +import { probability } from "../src/domain/probability.js"; +import { OrderBookPaperExecutor, type PaperFill, type PolymarketFeeParameters } from "../src/exec/paper.js"; +import { PaperCasePipeline } from "../src/harness/paper-pipeline.js"; +import { + paperCaseIdForSignal, + rehydratePaperState +} from "../src/harness/paper-state-rehydrator.js"; +import { PaperCaseScheduler } from "../src/harness/paper-scheduler.js"; +import { PaperPortfolio } from "../src/portfolio/paper.js"; +import { APPROVED_PAPER_RISK_CONFIG } from "../src/risk/paper.js"; +import { DecisionLedger } from "../src/store/decision-ledger.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function signal(): DetectorSignal { + return { + signalId: "restart-signal-1", + kind: "CONSENSUS_MOVE", + detectedAtTsMs: 10_000, + observedAtTsMs: 10_000, + fixtureId: "fixture-1", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }, + outcome: "over", + direction: "buy", + eligibility: "pretrade_review_required", + reason: "restart fixture", + evidence: { + consensusProbability: 0.56, + polymarketProbability: 0.52, + consensusVelocity: 0.02, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.04, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "verified", + scoreContextActions: [] + } + }; +} + +function thesis(candidate: DetectorSignal): TradeThesis { + return { + schemaVersion: 1, + signalId: candidate.signalId, + fixtureId: candidate.fixtureId, + marketKey: candidate.market.key, + outcome: candidate.outcome, + direction: candidate.direction, + recommendation: "paper_trade", + fairProbability: 0.56, + thesisSummary: "Executable book remains behind consensus after review.", + evidenceFor: ["The canonical ask remains inside the deterministic limit."], + steelmanAgainst: "Consensus can reverse before kickoff.", + invalidationConditions: ["The canonical ask catches up."], + submittedAtTsMs: 1, + expiresAtTsMs: 2, + analystModel: "restart-test" + }; +} + +function book(observedTsMs = 11_500): PolymarketBookEvent { + return { + schemaVersion: 1, + kind: "polymarket.book", + eventId: `book-${observedTsMs}`, + source: "polymarket", + sourceTsMs: observedTsMs, + observedTsMs, + fixtureId: "fixture-1", + market: signal().market, + mappingStatus: "verified", + conditionId: "condition-1", + assetId: "asset-over", + outcome: "over", + tokenRole: "canonical", + bids: [{ price: probability(0.51), size: "100" }], + asks: [{ price: probability(0.52), size: "100" }], + lastTradePrice: probability(0.515), + tickSize: "0.01" + }; +} + +function fees(asOfTsMs: number): PolymarketFeeParameters { + return { + source: "polymarket_clob_market_info", + conditionId: "condition-1", + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: asOfTsMs + }; +} + +function pipeline(ledger: DecisionLedger, spend?: { triage: number; analyst: number }): PaperCasePipeline { + return new PaperCasePipeline({ + triageAgent: { + triage: async () => { + if (spend) spend.triage += 1; + return { decision: "escalate", priority: "normal", rationale: "Restart-safe candidate." }; + } + }, + analystAgent: { + investigate: async ({ signal: candidate }) => { + if (spend) spend.analyst += 1; + return thesis(candidate); + } + }, + riskConfig: APPROVED_PAPER_RISK_CONFIG, + executor: new OrderBookPaperExecutor(), + ledger, + now: () => 0 + }); +} + +function scheduler(input: { + ledger: DecisionLedger; + portfolio: PaperPortfolio; + maximumPendingMs: number; + spend?: { triage: number; analyst: number }; + initialState?: ReturnType["schedulerInitialState"]; +}): PaperCaseScheduler { + return new PaperCaseScheduler({ + config: { + lane: "bounty", + executionLatencyMs: 500, + maximumPendingMs: input.maximumPendingMs, + minimumSignalToKickoffMs: 1_000, + eligibleMarketKeys: new Set([signal().market.key]), + kickoffByFixtureId: new Map([["fixture-1", 20_000]]) + }, + pipeline: pipeline(input.ledger, input.spend), + feeResolver: async (_book, asOfTsMs) => fees(asOfTsMs), + portfolio: input.portfolio, + ...(input.initialState === undefined ? {} : { initialState: input.initialState }) + }); +} + +function freshPortfolio(ledger: DecisionLedger, initialState?: ReturnType["portfolioInitialState"]): PaperPortfolio { + return new PaperPortfolio({ + lane: "bounty", + bankrollMicroUsd: APPROVED_PAPER_RISK_CONFIG.bankrollMicroUsd, + drawdownStopMicroUsd: APPROVED_PAPER_RISK_CONFIG.drawdownStopMicroUsd, + ledger, + ...(initialState === undefined ? {} : { initialState }) + }); +} + +function rehydrate(ledger: DecisionLedger, asOfTsMs: number, maximumPendingMs = 5_000) { + return rehydratePaperState({ + ledger, + lane: "bounty", + bankrollMicroUsd: APPROVED_PAPER_RISK_CONFIG.bankrollMicroUsd, + drawdownStopMicroUsd: APPROVED_PAPER_RISK_CONFIG.drawdownStopMicroUsd, + maximumPendingMs, + kickoffByFixtureId: new Map([["fixture-1", 20_000]]), + asOfTsMs + }); +} + +describe("paper restart state rehydration", () => { + it("rebuilds an open position, exposure, and portfolio state exactly", async () => { + const ledger = new DecisionLedger(":memory:"); + const originalPortfolio = freshPortfolio(ledger); + const originalScheduler = scheduler({ ledger, portfolio: originalPortfolio, maximumPendingMs: 5_000 }); + expect(await originalScheduler.enqueue(signal())).toBe(true); + const results = await originalScheduler.ingest(book()); + expect(results[0]?.status).toBe("filled"); + expect(originalPortfolio.positions()).toHaveLength(1); + + const restored = rehydrate(ledger, 11_500); + expect(restored.chain).toMatchObject({ valid: true, legacyV1Rows: 0, v2Rows: 9 }); + expect(restored.openPositions).toEqual(originalPortfolio.positions()); + expect(restored.closedPositions).toEqual([]); + expect(restored.settledPositions).toEqual([]); + expect(restored.aggregateExposureMicroUsd).toBe(originalPortfolio.riskState().openExposureMicroUsd); + expect(restored.realizedPnlMicroUsd).toBe(0); + expect(restored.equityMicroUsd).toBe(APPROVED_PAPER_RISK_CONFIG.bankrollMicroUsd); + + const rebuiltPortfolio = freshPortfolio(ledger, restored.portfolioInitialState); + expect(rebuiltPortfolio.positions()).toEqual(originalPortfolio.positions()); + expect(rebuiltPortfolio.summary()).toEqual(originalPortfolio.summary()); + expect(rebuiltPortfolio.riskState()).toEqual(originalPortfolio.riskState()); + ledger.close(); + }); + + it("restores the seen marker so duplicate signals cannot spend either model again", async () => { + const ledger = new DecisionLedger(":memory:"); + const originalPortfolio = freshPortfolio(ledger); + const originalScheduler = scheduler({ ledger, portfolio: originalPortfolio, maximumPendingMs: 5_000 }); + await originalScheduler.enqueue(signal()); + await originalScheduler.ingest(book()); + const restored = rehydrate(ledger, 11_500); + const spend = { triage: 0, analyst: 0 }; + const rebuiltPortfolio = freshPortfolio(ledger, restored.portfolioInitialState); + const rebuiltScheduler = scheduler({ + ledger, + portfolio: rebuiltPortfolio, + maximumPendingMs: 5_000, + spend, + initialState: restored.schedulerInitialState + }); + + expect(rebuiltScheduler.seenCount()).toBe(1); + expect(await rebuiltScheduler.enqueue(signal())).toBe(false); + expect(spend).toEqual({ triage: 0, analyst: 0 }); + expect(ledger.entries().filter((entry) => entry.kind === "signal_received")).toHaveLength(1); + ledger.close(); + }); + + it("rebuilds closed and settled positions plus historical peak equity and drawdown", async () => { + const ledger = new DecisionLedger(":memory:"); + const originalPortfolio = freshPortfolio(ledger); + const originalScheduler = scheduler({ ledger, portfolio: originalPortfolio, maximumPendingMs: 5_000 }); + await originalScheduler.enqueue(signal()); + await originalScheduler.ingest(book()); + const secondSignal: DetectorSignal = { + ...structuredClone(signal()), + signalId: "restart-signal-2", + detectedAtTsMs: 12_000, + observedAtTsMs: 12_000 + }; + await originalScheduler.enqueue(secondSignal); + await originalScheduler.ingest(book(13_500)); + const open = originalPortfolio.positions(); + expect(open).toHaveLength(2); + const closingBook: PolymarketBookEvent = { + ...book(19_900), + bids: [{ price: probability(0.57), size: "100" }], + asks: [{ price: probability(0.59), size: "100" }] + }; + for (const position of open) { + originalPortfolio.markAtClose({ + caseId: position.caseId, + book: closingBook, + cutoffTsMs: 20_000, + markedAtTsMs: 20_000 + }); + } + + const closed = rehydrate(ledger, 20_000); + expect(closed.openPositions).toEqual([]); + expect(closed.closedPositions).toHaveLength(2); + expect(closed.settledPositions).toEqual([]); + expect(closed.aggregateExposureMicroUsd).toBe( + open.reduce((sum, position) => sum + position.entryCostMicroUsd, 0) + ); + + originalPortfolio.settle({ caseId: open[0]!.caseId, won: true, settledAtTsMs: 21_000 }); + originalPortfolio.settle({ caseId: open[1]!.caseId, won: false, settledAtTsMs: 22_000 }); + const settled = rehydrate(ledger, 22_000); + const originalSummary = originalPortfolio.summary(); + expect(settled.settledPositions).toHaveLength(2); + expect(settled.aggregateExposureMicroUsd).toBe(0); + expect(settled.realizedPnlMicroUsd).toBe(originalSummary.realizedPnlMicroUsd); + expect(settled.peakEquityMicroUsd).toBe(originalSummary.peakEquityMicroUsd); + expect(settled.currentDrawdownMicroUsd).toBe(originalSummary.currentDrawdownMicroUsd); + expect(settled.equityMicroUsd).toBe( + APPROVED_PAPER_RISK_CONFIG.bankrollMicroUsd + originalSummary.realizedPnlMicroUsd + ); + expect(settled.currentDrawdownMicroUsd).toBeGreaterThan(0); + const rebuiltPortfolio = freshPortfolio(ledger, settled.portfolioInitialState); + expect(rebuiltPortfolio.summary()).toEqual(originalSummary); + expect(rebuiltPortfolio.positions()).toEqual(originalPortfolio.positions()); + ledger.close(); + }); + + it("derives and restores one deterministic pending expiry without repeating analysis", async () => { + const ledger = new DecisionLedger(":memory:"); + const originalPortfolio = freshPortfolio(ledger); + const originalScheduler = scheduler({ ledger, portfolio: originalPortfolio, maximumPendingMs: 2_000 }); + await originalScheduler.enqueue(signal()); + + const beforeExpiry = rehydrate(ledger, 13_499, 2_000); + expect(beforeExpiry.pendingExpirations).toEqual([expect.objectContaining({ + timeoutAtTsMs: 13_500, + effectiveExpiresAtTsMs: 13_500, + reason: "no_post_venue_delay_executable_book_before_expiry", + expiredAsOfTsMs: false + })]); + const atExpiry = rehydrate(ledger, 13_500, 2_000); + expect(atExpiry.pendingExpirations[0]?.expiredAsOfTsMs).toBe(true); + const spend = { triage: 0, analyst: 0 }; + const rebuiltPortfolio = freshPortfolio(ledger, atExpiry.portfolioInitialState); + const rebuiltScheduler = scheduler({ + ledger, + portfolio: rebuiltPortfolio, + maximumPendingMs: 2_000, + spend, + initialState: atExpiry.schedulerInitialState + }); + expect(rebuiltScheduler.pendingCount()).toBe(1); + const heartbeat: CanonicalEvent = { + schemaVersion: 1, + kind: "feed.heartbeat", + eventId: "restart-heartbeat", + source: "polymarket", + sourceTsMs: 13_500, + observedTsMs: 13_500, + fixtureId: null, + status: "healthy", + stream: "polymarket", + detail: null + }; + expect(await rebuiltScheduler.ingest(heartbeat)).toEqual([]); + expect(rebuiltScheduler.pendingCount()).toBe(0); + expect(spend).toEqual({ triage: 0, analyst: 0 }); + expect(ledger.entries().at(-1)).toMatchObject({ + kind: "case_terminal", + atTsMs: 13_500, + payload: { status: "failed", reason: "no_post_venue_delay_executable_book_before_expiry" } + }); + ledger.close(); + }); + + it("refuses lifecycle ordering that executes without approval and intent", () => { + const ledger = new DecisionLedger(":memory:"); + const candidate = signal(); + const caseId = paperCaseIdForSignal("bounty", candidate.signalId); + ledger.append({ + entryId: `${caseId}:1:signal_received`, + caseId, + kind: "signal_received", + atTsMs: candidate.observedAtTsMs, + payload: JSON.parse(JSON.stringify({ lane: "bounty", signal: candidate })) + }); + const invalidFill: PaperFill = { + adapter: "paper", + status: "filled", + reason: null, + assetId: "asset-over", + conditionId: "condition-1", + direction: "buy", + requestedStakeMicroUsd: microUsd(3_000_000), + grossMicroUsd: microUsd(2_990_000), + feeMicroUsd: microUsd(10_000), + netConsiderationMicroUsd: microUsd(3_000_000), + filledShares: 5.75, + averagePrice: 0.52, + bestPrice: 0.52, + halfSpreadBps: 50, + executableDepthUsd: 52, + slippageProbabilityBps: 0, + bookObservedTsMs: 11_500, + feeParameters: fees(11_500) + }; + ledger.append({ + entryId: `${caseId}:2:paper_execution`, + caseId, + kind: "paper_execution", + atTsMs: 11_500, + payload: JSON.parse(JSON.stringify(invalidFill)) + }); + expect(() => rehydrate(ledger, 11_500)).toThrow(/lacks intent and approval/); + ledger.close(); + }); + + it("verifies the hash chain before parsing otherwise plausible state", () => { + const directory = mkdtempSync(join(tmpdir(), "samaritan-rehydrate-")); + temporaryDirectories.push(directory); + const path = join(directory, "ledger.sqlite"); + const ledger = new DecisionLedger(path); + const candidate = signal(); + const caseId = paperCaseIdForSignal("bounty", candidate.signalId); + ledger.append({ + entryId: `${caseId}:1:signal_received`, + caseId, + kind: "signal_received", + atTsMs: candidate.observedAtTsMs, + payload: JSON.parse(JSON.stringify({ lane: "bounty", signal: candidate })) + }); + const raw = new Database(path); + raw.exec("DROP TRIGGER decision_events_no_update"); + raw.prepare("UPDATE decision_events SET payload_json = ? WHERE sequence = 1").run("{}"); + raw.close(); + expect(() => rehydrate(ledger, 10_000)).toThrow(/Broken decision hash/); + ledger.close(); + }); +}); diff --git a/test/paper-study-ledger.test.ts b/test/paper-study-ledger.test.ts new file mode 100644 index 0000000..9074f7f --- /dev/null +++ b/test/paper-study-ledger.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { initializePaperStudyLedger } from "../src/harness/paper-study-ledger.js"; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("persistent paper-study ledger initialization", () => { + it("records the frozen protocol once and reopens idempotently", () => { + const directory = mkdtempSync(join(tmpdir(), "samaritan-paper-ledger-")); + directories.push(directory); + const path = join(directory, "long-run.sqlite"); + const first = initializePaperStudyLedger({ path, lane: "long_run", startedAtTsMs: 1_000 }); + expect(first.created).toBe(true); + expect(first.initialization).toMatchObject({ + protocolVersion: "paper-study-v2-candidate-2026-07-14", + protocolStatus: "engineering_candidate_unregistered", + configHash: "93e61c1903d0a13bbeb1dbbd3ad9b11af0335b96c82bd2ca7aa9ddedeeabf3ce", + lane: "long_run", + startedAtTsMs: 1_000, + realMoneyGate: "closed", + frozenConfig: { + executionTiming: { + readinessClock: "observed_ts_ms", + decisionLatency: "max_measured_or_minimum", + venuePlacementDelayMs: 1_000 + }, + selectorAsOfBeforeKickoffMs: 10_800_000, + economicCases: { + version: "binary_totals_executable_buy_v1", + retain: "actual_buy_only", + sellOnly: "drop_unproven_complementary_ask" + }, + historicalEvidence: { + protocolId: "historical-gate-causal-economic-v4-2026-07-14", + configurationHash: "9a4eeff928f697fc55ab5147a4dc07f611c40bb749501fc3bd92b211f24b2e54" + }, + risk: { + bankrollMicroUsd: 50_000_000, + perTradeStakeMicroUsd: 3_000_000, + aggregateExposureMicroUsd: 15_000_000, + drawdownStopMicroUsd: 20_000_000, + realMoneyGate: "closed" + }, + evaluation: { minimumFilledMatches: 20, minimumFills: 40, bootstrapIterations: 10_000 } + } + }); + const hash = first.initialization.configHash; + expect(first.ledger.verifyChain()).toMatchObject({ valid: true, rows: 1 }); + first.ledger.close(); + + const reopened = initializePaperStudyLedger({ path, lane: "long_run", startedAtTsMs: 9_999 }); + expect(reopened.created).toBe(false); + expect(reopened.initialization.startedAtTsMs).toBe(1_000); + expect(reopened.initialization.configHash).toBe(hash); + expect(reopened.ledger.entries()).toHaveLength(1); + reopened.ledger.close(); + }); + + it("refuses to reuse a lane ledger under a different lane", () => { + const directory = mkdtempSync(join(tmpdir(), "samaritan-paper-ledger-")); + directories.push(directory); + const path = join(directory, "lane.sqlite"); + const bounty = initializePaperStudyLedger({ path, lane: "bounty", startedAtTsMs: 1_000 }); + bounty.ledger.close(); + expect(() => initializePaperStudyLedger({ path, lane: "long_run", startedAtTsMs: 1_000 })).toThrow( + /does not match/ + ); + }); +}); diff --git a/test/paper-study-metrics.test.ts b/test/paper-study-metrics.test.ts new file mode 100644 index 0000000..e758790 --- /dev/null +++ b/test/paper-study-metrics.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + evaluatePaperStudy, + type PaperStudyObservation +} from "../src/metrics/paper-study.js"; + +function observations(input: { + lane: "bounty" | "long_run"; + matches: number; + fillsPerMatch: number; + unfilledPerMatch?: number; + netClvBps?: number; + slippageBps?: number; + pnlMicroUsd?: number; +}): PaperStudyObservation[] { + const rows: PaperStudyObservation[] = []; + for (let match = 0; match < input.matches; match += 1) { + for (let signal = 0; signal < input.fillsPerMatch + (input.unfilledPerMatch ?? 0); signal += 1) { + const filled = signal < input.fillsPerMatch; + rows.push({ + caseId: `case-${match}-${signal}`, + lane: input.lane, + fixtureId: `fixture-${match}`, + kickoffTsMs: 1_000_000 + match * 10_000, + selectedLineMilli: 2_500, + signalId: `signal-${match}-${signal}`, + fill: filled ? { + status: "filled", + entryCostMicroUsd: 3_000_000, + halfSpreadBps: 20, + slippageBps: input.slippageBps ?? 10, + selectedDepthUsd: 100, + grossClvBps: (input.netClvBps ?? 100) + 20, + netClvBps: input.netClvBps ?? 100, + executableLiquidationClvBps: (input.netClvBps ?? 100) - 20, + settledAtTsMs: 2_000_000 + match * 10_000 + signal, + settlementPnlMicroUsd: input.pnlMicroUsd ?? 100_000 + } : null + }); + } + } + return rows; +} + +describe("registered paper-study evaluator", () => { + it("seals long-run endpoints until both stopping thresholds are met", () => { + const notEnoughMatchesInput = observations({ + lane: "long_run", + matches: 19, + fillsPerMatch: 3 + }); + const notEnoughMatches = evaluatePaperStudy({ lane: "long_run", observations: notEnoughMatchesInput }); + expect(notEnoughMatches).toMatchObject({ status: "sealed", stoppingRuleMet: false }); + expect(notEnoughMatches.rows).toBeNull(); + expect(notEnoughMatches.endpoints).toBeNull(); + expect(notEnoughMatches.guardrails).toBeNull(); + + const notEnoughFillsInput = observations({ + lane: "long_run", + matches: 20, + fillsPerMatch: 1 + }); + const notEnoughFills = evaluatePaperStudy({ lane: "long_run", observations: notEnoughFillsInput }); + expect(notEnoughFills).toMatchObject({ status: "sealed", stoppingRuleMet: false }); + }); + + it("accepts only after positive clustered evidence and every guardrail pass", () => { + const input = observations({ + lane: "long_run", + matches: 20, + fillsPerMatch: 2, + netClvBps: 100, + pnlMicroUsd: 100_000 + }); + const report = evaluatePaperStudy({ lane: "long_run", observations: input }); + expect(report.status).toBe("accept"); + expect(report.endpoints?.netClvInterval).toMatchObject({ + iterations: 10_000, + matches: 20, + signals: 40, + low: 100, + median: 100, + high: 100 + }); + expect(report.endpoints!.meanNetClvBps).toBeGreaterThan( + report.endpoints!.randomDirectionControlClvBps + ); + expect(report.rows).toHaveLength(20); + }); + + it("rejects a positive point estimate when a registered guardrail fails", () => { + const input = observations({ + lane: "long_run", + matches: 20, + fillsPerMatch: 2, + slippageBps: 101 + }); + const report = evaluatePaperStudy({ lane: "long_run", observations: input }); + expect(report.status).toBe("reject"); + expect(report.guardrails).toMatchObject({ slippagePassed: false }); + }); + + it("treats reaching the drawdown stop as a failed guardrail", () => { + const input = observations({ + lane: "long_run", + matches: 20, + fillsPerMatch: 2, + pnlMicroUsd: -500_000 + }); + const report = evaluatePaperStudy({ lane: "long_run", observations: input }); + expect(report.status).toBe("reject"); + expect(report.guardrails).toMatchObject({ + maxDrawdownMicroUsd: 20_000_000, + drawdownPassed: false + }); + }); + + it("keeps bounty evidence explicitly exploratory and bootstrap output reproducible", () => { + const input = observations({ lane: "bounty", matches: 3, fillsPerMatch: 2 }); + const first = evaluatePaperStudy({ lane: "bounty", observations: input }); + const second = evaluatePaperStudy({ lane: "bounty", observations: input }); + expect(first.status).toBe("exploratory"); + expect(first.endpoints).toEqual(second.endpoints); + }); + + it("will not score filled cases with missing close or settlement evidence", () => { + const input = observations({ lane: "long_run", matches: 20, fillsPerMatch: 2 }); + input[0]!.fill!.netClvBps = null; + const report = evaluatePaperStudy({ lane: "long_run", observations: input }); + expect(report.status).toBe("inconclusive"); + expect(report.endpoints).toBeNull(); + expect(report.guardrails).toMatchObject({ closeMarksComplete: false }); + }); + + it("returns a sealed zero-count report for a fresh long-run ledger", () => { + const report = evaluatePaperStudy({ lane: "long_run", observations: [] }); + expect(report).toMatchObject({ + status: "sealed", + counts: { matches: 0, signals: 0, filledMatches: 0, fills: 0, settledFills: 0 } + }); + expect(report.endpoints).toBeNull(); + }); + + it("does not call empty bounty evidence complete", () => { + const report = evaluatePaperStudy({ lane: "bounty", observations: [] }); + expect(report.guardrails).toMatchObject({ + selectedDepthComplete: false, + closeMarksComplete: false, + settlementComplete: false + }); + }); +}); diff --git a/test/paper-study-observations.test.ts b/test/paper-study-observations.test.ts new file mode 100644 index 0000000..5569118 --- /dev/null +++ b/test/paper-study-observations.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { DetectorSignal } from "../src/detectors/types.js"; +import { microUsd } from "../src/domain/money.js"; +import type { PaperFill } from "../src/exec/paper.js"; +import { PaperCasePipeline } from "../src/harness/paper-pipeline.js"; +import { + buildPaperStudyObservations, + evaluatePaperStudyLedger +} from "../src/metrics/paper-study-observations.js"; +import { PaperPortfolio } from "../src/portfolio/paper.js"; +import { DecisionLedger } from "../src/store/decision-ledger.js"; + +const signal: DetectorSignal = { + signalId: "signal-1", + kind: "CONSENSUS_MOVE", + detectedAtTsMs: 1_000, + observedAtTsMs: 1_000, + fixtureId: "fixture-1", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }, + outcome: "over", + direction: "buy", + eligibility: "research_only", + reason: "test", + evidence: { + consensusProbability: 0.55, + polymarketProbability: 0.52, + consensusVelocity: 0.01, + consensusZScore: 1.5, + polymarketVelocity: 0, + polymarketZScore: 0, + cusumUp: 0.002, + cusumDown: 0, + rawGap: 0.03, + gapBasis: "live_book", + persistenceMs: 0, + mappingStatus: "candidate", + scoreContextActions: [] + } +}; + +const fill: PaperFill = { + adapter: "paper", + status: "filled", + reason: null, + assetId: "asset-over", + conditionId: "condition-1", + direction: "buy", + requestedStakeMicroUsd: microUsd(3_000_000), + grossMicroUsd: microUsd(2_990_000), + feeMicroUsd: microUsd(10_000), + netConsiderationMicroUsd: microUsd(3_000_000), + filledShares: 5.5, + averagePrice: 2.99 / 5.5, + bestPrice: 0.54, + halfSpreadBps: 100, + executableDepthUsd: 54, + slippageProbabilityBps: 36.36, + bookObservedTsMs: 1_500, + feeParameters: { + source: "polymarket_clob_market_info", + conditionId: "condition-1", + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: 1_400 + } +}; + +describe("paper-study observation builder", () => { + it("derives filled and unfilled study cases from the immutable ledger", () => { + const ledger = new DecisionLedger(":memory:"); + const pipeline = new PaperCasePipeline({ + triageAgent: { triage: async () => ({ decision: "drop", priority: "normal", rationale: "unused" }) }, + analystAgent: { investigate: async () => { throw new Error("unused"); } }, + riskConfig: {} as never, + executor: { execute: async () => { throw new Error("unused"); } }, + ledger + }); + const filledCaseId = pipeline.recordSignal("long_run", signal); + pipeline.recordSignal("long_run", { ...signal, signalId: "signal-2" }); + const portfolio = new PaperPortfolio({ + lane: "long_run", + bankrollMicroUsd: microUsd(50_000_000), + drawdownStopMicroUsd: microUsd(20_000_000), + ledger + }); + portfolio.open({ caseId: filledCaseId, signal, fill, openedAtTsMs: 1_500 }); + + const result = buildPaperStudyObservations({ + lane: "long_run", + ledger, + kickoffByFixtureId: new Map([["fixture-1", 5_000]]) + }); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + caseId: filledCaseId, + selectedLineMilli: 2_500, + fill: { halfSpreadBps: 100, slippageBps: 36.36, selectedDepthUsd: 54 } + }); + expect(result[1]?.fill).toBeNull(); + expect(evaluatePaperStudyLedger({ + lane: "long_run", + ledger, + kickoffByFixtureId: new Map([["fixture-1", 5_000]]) + })).toMatchObject({ status: "sealed", counts: { signals: 2, fills: 1 } }); + ledger.close(); + }); +}); diff --git a/test/paper-study-report.test.ts b/test/paper-study-report.test.ts new file mode 100644 index 0000000..85de3a0 --- /dev/null +++ b/test/paper-study-report.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { initializePaperStudyLedger } from "../src/harness/paper-study-ledger.js"; +import { evaluatePaperStudy } from "../src/metrics/paper-study.js"; +import { + renderPaperStudyEvidence, + type PaperStudyEvidenceArtifact +} from "../src/metrics/paper-study-report.js"; + +function artifact(): PaperStudyEvidenceArtifact { + const bounty = initializePaperStudyLedger({ path: ":memory:", lane: "bounty", startedAtTsMs: 1_000 }); + const longRun = initializePaperStudyLedger({ path: ":memory:", lane: "long_run", startedAtTsMs: 1_000 }); + try { + return { + generatedAt: "2026-07-12T12:00:00.000Z", + protocolVersion: bounty.initialization.protocolVersion, + configHash: bounty.initialization.configHash, + realMoneyGate: "closed", + fixtureUniverseGeneratedAt: "2026-07-12T11:00:00.000Z", + lanes: { + bounty: { + initialization: bounty.initialization, + chain: bounty.ledger.verifyChain(), + report: evaluatePaperStudy({ lane: "bounty", observations: [] }) + }, + longRun: { + initialization: longRun.initialization, + chain: longRun.ledger.verifyChain(), + report: evaluatePaperStudy({ lane: "long_run", observations: [] }) + } + } + }; + } finally { + bounty.ledger.close(); + longRun.ledger.close(); + } +} + +describe("paper-study evidence report", () => { + it("renders chain evidence while keeping long-run endpoints sealed", () => { + const markdown = renderPaperStudyEvidence(artifact()); + expect(markdown).toContain("Real-money gate: **CLOSED**"); + expect(markdown).toContain("Long-Run Profitability Lane"); + expect(markdown).toContain("Primary endpoints and per-match rows are sealed"); + expect(markdown).toContain("0 matches / 0 signals"); + }); + + it("refuses to render a sealed report carrying leaked rows", () => { + const value = artifact(); + value.lanes.longRun.report.rows = [{ + fixtureId: "leaked-fixture", + kickoffTsMs: 1_000, + selectedLineMilli: 2_500, + signals: 1, + fills: 0, + fillRate: 0, + meanHalfSpreadBps: null, + meanSlippageBps: null, + grossClvBps: null, + netClvBps: null, + settlementPnlMicroUsd: null, + netReturnBps: null + }]; + expect(() => renderPaperStudyEvidence(value)).toThrow(/unsealed results/); + }); +}); diff --git a/test/polymarket-fees.test.ts b/test/polymarket-fees.test.ts new file mode 100644 index 0000000..8e172ae --- /dev/null +++ b/test/polymarket-fees.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { PolymarketBookEvent } from "../src/bus/events.js"; +import { probability } from "../src/domain/probability.js"; +import { PolymarketClobFeeResolver } from "../src/ingest/polymarket/fees.js"; + +function book(): PolymarketBookEvent { + return { + schemaVersion: 1, + kind: "polymarket.book", + eventId: "fee-book", + source: "polymarket", + sourceTsMs: 1_000, + observedTsMs: 1_000, + fixtureId: "fixture-1", + market: { + family: "total_goals", + period: "full_time", + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }, + mappingStatus: "candidate", + conditionId: "condition-1", + assetId: "asset-over", + outcome: "over", + tokenRole: "canonical", + bids: [{ price: probability(0.49), size: "10" }], + asks: [{ price: probability(0.51), size: "10" }], + lastTradePrice: probability(0.5), + tickSize: "0.01" + }; +} + +function marketInfo(overrides: Record = {}): Record { + return { + c: "condition-1", + t: [ + { t: "asset-over", o: "Over" }, + { t: "asset-under", o: "Under" } + ], + mos: 5, + mts: 0.01, + tbf: 1_000, + fd: { r: 0.05, e: 1, to: true }, + ...overrides + }; +} + +describe("Polymarket public fee resolver", () => { + it("parses and caches explicit V2 fee-curve and market execution parameters", async () => { + let requests = 0; + let now = 2_000; + const resolver = new PolymarketClobFeeResolver({ + now: () => now, + fetchImpl: async () => { + requests += 1; + return new Response(JSON.stringify(marketInfo()), { + status: 200, + headers: { "content-type": "application/json" } + }); + } + }); + await expect(resolver.resolve(book())).resolves.toEqual({ + source: "polymarket_clob_market_info", + conditionId: "condition-1", + feesEnabled: true, + takerFeeRate: 0.05, + feeCurveExponent: 1, + takerOnly: true, + minimumOrderSize: 5, + minimumTickSize: 0.01, + fetchedAtTsMs: 2_000 + }); + now = 2_500; + await resolver.resolve(book()); + expect(requests).toBe(1); + }); + + it("rejects legacy fee metadata without an explicit V2 fee curve", async () => { + const resolver = new PolymarketClobFeeResolver({ + fetchImpl: async () => new Response(JSON.stringify(marketInfo({ fd: undefined })), { status: 200 }) + }); + await expect(resolver.resolve(book())).rejects.toThrow(/omitted explicit V2 fee-curve/); + }); + + it("rejects market info that does not contain the canonical asset", async () => { + const resolver = new PolymarketClobFeeResolver({ + fetchImpl: async () => new Response(JSON.stringify(marketInfo({ + t: [{ t: "different-asset", o: "Over" }] + })), { status: 200 }) + }); + await expect(resolver.resolve(book())).rejects.toThrow(/does not contain canonical book asset/); + }); +}); diff --git a/test/public-artifact-safety.test.ts b/test/public-artifact-safety.test.ts new file mode 100644 index 0000000..2e94882 --- /dev/null +++ b/test/public-artifact-safety.test.ts @@ -0,0 +1,307 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parsePublicArtifactAuditArgs } from "../src/public/audit-public-artifacts.js"; +import { + assertPublicArtifactsSafe, + auditPublicArtifacts, + type PublicArtifactAuditReport, + type PublicArtifactViolationCode +} from "../src/public/artifact-safety.js"; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "samaritan-public-artifacts-")); + directories.push(directory); + return directory; +} + +function writeJson(directory: string, filename: string, value: unknown): string { + const path = join(directory, filename); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); + return path; +} + +function codes(report: PublicArtifactAuditReport): PublicArtifactViolationCode[] { + return report.violations.map((violation) => violation.code); +} + +describe("public artifact safety auditor", () => { + it("accepts derived-only movements and a hash-only decision receipt", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "decision-receipt.json", { + schemaVersion: 1, + provenance: { + evidenceClass: "captured_paper_case", + performanceUse: "subject_to_registered_evaluation" + }, + signal: { + detector: "CONSENSUS_MOVE", + derivedEvidence: { + consensusMoveFromBaselineBps: 25, + consensusVelocityBps: 40, + crossMarketGapBps: 175, + gapBasis: "live_book" + } + }, + thesis: { + fairProbability: 0.54, + recommendation: "paper_trade" + }, + execution: { + bestPrice: 0.51, + halfSpreadBps: 50 + }, + sourceEvidence: [ + { + source: "txline", + role: "signal", + disclosure: "hash_only", + payloadSha256: "a".repeat(64) + } + ] + }); + writeJson(directory, "matchroom-derived.json", { + publicDataPolicy: { + derivedOnly: true, + txlineProbabilityDisplay: "bucketed_movement_only", + txlineMovementBucketBps: 25 + }, + replay: { + consensusMoveFromBaselineBps: 25, + firstMaterialMoveLatencyMs: 228, + bestBid: 0.17, + bestAsk: 0.18 + } + }); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(report).toMatchObject({ ok: true, mode: "bundle", existingAllowlistedRoots: 1, filesScanned: 2 }); + expect(report.violations).toEqual([]); + expect(() => assertPublicArtifactsSafe(report)).not.toThrow(); + }); + + it("rejects forbidden raw keys and a nested TXLine envelope", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "public-data.json", { + harmless: true, + nested: { + raw: { + FixtureId: 18_218_149, + MessageId: 99, + Ts: 1_700_000_000_000, + SuperOddsType: "Over/Under", + BookmakerId: 10_021, + Bookmaker: "TXLineStablePriceDemargined", + Pct: ["55.000", "45.000"], + Prices: [1800, 2200] + } + } + }); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(report.ok).toBe(false); + expect(codes(report)).toEqual(expect.arrayContaining(["RAW_TXLINE_KEY", "RAW_TXLINE_ENVELOPE"])); + expect(report.violations.some((violation) => violation.jsonPath?.includes("nested"))).toBe(true); + }); + + it("rejects direct exact consensus levels and series while allowing derived bps", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "levels.json", { + safe: { consensusMoveFromBaselineBps: 50 }, + nested: { + consensusProbability: 0.5375, + stablePriceSeries: [0.52, 0.53, 0.5375] + } + }); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toContain("EXACT_TXLINE_CONSENSUS"); + expect(report.violations.filter((violation) => violation.code === "EXACT_TXLINE_CONSENSUS")).toHaveLength(2); + }); + + it("rejects algebraically reconstructive exact venue-level plus consensus-gap shapes", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "reconstructive.json", { + case: { + venue: { polymarketProbability: 0.5125 }, + derived: { crossMarketGapBps: 225 } + } + }); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toContain("RECONSTRUCTIVE_TXLINE_SHAPE"); + }); + + it("rejects a TXLine-labelled nested exact level but permits hash-only provenance", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "sources.json", { + safe: { source: "txline", payloadSha256: "b".repeat(64), disclosure: "hash_only" }, + unsafe: { source: "txline", observations: [{ probability: 0.51 }, { probability: 0.52 }] } + }); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toContain("EXACT_TXLINE_CONSENSUS"); + expect(report.violations.filter((violation) => violation.code === "EXACT_TXLINE_CONSENSUS")).toHaveLength(1); + }); + + it("rejects sensitive structured keys and concrete secret value patterns", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "config.json", { + nested: { + apiToken: "txline-secret-that-must-never-ship", + harmless: "value" + } + }); + writeFileSync(join(directory, "notes.md"), "Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123456789\n"); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toEqual(expect.arrayContaining(["SENSITIVE_DATA_KEY", "SECRET_VALUE_PATTERN"])); + }); + + it("rejects private workstation paths in otherwise harmless files", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "build-metadata.json", { + generatedFrom: "/Users/deborah/Documents/samaritan/private-ledger.sqlite" + }); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toContain("PRIVATE_ABSOLUTE_PATH"); + }); + + it("rejects raw capture extensions/names and symlinks in bundle mode", async () => { + const directory = temporaryDirectory(); + writeFileSync(join(directory, "txline-odds-capture.ndjson"), "{\"FixtureId\":1}\n"); + writeFileSync(join(directory, "target.txt"), "safe\n"); + symlinkSync(join(directory, "target.txt"), join(directory, "linked.txt")); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toEqual( + expect.arrayContaining(["RAW_CAPTURE_EXTENSION", "RAW_CAPTURE_FILENAME", "SYMLINK_NOT_ALLOWED"]) + ); + }); + + it("rejects renamed opaque payloads and still inspects JSON disguised as text", async () => { + const directory = temporaryDirectory(); + writeFileSync(join(directory, "renamed.bin"), Buffer.from([0, 1, 2, 3, 4])); + writeFileSync(join(directory, "looks-harmless.txt"), JSON.stringify({ nested: { Pct: ["51.0", "49.0"] } })); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toEqual(expect.arrayContaining(["OPAQUE_DATA_ARTIFACT", "RAW_TXLINE_KEY"])); + }); + + it("rejects per-file and aggregate oversize artifacts using caller-frozen limits", async () => { + const directory = temporaryDirectory(); + writeFileSync(join(directory, "one.txt"), "a".repeat(12)); + writeFileSync(join(directory, "two.txt"), "b".repeat(12)); + + const report = await auditPublicArtifacts({ + allowlistedPaths: [directory], + cwd: directory, + maxFileBytes: 10, + maxTotalBytes: 20 + }); + + expect(codes(report)).toEqual(expect.arrayContaining(["FILE_TOO_LARGE", "BUNDLE_TOO_LARGE"])); + }); + + it("fails closed without an explicit existing bundle allowlist", async () => { + const directory = temporaryDirectory(); + const emptyDirectory = join(directory, "empty"); + mkdirSync(emptyDirectory); + const empty = await auditPublicArtifacts({ allowlistedPaths: [], cwd: directory }); + const missing = await auditPublicArtifacts({ allowlistedPaths: ["does-not-exist"], cwd: directory }); + const emptyBundle = await auditPublicArtifacts({ allowlistedPaths: [emptyDirectory], cwd: directory }); + + expect(codes(empty)).toEqual(expect.arrayContaining(["NO_EXPLICIT_ALLOWLIST", "NO_ALLOWLISTED_BUNDLE"])); + expect(codes(missing)).toEqual(expect.arrayContaining(["ALLOWLIST_PATH_MISSING", "NO_ALLOWLISTED_BUNDLE"])); + expect(codes(emptyBundle)).toEqual(["EMPTY_ALLOWLISTED_BUNDLE"]); + expect(() => assertPublicArtifactsSafe(empty)).toThrow(/NO_EXPLICIT_ALLOWLIST/); + }); + + it("requires explicit source-audit mode and paths but ignores implementation identifiers", async () => { + const directory = temporaryDirectory(); + writeFileSync( + join(directory, "normalizer.ts"), + [ + "export type RawOdds = { Pct: string[]; Prices: number[]; BookmakerId: number };", + "export const token = process.env.TXLINE_API_TOKEN;", + "export const consensusProbability = (row: RawOdds) => Number(row.Pct[0]) / 100;" + ].join("\n") + ); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory, mode: "source" }); + const noPaths = await auditPublicArtifacts({ allowlistedPaths: [], cwd: directory, mode: "source" }); + + expect(report.ok).toBe(true); + expect(codes(noPaths)).toEqual(["NO_EXPLICIT_ALLOWLIST"]); + }); + + it("distinguishes source references from concrete data embedded in a shipped script", async () => { + const safeDirectory = temporaryDirectory(); + writeFileSync( + join(safeDirectory, "normalizer.js"), + "export const normalize = (row) => ({ probability: Number(row.Pct[0]), odds: row.Prices });\n" + ); + const unsafeDirectory = temporaryDirectory(); + writeFileSync( + join(unsafeDirectory, "bootstrap.js"), + "window.__BOOTSTRAP__ = { Pct: [\"51.000\", \"49.000\"], Prices: [1960, 2040] };\n" + ); + + const safe = await auditPublicArtifacts({ allowlistedPaths: [safeDirectory], cwd: safeDirectory }); + const unsafe = await auditPublicArtifacts({ allowlistedPaths: [unsafeDirectory], cwd: unsafeDirectory }); + + expect(safe.ok).toBe(true); + expect(codes(unsafe)).toContain("RAW_TXLINE_KEY"); + }); + + it("recognizes JSON-escaped Windows workstation paths", async () => { + const directory = temporaryDirectory(); + writeJson(directory, "metadata.json", { buildRoot: "C:\\Users\\Deborah\\samaritan\\private" }); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toContain("PRIVATE_ABSOLUTE_PATH"); + }); + + it("rejects invalid public JSON instead of silently treating it as source text", async () => { + const directory = temporaryDirectory(); + writeFileSync(join(directory, "broken.json"), "{ not: valid json }\n"); + + const report = await auditPublicArtifacts({ allowlistedPaths: [directory], cwd: directory }); + + expect(codes(report)).toContain("UNPARSEABLE_ARTIFACT_DATA"); + }); +}); + +describe("public artifact audit CLI arguments", () => { + it("keeps bundle and source allowlists explicit and mutually exclusive", () => { + expect(parsePublicArtifactAuditArgs(["--bundle", "dist/public-bundle"])).toMatchObject({ + mode: "bundle", + allowlistedPaths: ["dist/public-bundle"] + }); + expect(parsePublicArtifactAuditArgs(["--source-audit", "--path", "src", "--path", "docs/submission"])) + .toMatchObject({ mode: "source", allowlistedPaths: ["src", "docs/submission"] }); + expect(() => parsePublicArtifactAuditArgs(["--source-audit", "--bundle", "dist"])).toThrow( + /cannot be combined/ + ); + expect(() => parsePublicArtifactAuditArgs(["--path", "src"])).toThrow(/only valid/); + }); +}); diff --git a/test/public-dashboard-bundle.test.ts b/test/public-dashboard-bundle.test.ts new file mode 100644 index 0000000..89fd032 --- /dev/null +++ b/test/public-dashboard-bundle.test.ts @@ -0,0 +1,168 @@ +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { stableJson } from "../src/domain/json.js"; +import { handleDashboardApi, STUDY_API_PATH } from "../src/dash/api.js"; +import { exportPublicDashboardBundle } from "../src/dash/export-public-bundle.js"; +import { + PUBLIC_DASHBOARD_BUNDLE_RELATIVE_DIR, + PUBLIC_DASHBOARD_FILES, + PUBLIC_SYNTHETIC_RECEIPT_FILENAME, + parsePublicDashboardManifest, + parsePublicDashboardResponse, + readFrozenDashboardResponse, + type PublicDashboardApiPath +} from "../src/dash/public-bundle.js"; +import { auditPublicArtifacts } from "../src/public/artifact-safety.js"; +import { verifyDecisionReceipt } from "../src/proof/decision-receipt-schema.js"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const trackedBundle = resolve(repoRoot, PUBLIC_DASHBOARD_BUNDLE_RELATIVE_DIR); +const privateProjectionEvidenceAvailable = process.env.SAMARITAN_TEST_NO_PRIVATE_DATA !== "1" && existsSync( + resolve(repoRoot, "data/research/historical-gate-study-causal-economic-v4.json") +); +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "samaritan-dashboard-bundle-")); + directories.push(directory); + return directory; +} + +function copyTrackedBundle(targetRepoRoot: string): string { + const target = resolve(targetRepoRoot, PUBLIC_DASHBOARD_BUNDLE_RELATIVE_DIR); + mkdirSync(target, { recursive: true }); + for (const filename of [ + ...PUBLIC_DASHBOARD_FILES.map((entry) => entry.file), + PUBLIC_SYNTHETIC_RECEIPT_FILENAME, + "manifest.json" + ]) { + copyFileSync(resolve(trackedBundle, filename), resolve(target, filename)); + } + return target; +} + +describe("tracked public dashboard bundle", () => { + it("has a valid deterministic manifest, response hashes, strict schemas, and public-data audit", async () => { + const manifest = parsePublicDashboardManifest(JSON.parse(readFileSync(resolve(trackedBundle, "manifest.json"), "utf8"))); + + expect(manifest.files.map((entry) => entry.apiPath)).toEqual(PUBLIC_DASHBOARD_FILES.map((entry) => entry.apiPath)); + for (const definition of PUBLIC_DASHBOARD_FILES) { + const frozen = await readFrozenDashboardResponse(repoRoot, definition.apiPath); + expect(frozen.manifest.bundleSha256).toBe(manifest.bundleSha256); + expect(JSON.parse(frozen.body)).toHaveProperty("data.snapshotId", definition.snapshotId); + } + const audit = await auditPublicArtifacts({ allowlistedPaths: [trackedBundle], cwd: repoRoot }); + expect(audit.ok, JSON.stringify(audit.violations, null, 2)).toBe(true); + const receipt = JSON.parse(readFileSync(resolve(trackedBundle, PUBLIC_SYNTHETIC_RECEIPT_FILENAME), "utf8")); + expect(verifyDecisionReceipt(receipt)).toMatchObject({ + valid: true, + synthetic: true, + lifecycleStatus: "filled_settled", + solanaNetworkVerificationPerformed: false + }); + expect(manifest.downloads).toEqual([ + expect.objectContaining({ + file: PUBLIC_SYNTHETIC_RECEIPT_FILENAME, + synthetic: true, + performanceUse: "excluded_synthetic" + }) + ]); + }); + + it("serves from a clean-clone-shaped root containing no ignored data directory", async () => { + const cleanRoot = temporaryDirectory(); + copyTrackedBundle(cleanRoot); + + const response = await handleDashboardApi(STUDY_API_PATH, cleanRoot); + + expect(response).toMatchObject({ status: 200, headers: { "x-samaritan-public-bundle": expect.stringMatching(/^[a-f0-9]{64}$/) } }); + expect(JSON.parse(response!.body)).toMatchObject({ + data: { + protocol: { status: "invalidated_suspended", active: false }, + correctedHistoricalCandidate: { + trainingNormalizedCases: 135, + heldoutNormalizedCases: 38, + heldoutFixtures: 18, + meanNetAfterCostProxyBps: 132.7, + matchClustered95Bps: { low: 14.3, high: 243.9 }, + activeStudy: false, + executable: false, + registration: "engineering_candidate_unregistered" + }, + syntheticProof: { + lifecycleStatus: "filled_settled", + offlineVerified: true, + performanceUse: "excluded_synthetic", + externalCalls: 0, + solanaAnchorStatus: "not_submitted" + } + } + }); + }); + + it("rejects response-byte tampering before parsing", async () => { + const cleanRoot = temporaryDirectory(); + const bundle = copyTrackedBundle(cleanRoot); + const commandPath = resolve(bundle, "command.json"); + writeFileSync(commandPath, `${readFileSync(commandPath, "utf8")} `); + + await expect(readFrozenDashboardResponse(cleanRoot, "/api/v1/command")).rejects.toThrow(/byte count changed/); + }); + + it("rejects manifest tampering and route-map substitution", () => { + const source = JSON.parse(readFileSync(resolve(trackedBundle, "manifest.json"), "utf8")) as { + bundleSha256: string; + files: Array<{ file: string }>; + }; + const changedHash = structuredClone(source); + changedHash.bundleSha256 = "0".repeat(64); + expect(() => parsePublicDashboardManifest(changedHash)).toThrow(/bundle hash is invalid/); + + const changedRoute = structuredClone(source); + changedRoute.files[0]!.file = "study.json"; + expect(() => parsePublicDashboardManifest(changedRoute)).toThrow(/route map/); + }); + + it("rejects schema additions and corrected-evidence claim drift even with valid JSON", () => { + const command = JSON.parse(readFileSync(resolve(trackedBundle, "command.json"), "utf8")) as { + data: Record; + }; + command.data.Pct = ["51.000", "49.000"]; + expect(() => parsePublicDashboardResponse("/api/v1/command", command)).toThrow(); + + const study = JSON.parse(readFileSync(resolve(trackedBundle, "study.json"), "utf8")) as { + data: { correctedHistoricalCandidate: { meanNetAfterCostProxyBps: number } }; + }; + study.data.correctedHistoricalCandidate.meanNetAfterCostProxyBps = 999; + expect(() => parsePublicDashboardResponse(STUDY_API_PATH, study)).toThrow(); + }); + + it.runIf(privateProjectionEvidenceAvailable)("re-exports byte-identically from unchanged private projections", async () => { + const first = temporaryDirectory(); + const second = temporaryDirectory(); + const firstResult = await exportPublicDashboardBundle({ repoRoot, outputDir: first }); + const secondResult = await exportPublicDashboardBundle({ repoRoot, outputDir: second }); + + expect(secondResult.manifest.bundleSha256).toBe(firstResult.manifest.bundleSha256); + expect(stableJson(secondResult.manifest)).toBe(stableJson(firstResult.manifest)); + for (const filename of [ + ...PUBLIC_DASHBOARD_FILES.map((entry) => entry.file), + PUBLIC_SYNTHETIC_RECEIPT_FILENAME, + "manifest.json" + ]) { + expect(readFileSync(resolve(second, filename), "utf8")).toBe(readFileSync(resolve(first, filename), "utf8")); + } + }); + + it.each(PUBLIC_DASHBOARD_FILES)("strictly serves frozen $apiPath", async ({ apiPath, snapshotId }) => { + const response = await handleDashboardApi(apiPath as PublicDashboardApiPath, repoRoot); + expect(response?.status).toBe(200); + expect(JSON.parse(response!.body)).toHaveProperty("data.snapshotId", snapshotId); + }); +}); diff --git a/test/replay-metrics.test.ts b/test/replay-metrics.test.ts index d96859e..aa4e590 100644 --- a/test/replay-metrics.test.ts +++ b/test/replay-metrics.test.ts @@ -9,7 +9,11 @@ import { ClassificationMetrics, closingLineValue } from "../src/metrics/classification.js"; -import { mergeReplaySources, type ReplayClock } from "../src/replay/merge.js"; +import { + mergeCapturedSourcesByObservedTime, + mergeReplaySources, + type ReplayClock +} from "../src/replay/merge.js"; function event(source: EventSource, sourceTsMs: number, suffix: string): CanonicalEvent { return { @@ -83,6 +87,37 @@ describe("timestamp-merged replay", () => { }); }); +describe("capture-order replay", () => { + it("interleaves by local observation while preserving regressing source timestamps", async () => { + const first = event("txline", 2_000, "a"); + const regressing = { ...event("txline", 1_000, "c"), observedTsMs: 3_005 }; + const middle = event("polymarket", 2_500, "b"); + const merged: CanonicalEvent[] = []; + for await (const item of mergeCapturedSourcesByObservedTime( + [source([first, regressing]), source([middle])], + { speed: Number.POSITIVE_INFINITY } + )) { + merged.push(item); + } + expect(merged.map((item) => item.eventId)).toEqual(["txline-a", "polymarket-b", "txline-c"]); + expect(merged[2]?.sourceTsMs).toBe(1_000); + }); + + it("fails closed when a capture source observation time regresses", async () => { + const consume = async () => { + const later = event("txline", 1_000, "a"); + const earlier = { ...event("txline", 2_000, "b"), observedTsMs: 500 }; + for await (const _item of mergeCapturedSourcesByObservedTime( + [source([later, earlier])], + { speed: Number.POSITIVE_INFINITY } + )) { + // Drain the replay. + } + }; + await expect(consume()).rejects.toThrow(/observation moved backward/); + }); +}); + describe("detector metrics primitives", () => { it("reports the complete confusion matrix", () => { const metrics = new ClassificationMetrics(); diff --git a/test/solana-decision-anchor.test.ts b/test/solana-decision-anchor.test.ts new file mode 100644 index 0000000..9f31fa1 --- /dev/null +++ b/test/solana-decision-anchor.test.ts @@ -0,0 +1,348 @@ +import { + PublicKey, + type ParsedTransactionWithMeta, + type Signer +} from "@solana/web3.js"; +import { describe, expect, it, vi } from "vitest"; +import { stableJson } from "../src/domain/json.js"; +import { buildSyntheticDecisionReceipt } from "../src/proof/synthetic-decision-receipt.js"; +import { + DECISION_ANCHOR_MEMO_DOMAIN, + SOLANA_DEVNET_GENESIS_HASH, + SOLANA_MAINNET_BETA_GENESIS_HASH, + SOLANA_MEMO_PROGRAM_ID, + buildDecisionAnchorIntent, + buildDecisionAnchorMemoPayload, + canonicalDecisionAnchorMemo, + parseCanonicalDecisionAnchorMemo, + verifyDecisionAnchorOnChain, + type DecisionAnchorMemoPayload, + type DecisionAnchorReadRpc +} from "../src/chain/decision-anchor.js"; +import { + submitDecisionAnchor, + type DecisionAnchorSubmitDependencies, + type DecisionAnchorSubmitRequest, + type DecisionAnchorSubmitRpc +} from "../src/chain/decision-anchor-submit.js"; + +const SIGNATURE = "1".repeat(64); + +function parsedTransaction(memo: string): ParsedTransactionWithMeta { + return { + slot: 987_654, + blockTime: 1_800_000_000, + version: "legacy", + transaction: { + signatures: [SIGNATURE], + message: { + accountKeys: [], + recentBlockhash: "11111111111111111111111111111111", + instructions: [{ + program: "spl-memo", + programId: new PublicKey(SOLANA_MEMO_PROGRAM_ID), + parsed: memo + }] + } + }, + meta: { + err: null, + fee: 5_000, + preBalances: [], + postBalances: [] + } + }; +} + +function readRpc(input: { + memo: string; + genesisHash?: string; +}): DecisionAnchorReadRpc { + return { + getGenesisHash: vi.fn(async () => input.genesisHash ?? SOLANA_DEVNET_GENESIS_HASH), + getParsedTransaction: vi.fn(async () => parsedTransaction(input.memo)) + }; +} + +function submitRequest(overrides: Partial = {}): DecisionAnchorSubmitRequest { + const receipt = buildSyntheticDecisionReceipt(); + return { + receipt, + intent: buildDecisionAnchorIntent(receipt), + network: "devnet", + confirmedReceiptHash: receipt.integrity.receiptHash, + signerPath: ".wallet/anchor-devnet.json", + humanApproved: true, + ...overrides + }; +} + +function submitMocks(): { + dependencies: DecisionAnchorSubmitDependencies; + assertSignerPathIgnored: ReturnType; + connect: ReturnType; + loadSigner: ReturnType; + signAndSend: ReturnType; +} { + const receipt = buildSyntheticDecisionReceipt(); + const memo = canonicalDecisionAnchorMemo(buildDecisionAnchorMemoPayload(receipt)); + const rpc: DecisionAnchorSubmitRpc = { + ...readRpc({ memo }), + getLatestBlockhash: vi.fn(async () => ({ + blockhash: "11111111111111111111111111111111", + lastValidBlockHeight: 1 + })), + sendRawTransaction: vi.fn(async () => SIGNATURE), + confirmTransaction: vi.fn(async () => ({ value: { err: null } })) + }; + const assertSignerPathIgnored = vi.fn(); + const connect = vi.fn(() => rpc); + const dummySigner: Signer = { + publicKey: new PublicKey("11111111111111111111111111111111"), + secretKey: new Uint8Array(64) + }; + const loadSigner = vi.fn(async () => dummySigner); + const signAndSend = vi.fn(async () => SIGNATURE); + return { + dependencies: { assertSignerPathIgnored, connect, loadSigner, signAndSend }, + assertSignerPathIgnored, + connect, + loadSigner, + signAndSend + }; +} + +describe("Solana decision receipt anchor", () => { + it("canonicalizes the memo deterministically and rejects non-canonical JSON", () => { + const receipt = buildSyntheticDecisionReceipt(); + const payload = buildDecisionAnchorMemoPayload(receipt); + const reordered = { + ledgerHead: payload.ledgerHead, + receipt: payload.receipt, + commitmentType: payload.commitmentType, + network: payload.network, + anchorType: payload.anchorType, + schemaVersion: payload.schemaVersion + }; + const memo = canonicalDecisionAnchorMemo(payload); + + expect(canonicalDecisionAnchorMemo(reordered)).toBe(memo); + expect(parseCanonicalDecisionAnchorMemo(memo)).toEqual(payload); + expect(Buffer.byteLength(memo, "utf8")).toBeLessThanOrEqual(566); + const pretty = `${DECISION_ANCHOR_MEMO_DOMAIN}\n${JSON.stringify(payload, null, 2)}`; + expect(() => parseCanonicalDecisionAnchorMemo(pretty)).toThrow(/not canonically encoded/); + }); + + it("prepares a deterministic unsigned intent without payer, blockhash, signature, or access claims", () => { + const receipt = buildSyntheticDecisionReceipt(); + const first = buildDecisionAnchorIntent(receipt); + const second = buildDecisionAnchorIntent(receipt); + + expect(first).toEqual(second); + expect(first).toMatchObject({ + network: "devnet", + transaction: { + feePayer: "supplied_only_at_submit", + recentBlockhash: "fetched_only_at_submit", + signatures: "none" + }, + preparation: { + offline: true, + signerAccessed: false, + networkAccessed: false + } + }); + expect(first.instruction.keys).toEqual([]); + }); + + it("verifies a canonical devnet memo against the exact receipt and ledger head using reads only", async () => { + const receipt = buildSyntheticDecisionReceipt(); + const memo = canonicalDecisionAnchorMemo(buildDecisionAnchorMemoPayload(receipt)); + const rpc = readRpc({ memo }); + + await expect(verifyDecisionAnchorOnChain({ + receipt, + signature: SIGNATURE, + network: "devnet", + rpc + })).resolves.toMatchObject({ + network: "devnet", + transactionSignature: SIGNATURE, + receiptHash: receipt.integrity.receiptHash, + ledgerHeadHash: receipt.ledger.finalHeadHash, + rpcCommitment: "confirmed" + }); + expect(rpc.getGenesisHash).toHaveBeenCalledTimes(1); + expect(rpc.getParsedTransaction).toHaveBeenCalledWith(SIGNATURE); + }); + + it("rejects an RPC connected to the wrong Solana network before fetching a transaction", async () => { + const receipt = buildSyntheticDecisionReceipt(); + const memo = canonicalDecisionAnchorMemo(buildDecisionAnchorMemoPayload(receipt)); + const rpc = readRpc({ memo, genesisHash: SOLANA_MAINNET_BETA_GENESIS_HASH }); + + await expect(verifyDecisionAnchorOnChain({ + receipt, + signature: SIGNATURE, + network: "devnet", + rpc + })).rejects.toThrow(/network mismatch/); + expect(rpc.getParsedTransaction).not.toHaveBeenCalled(); + }); + + it("rejects a canonical memo that commits the wrong receipt hash", async () => { + const receipt = buildSyntheticDecisionReceipt(); + const payload: DecisionAnchorMemoPayload = structuredClone( + buildDecisionAnchorMemoPayload(receipt) + ); + payload.receipt.hash = "a".repeat(64); + + await expect(verifyDecisionAnchorOnChain({ + receipt, + signature: SIGNATURE, + network: "devnet", + rpc: readRpc({ memo: canonicalDecisionAnchorMemo(payload) }) + })).rejects.toThrow(/receipt hash does not match/); + }); + + it("rejects a canonical memo that commits the wrong ledger head", async () => { + const receipt = buildSyntheticDecisionReceipt(); + const payload: DecisionAnchorMemoPayload = structuredClone( + buildDecisionAnchorMemoPayload(receipt) + ); + payload.ledgerHead.hash = "b".repeat(64); + + await expect(verifyDecisionAnchorOnChain({ + receipt, + signature: SIGNATURE, + network: "devnet", + rpc: readRpc({ memo: canonicalDecisionAnchorMemo(payload) }) + })).rejects.toThrow(/ledger head does not match/); + }); + + it("rejects a memo with any other commitment type", async () => { + const receipt = buildSyntheticDecisionReceipt(); + const payload = { + ...buildDecisionAnchorMemoPayload(receipt), + commitmentType: "ledger_head_only" + }; + const memo = `${DECISION_ANCHOR_MEMO_DOMAIN}\n${stableJson(payload)}`; + + await expect(verifyDecisionAnchorOnChain({ + receipt, + signature: SIGNATURE, + network: "devnet", + rpc: readRpc({ memo }) + })).rejects.toThrow(); + }); + + it("rejects a missing signer before any ignored-path, RPC, signer, or send dependency runs", async () => { + const mocks = submitMocks(); + const request = submitRequest(); + delete request.signerPath; + + await expect(submitDecisionAnchor(request, mocks.dependencies)).rejects.toThrow( + /signer path is required/ + ); + expect(mocks.assertSignerPathIgnored).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.loadSigner).not.toHaveBeenCalled(); + expect(mocks.signAndSend).not.toHaveBeenCalled(); + }); + + it("refuses mainnet before any signer or network dependency runs", async () => { + const mocks = submitMocks(); + + await expect(submitDecisionAnchor( + submitRequest({ network: "mainnet-beta" }), + mocks.dependencies + )).rejects.toThrow(/mainnet-beta is forbidden/); + expect(mocks.assertSignerPathIgnored).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.loadSigner).not.toHaveBeenCalled(); + expect(mocks.signAndSend).not.toHaveBeenCalled(); + }); + + it("requires the explicit human write-approval flag before dependencies run", async () => { + const mocks = submitMocks(); + + await expect(submitDecisionAnchor( + submitRequest({ humanApproved: false }), + mocks.dependencies + )).rejects.toThrow(/Human approval is required/); + expect(mocks.assertSignerPathIgnored).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.loadSigner).not.toHaveBeenCalled(); + expect(mocks.signAndSend).not.toHaveBeenCalled(); + }); + + it("refuses an inexact receipt-hash confirmation before dependencies run", async () => { + const mocks = submitMocks(); + + await expect(submitDecisionAnchor( + submitRequest({ confirmedReceiptHash: "c".repeat(64) }), + mocks.dependencies + )).rejects.toThrow(/Exact receipt-hash confirmation/); + expect(mocks.assertSignerPathIgnored).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.loadSigner).not.toHaveBeenCalled(); + expect(mocks.signAndSend).not.toHaveBeenCalled(); + }); + + it("checks the RPC genesis before loading a signer or sending", async () => { + const mocks = submitMocks(); + const wrongRpc: DecisionAnchorSubmitRpc = { + ...readRpc({ + memo: canonicalDecisionAnchorMemo( + buildDecisionAnchorMemoPayload(buildSyntheticDecisionReceipt()) + ), + genesisHash: SOLANA_MAINNET_BETA_GENESIS_HASH + }), + getLatestBlockhash: vi.fn(), + sendRawTransaction: vi.fn(), + confirmTransaction: vi.fn() + }; + mocks.connect.mockReturnValue(wrongRpc); + + await expect(submitDecisionAnchor( + submitRequest(), + mocks.dependencies + )).rejects.toThrow(/submission refuses genesis/); + expect(mocks.assertSignerPathIgnored).toHaveBeenCalledTimes(1); + expect(mocks.loadSigner).not.toHaveBeenCalled(); + expect(mocks.signAndSend).not.toHaveBeenCalled(); + }); + + it("refuses a non-ignored signer path before connecting or loading it", async () => { + const mocks = submitMocks(); + mocks.assertSignerPathIgnored.mockImplementation(() => { + throw new Error("not ignored"); + }); + + await expect(submitDecisionAnchor( + submitRequest({ signerPath: "unsafe-keypair.json" }), + mocks.dependencies + )).rejects.toThrow(/not ignored/); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.loadSigner).not.toHaveBeenCalled(); + expect(mocks.signAndSend).not.toHaveBeenCalled(); + }); + + it("runs the fully authorized path through mocked sign/send and then read-only verification", async () => { + const mocks = submitMocks(); + const receipt = buildSyntheticDecisionReceipt(); + + await expect(submitDecisionAnchor( + submitRequest({ receipt, intent: buildDecisionAnchorIntent(receipt) }), + mocks.dependencies + )).resolves.toMatchObject({ + receiptHash: receipt.integrity.receiptHash, + ledgerHeadHash: receipt.ledger.finalHeadHash, + transactionSignature: SIGNATURE + }); + expect(mocks.assertSignerPathIgnored).toHaveBeenCalledTimes(1); + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.loadSigner).toHaveBeenCalledTimes(1); + expect(mocks.signAndSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/synthetic-judge-case.test.ts b/test/synthetic-judge-case.test.ts new file mode 100644 index 0000000..313859f --- /dev/null +++ b/test/synthetic-judge-case.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { runSyntheticJudgeCase } from "../src/demo/synthetic-judge-case.js"; +import { verifyDecisionReceipt } from "../src/proof/decision-receipt-schema.js"; + +describe("synthetic judge case", () => { + it("runs the frozen paper path through verified settlement without any external boundary", async () => { + const report = await runSyntheticJudgeCase(); + + expect(report).toMatchObject({ + synthetic: true, + performanceUse: "excluded_synthetic", + protocolStatus: "engineering_candidate_unregistered", + realMoneyGate: "closed", + boundaries: { + triageStubCalls: 1, + analystStubCalls: 1, + anthropicCalls: 0, + txlineApiCalls: 0, + polymarketApiCalls: 0, + walletCalls: 0, + solanaRpcCalls: 0, + realOrders: 0 + }, + runtime: { + rawSignals: 1, + normalizedSignals: 1, + routedSignals: 1, + closeMarks: 1, + settlements: 1, + finalLifecycleStatus: expect.stringMatching(/^(filled|partial)_settled$/), + ledgerHashSchemaVersions: [2] + }, + receiptVerification: { + valid: true, + synthetic: true, + solanaAnchorMetadataPresent: false, + solanaNetworkVerificationPerformed: false + } + }); + expect(report.runtime.canonicalEventsPublished).toBeGreaterThan(15); + expect(report.runtime.ledgerRows).toBe(12); + expect(verifyDecisionReceipt(report.receipt)).toEqual(report.receiptVerification); + }); + + it("emits a public-safe receipt with only commitments and bucketed TXLine movement", async () => { + const { receipt } = await runSyntheticJudgeCase(); + const serialized = JSON.stringify(receipt); + + for (const forbidden of [ + "fairProbability", + "consensusProbability", + "polymarketProbability", + "limitProbability", + "rawGap", + "crossMarketGapBps", + "\"Pct\"", + "\"Prices\"" + ]) { + expect(serialized).not.toContain(forbidden); + } + expect(receipt.disclosure).toEqual({ + policy: "hashes_and_derived_signals_only", + rawTxlineFieldsIncluded: false + }); + expect(receipt.lifecycle.signal.derivedEvidence.txlineMovementBucketBps).toBe(25); + expect(receipt.provenance.label).toContain("SYNTHETIC JUDGE CASE"); + }); + + it("produces the same committed receipt and ledger head on every run", async () => { + const first = await runSyntheticJudgeCase(); + const second = await runSyntheticJudgeCase(); + + expect(second.receipt.integrity.receiptHash).toBe(first.receipt.integrity.receiptHash); + expect(second.runtime.ledgerHeadSha256).toBe(first.runtime.ledgerHeadSha256); + expect(second.receipt).toEqual(first.receipt); + }); +}); diff --git a/test/threshold-grid.test.ts b/test/threshold-grid.test.ts index 3cc7a83..899a9c5 100644 --- a/test/threshold-grid.test.ts +++ b/test/threshold-grid.test.ts @@ -37,6 +37,7 @@ function snapshot(): FeatureSnapshot { triggerEventId: "trigger", triggerSource: "polymarket", asOfTsMs: 10_000, + observedAtTsMs: 10_000, fixtureId: "fixture-1", market: { family: "match_result", @@ -109,4 +110,35 @@ describe("detector threshold grid", () => { expect(grid.pendingCaseCount).toBe(0); expect(grid.results()[0]?.classification[0]?.truePositive).toBe(1); }); + + it("uses normalized total-goals cases for grid signal counts while retaining raw audit counts", () => { + const grid = new DetectorThresholdGrid([base]); + const totalMarket = { + family: "total_goals" as const, + period: "full_time" as const, + lineMilli: 2_500, + key: "fixture-1:total_goals:full_time:2500" + }; + const over = { ...snapshot(), triggerEventId: "over", market: totalMarket, outcome: "over" as const }; + const under = { + ...snapshot(), + triggerEventId: "under", + market: totalMarket, + outcome: "under" as const, + consensus: { ...snapshot().consensus, probability: probability(0.45) }, + spread: { consensusMinusPolymarket: -0.05, rawBuyGap: null, rawSellGap: null } + }; + + grid.ingest(over, { XMARKET_DIVERGENCE: true }); + grid.ingest(under, { XMARKET_DIVERGENCE: true }); + const result = grid.results()[0]!; + + expect(result.rawSignalCounts.XMARKET_DIVERGENCE).toBe(2); + expect(result.signalCounts.XMARKET_DIVERGENCE).toBe(1); + expect(result.economicCaseNormalization).toMatchObject({ + rawEmissions: 2, + normalizedCases: 1, + complementarySellsCollapsed: 1 + }); + }); }); diff --git a/test/total-line-evidence-query.test.ts b/test/total-line-evidence-query.test.ts new file mode 100644 index 0000000..bfc2898 --- /dev/null +++ b/test/total-line-evidence-query.test.ts @@ -0,0 +1,134 @@ +import { DuckDBInstance } from "@duckdb/node-api"; +import { describe, expect, it } from "vitest"; +import { probability } from "../src/domain/probability.js"; +import { + selectMainTotalLine, + type TotalLineEvidence +} from "../src/research/main-total-selector.js"; +import { buildTotalLineHistoryEvidenceQuery } from "../src/research/total-line-evidence-query.js"; + +type EvidenceRow = { + marketId: string; + selectorCutoffTsMs: number; + coveragePoints: number; + coverageFirstPointTsMs: number | null; + coverageLastPointTsMs: number | null; + probability: number | null; + probabilityPointTsMs: number | null; +}; + +function parseRows(rows: Record[]): EvidenceRow[] { + return rows.map((row) => ({ + marketId: String(row["market_id"]), + selectorCutoffTsMs: Number(row["selector_cutoff_ts_ms"]), + coveragePoints: Number(row["coverage_points"]), + coverageFirstPointTsMs: + row["coverage_first_point_ts_ms"] === null + ? null + : Number(row["coverage_first_point_ts_ms"]), + coverageLastPointTsMs: + row["coverage_last_point_ts_ms"] === null + ? null + : Number(row["coverage_last_point_ts_ms"]), + probability: + row["pre_kickoff_probability"] === null + ? null + : Number(row["pre_kickoff_probability"]), + probabilityPointTsMs: + row["pre_kickoff_point_ts_ms"] === null + ? null + : Number(row["pre_kickoff_point_ts_ms"]) + })); +} + +function selectedMarket(rows: readonly EvidenceRow[]): string | undefined { + const evidence: TotalLineEvidence[] = rows.map((row, index) => ({ + fixtureId: "fixture-1", + marketId: row.marketId, + marketKey: `fixture-1:total_goals:full_time:${index === 0 ? 2500 : 3500}`, + lineMilli: index === 0 ? 2_500 : 3_500, + mappingStatus: "candidate", + txlineMarketObserved: true, + selectorCutoffTsMs: row.selectorCutoffTsMs, + preKickoffOverProbability: row.probability === null ? null : probability(row.probability), + preKickoffPointTsMs: row.probabilityPointTsMs, + coverageFirstPointTsMs: row.coverageFirstPointTsMs, + coverageLastPointTsMs: row.coverageLastPointTsMs, + volume: 0, + liquidity: 0, + coveragePoints: row.coveragePoints + })); + return selectMainTotalLine("fixture-1", evidence, { + minimumCoveragePoints: 2, + minimumVolume: 0, + minimumLiquidity: 0, + maximumDistanceFromEven: 0.5, + weights: { balance: 1, volume: 0, liquidity: 0, coverage: 0 } + }, 2_000).selected?.marketId; +} + +describe("causal total-line history evidence query", () => { + it("does not let future prices or future coverage affect evidence", async () => { + const instance = await DuckDBInstance.create(":memory:"); + const connection = await instance.connect(); + try { + await connection.run(` + CREATE TABLE polymarket_history( + event_id VARCHAR, + asset_id VARCHAR, + source_ts_ms BIGINT, + price DOUBLE + ); + INSERT INTO polymarket_history VALUES + ('a-early', 'asset-a', 1000, 0.40), + ('a-cutoff', 'asset-a', 2000, 0.49), + ('b-early', 'asset-b', 1000, 0.60), + ('b-cutoff', 'asset-b', 2000, 0.55) + `); + const query = buildTotalLineHistoryEvidenceQuery([ + { marketId: "market-a", assetId: "asset-a", selectorCutoffTsMs: 2_000 }, + { marketId: "market-b", assetId: "asset-b", selectorCutoffTsMs: 2_000 } + ]); + const beforeFutureRows = parseRows( + (await connection.runAndReadAll(query)).getRowObjectsJson() as Record[] + ); + + await connection.run(` + INSERT INTO polymarket_history VALUES + ('a-future-1', 'asset-a', 2001, 0.99), + ('a-future-2', 'asset-a', 3000, 0.01), + ('b-future-1', 'asset-b', 2001, 0.01) + `); + const afterFutureRows = parseRows( + (await connection.runAndReadAll(query)).getRowObjectsJson() as Record[] + ); + + expect(afterFutureRows).toEqual(beforeFutureRows); + expect(selectedMarket(beforeFutureRows)).toBe("market-a"); + expect(selectedMarket(afterFutureRows)).toBe("market-a"); + expect(afterFutureRows).toEqual([ + { + marketId: "market-a", + selectorCutoffTsMs: 2_000, + coveragePoints: 2, + coverageFirstPointTsMs: 1_000, + coverageLastPointTsMs: 2_000, + probability: 0.49, + probabilityPointTsMs: 2_000 + }, + { + marketId: "market-b", + selectorCutoffTsMs: 2_000, + coveragePoints: 2, + coverageFirstPointTsMs: 1_000, + coverageLastPointTsMs: 2_000, + probability: 0.55, + probabilityPointTsMs: 2_000 + } + ]); + } finally { + connection.closeSync(); + instance.closeSync(); + } + }); +}); diff --git a/test/txline-normalizer.test.ts b/test/txline-normalizer.test.ts index c6fe478..848d4f1 100644 --- a/test/txline-normalizer.test.ts +++ b/test/txline-normalizer.test.ts @@ -61,6 +61,23 @@ describe("TXLine normalization", () => { expect(event.outcomes.map((outcome) => outcome.fairProbability)).toEqual([null, null]); }); + it("skips a mapped market row when both quote arrays are empty", () => { + const suspended = { + ...odds, + MessageId: "total-empty", + SuperOddsType: "OVERUNDER_PARTICIPANT_GOALS", + MarketParameters: "line=1.75", + PriceNames: ["over", "under"], + Prices: [], + Pct: [] + }; + expect(normalizeTxLineEnvelope(envelope(suspended))).toEqual([]); + + expect(() => normalizeTxLineEnvelope(envelope({ ...suspended, Prices: [2000] }))).toThrow( + /outcome array lengths disagree/ + ); + }); + it("uses the same normalizer for a synthetic replay-shaped frame and a live envelope", () => { const rawFrame = `data: ${JSON.stringify(odds)}\nid: synthetic-sse-1`; const replayEnvelope = capturedFrameToEnvelope({ receivedAt, stream: "odds", rawFrame }); From 9da7dbdd58d5a53e5e8b93c3d14225218d4503ff Mon Sep 17 00:00:00 2001 From: Pavilion-devs Date: Wed, 15 Jul 2026 16:24:39 +0100 Subject: [PATCH 2/6] Harden autonomous paper runtime and judge evidence --- .github/workflows/ci.yml | 4 +- README.md | 12 +- apps/dashboard/src/Casebook.tsx | 80 +- apps/dashboard/src/Command.tsx | 65 +- apps/dashboard/src/Matchroom.tsx | 45 +- apps/dashboard/src/Shell.tsx | 27 +- apps/dashboard/src/Study.tsx | 16 +- apps/dashboard/src/styles.css | 72 +- .../england-argentina-2026-07-15.json | 8 +- config/captures/france-spain-2026-07-14.json | 8 +- docs/01-research.md | 2 +- docs/02-architecture.md | 17 +- docs/03-strategy-playbook.md | 4 +- docs/08-winning-submission-plan.md | 85 +- docs/PHASE-1-STATUS.md | 6 +- docs/PHASE-4-STATUS.md | 26 +- docs/UI.md | 29 +- ...ired-spain-belgium-2026-07-10-live-lane.md | 2 +- docs/research/semifinal-capture-schedule.md | 30 +- docs/submission/demo-script-v1.md | 14 +- docs/submission/public-release-scope.md | 4 +- docs/submission/submission-claims-matrix.md | 10 +- docs/submission/submission-form-draft.md | 10 +- docs/submission/technical-overview.md | 6 +- package.json | 15 +- phase0/pnpm-workspace.yaml | 8 + phase0/src/capture-polymarket-ws.ts | 488 +++++- phase0/src/capture-sse.ts | 198 ++- phase0/src/capture-window.ts | 42 + phase0/src/polymarket-lib.ts | 147 +- phase0/test/capture-sse.test.ts | 72 +- .../test/polymarket-exact-discovery.test.ts | 196 +++ plan.md | 21 +- pnpm-workspace.yaml | 6 + public/artifacts/dashboard/casebook.json | 2 +- public/artifacts/dashboard/command.json | 2 +- public/artifacts/dashboard/manifest.json | 2 +- .../dashboard/matchroom-spain-belgium.json | 2 +- src/agents/claude-evidence-ledger.ts | 251 +++ src/agents/claude.ts | 96 +- src/agents/contracts.ts | 7 +- src/dash/casebook-projection.ts | 111 +- src/dash/command-projection.ts | 400 ++++- src/dash/export-public-bundle.ts | 11 +- src/dash/projection.ts | 368 ++++- src/dash/public-bundle.ts | 150 +- src/dash/public-contract.ts | 113 +- src/demo/synthetic-judge-case.ts | 202 ++- src/domain/bounded-async-queue.ts | 139 ++ src/domain/filesystem-paths.ts | 97 ++ src/harness/build-paired-capture-analysis.ts | 1410 +++++++++++++++++ src/harness/build-paper-fixture-universe.ts | 50 +- src/harness/capture-config.ts | 54 +- src/harness/captured-paper-admission.ts | 160 ++ src/harness/claude-paper-study.ts | 51 +- src/harness/paired-capture-manifest.ts | 483 ++++++ src/harness/paper-event-source.ts | 167 ++ src/harness/paper-fixture-universe.ts | 166 +- src/harness/paper-lane-runtime.ts | 10 +- src/harness/paper-pipeline.ts | 104 +- src/harness/paper-runtime.ts | 36 +- src/harness/paper-scheduler.ts | 103 +- src/harness/paper-session.ts | 569 +++++++ src/harness/run-captured-paper-session.ts | 1099 +++++++++++++ src/harness/run-scheduled-capture.ts | 864 ++++++++++ src/harness/verify-claude-paper-runtime.ts | 26 +- src/ingest/polymarket/fees.ts | 12 +- src/ingest/polymarket/live.ts | 127 +- src/ingest/polymarket/replay.ts | 8 +- src/ingest/txline/live.ts | 46 +- src/ingest/txline/replay.ts | 9 +- src/proof/claude-invocation-evidence.ts | 87 + src/proof/decision-receipt-schema.ts | 41 +- src/replay/files.ts | 41 +- src/store/decision-ledger.ts | 69 +- test/build-paired-capture-analysis.test.ts | 700 ++++++++ test/capture-config.test.ts | 52 +- test/captured-paper-admission.test.ts | 212 +++ test/claude-agents.test.ts | 112 ++ test/claude-evidence-ledger.test.ts | 122 ++ test/claude-paper-study.test.ts | 115 +- test/dashboard-projection.test.ts | 246 ++- test/decision-ledger.test.ts | 20 + test/decision-receipt.test.ts | 30 + test/filesystem-paths.test.ts | 37 + test/paired-capture-manifest.test.ts | 272 ++++ test/paper-event-source.test.ts | 252 +++ test/paper-fixture-universe.test.ts | 214 ++- test/paper-lane-runtime.test.ts | 33 +- test/paper-session.test.ts | 772 +++++++++ test/polymarket-fees.test.ts | 19 + test/polymarket-live.test.ts | 197 +++ test/run-captured-paper-session.test.ts | 239 +++ test/run-scheduled-capture.test.ts | 364 +++++ test/txline-live.test.ts | 86 + 95 files changed, 12702 insertions(+), 912 deletions(-) create mode 100644 phase0/pnpm-workspace.yaml create mode 100644 phase0/src/capture-window.ts create mode 100644 phase0/test/polymarket-exact-discovery.test.ts create mode 100644 src/agents/claude-evidence-ledger.ts create mode 100644 src/domain/bounded-async-queue.ts create mode 100644 src/domain/filesystem-paths.ts create mode 100644 src/harness/build-paired-capture-analysis.ts create mode 100644 src/harness/captured-paper-admission.ts create mode 100644 src/harness/paired-capture-manifest.ts create mode 100644 src/harness/paper-event-source.ts create mode 100644 src/harness/paper-session.ts create mode 100644 src/harness/run-captured-paper-session.ts create mode 100644 src/harness/run-scheduled-capture.ts create mode 100644 src/proof/claude-invocation-evidence.ts create mode 100644 test/build-paired-capture-analysis.test.ts create mode 100644 test/captured-paper-admission.test.ts create mode 100644 test/claude-evidence-ledger.test.ts create mode 100644 test/filesystem-paths.test.ts create mode 100644 test/paired-capture-manifest.test.ts create mode 100644 test/paper-event-source.test.ts create mode 100644 test/paper-session.test.ts create mode 100644 test/polymarket-live.test.ts create mode 100644 test/run-captured-paper-session.test.ts create mode 100644 test/run-scheduled-capture.test.ts create mode 100644 test/txline-live.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cd8963..5641708 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 10.32.1 + version: 11.13.0 run_install: false - name: Install Node.js 22 @@ -47,7 +47,7 @@ jobs: run: pnpm public:audit - name: Install isolated Phase 0 capture dependencies - run: pnpm --dir phase0 install --frozen-lockfile --ignore-workspace + run: pnpm --dir phase0 install --frozen-lockfile - name: Typecheck Phase 0 capture tools run: pnpm --dir phase0 exec tsc -p tsconfig.json --noEmit diff --git a/README.md b/README.md index 58b8966..2ffc453 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,17 @@ ## Status -**PHASE: PHASE 4 PAPER REASONING RUNTIME IMPLEMENTED — LIVE EVIDENCE COLLECTION ACTIVE.** The strict Node 22 workspace, canonical event contracts, evidence-bearing mapping registry, TXLine SSE clients, Gamma-refreshed Polymarket WebSocket source, streaming replay adapters, append-only SQLite/DuckDB stores, feature engine, detectors, chronological research harness, bounded Claude layer, deterministic paper risk, and paper portfolio are implemented. The local research database holds 9,025,361 normalized TXLine outcomes and 85,249,582 mapped Polymarket points. The synchronized Spain-Belgium study found no supporting post-TXLine STALE_QUOTE case, so that detector remains disabled. +**PHASE: PHASE 4 PAPER REASONING RUNTIME IMPLEMENTED — BOUNDED EVIDENCE COLLECTION IN PROGRESS.** The strict Node 22 workspace, canonical event contracts, evidence-bearing mapping registry, TXLine SSE clients, Gamma-refreshed Polymarket WebSocket source, streaming replay adapters, append-only SQLite/DuckDB stores, feature engine, detectors, chronological research harness, bounded Claude layer, deterministic paper risk, and paper portfolio are implemented. Live and captured-replay source adapters feed the same `runPaperSession` conductor; the strategy runtime receives canonical events rather than a live/replay mode flag. This is an implemented orchestration boundary, not a claim that Samaritan is already a deployed 24/7 autonomous service. The local research database holds 9,025,361 normalized TXLine outcomes and 85,249,582 mapped Polymarket points. The synchronized Spain-Belgium study found no supporting post-TXLine STALE_QUOTE case, so that detector remains disabled. **Evidence correction, July 14:** Samaritan invalidated its original dynamic-total result after finding a future-informed selector and inflated emission semantics, preserved the artifact, repaired both defects, and reran the unchanged chronological boundary. The corrected Total Goals–only `CONSENSUS_MOVE` family has 38 held-out normalized buy cases across 18 fixtures, mean `+132.7` probability bps after the 100 bps historical proxy, and a fixture-clustered 95% interval of `+14.3` to `+243.9` bps. This is sampled-price historical signal evidence for a fresh forward paper review—not alpha, profitability, or executable fills. Paper v2 remains unregistered and the real-money gate remains closed. See [the corrected v4 report](docs/research/historical-gate-study-causal-economic-v4.md) and [v1 invalidation record](docs/research/historical-gate-study-v1-invalidation.md). -The Phase-4 paper spine runs canonical events through deterministic detection, ledgers signals before model I/O, measures Haiku/Opus wall-clock decision latency from local observation time, and waits for both that latency and the current sports-order placement delay before deterministic risk and depth-aware simulation. Haiku and Opus can submit only strict structured judgments; neither can size or place an order. The July 12 paper-study registration and its empty ledgers are preserved but suspended after the July 14 selector audit. The corrected v2 protocol is an unregistered engineering candidate: its operational Claude-readiness command is disabled until Deborah signs a replacement protocol. No trading credential is connected and the real-money gate remains closed. +The Phase-4 paper spine runs canonical events through deterministic detection, ledgers signals before model I/O, measures Haiku/Opus wall-clock decision latency from local observation time, and waits for both that latency and the current sports-order placement delay before deterministic risk and depth-aware simulation. Haiku and Opus can submit only strict structured judgments; neither can size or place an order. Real invocation metadata can be appended to a separate local hash chain. Receipt generation accepts a reference only after that local chain and exact record verify; the portable offline receipt does not independently prove membership. This is a local integrity audit—not Anthropic/provider attestation, independently signed billing proof, or proof of model quality. The July 12 paper-study registration and its empty ledgers are preserved but suspended after the July 14 selector audit. The corrected v2 protocol is an unregistered engineering candidate: real Anthropic/runtime admission and its operational readiness command remain blocked until Deborah signs a replacement protocol and fresh ledgers are initialized. No trading credential is connected and the real-money gate remains closed. + +The scheduled France–Spain paired capture failed closed before kickoff and is inadmissible. England–Argentina remains capture-only and is scheduled for the absolute `2026-07-15T16:00:00Z`–`2026-07-15T22:00:00Z` window under startup, stream-freshness, and terminal-artifact watchdogs. A successful capture would still require explicit evidence verification and Deborah's v2 registration before any study admission or Claude spend. ## Judge quickstart -Requirements: Node `22.23.1` and pnpm `10.32.1`. +Requirements: Node `22.23.1` and pnpm `11.13.0`. ```bash corepack enable @@ -28,7 +30,7 @@ Then open `http://127.0.0.1:4173`. `pnpm judge` first runs the complete clean-cl The verification gate proves: - all TypeScript checks and 200+ tests pass; -- a deterministic synthetic case traverses the production paper components from canonical events through settlement; +- a deterministic synthetic case traverses the shared `runPaperSession` conductor from canonical events through settlement, using deterministic model stubs; - the public Decision Receipt verifies offline; - the frozen dashboard bundle contains only allowlisted, derived artifacts; and - the production dashboard builds from that clean-clone bundle. @@ -36,7 +38,7 @@ The verification gate proves: The public experience deliberately separates two evidence classes: 1. **Captured refusal:** the Spain–Belgium replay is real synchronized evidence and correctly ends in no trade. Only bucketed TXLine movement is public. -2. **Synthetic full lifecycle:** `pnpm demo` exercises signal → triage → thesis → deterministic risk → intent → paper fill → close → settlement → receipt with zero external calls. It is visibly synthetic and permanently excluded from performance evidence. +2. **Synthetic full lifecycle:** `pnpm demo` sends 20 invented canonical events through the same `runPaperSession` conductor used by the source adapters, then exercises signal → triage → thesis → deterministic risk → intent → paper fill → close → settlement → receipt. Its two model boundaries are deterministic stubs, it makes zero external calls, and it is visibly synthetic and permanently excluded from performance evidence. Useful standalone commands: diff --git a/apps/dashboard/src/Casebook.tsx b/apps/dashboard/src/Casebook.tsx index 1847447..27df73c 100644 --- a/apps/dashboard/src/Casebook.tsx +++ b/apps/dashboard/src/Casebook.tsx @@ -1,7 +1,7 @@ import { startTransition, useDeferredValue, useEffect, useState } from "react"; import type { CasebookCaseSummary, CasebookSnapshot } from "../../../src/dash/public-contract"; import { loadCasebook } from "./api"; -import { BrandMark, Icon, MobileNavigation, Navigation, Topbar } from "./Shell"; +import { BrandMark, Icon, MobileNavigation, Navigation, ProvenanceBadge, Topbar } from "./Shell"; type Filters = { fixture: string; @@ -75,16 +75,17 @@ function CasebookHero({ snapshot }: { snapshot: CasebookSnapshot }) { return (
- Append-only decision record -

Every decision.
Especially the pass.

-

Inspect what Samaritan saw, why it refused the opportunity, and the proof that no money moved. Unfavorable and incomplete cases stay visible by default.

+ +

Every measured observation.
One honest exemplar.

+

The index contains all {snapshot.corpus.marketEventCases} goal×market observations reported by this one verified capture replay. The detail pane is a deterministic exemplar, not the whole corpus or a performance sample.

-
Verified record{snapshot.statistics.totalCases.toString().padStart(2, "0")}complete case
-
No trade{snapshot.statistics.noTradeCases}capital-preserving pass
-
Executed{snapshot.statistics.executedCases}no orders constructed
-
Capital moved$0.00paper observer state
-
100% verifiedReplay identity parity
+
Goal×market observations{snapshot.statistics.totalCases.toString().padStart(2, "0")}{snapshot.corpus.goalEvents} goals · one captured fixture
+
Moved before TXLine{snapshot.corpus.movedBeforeTxlineCases}pre-trigger repricing observed
+
No material reprice{snapshot.corpus.noMaterialRepriceCases}inside the 30-second window
+
Executed{snapshot.statistics.executedCases}operational execution not reached
+
Execution runtimeNot enteredretrospective research only
+
Full reported corpus projected{snapshot.statistics.reconciledCases} observations internally reconciled · capture identity checked separately
); @@ -98,7 +99,7 @@ function CaseFilters({ snapshot, search, onSearch, filters, onFilter, onClear }: onFilter: (key: keyof Filters, value: string) => void; onClear: () => void; }) { - const dateOption = snapshot.cases[0]?.occurredAt.slice(0, 10) ?? ""; + const dateOptions = [...new Set(snapshot.cases.map((item) => item.occurredAt.slice(0, 10)))].sort(); const hasFilters = Boolean(search || Object.values(filters).some(Boolean)); return (
@@ -116,42 +117,44 @@ function CaseFilters({ snapshot, search, onSearch, filters, onFilter, onClear }: onFilter("outcome", value)} /> onFilter("lane", value)} /> onFilter("source", value)} /> - onFilter("date", value)} /> + onFilter("date", value)} />
); } -function CaseIndex({ cases, total, nextEvidence }: { +function CaseIndex({ cases, total, nextEvidence, selectedCaseId }: { cases: CasebookCaseSummary[]; total: number; nextEvidence: CasebookSnapshot["nextEvidence"]; + selectedCaseId: string; }) { return ( ); } -function DecisionRail({ lifecycle }: { lifecycle: CasebookSnapshot["selectedCase"]["lifecycle"] }) { +function DecisionRail({ selected }: { selected: CasebookSnapshot["selectedCase"] }) { + const finalState = selected.decision.ordersPlaced === 0 ? "Execution not entered" : `${selected.decision.ordersPlaced} orders placed`; return (
-
Lifecycle audit

Decision rail

No trade
+
Retrospective lifecycle

Feasibility rail

{finalState}
- {lifecycle.map((stage, index) => ( + {selected.lifecycle.map((stage, index) => (
{String(index + 1).padStart(2, "0")} @@ -168,7 +171,7 @@ function EvidenceReadout({ selected }: { selected: CasebookSnapshot["selectedCas const readout = selected.evidenceReadout; return (
-
Executable market evidence

What Samaritan actually saw

Moved first
+
Captured executable-book evidence

What the capture contained

Moved first
TXLine movement{movementBps(readout.consensusMoveFromBaselineBps)}25-bps bucket · level withheld
Best bid{percentage(readout.bestBid)}Executable book
@@ -187,11 +190,11 @@ function AnalysisBoundary({ selected }: { selected: CasebookSnapshot["selectedCa
-
Analyst boundary

Thesis not requested

{selected.analysis.thesisReason}

Invalidation: {selected.analysis.invalidation}
+
Analyst boundary

No operational thesis requested

Retrospective feasibility ended before the analyst stage; no Claude thesis ran for this captured case.

Invalidation: {selected.analysis.invalidation}
-
Execution boundary

$0.00 total cost

{selected.analysis.costReason}

{selected.decision.ordersPlaced} orders · wallet untouched · gate closed
+
Execution boundary

Operational cost not applicable

{selected.analysis.costReason}

Research-only path · execution and wallet layers were not entered
); @@ -200,7 +203,7 @@ function AnalysisBoundary({ selected }: { selected: CasebookSnapshot["selectedCa function EvidenceSequence({ selected }: { selected: CasebookSnapshot["selectedCase"] }) { return (
-
Derived observation sequence

Evidence ledger

{selected.evidence.length} observations
+
Derived captured sequence

Captured evidence timeline

{selected.evidence.length} observations
MomentSourceObservationBest askAssessment
{selected.evidence.map((row) => (
@@ -220,23 +223,23 @@ function CaseDetail({ snapshot }: { snapshot: CasebookSnapshot }) { return (
-
Selected case{selected.summary.caseId}{caseDate(selected.summary.occurredAt)}
+
Deterministic detail exemplar{selected.summary.caseId}{caseDate(selected.summary.occurredAt)}
- ESPSpain - {selected.match.clockLabel}{selected.match.scoreAtCursor.home}–{selected.match.scoreAtCursor.away}first goal seen - BelgiumBEL + {selected.match.home.code}{selected.match.home.name} + {selected.match.clockLabel}{selected.match.scoreAtCursor.home}–{selected.match.scoreAtCursor.away}goal {selected.summary.goalOrdinal} first seen + {selected.match.away.name}{selected.match.away.code}
-
Authoritative disposition

Market moved before signal.

{selected.decision.explanation}

- Open full Matchroom replay +
Retrospective feasibility verdict

{selected.summary.reason}.

{selected.decision.explanation}

+ Open captured Matchroom replay
- +
- Replay identity verified{selected.proof.canonicalEvents.toLocaleString("en-US")} canonical events + Underlying capture identity checkedCorpus is locally committed, not a member of the replay identity hash Identity hash{compactHash(selected.proof.identityHash)} - Replay journal head{compactHash(selected.proof.headHash)} + Corpus SHA-256{compactHash(selected.proof.corpusCommitment)} Derived evidence only
@@ -261,19 +264,20 @@ function CasebookView({ snapshot }: { snapshot: CasebookSnapshot }) { const deferredSearch = useDeferredValue(search.trim()); const [filters, setFilters] = useState(emptyFilters); const visibleCases = snapshot.cases.filter((item) => matchesFilters(item, deferredSearch, filters)); + const selectedExemplarVisible = visibleCases.some((item) => item.caseId === snapshot.corpus.selectedExemplar.caseId); const updateFilter = (key: keyof Filters, value: string) => setFilters((current) => ({ ...current, [key]: value })); const clear = () => { setSearch(""); setFilters(emptyFilters); }; return (
- +
- - {visibleCases.length > 0 ? :

No verified case selected

The current filters exclude every recorded case. Clear filters to reopen the complete Spain–Belgium refusal record.

} + + {selectedExemplarVisible ? :

{visibleCases.length === 0 ? "No observation matches" : "Detail exemplar filtered out"}

{visibleCases.length === 0 ? "The current filters exclude every reported observation." : "The visible rows remain part of the complete corpus, but only the explicitly marked exemplar has the three-state Matchroom detail."} Clear filters to reopen the selected exemplar.

}
@@ -283,11 +287,11 @@ function CasebookView({ snapshot }: { snapshot: CasebookSnapshot }) { } function CasebookLoading() { - return
Samaritan / Casebook

Verifying the decision record

Rebuilding the public case index from replay proof and append-only decision evidence.

; + return
Samaritan / Casebook

Checking the captured corpus

Projecting every reported goal×market observation and reconciling it with the local replay evidence.

; } function CasebookError({ retry }: { retry: () => void }) { - return
Fail-closed boundary

Casebook evidence unavailable

The verified replay record changed or could not be reconstructed. Samaritan will not display a partial or fabricated case.

; + return
Fail-closed boundary

Casebook evidence unavailable

The captured replay record changed or could not be reconstructed. Samaritan will not display a partial or fabricated case.

; } export function CasebookApp() { diff --git a/apps/dashboard/src/Command.tsx b/apps/dashboard/src/Command.tsx index ad8018d..524091a 100644 --- a/apps/dashboard/src/Command.tsx +++ b/apps/dashboard/src/Command.tsx @@ -5,7 +5,7 @@ import type { PublicBookPoint } from "../../../src/dash/public-contract"; import { loadCommand } from "./api"; -import { BrandMark, Icon, MobileNavigation, Navigation, Topbar } from "./Shell"; +import { BrandMark, Icon, MobileNavigation, Navigation, ProvenanceBadge, Topbar } from "./Shell"; function percent(value: number, digits = 2) { return `${(value * 100).toFixed(digits)}%`; @@ -36,10 +36,15 @@ function relativeStart(target: string, generatedAt: string) { return days > 0 ? `Starts in ${days}d ${hours % 24}h` : `Starts in ${hours}h`; } -function MiniProbabilityChart({ points }: { points: PublicBookPoint[] }) { +function MiniProbabilityChart({ featured }: { featured: CommandSnapshot["featuredCase"] }) { + const points: PublicBookPoint[] = featured.chart; const frame = { left: 28, right: 624, top: 24, bottom: 194 }; const minimumOffset = Math.min(...points.map((point) => point.offsetMs)); const maximumOffset = Math.max(...points.map((point) => point.offsetMs)); + const goalPoint = points.find((point) => point.offsetMs === 0); + if (!goalPoint || minimumOffset === maximumOffset) { + throw new Error("Featured capture chart is missing its trigger-aligned evidence window"); + } const x = (offset: number) => frame.left + ((offset - minimumOffset) / (maximumOffset - minimumOffset)) * (frame.right - frame.left); const y = (probability: number) => frame.top + ((0.3 - probability) / 0.15) * (frame.bottom - frame.top); const path = (value: (point: PublicBookPoint) => number) => points.map((point, index) => `${index === 0 ? "M" : "L"}${x(point.offsetMs).toFixed(1)} ${y(value(point)).toFixed(1)}`).join(" "); @@ -51,15 +56,15 @@ function MiniProbabilityChart({ points }: { points: PublicBookPoint[] }) {
Public executable bookBest bid Best ask
- Spain–Belgium public draw book around the first goal + {featured.home.name}–{featured.away.name} public {featured.marketOutcomeLabel} book around the first goal The public executable bid and ask repriced before the goal reached Samaritan. Exact TXLine probability levels are withheld. - point.offsetMs === 0)?.bestAsk ?? 0.18)} r="5" />GOAL FIRST SEEN - T−5s29:21T+30s + GOAL FIRST SEEN + T−5s{featured.clockLabel}T+30s
); @@ -70,7 +75,7 @@ function SystemDeck({ snapshot }: { snapshot: CommandSnapshot }) {
- Current posture{snapshot.system.label}{snapshot.system.detail} + Snapshot posture{snapshot.system.label}{snapshot.system.detail}
{snapshot.system.feeds.map((feed) => ( @@ -79,23 +84,26 @@ function SystemDeck({ snapshot }: { snapshot: CommandSnapshot }) {
))}
-
SnapshotDerived · read only
+
Offline snapshotGenerated artifact · read only
); } function FeaturedCase({ snapshot }: { snapshot: CommandSnapshot }) { const featured = snapshot.featuredCase; + const minutes = Math.floor(featured.clockSeconds / 60); + const seconds = featured.clockSeconds % 60; + const orderLabel = featured.ordersPlaced === 0 ? "Execution runtime not entered" : `${featured.ordersPlaced} orders placed`; return (
-
Verified replayCase {featured.caseId}
-
- ESPSpain +
Case {featured.caseId}
+
+ {featured.home.code}{featured.home.name} {featured.clockLabel}{featured.scoreLabel}first goal seen - BelgiumBEL + {featured.away.name}{featured.away.code}
- The trade Samaritan refused + Retrospective opportunity review

{featured.conclusion}

@@ -103,12 +111,12 @@ function FeaturedCase({ snapshot }: { snapshot: CommandSnapshot }) {
Executable ask{percent(featured.bestAsk)}
Pre-trigger market move{movementBps(featured.preTriggerMarketMoveBps)}
-
Authoritative outcomeNo trade · capital preserved
- Replay verified case +
Retrospective feasibility verdict{orderLabel} · research only
+ Open captured case replay
- -
Market repriced first{movementBps(featured.preTriggerMarketMoveBps)}public draw-book move before TXLine first seen
+ +
Market repriced first{movementBps(featured.preTriggerMarketMoveBps)}public {featured.marketOutcomeLabel} book move before TXLine first seen
); @@ -118,10 +126,10 @@ function FixtureCard({ fixture, generatedAt, lead }: { fixture: CommandFixture; const kickoff = dateParts(fixture.kickoffUtc); const capture = dateParts(fixture.captureStartUtc); return ( -
+
{kickoff.day}{kickoff.month}
-
{fixture.statusLabel}{relativeStart(fixture.captureStartUtc, generatedAt)}
+
{fixture.statusLabel}{fixture.phase === "scheduled" ? relativeStart(fixture.captureStartUtc, generatedAt) : fixture.statusDetail}
{fixture.home.code}{fixture.home.name}vs{fixture.away.name}{fixture.away.code}
Capture begins{capture.time} UTCKickoff{kickoff.time} UTC
Capture onlyExact identity confirmed · non-tradeable
@@ -133,7 +141,7 @@ function FixtureCard({ fixture, generatedAt, lead }: { fixture: CommandFixture; function CaptureSchedule({ snapshot }: { snapshot: CommandSnapshot }) { return (