Skip to content

Commit 657f430

Browse files
committed
feat(engine): add the Pareto-floor comparator for backtest score reports (#8086)
1 parent d07b057 commit 657f430

4 files changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Pareto-floor comparator for backtest score reports (#8086, parent epic #8082) -- applies the no-regression
2+
// discipline vanguarstew's score_pr_delta.py established (cited for the scoring METHOD only; nothing imported
3+
// or copied): a candidate that regresses on ANY measured axis is a regression, even while improving another.
4+
// "Trading one axis for the other" can never read as a net win, so a rule fix can't be gamed by sacrificing
5+
// recall for precision or vice versa.
6+
//
7+
// Pure, like everything in this module: no IO, no randomness, no wall-clock reads.
8+
9+
import type { BacktestScoreReport } from "./backtest-score.js";
10+
11+
export type BacktestComparison = {
12+
ruleId: string;
13+
baseline: BacktestScoreReport;
14+
candidate: BacktestScoreReport;
15+
regressedAxes: Array<"precision" | "recall">;
16+
improvedAxes: Array<"precision" | "recall">;
17+
verdict: "improved" | "regressed" | "unchanged";
18+
};
19+
20+
/**
21+
* Compare a baseline and candidate score for the SAME rule. Per axis (precision, recall): when EITHER value
22+
* is null the axis is excluded from both lists (insufficient decided data is not comparable -- null is never
23+
* treated as 0 or as "no change"); otherwise strictly-less = regressed, strictly-greater = improved, equal =
24+
* neither. The verdict is "regressed" whenever ANY axis regressed -- the Pareto-floor rule, never a
25+
* weighted/averaged score -- else "improved" when any axis improved, else "unchanged". Throws when the two
26+
* reports carry different ruleIds (a caller bug, not a valid comparison).
27+
*/
28+
export function compareBacktestScores(baseline: BacktestScoreReport, candidate: BacktestScoreReport): BacktestComparison {
29+
if (baseline.ruleId !== candidate.ruleId) {
30+
throw new Error(`cannot compare backtest scores for different rules: ${baseline.ruleId} vs ${candidate.ruleId}`);
31+
}
32+
const regressedAxes: Array<"precision" | "recall"> = [];
33+
const improvedAxes: Array<"precision" | "recall"> = [];
34+
for (const axis of ["precision", "recall"] as const) {
35+
const baselineValue = baseline[axis];
36+
const candidateValue = candidate[axis];
37+
if (baselineValue === null || candidateValue === null) continue;
38+
if (candidateValue < baselineValue) regressedAxes.push(axis);
39+
else if (candidateValue > baselineValue) improvedAxes.push(axis);
40+
}
41+
const verdict = regressedAxes.length > 0 ? "regressed" : improvedAxes.length > 0 ? "improved" : "unchanged";
42+
return { ruleId: baseline.ruleId, baseline, candidate, regressedAxes, improvedAxes, verdict };
43+
}

packages/loopover-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export * from "./governor/chokepoint.js";
165165
export * from "./calibration/signal-tracking.js";
166166
export * from "./calibration/backtest-corpus.js";
167167
export * from "./calibration/backtest-score.js";
168+
export * from "./calibration/backtest-compare.js";
168169
export {
169170
GOVERNOR_LEDGER_EVENT_TYPES,
170171
normalizeGovernorLedgerEvent,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import { compareBacktestScores, type BacktestScoreReport } from "../dist/index.js";
5+
6+
// #8086: the Pareto-floor comparator. The one non-negotiable case: an improvement on one axis NEVER cancels
7+
// a regression on the other — verdict is "regressed" the moment any axis regressed.
8+
9+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
10+
return {
11+
ruleId: "rule",
12+
caseCount: 10,
13+
truePositive: 4,
14+
falsePositive: 1,
15+
trueNegative: 4,
16+
falseNegative: 1,
17+
precision: 0.8,
18+
recall: 0.8,
19+
...overrides,
20+
};
21+
}
22+
23+
test("both axes improve -> verdict improved with empty regressedAxes", () => {
24+
const comparison = compareBacktestScores(report(), report({ precision: 0.9, recall: 0.85 }));
25+
assert.deepEqual(comparison.regressedAxes, []);
26+
assert.deepEqual(comparison.improvedAxes, ["precision", "recall"]);
27+
assert.equal(comparison.verdict, "improved");
28+
});
29+
30+
test("PARETO FLOOR: one axis improves while the other regresses -> verdict regressed", () => {
31+
const comparison = compareBacktestScores(report(), report({ precision: 0.95, recall: 0.6 }));
32+
assert.deepEqual(comparison.improvedAxes, ["precision"]);
33+
assert.deepEqual(comparison.regressedAxes, ["recall"]);
34+
assert.equal(comparison.verdict, "regressed");
35+
});
36+
37+
test("a null on either side excludes that axis from both lists — null is never 0 and never 'no change'", () => {
38+
const baselineNull = compareBacktestScores(report({ precision: null }), report({ precision: 0.99, recall: 0.9 }));
39+
assert.deepEqual(baselineNull.regressedAxes, []);
40+
assert.deepEqual(baselineNull.improvedAxes, ["recall"]);
41+
42+
const candidateNull = compareBacktestScores(report(), report({ recall: null, precision: 0.7 }));
43+
assert.deepEqual(candidateNull.regressedAxes, ["precision"]);
44+
assert.deepEqual(candidateNull.improvedAxes, []);
45+
assert.equal(candidateNull.verdict, "regressed");
46+
});
47+
48+
test("mismatched ruleId throws, and the message contains both rule IDs", () => {
49+
assert.throws(
50+
() => compareBacktestScores(report({ ruleId: "rule_a" }), report({ ruleId: "rule_b" })),
51+
(error: Error) => error.message.includes("rule_a") && error.message.includes("rule_b"),
52+
);
53+
});
54+
55+
test("all comparable axes equal -> verdict unchanged with both lists empty", () => {
56+
const comparison = compareBacktestScores(report(), report());
57+
assert.deepEqual(comparison.regressedAxes, []);
58+
assert.deepEqual(comparison.improvedAxes, []);
59+
assert.equal(comparison.verdict, "unchanged");
60+
});
61+
62+
test("the comparison carries ruleId and both full reports through", () => {
63+
const baseline = report();
64+
const candidate = report({ precision: 0.9 });
65+
const comparison = compareBacktestScores(baseline, candidate);
66+
assert.equal(comparison.ruleId, "rule");
67+
assert.equal(comparison.baseline, baseline);
68+
assert.equal(comparison.candidate, candidate);
69+
});

test/unit/backtest-compare.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
// Direct src-path import (not the package barrel, which resolves to dist and is outside vitest's
3+
// coverage.include) — the same coverage-twin pattern test/unit/backtest-corpus.test.ts established for this
4+
// module: the engine's own node:test suite runs against dist and is invisible to codecov/patch.
5+
import { compareBacktestScores } from "../../packages/loopover-engine/src/calibration/backtest-compare.js";
6+
import type { BacktestScoreReport } from "../../packages/loopover-engine/src/calibration/backtest-score.js";
7+
8+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
9+
return {
10+
ruleId: "rule",
11+
caseCount: 10,
12+
truePositive: 4,
13+
falsePositive: 1,
14+
trueNegative: 4,
15+
falseNegative: 1,
16+
precision: 0.8,
17+
recall: 0.8,
18+
...overrides,
19+
};
20+
}
21+
22+
describe("compareBacktestScores (#8086)", () => {
23+
it("marks both axes improved when both rise, with an improved verdict", () => {
24+
const comparison = compareBacktestScores(report(), report({ precision: 0.9, recall: 0.85 }));
25+
expect(comparison).toMatchObject({ regressedAxes: [], improvedAxes: ["precision", "recall"], verdict: "improved" });
26+
});
27+
28+
it("PARETO FLOOR: a single regressed axis forces the regressed verdict even when the other axis improved", () => {
29+
const comparison = compareBacktestScores(report(), report({ precision: 0.95, recall: 0.6 }));
30+
expect(comparison.improvedAxes).toEqual(["precision"]);
31+
expect(comparison.regressedAxes).toEqual(["recall"]);
32+
expect(comparison.verdict).toBe("regressed");
33+
});
34+
35+
it("excludes an axis from both lists when either side is null — never treated as 0 or as no-change", () => {
36+
const baselineNull = compareBacktestScores(report({ precision: null }), report({ precision: 0.99, recall: 0.9 }));
37+
expect(baselineNull.regressedAxes).toEqual([]);
38+
expect(baselineNull.improvedAxes).toEqual(["recall"]);
39+
40+
const candidateNull = compareBacktestScores(report(), report({ recall: null, precision: 0.7 }));
41+
expect(candidateNull.regressedAxes).toEqual(["precision"]);
42+
expect(candidateNull.verdict).toBe("regressed");
43+
44+
const bothNull = compareBacktestScores(report({ precision: null, recall: null }), report({ precision: null, recall: null }));
45+
expect(bothNull.verdict).toBe("unchanged");
46+
});
47+
48+
it("throws on mismatched ruleIds, naming both", () => {
49+
expect(() => compareBacktestScores(report({ ruleId: "rule_a" }), report({ ruleId: "rule_b" }))).toThrow(
50+
"cannot compare backtest scores for different rules: rule_a vs rule_b",
51+
);
52+
});
53+
54+
it("reports unchanged when every comparable axis is equal", () => {
55+
expect(compareBacktestScores(report(), report()).verdict).toBe("unchanged");
56+
});
57+
});

0 commit comments

Comments
 (0)