Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/loopover-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
"./calibration/backtest-corpus": {
"types": "./dist/calibration/backtest-corpus.d.ts",
"default": "./dist/calibration/backtest-corpus.js"
},
"./calibration/backtest-checksum": {
"types": "./dist/calibration/backtest-checksum.d.ts",
"default": "./dist/calibration/backtest-checksum.js"
}
},
"files": [
Expand Down
40 changes: 40 additions & 0 deletions packages/loopover-engine/src/calibration/backtest-checksum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// The backtest corpus freeze point (#8136's reproducibility posture, moved here by #9639).
//
// This lived in scripts/backtest-corpus-export-core.ts, which made it reachable only from the CI-side CLIs.
// The in-Worker threshold backtest (src/services/threshold-backtest-run.ts) needs the SAME function to stamp
// its own runs: a deployment whose only backtest history is in-Worker was publishing no freeze point at all,
// so /v1/public/eval-scores served zero EvalScoreRecords. Two checksum implementations would have been worse
// than none -- a manifest frozen by one and validated by the other would disagree for no visible reason --
// so the function moved rather than being copied.
//
// It is byte-stable by contract, not by accident: existing corpus manifests on disk carry checksums produced
// by the pre-move implementation, and scripts/backtest-logic-check.ts and scripts/attested-backtest-run.ts
// both re-validate a manifest against it before trusting the cases. Any change to the canonicalization or the
// hash input invalidates every manifest ever exported. backtest-checksum.test.ts pins the exact digest of a
// fixed fixture for that reason.
//
// node:crypto rather than Web Crypto: this must be SYNCHRONOUS (checksumCases is called inside pure,
// non-async manifest builders), and the Worker runs with nodejs_compat -- the same basis on which
// attester.ts, attestation-envelope.ts, backtest-split.ts and counterfactual-fixtures.ts in this same
// directory already import createHash.
import { createHash } from "node:crypto";
import type { BacktestCase } from "./backtest-corpus.js";

/** Canonicalize one case (sort keys) so property-order differences don't change the checksum -- same technique
* as scripts/export-d1-core.ts's canonicalizeRow. */
function canonicalizeCase(backtestCase: BacktestCase): Record<string, unknown> {
// Sorted with the DEFAULT comparator over the keys, not a hand-written `a < b ? -1 : a > b ? 1 : 0`. For
// strings the two give the identical total order -- the pinned digest in this module's two test suites is
// what proves the output did not move -- but the hand-written form carries an equality arm that
// Object.keys can never trigger, since it yields each key exactly once. An unreachable branch cannot be
// tested, so it either sits uncovered or needs an ignore pragma; not writing it beats both. (The pragma
// was also flag-dependent: vitest's v8 provider honoured it, the engine package's own coverage did not.)
const source = backtestCase as unknown as Record<string, unknown>;
return Object.fromEntries(Object.keys(source).sort().map((key) => [key, source[key]]));
}

/** Deterministic SHA-256 over the canonicalized cases -- mirrors scripts/export-d1-core.ts's checksumRows
* exactly (canonicalize each entry, JSON-stringify the array, hash). */
export function checksumCases(cases: readonly BacktestCase[]): string {
return createHash("sha256").update(JSON.stringify(cases.map(canonicalizeCase))).digest("hex");
}
3 changes: 3 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ export * from "./governor/action-mode.js";
export * from "./governor/chokepoint.js";
export * from "./calibration/signal-tracking.js";
export * from "./calibration/backtest-corpus.js";
// #9639: the corpus freeze point, moved out of scripts/ so the in-Worker threshold backtest can stamp its
// own runs with the same checksum the CI-side manifests are frozen and validated by.
export * from "./calibration/backtest-checksum.js";
export * from "./calibration/repo-corpus-slice.js";
export * from "./calibration/ams-prediction-corpus.js";
export * from "./calibration/ams-rank-corpus.js";
Expand Down
50 changes: 50 additions & 0 deletions packages/loopover-engine/test/backtest-checksum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { checksumCases } from "../dist/index.js";
import type { BacktestCase } from "../dist/calibration/backtest-corpus.js";

// #9639: checksumCases moved here from scripts/backtest-corpus-export-core.ts so the in-Worker threshold
// backtest can stamp its runs with the same freeze point the CI-side manifests are frozen and validated by.
//
// The root test/unit/backtest-checksum.test.ts covers this from the host side; this is its coverage in its
// OWN package, where the `engine` flag measures it. More than a flag exercise, though: this suite runs
// against dist/, so it is the only place the moved function is checked through the exact artifact published
// consumers import.

// A fixed fixture with deliberately unsorted keys, so canonicalization is exercised and not just the hash.
const FIXTURE: BacktestCase[] = [
{ targetKey: "acme/widgets#2", ruleId: "ai_consensus_defect", outcome: "close", label: "reversed", decidedAt: "2026-01-03T00:00:00.000Z", firedAt: "2026-01-02T00:00:00.000Z", metadata: { confidence: 0.91 } },
{ ruleId: "ai_consensus_defect", firedAt: "2026-01-01T00:00:00.000Z", decidedAt: "2026-01-02T00:00:00.000Z", targetKey: "acme/widgets#1", label: "confirmed", outcome: "merge", metadata: { confidence: 0.42 } },
];

// Produced by the PRE-MOVE implementation. Corpus manifests already on disk carry checksums from it, and
// scripts/backtest-logic-check.ts and scripts/attested-backtest-run.ts both re-validate a manifest against
// this function before trusting its cases -- so drift here does not fail loudly, it silently rejects every
// corpus ever exported. If this constant has to be edited to make the test pass, that has happened.
const FIXTURE_DIGEST = "bba3c7db2e1ff0b6943802416ebf94c789f37ac5ed962cc7167c2dd33a33a861";

test("checksumCases produces the pinned pre-move digest", () => {
assert.equal(checksumCases(FIXTURE), FIXTURE_DIGEST);
});

test("checksumCases ignores property order", () => {
const reordered = FIXTURE.map((c) => Object.fromEntries(Object.entries(c).reverse()) as unknown as BacktestCase);
assert.equal(checksumCases(reordered), FIXTURE_DIGEST);
});

test("checksumCases does NOT ignore case order -- the corpus is a sequence", () => {
assert.notEqual(checksumCases([...FIXTURE].reverse()), FIXTURE_DIGEST);
});

test("checksumCases distinguishes a changed value, so it genuinely commits to the cases", () => {
const mutated = FIXTURE.map((c, i) => (i === 0 ? { ...c, reversed: false } : c));
assert.notEqual(checksumCases(mutated), FIXTURE_DIGEST);
});

test("checksumCases is deterministic and 64 hex chars for the empty corpus", () => {
// The value EMPTY_CORPUS_CHECKSUM recognises: identical for every rule, window and deployment, which is
// why eval-score-records.ts refuses to publish a record against it.
assert.equal(checksumCases([]), checksumCases([]));
assert.match(checksumCases([]), /^[0-9a-f]{64}$/);
});
2 changes: 1 addition & 1 deletion scripts/attested-backtest-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { readFileSync } from "node:fs";
import { assembleAttestationEnvelope, createSampleAttester, type Attester } from "@loopover/engine/calibration/attester";
import type { BacktestCase } from "@loopover/engine/calibration/backtest-corpus";

import { checksumCases } from "./backtest-corpus-export-core";
import { checksumCases } from "@loopover/engine/calibration/backtest-checksum";
import {
attestedRunExitCode,
buildAttestedRunAuditInsertSql,
Expand Down
20 changes: 7 additions & 13 deletions scripts/backtest-corpus-export-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,22 @@
// built BacktestCase[] (from buildBacktestCorpus) into a versioned, checksummed manifest a scorer can reload
// without re-querying D1. No IO here — the CLI (backtest-corpus-export.ts) does the wrangler/D1 reads and the
// file write — so this stays unit-testable. Mirrors scripts/export-d1-core.ts's pure-core / thin-IO split.
import { createHash } from "node:crypto";
import { checksumCases } from "@loopover/engine";
import type { BacktestCase } from "@loopover/engine/calibration/backtest-corpus";

// #9639: checksumCases moved into the engine so the in-Worker threshold backtest can use it too. Re-exported
// here because this module's own consumers (and its test) have always imported it from this path, and the
// checksum must stay ONE function -- a manifest frozen by one copy and validated by another would disagree
// for no visible reason.
export { checksumCases };

export type BacktestCorpusManifest = {
ruleId: string;
caseCount: number;
checksum: string;
cases: BacktestCase[];
};

/** Canonicalize one case (sort keys) so property-order differences don't change the checksum — same technique
* as export-d1-core.ts's canonicalizeRow. */
function canonicalizeCase(backtestCase: BacktestCase): Record<string, unknown> {
return Object.fromEntries(Object.entries(backtestCase).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
}

/** Deterministic SHA-256 over the canonicalized cases — mirrors export-d1-core.ts's checksumRows exactly
* (canonicalize each entry, JSON-stringify the array, hash). */
export function checksumCases(cases: readonly BacktestCase[]): string {
return createHash("sha256").update(JSON.stringify(cases.map(canonicalizeCase))).digest("hex");
}

/**
* Build the export manifest for one rule's labeled corpus. Spreads an optional `meta` bag into the result
* (the CLI attaches `generatedAt`); this core never reads the clock. Mirrors export-d1-core.ts's
Expand Down
2 changes: 1 addition & 1 deletion scripts/backtest-logic-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import type { BacktestCase } from "@loopover/engine";
import { checksumCases } from "./backtest-corpus-export-core";
import { checksumCases } from "@loopover/engine";
import {
buildLogicBacktestAuditInsertSql,
filterReplayableCases,
Expand Down
10 changes: 5 additions & 5 deletions scripts/replay-runner-image-manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"schemaVersion": 1,
"baseImageRef": "node:22-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3",
"dockerfileSha256": "7e4d3c18fbc3a4153d7ba25e3cff8329b07fceb6a52a62350ba0f3c65f54f2ff",
"packageLockSha256": "050ad66cb6a9ba6cfceb6e379f59341fea11eac0bdf5ffc2cfe17febdb0d5038",
"dockerfileSha256": "18b434af0c6359105293a9bd6bdd890f4b5662ce72308df4bcfc3bb30ae7c1ee",
"packageLockSha256": "35c61915f0b21d5980f5f5aa1bff532da3d9d51c71257c8b28904f7b798e0d71",
"sourceFiles": {
"scripts/attested-backtest-run-core.ts": "e5f5659dc9204f9406c9874317d71cb02fd7f4c993c9f5caa92545595f522167",
"scripts/attested-backtest-run.ts": "0db6b462afdf80086eb68da793a6dccbe3d5751eceeb19e338d2746a1c319fa5",
"scripts/backtest-corpus-export-core.ts": "6a268410ea1d8041e95af345149aeb4848376c4c72127b801ee9da377b81e85e",
"scripts/attested-backtest-run.ts": "49e0e046e3c4882c0e4afd422a464957b6c4789bad28f5b6682afa9d534424f7",
"scripts/backtest-corpus-export-core.ts": "060d48a2a6073a7d859efc64c87c18ae1d3d25e06391ce94d6ed40874050b2e8",
"scripts/snp-attester.ts": "b917e8541908150bd0c885fbe57e178247e5cd5f78f80eda7dc9e8b77da6deb3"
},
"digest": "cdf7c3aaf83f814cf5b0ce37887abb264c9b5ee2d903ddb47a3d9dc60c64fa0c"
"digest": "5b8665f586317a0f3d77c5ac76891b4cf2ba8f1784977d268b093ce6644b2210"
}
8 changes: 6 additions & 2 deletions scripts/replay-runner/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ COPY --from=runtime-deps /app/node_modules ./node_modules
# @loopover/engine is constructed here directly from its own compiled output -- NOT `npm ci`'d -- because npm
# would install every dependency the FULL package declares (@anthropic-ai/claude-agent-sdk, tree-sitter-wasms,
# ...), regardless of which files this image actually imports. attester.js/attestation-envelope.js/
# backtest-corpus.js's entire runtime closure is these three files plus node:crypto (verified: `grep -n
# "^import" packages/loopover-engine/dist/calibration/*.js` -- attested-backtest-run.ts's own subpath imports,
# backtest-corpus.js/backtest-checksum.js's entire runtime closure is these four files plus node:crypto
# (verified: `grep -n "^import" packages/loopover-engine/dist/calibration/*.js` -- backtest-checksum.js, added
# by #9639, compiles to a single `node:crypto` import because its only other import is type-only and erased,
# so it widens this image's file list without widening its trusted computing base at all)
# -- attested-backtest-run.ts's own subpath imports,
# #9214, are what make this closure reachable without ever loading the barrel's web-tree-sitter/
# claude-agent-sdk-carrying re-export graph in the first place). This measured image is meant to run inside
# an eventual TEE (#8534); keeping its dependency footprint to exactly what replay needs is a real security
Expand All @@ -64,6 +67,7 @@ COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-eng
COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/dist/calibration/attester.js ./node_modules/@loopover/engine/dist/calibration/attester.js
COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/dist/calibration/attestation-envelope.js ./node_modules/@loopover/engine/dist/calibration/attestation-envelope.js
COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/dist/calibration/backtest-corpus.js ./node_modules/@loopover/engine/dist/calibration/backtest-corpus.js
COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/dist/calibration/backtest-checksum.js ./node_modules/@loopover/engine/dist/calibration/backtest-checksum.js
# The runner script's own sources -- listed individually (rather than `COPY scripts/`) so this image's content
# is EXACTLY the set replay-runner-image-manifest.json enumerates under sourceFiles; adding a new script
# dependency here without a matching manifest entry is precisely the drift `replay-runner-manifest:check` catches.
Expand Down
7 changes: 5 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8595,9 +8595,12 @@ export async function resolveThresholdBacktestAdvisory(
if (mode === "off") return "";
if (!files.some((file) => THRESHOLD_BACKTEST_WATCHED_PATHS.has(file.path))) return "";
try {
const { changed, comparisons } = await runThresholdBacktestAdvisory(env, buildSecretScanDiff(files));
const { changed, comparisons, corpusChecksumByRuleId } = await runThresholdBacktestAdvisory(env, buildSecretScanDiff(files));
if (comparisons.length === 0) return "";
await persistThresholdBacktestRuns(env, repoFullName, pr.number, changed, comparisons);
// #9639: the freeze point travels with the run. Without it the persisted event is invisible to
// loadPublicRulePrecision's `corpusChecksum IS NOT NULL` filter, so /v1/public/eval-scores publishes
// nothing no matter how many backtests this deployment has actually run.
await persistThresholdBacktestRuns(env, repoFullName, pr.number, changed, comparisons, corpusChecksumByRuleId);
// #8105 block mode: a REGRESSED verdict becomes a configured gate blocker. The finding only exists in
// block mode (advisory keeps today's comment-only behavior byte-identically), and
// isConfiguredGateBlocker's own `backtest_regression` branch is the defense-in-depth mirror of this
Expand Down
Loading