Skip to content

Commit c774db9

Browse files
committed
feat(calibration): render backtest score/comparison reports as Markdown (#8088)
1 parent e2d8ef8 commit c774db9

4 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Markdown renderers for backtest results (#8088) -- the human-readable "receipt" a maintainer (and, per
2+
// the parent epic, eventually an advisory CI comment) reads for a BacktestScoreReport (#8085) or a
3+
// BacktestComparison (#8086). Deterministic pure functions producing stable Markdown, not ad-hoc console
4+
// logging: byte-identical input always renders byte-identical output.
5+
//
6+
// Same purity contract as the rest of this module family: no IO, no randomness, no wall-clock reads.
7+
8+
import type { BacktestComparison } from "./backtest-compare.js";
9+
import type { BacktestScoreReport } from "./backtest-score.js";
10+
11+
/** Render a nullable ratio for display: `null` is `N/A` -- never `0`, the word `null`, or an empty cell
12+
* (the same null-is-not-zero discipline BacktestScoreReport itself establishes). */
13+
function renderRatio(value: number | null): string {
14+
return value === null ? "N/A" : String(value);
15+
}
16+
17+
/**
18+
* Render one {@link BacktestScoreReport} as a Markdown table: the rule ID as a heading, then every count
19+
* and both (nullable) ratios. Pure string-in/string-out; the exact layout is pinned by a snapshot test.
20+
*/
21+
export function renderBacktestScoreReport(report: BacktestScoreReport): string {
22+
return [
23+
`### Backtest score — \`${report.ruleId}\``,
24+
"",
25+
"| Metric | Value |",
26+
"| --- | --- |",
27+
`| Cases scored | ${report.caseCount} |`,
28+
`| True positives | ${report.truePositive} |`,
29+
`| False positives | ${report.falsePositive} |`,
30+
`| True negatives | ${report.trueNegative} |`,
31+
`| False negatives | ${report.falseNegative} |`,
32+
`| Precision | ${renderRatio(report.precision)} |`,
33+
`| Recall | ${renderRatio(report.recall)} |`,
34+
].join("\n");
35+
}
36+
37+
/**
38+
* Render one {@link BacktestComparison} as Markdown: the rule ID as a heading, a "Regressed" section for
39+
* every regressed axis, a visually separate "Improved" section for every improved axis (an axis can only
40+
* ever appear under its own section -- the two lists are disjoint by construction upstream), and a closing
41+
* verdict line. The `"regressed"` closing line contains the literal word `REGRESSED` and states the change
42+
* should not be merged -- pinned wording, so a future automated consumer can detect the regressed case by
43+
* string match without re-implementing the comparison logic. Sections with no axes render as "(none)"
44+
* rather than listing anything, so an empty regression list can never read as if something regressed.
45+
*/
46+
export function renderBacktestComparison(comparison: BacktestComparison): string {
47+
const axisLines = (axes: ReadonlyArray<"precision" | "recall">): string[] =>
48+
axes.length === 0 ? ["- (none)"] : axes.map((axis) => `- ${axis}`);
49+
const verdictLine =
50+
comparison.verdict === "regressed"
51+
? "Verdict: REGRESSED — do not merge"
52+
: comparison.verdict === "improved"
53+
? "Verdict: improved"
54+
: "Verdict: unchanged";
55+
return [
56+
`### Backtest comparison — \`${comparison.ruleId}\``,
57+
"",
58+
"**Regressed**",
59+
"",
60+
...axisLines(comparison.regressedAxes),
61+
"",
62+
"**Improved**",
63+
"",
64+
...axisLines(comparison.improvedAxes),
65+
"",
66+
verdictLine,
67+
].join("\n");
68+
}

packages/loopover-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export * from "./calibration/signal-tracking.js";
166166
export * from "./calibration/backtest-corpus.js";
167167
export * from "./calibration/backtest-score.js";
168168
export * from "./calibration/backtest-compare.js";
169+
export * from "./calibration/backtest-report.js";
169170
export {
170171
GOVERNOR_LEDGER_EVENT_TYPES,
171172
normalizeGovernorLedgerEvent,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import {
5+
renderBacktestComparison,
6+
renderBacktestScoreReport,
7+
type BacktestComparison,
8+
type BacktestScoreReport,
9+
} from "../dist/index.js";
10+
11+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
12+
return {
13+
ruleId: "missing_linked_issue",
14+
caseCount: 4,
15+
truePositive: 1,
16+
falsePositive: 1,
17+
trueNegative: 1,
18+
falseNegative: 1,
19+
precision: 0.5,
20+
recall: 0.5,
21+
...overrides,
22+
};
23+
}
24+
25+
function comparison(overrides: Partial<BacktestComparison> = {}): BacktestComparison {
26+
return {
27+
ruleId: "missing_linked_issue",
28+
baseline: report(),
29+
candidate: report({ precision: 0.75 }),
30+
regressedAxes: [],
31+
improvedAxes: ["precision"],
32+
verdict: "improved",
33+
...overrides,
34+
};
35+
}
36+
37+
test("renderBacktestScoreReport: snapshot -- a non-null report renders every count and both ratios", () => {
38+
assert.equal(
39+
renderBacktestScoreReport(report()),
40+
[
41+
"### Backtest score — `missing_linked_issue`",
42+
"",
43+
"| Metric | Value |",
44+
"| --- | --- |",
45+
"| Cases scored | 4 |",
46+
"| True positives | 1 |",
47+
"| False positives | 1 |",
48+
"| True negatives | 1 |",
49+
"| False negatives | 1 |",
50+
"| Precision | 0.5 |",
51+
"| Recall | 0.5 |",
52+
].join("\n"),
53+
);
54+
});
55+
56+
test("renderBacktestScoreReport: null precision/recall render as N/A, never 0, null, or an empty cell", () => {
57+
const rendered = renderBacktestScoreReport(report({ precision: null, recall: null }));
58+
assert.match(rendered, /\| Precision \| N\/A \|/);
59+
assert.match(rendered, /\| Recall \| N\/A \|/);
60+
assert.doesNotMatch(rendered, /\| Precision \| (0|null)? \|/);
61+
assert.doesNotMatch(rendered, /\| Recall \| (0|null)? \|/);
62+
});
63+
64+
test("renderBacktestComparison: a regressed comparison names the regressed axis under Regressed and closes with the literal REGRESSED wording", () => {
65+
const rendered = renderBacktestComparison(
66+
comparison({ regressedAxes: ["recall"], improvedAxes: ["precision"], verdict: "regressed" }),
67+
);
68+
assert.match(rendered, /\*\*Regressed\*\*\n\n- recall/);
69+
assert.match(rendered, /\*\*Improved\*\*\n\n- precision/);
70+
assert.match(rendered, /Verdict: REGRESSED do not merge/);
71+
});
72+
73+
test("renderBacktestComparison: an improved comparison claims no regressed axis", () => {
74+
const rendered = renderBacktestComparison(comparison());
75+
assert.match(rendered, /\*\*Regressed\*\*\n\n- \(none\)/);
76+
assert.match(rendered, /\*\*Improved\*\*\n\n- precision/);
77+
assert.match(rendered, /Verdict: improved/);
78+
assert.doesNotMatch(rendered, /REGRESSED/);
79+
});
80+
81+
test("renderBacktestComparison: an unchanged comparison lists no axis on either side", () => {
82+
const rendered = renderBacktestComparison(
83+
comparison({ improvedAxes: [], verdict: "unchanged", candidate: report() }),
84+
);
85+
assert.match(rendered, /\*\*Regressed\*\*\n\n- \(none\)/);
86+
assert.match(rendered, /\*\*Improved\*\*\n\n- \(none\)/);
87+
assert.match(rendered, /Verdict: unchanged/);
88+
});
89+
90+
test("both renderers are deterministic: identical input renders byte-identical output", () => {
91+
assert.equal(renderBacktestScoreReport(report()), renderBacktestScoreReport(report()));
92+
const regressed = comparison({ regressedAxes: ["recall"], verdict: "regressed" });
93+
assert.equal(renderBacktestComparison(regressed), renderBacktestComparison(regressed));
94+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 {
8+
renderBacktestComparison,
9+
renderBacktestScoreReport,
10+
} from "../../packages/loopover-engine/src/calibration/backtest-report";
11+
import type { BacktestComparison } from "../../packages/loopover-engine/src/calibration/backtest-compare";
12+
import type { BacktestScoreReport } from "../../packages/loopover-engine/src/calibration/backtest-score";
13+
14+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
15+
return {
16+
ruleId: "missing_linked_issue",
17+
caseCount: 4,
18+
truePositive: 1,
19+
falsePositive: 1,
20+
trueNegative: 1,
21+
falseNegative: 1,
22+
precision: 0.5,
23+
recall: 0.5,
24+
...overrides,
25+
};
26+
}
27+
28+
function comparison(overrides: Partial<BacktestComparison> = {}): BacktestComparison {
29+
return {
30+
ruleId: "missing_linked_issue",
31+
baseline: report(),
32+
candidate: report({ precision: 0.75 }),
33+
regressedAxes: [],
34+
improvedAxes: ["precision"],
35+
verdict: "improved",
36+
...overrides,
37+
};
38+
}
39+
40+
describe("renderBacktestScoreReport (#8088)", () => {
41+
it("renders the exact snapshot for a non-null report", () => {
42+
expect(renderBacktestScoreReport(report())).toBe(
43+
[
44+
"### Backtest score — `missing_linked_issue`",
45+
"",
46+
"| Metric | Value |",
47+
"| --- | --- |",
48+
"| Cases scored | 4 |",
49+
"| True positives | 1 |",
50+
"| False positives | 1 |",
51+
"| True negatives | 1 |",
52+
"| False negatives | 1 |",
53+
"| Precision | 0.5 |",
54+
"| Recall | 0.5 |",
55+
].join("\n"),
56+
);
57+
});
58+
59+
it("renders null precision/recall as N/A -- never 0, null, or an empty cell", () => {
60+
const rendered = renderBacktestScoreReport(report({ precision: null, recall: null }));
61+
expect(rendered).toContain("| Precision | N/A |");
62+
expect(rendered).toContain("| Recall | N/A |");
63+
expect(rendered).not.toContain("| Precision | 0 |");
64+
expect(rendered).not.toContain("null");
65+
});
66+
67+
it("is deterministic for identical input", () => {
68+
expect(renderBacktestScoreReport(report())).toBe(renderBacktestScoreReport(report()));
69+
});
70+
});
71+
72+
describe("renderBacktestComparison (#8088)", () => {
73+
it("puts each axis under its own section and pins the literal REGRESSED do-not-merge wording", () => {
74+
const rendered = renderBacktestComparison(
75+
comparison({ regressedAxes: ["recall"], improvedAxes: ["precision"], verdict: "regressed" }),
76+
);
77+
expect(rendered).toContain("**Regressed**\n\n- recall");
78+
expect(rendered).toContain("**Improved**\n\n- precision");
79+
expect(rendered).toContain("Verdict: REGRESSED — do not merge");
80+
});
81+
82+
it("claims no regressed axis for an improved comparison", () => {
83+
const rendered = renderBacktestComparison(comparison());
84+
expect(rendered).toContain("**Regressed**\n\n- (none)");
85+
expect(rendered).toContain("**Improved**\n\n- precision");
86+
expect(rendered).toContain("Verdict: improved");
87+
expect(rendered).not.toContain("REGRESSED");
88+
});
89+
90+
it("lists no axis on either side for an unchanged comparison", () => {
91+
const rendered = renderBacktestComparison(
92+
comparison({ improvedAxes: [], verdict: "unchanged", candidate: report() }),
93+
);
94+
expect(rendered).toContain("**Regressed**\n\n- (none)");
95+
expect(rendered).toContain("**Improved**\n\n- (none)");
96+
expect(rendered).toContain("Verdict: unchanged");
97+
});
98+
99+
it("is deterministic for identical input", () => {
100+
const regressed = comparison({ regressedAxes: ["recall"], verdict: "regressed" });
101+
expect(renderBacktestComparison(regressed)).toBe(renderBacktestComparison(regressed));
102+
});
103+
});

0 commit comments

Comments
 (0)