Skip to content

Commit cf5cd19

Browse files
andriypolanskiandriy-polanskicursoragent
authored
feat(scripts): export rule-precision backtest corpus as checksummed JSON (#8084) (#8102)
Add a pure buildBacktestCorpusManifest/checksumCases core (mirroring export-d1-core) plus a thin wrangler D1 CLI that snapshots one rule's fired/override audit_events into a portable labeled corpus file. Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 01d2b5a commit cf5cd19

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Pure core for the rule-precision backtest corpus export (#8084, part of epic #8082). Transforms an already-
2+
// built BacktestCase[] (from buildBacktestCorpus) into a versioned, checksummed manifest a scorer can reload
3+
// without re-querying D1. No IO here — the CLI (backtest-corpus-export.ts) does the wrangler/D1 reads and the
4+
// file write — so this stays unit-testable. Mirrors scripts/export-d1-core.ts's pure-core / thin-IO split.
5+
import { createHash } from "node:crypto";
6+
import type { BacktestCase } from "@loopover/engine";
7+
8+
export type BacktestCorpusManifest = {
9+
ruleId: string;
10+
caseCount: number;
11+
checksum: string;
12+
cases: BacktestCase[];
13+
};
14+
15+
/** Canonicalize one case (sort keys) so property-order differences don't change the checksum — same technique
16+
* as export-d1-core.ts's canonicalizeRow. */
17+
function canonicalizeCase(backtestCase: BacktestCase): Record<string, unknown> {
18+
return Object.fromEntries(Object.entries(backtestCase).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
19+
}
20+
21+
/** Deterministic SHA-256 over the canonicalized cases — mirrors export-d1-core.ts's checksumRows exactly
22+
* (canonicalize each entry, JSON-stringify the array, hash). */
23+
export function checksumCases(cases: readonly BacktestCase[]): string {
24+
return createHash("sha256").update(JSON.stringify(cases.map(canonicalizeCase))).digest("hex");
25+
}
26+
27+
/**
28+
* Build the export manifest for one rule's labeled corpus. Spreads an optional `meta` bag into the result
29+
* (the CLI attaches `generatedAt`); this core never reads the clock. Mirrors export-d1-core.ts's
30+
* {@link buildExportManifest} signature shape.
31+
*/
32+
export function buildBacktestCorpusManifest(
33+
ruleId: string,
34+
cases: readonly BacktestCase[],
35+
meta: Record<string, unknown> = {},
36+
): BacktestCorpusManifest & Record<string, unknown> {
37+
return {
38+
...meta,
39+
ruleId,
40+
caseCount: cases.length,
41+
checksum: checksumCases(cases),
42+
cases: [...cases],
43+
};
44+
}

scripts/backtest-corpus-export.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env node
2+
// Read-only ORB D1 → rule-precision backtest corpus export (#8084, epic #8082). Queries audit_events for one
3+
// rule's `signal.rule_fired:<ruleId>` / `signal.human_override:<ruleId>` rows via `wrangler d1 execute --json`
4+
// (no writes), reconstructs RuleFiredEvent/HumanOverrideEvent the same way signal-tracking-wire.ts does, runs
5+
// buildBacktestCorpus, and writes a checksummed JSON snapshot. The pure transform lives in
6+
// backtest-corpus-export-core.ts (unit-tested); this file is the thin IO wrapper — mirrors export-d1-data.ts.
7+
//
8+
// tsx scripts/backtest-corpus-export.ts --rule-id <ruleId> --output <file.json> [--remote] [--since-date <iso>] [--db loopover]
9+
//
10+
// --remote reads the deployed D1 (default is the local miniflare DB). --since-date does an INCREMENTAL export
11+
// (rows whose created_at is >= the date); omit it for a full history. NEVER pass a write command. ORB only —
12+
// AMS's event-ledger export is out of scope.
13+
import { writeFileSync } from "node:fs";
14+
import { spawnSync } from "node:child_process";
15+
import { buildBacktestCorpus, type BacktestCase, type HumanOverrideEvent, type RuleFiredEvent } from "@loopover/engine";
16+
import { buildBacktestCorpusManifest } from "./backtest-corpus-export-core.js";
17+
18+
type D1Row = Record<string, unknown>;
19+
20+
type Args = {
21+
ruleId: string | undefined;
22+
output: string | undefined;
23+
remote: boolean;
24+
sinceDate: string | undefined;
25+
db: string;
26+
};
27+
28+
const RULE_FIRED_EVENT_TYPE_PREFIX = "signal.rule_fired:";
29+
const HUMAN_OVERRIDE_EVENT_TYPE_PREFIX = "signal.human_override:";
30+
31+
function parseArgs(argv: string[]): Args {
32+
const args: Args = { ruleId: undefined, output: undefined, remote: false, sinceDate: undefined, db: "loopover" };
33+
for (let i = 0; i < argv.length; i += 1) {
34+
const flag = argv[i];
35+
if (flag === "--remote") args.remote = true;
36+
else if (flag === "--rule-id") args.ruleId = argv[++i];
37+
else if (flag === "--output") args.output = argv[++i];
38+
else if (flag === "--since-date") args.sinceDate = argv[++i];
39+
else if (flag === "--db") args.db = argv[++i]!;
40+
}
41+
return args;
42+
}
43+
44+
// Run a read-only SQL statement via wrangler and return the result rows. Throws on any wrangler failure so a
45+
// partial/garbled export can never be mistaken for a complete one. Mirrors export-d1-data.ts's d1Query.
46+
function d1Query(db: string, remote: boolean, sql: string): D1Row[] {
47+
const result = spawnSync("npx", ["wrangler", "d1", "execute", db, remote ? "--remote" : "--local", "--json", "--command", sql], {
48+
encoding: "utf8",
49+
maxBuffer: 256 * 1024 * 1024,
50+
});
51+
if (result.status !== 0) {
52+
throw new Error(`wrangler d1 execute failed (${result.status}): ${(result.stderr || result.stdout || "").slice(0, 500)}`);
53+
}
54+
const parsed = JSON.parse(result.stdout);
55+
// wrangler returns [{ results: [...], success, meta }] (one entry per statement).
56+
const first = Array.isArray(parsed) ? parsed[0] : parsed;
57+
return first?.results ?? [];
58+
}
59+
60+
function sqlStringLiteral(value: string): string {
61+
return `'${value.replace(/'/g, "''")}'`;
62+
}
63+
64+
function parseMetadataJson(raw: unknown): Record<string, unknown> {
65+
if (typeof raw !== "string") return {};
66+
try {
67+
const parsed: unknown = JSON.parse(raw);
68+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
69+
} catch {
70+
/* corrupt row -- fail open to {} (mirrors listAuditEventsByType) */
71+
}
72+
return {};
73+
}
74+
75+
// Mirrors src/review/signal-tracking-wire.ts's toRuleFiredEvent — keep in sync with that adapter; do not import
76+
// it (private to the live ORB adapter; this CLI is a read-only export path).
77+
function toRuleFiredEvent(ruleId: string, row: { targetKey: string | null; metadata: Record<string, unknown>; createdAt: string }): RuleFiredEvent {
78+
const outcome = typeof row.metadata.outcome === "string" ? row.metadata.outcome : "";
79+
const extraMetadata = { ...row.metadata };
80+
delete extraMetadata.outcome;
81+
return {
82+
ruleId,
83+
targetKey: row.targetKey ?? "",
84+
outcome,
85+
occurredAt: row.createdAt,
86+
...(Object.keys(extraMetadata).length > 0 ? { metadata: extraMetadata } : {}),
87+
};
88+
}
89+
90+
// Mirrors src/review/signal-tracking-wire.ts's toHumanOverrideEvent — keep in sync with that adapter.
91+
function toHumanOverrideEvent(ruleId: string, row: { targetKey: string | null; metadata: Record<string, unknown>; createdAt: string }): HumanOverrideEvent {
92+
const verdict = row.metadata.verdict === "reversed" ? "reversed" : "confirmed";
93+
const extraMetadata = { ...row.metadata };
94+
delete extraMetadata.verdict;
95+
return {
96+
ruleId,
97+
targetKey: row.targetKey ?? "",
98+
verdict,
99+
occurredAt: row.createdAt,
100+
...(Object.keys(extraMetadata).length > 0 ? { metadata: extraMetadata } : {}),
101+
};
102+
}
103+
104+
function rowCreatedAt(row: D1Row): string {
105+
return typeof row.created_at === "string" ? row.created_at : "";
106+
}
107+
108+
function rowTargetKey(row: D1Row): string | null {
109+
return typeof row.target_key === "string" ? row.target_key : null;
110+
}
111+
112+
function rowEventType(row: D1Row): string {
113+
return typeof row.event_type === "string" ? row.event_type : "";
114+
}
115+
116+
function main() {
117+
const args = parseArgs(process.argv.slice(2));
118+
if (!args.ruleId || !args.output) {
119+
console.error(
120+
"Usage: tsx scripts/backtest-corpus-export.ts --rule-id <ruleId> --output <file.json> [--remote] [--since-date <iso>] [--db loopover]",
121+
);
122+
process.exit(2);
123+
}
124+
125+
const firedType = `${RULE_FIRED_EVENT_TYPE_PREFIX}${args.ruleId}`;
126+
const overrideType = `${HUMAN_OVERRIDE_EVENT_TYPE_PREFIX}${args.ruleId}`;
127+
const sinceClause = args.sinceDate ? ` AND created_at >= ${sqlStringLiteral(args.sinceDate)}` : "";
128+
const sql =
129+
`SELECT event_type, target_key, metadata_json, created_at FROM audit_events` +
130+
` WHERE (event_type = ${sqlStringLiteral(firedType)} OR event_type = ${sqlStringLiteral(overrideType)})` +
131+
sinceClause +
132+
` ORDER BY created_at ASC`;
133+
134+
const rows = d1Query(args.db, args.remote, sql);
135+
const fired: RuleFiredEvent[] = [];
136+
const overrides: HumanOverrideEvent[] = [];
137+
for (const row of rows) {
138+
const projected = {
139+
targetKey: rowTargetKey(row),
140+
metadata: parseMetadataJson(row.metadata_json),
141+
createdAt: rowCreatedAt(row),
142+
};
143+
const eventType = rowEventType(row);
144+
if (eventType === firedType) fired.push(toRuleFiredEvent(args.ruleId, projected));
145+
else if (eventType === overrideType) overrides.push(toHumanOverrideEvent(args.ruleId, projected));
146+
}
147+
148+
const cases: BacktestCase[] = buildBacktestCorpus(args.ruleId, fired, overrides);
149+
const manifest = buildBacktestCorpusManifest(args.ruleId, cases, {
150+
generatedAt: new Date().toISOString(),
151+
source: args.remote ? "d1-remote" : "d1-local",
152+
database: args.db,
153+
incremental: Boolean(args.sinceDate),
154+
...(args.sinceDate ? { sinceDate: args.sinceDate } : {}),
155+
});
156+
writeFileSync(args.output, `${JSON.stringify(manifest, null, 2)}\n`);
157+
console.error(`exported ${manifest.caseCount} cases for rule ${args.ruleId} (checksum ${manifest.checksum.slice(0, 12)}…) → ${args.output}`);
158+
}
159+
160+
main();
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { BacktestCase } from "@loopover/engine";
3+
import { buildBacktestCorpusManifest, checksumCases } from "../../scripts/backtest-corpus-export-core.js";
4+
5+
function sampleCase(overrides: Partial<BacktestCase> = {}): BacktestCase {
6+
return {
7+
ruleId: "missing_linked_issue",
8+
targetKey: "owner/repo#1",
9+
outcome: "block",
10+
label: "confirmed",
11+
firedAt: "2026-07-22T00:00:00.000Z",
12+
decidedAt: "2026-07-22T01:00:00.000Z",
13+
...overrides,
14+
};
15+
}
16+
17+
describe("backtest-corpus-export-core checksumCases (#8084)", () => {
18+
it("is stable for the same input regardless of key order within each case object", () => {
19+
const a: BacktestCase[] = [sampleCase({ outcome: "block", label: "confirmed" })];
20+
// Rebuild with a different enumeration order by spreading into a freshly keyed object.
21+
const b: BacktestCase[] = [
22+
{
23+
decidedAt: a[0]!.decidedAt,
24+
firedAt: a[0]!.firedAt,
25+
label: a[0]!.label,
26+
outcome: a[0]!.outcome,
27+
ruleId: a[0]!.ruleId,
28+
targetKey: a[0]!.targetKey,
29+
},
30+
];
31+
expect(checksumCases(a)).toBe(checksumCases(b));
32+
expect(checksumCases(a)).toMatch(/^[0-9a-f]{64}$/);
33+
});
34+
35+
it("changes when cases changes", () => {
36+
expect(checksumCases([sampleCase({ label: "confirmed" })])).not.toBe(checksumCases([sampleCase({ label: "reversed" })]));
37+
expect(checksumCases([sampleCase()])).not.toBe(checksumCases([sampleCase(), sampleCase({ targetKey: "owner/repo#2" })]));
38+
});
39+
});
40+
41+
describe("backtest-corpus-export-core buildBacktestCorpusManifest (#8084)", () => {
42+
it("caseCount matches cases.length and an empty corpus produces a valid manifest with caseCount 0", () => {
43+
const empty = buildBacktestCorpusManifest("missing_linked_issue", []);
44+
expect(empty.ruleId).toBe("missing_linked_issue");
45+
expect(empty.caseCount).toBe(0);
46+
expect(empty.cases).toEqual([]);
47+
expect(empty.checksum).toBe(checksumCases([]));
48+
expect(empty.checksum).toMatch(/^[0-9a-f]{64}$/);
49+
50+
const cases = [sampleCase(), sampleCase({ targetKey: "owner/repo#2", label: "reversed" })];
51+
const filled = buildBacktestCorpusManifest("missing_linked_issue", cases);
52+
expect(filled.caseCount).toBe(2);
53+
expect(filled.cases).toEqual(cases);
54+
expect(filled.checksum).toBe(checksumCases(cases));
55+
});
56+
57+
it("spreads meta fields into the result without reading a clock", () => {
58+
const manifest = buildBacktestCorpusManifest("rule_a", [sampleCase({ ruleId: "rule_a" })], {
59+
generatedAt: "2026-07-22T12:00:00.000Z",
60+
source: "d1-local",
61+
});
62+
expect(manifest.generatedAt).toBe("2026-07-22T12:00:00.000Z");
63+
expect(manifest.source).toBe("d1-local");
64+
expect(manifest.ruleId).toBe("rule_a");
65+
expect(manifest.caseCount).toBe(1);
66+
});
67+
68+
it("copies cases rather than retaining the caller's array reference", () => {
69+
const cases = [sampleCase()];
70+
const manifest = buildBacktestCorpusManifest("missing_linked_issue", cases);
71+
expect(manifest.cases).toEqual(cases);
72+
expect(manifest.cases).not.toBe(cases);
73+
});
74+
});

0 commit comments

Comments
 (0)