Skip to content

Commit 0738a70

Browse files
kai392RealDiligent
andauthored
feat(calibration): Pareto-floor comparator between two BacktestScoreReports (#8086) (#8108)
Co-authored-by: RealDiligent <brave.challenge007@gmail.com>
1 parent a2d2517 commit 0738a70

4 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Pareto-floor comparator between two BacktestScoreReports (#8086) -- the dual-axis no-regression method:
2+
// a candidate rule change may not regress on ANY measured axis even while improving another; "trading one
3+
// axis for the other" is a regression, not a net win. This is deliberately NOT a weighted/averaged score --
4+
// a single regressed axis decides the verdict, which is the entire point of the floor.
5+
//
6+
// Same purity contract as the rest of this module family: no IO, no randomness, no wall-clock reads.
7+
8+
import type { BacktestScoreReport } from "./backtest-score.js";
9+
10+
/** The two comparable axes of a {@link BacktestScoreReport}. */
11+
type ComparisonAxis = "precision" | "recall";
12+
13+
export type BacktestComparison = {
14+
ruleId: string;
15+
baseline: BacktestScoreReport;
16+
candidate: BacktestScoreReport;
17+
regressedAxes: Array<"precision" | "recall">;
18+
improvedAxes: Array<"precision" | "recall">;
19+
verdict: "improved" | "regressed" | "unchanged";
20+
};
21+
22+
/**
23+
* Compare a candidate rule change's backtest score against its baseline under the Pareto-floor rule: an
24+
* axis regresses when the candidate's value is strictly below the baseline's, improves when strictly above,
25+
* and is excluded from BOTH lists when either side is null (insufficient decided data is never treated as 0
26+
* or as "no change" -- the same "unknown stays unknown" discipline the reports themselves use). The verdict
27+
* is "regressed" whenever ANY axis regressed -- even if the other axis improved -- else "improved" when any
28+
* axis improved, else "unchanged". Throws when the two reports describe different rules: that is a caller
29+
* bug, not a valid comparison.
30+
*/
31+
export function compareBacktestScores(baseline: BacktestScoreReport, candidate: BacktestScoreReport): BacktestComparison {
32+
if (baseline.ruleId !== candidate.ruleId) {
33+
throw new Error(`cannot compare backtest scores for different rules: ${baseline.ruleId} vs ${candidate.ruleId}`);
34+
}
35+
const regressedAxes: ComparisonAxis[] = [];
36+
const improvedAxes: ComparisonAxis[] = [];
37+
for (const axis of ["precision", "recall"] as const) {
38+
const baselineValue = baseline[axis];
39+
const candidateValue = candidate[axis];
40+
if (baselineValue === null || candidateValue === null) continue;
41+
if (candidateValue < baselineValue) regressedAxes.push(axis);
42+
else if (candidateValue > baselineValue) improvedAxes.push(axis);
43+
}
44+
return {
45+
ruleId: baseline.ruleId,
46+
baseline,
47+
candidate,
48+
regressedAxes,
49+
improvedAxes,
50+
verdict: regressedAxes.length > 0 ? "regressed" : improvedAxes.length > 0 ? "improved" : "unchanged",
51+
};
52+
}

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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
7+
return {
8+
ruleId: "missing_linked_issue",
9+
caseCount: 10,
10+
truePositive: 4,
11+
falsePositive: 2,
12+
trueNegative: 3,
13+
falseNegative: 1,
14+
precision: 0.5,
15+
recall: 0.5,
16+
...overrides,
17+
};
18+
}
19+
20+
test("barrel: the public entrypoint re-exports the Pareto-floor comparator (#8086)", () => {
21+
assert.equal(typeof compareBacktestScores, "function");
22+
});
23+
24+
test("compareBacktestScores: both axes improving is an improved verdict with empty regressedAxes", () => {
25+
const comparison = compareBacktestScores(report(), report({ precision: 0.7, recall: 0.6 }));
26+
assert.deepEqual(comparison.improvedAxes, ["precision", "recall"]);
27+
assert.deepEqual(comparison.regressedAxes, []);
28+
assert.equal(comparison.verdict, "improved");
29+
});
30+
31+
test("compareBacktestScores: PARETO FLOOR -- one axis improving while the other regresses is a regressed verdict", () => {
32+
const comparison = compareBacktestScores(report(), report({ precision: 0.9, recall: 0.3 }));
33+
assert.deepEqual(comparison.improvedAxes, ["precision"]);
34+
assert.deepEqual(comparison.regressedAxes, ["recall"]);
35+
assert.equal(comparison.verdict, "regressed");
36+
});
37+
38+
test("compareBacktestScores: an axis with a null on either side is excluded from both lists", () => {
39+
const nullBaseline = compareBacktestScores(report({ precision: null }), report({ precision: 0.9, recall: 0.6 }));
40+
assert.deepEqual(nullBaseline.improvedAxes, ["recall"]);
41+
assert.deepEqual(nullBaseline.regressedAxes, []);
42+
assert.equal(nullBaseline.verdict, "improved");
43+
44+
const nullCandidate = compareBacktestScores(report(), report({ recall: null }));
45+
assert.deepEqual(nullCandidate.improvedAxes, []);
46+
assert.deepEqual(nullCandidate.regressedAxes, []);
47+
assert.equal(nullCandidate.verdict, "unchanged");
48+
});
49+
50+
test("compareBacktestScores: equal non-null axes land in neither list and yield an unchanged verdict", () => {
51+
const comparison = compareBacktestScores(report(), report());
52+
assert.deepEqual(comparison.improvedAxes, []);
53+
assert.deepEqual(comparison.regressedAxes, []);
54+
assert.equal(comparison.verdict, "unchanged");
55+
assert.equal(comparison.ruleId, "missing_linked_issue");
56+
});
57+
58+
test("compareBacktestScores: mismatched ruleIds throw, naming both rules", () => {
59+
assert.throws(
60+
() => compareBacktestScores(report(), report({ ruleId: "other_rule" })),
61+
/cannot compare backtest scores for different rules: missing_linked_issue vs other_rule/,
62+
);
63+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
// Import the engine SOURCE directly (not the built dist) -- coverage.include lists
4+
// packages/loopover-engine/src/**, so only a source-path import exercises the .ts these branches live in
5+
// (the dist-importing twin in packages/loopover-engine/test/ covers the built barrel for the workspace
6+
// suite). Same pattern as backtest-corpus-engine.test.ts / miner-deny-hook-synthesis.test.ts.
7+
import { compareBacktestScores } from "../../packages/loopover-engine/src/calibration/backtest-compare";
8+
import type { BacktestScoreReport } from "../../packages/loopover-engine/src/calibration/backtest-score";
9+
10+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
11+
return {
12+
ruleId: "missing_linked_issue",
13+
caseCount: 10,
14+
truePositive: 4,
15+
falsePositive: 2,
16+
trueNegative: 3,
17+
falseNegative: 1,
18+
precision: 0.5,
19+
recall: 0.5,
20+
...overrides,
21+
};
22+
}
23+
24+
describe("compareBacktestScores (#8086)", () => {
25+
it("marks both-axes improvement as improved with empty regressedAxes", () => {
26+
const comparison = compareBacktestScores(report(), report({ precision: 0.7, recall: 0.6 }));
27+
expect(comparison.improvedAxes).toEqual(["precision", "recall"]);
28+
expect(comparison.regressedAxes).toEqual([]);
29+
expect(comparison.verdict).toBe("improved");
30+
expect(comparison.baseline.precision).toBe(0.5);
31+
expect(comparison.candidate.precision).toBe(0.7);
32+
});
33+
34+
it("PARETO FLOOR: one axis improving while the other regresses is a regressed verdict", () => {
35+
const comparison = compareBacktestScores(report(), report({ precision: 0.9, recall: 0.3 }));
36+
expect(comparison.improvedAxes).toEqual(["precision"]);
37+
expect(comparison.regressedAxes).toEqual(["recall"]);
38+
expect(comparison.verdict).toBe("regressed");
39+
});
40+
41+
it("marks a regression on both axes as regressed with empty improvedAxes", () => {
42+
const comparison = compareBacktestScores(report(), report({ precision: 0.1, recall: 0.2 }));
43+
expect(comparison.regressedAxes).toEqual(["precision", "recall"]);
44+
expect(comparison.improvedAxes).toEqual([]);
45+
expect(comparison.verdict).toBe("regressed");
46+
});
47+
48+
it("excludes an axis from both lists when either side is null -- null is never 0 and never 'no change'", () => {
49+
const nullBaseline = compareBacktestScores(report({ precision: null }), report({ precision: 0.9, recall: 0.6 }));
50+
expect(nullBaseline.improvedAxes).toEqual(["recall"]);
51+
expect(nullBaseline.regressedAxes).toEqual([]);
52+
expect(nullBaseline.verdict).toBe("improved");
53+
54+
const nullCandidate = compareBacktestScores(report(), report({ recall: null }));
55+
expect(nullCandidate.improvedAxes).toEqual([]);
56+
expect(nullCandidate.regressedAxes).toEqual([]);
57+
expect(nullCandidate.verdict).toBe("unchanged");
58+
});
59+
60+
it("yields unchanged when every comparable axis is equal", () => {
61+
const comparison = compareBacktestScores(report(), report());
62+
expect(comparison.improvedAxes).toEqual([]);
63+
expect(comparison.regressedAxes).toEqual([]);
64+
expect(comparison.verdict).toBe("unchanged");
65+
expect(comparison.ruleId).toBe("missing_linked_issue");
66+
});
67+
68+
it("throws on mismatched ruleIds, naming both rules in the message", () => {
69+
expect(() => compareBacktestScores(report(), report({ ruleId: "other_rule" }))).toThrow(
70+
"cannot compare backtest scores for different rules: missing_linked_issue vs other_rule",
71+
);
72+
});
73+
});

0 commit comments

Comments
 (0)