Skip to content

Commit be6f2df

Browse files
cleanjuncJSONbored
authored andcommitted
feat(engine): render backtest score and comparison reports as deterministic Markdown (#8088)
1 parent c52d862 commit be6f2df

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Markdown renderers for backtest results (#8088, parent epic #8082) -- the human-readable "receipt" a
2+
// maintainer (and, per the epic's proposal, a future advisory CI comment) reads directly. Deterministic pure
3+
// functions producing stable Markdown: byte-identical input -> byte-identical output, never ad-hoc logging.
4+
//
5+
// Pure, like everything in this module: string in, string out; no IO, no wall-clock reads.
6+
7+
import type { BacktestComparison } from "./backtest-compare.js";
8+
import type { BacktestScoreReport } from "./backtest-score.js";
9+
10+
/** Render a null-able rate as its number, or the literal `N/A` -- never `0`, `null`, or an empty cell (the
11+
* null-is-not-zero discipline BacktestScoreReport itself establishes). */
12+
function renderRate(value: number | null): string {
13+
return value === null ? "N/A" : String(value);
14+
}
15+
16+
/** Render one score report as a Markdown table: rule ID, case count, all four confusion-matrix counts, and
17+
* precision/recall (null rendered as `N/A`). */
18+
export function renderBacktestScoreReport(report: BacktestScoreReport): string {
19+
return [
20+
`### Backtest score: \`${report.ruleId}\``,
21+
"",
22+
"| Metric | Value |",
23+
"| --- | --- |",
24+
`| Cases scored | ${report.caseCount} |`,
25+
`| True positives | ${report.truePositive} |`,
26+
`| False positives | ${report.falsePositive} |`,
27+
`| True negatives | ${report.trueNegative} |`,
28+
`| False negatives | ${report.falseNegative} |`,
29+
`| Precision | ${renderRate(report.precision)} |`,
30+
`| Recall | ${renderRate(report.recall)} |`,
31+
"",
32+
].join("\n");
33+
}
34+
35+
/**
36+
* Render a comparison with clearly separated Regressed / Improved sections (an axis only ever appears under
37+
* its own heading) and a closing verdict line. The regressed closing line contains the literal word
38+
* `REGRESSED` and states the change should not be merged -- exact wording a future automated consumer can
39+
* string-match without re-implementing the comparison.
40+
*/
41+
export function renderBacktestComparison(comparison: BacktestComparison): string {
42+
const lines: string[] = [`### Backtest comparison: \`${comparison.ruleId}\``, ""];
43+
if (comparison.regressedAxes.length > 0) {
44+
lines.push("**Regressed**", "");
45+
for (const axis of comparison.regressedAxes) {
46+
// A listed axis is non-null on BOTH sides by compareBacktestScores's own exclusion rule.
47+
lines.push(`- ${axis}: ${comparison.baseline[axis]}${comparison.candidate[axis]}`);
48+
}
49+
lines.push("");
50+
}
51+
if (comparison.improvedAxes.length > 0) {
52+
lines.push("**Improved**", "");
53+
for (const axis of comparison.improvedAxes) {
54+
lines.push(`- ${axis}: ${comparison.baseline[axis]}${comparison.candidate[axis]}`);
55+
}
56+
lines.push("");
57+
}
58+
if (comparison.verdict === "regressed") {
59+
lines.push("Verdict: REGRESSED — do not merge (a regression on any axis outweighs improvement on another).");
60+
} else if (comparison.verdict === "improved") {
61+
lines.push("Verdict: improved — no axis regressed and at least one improved.");
62+
} else {
63+
lines.push("Verdict: unchanged — no comparable axis moved in either direction.");
64+
}
65+
lines.push("");
66+
return lines.join("\n");
67+
}

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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import {
5+
compareBacktestScores,
6+
renderBacktestComparison,
7+
renderBacktestScoreReport,
8+
type BacktestScoreReport,
9+
} from "../dist/index.js";
10+
11+
// #8088: the Markdown "receipt" renderers. Null rates render as the literal `N/A` (never 0/null/empty), a
12+
// regressed comparison's closing line contains the literal REGRESSED + do-not-merge wording, and both
13+
// functions are byte-identically deterministic.
14+
15+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
16+
return {
17+
ruleId: "rule",
18+
caseCount: 10,
19+
truePositive: 4,
20+
falsePositive: 1,
21+
trueNegative: 4,
22+
falseNegative: 1,
23+
precision: 0.8,
24+
recall: 0.8,
25+
...overrides,
26+
};
27+
}
28+
29+
test("renderBacktestScoreReport: exact snapshot for a non-null fixture", () => {
30+
assert.equal(
31+
renderBacktestScoreReport(report()),
32+
[
33+
"### Backtest score: `rule`",
34+
"",
35+
"| Metric | Value |",
36+
"| --- | --- |",
37+
"| Cases scored | 10 |",
38+
"| True positives | 4 |",
39+
"| False positives | 1 |",
40+
"| True negatives | 4 |",
41+
"| False negatives | 1 |",
42+
"| Precision | 0.8 |",
43+
"| Recall | 0.8 |",
44+
"",
45+
].join("\n"),
46+
);
47+
});
48+
49+
test("renderBacktestScoreReport: null precision/recall render as the literal N/A, never 0 or null", () => {
50+
const rendered = renderBacktestScoreReport(report({ precision: null, recall: null }));
51+
assert.ok(rendered.includes("| Precision | N/A |"));
52+
assert.ok(rendered.includes("| Recall | N/A |"));
53+
assert.ok(!rendered.includes("| Precision | 0 |"));
54+
assert.ok(!rendered.includes("null"));
55+
});
56+
57+
test("renderBacktestComparison: a regressed verdict's closing line contains REGRESSED and do-not-merge wording", () => {
58+
const comparison = compareBacktestScores(report(), report({ precision: 0.95, recall: 0.6 }));
59+
const rendered = renderBacktestComparison(comparison);
60+
assert.ok(rendered.includes("REGRESSED"));
61+
assert.ok(rendered.includes("do not merge"));
62+
// The regressed axis appears only under Regressed; the improved axis only under Improved.
63+
const regressedSection = rendered.slice(rendered.indexOf("**Regressed**"), rendered.indexOf("**Improved**"));
64+
assert.ok(regressedSection.includes("recall"));
65+
assert.ok(!regressedSection.includes("- precision"));
66+
});
67+
68+
test("renderBacktestComparison: an improved comparison renders no regressed axis claim", () => {
69+
const comparison = compareBacktestScores(report(), report({ precision: 0.9, recall: 0.85 }));
70+
const rendered = renderBacktestComparison(comparison);
71+
assert.ok(rendered.includes("**Improved**"));
72+
assert.ok(!rendered.includes("**Regressed**"));
73+
assert.ok(rendered.includes("Verdict: improved"));
74+
});
75+
76+
test("renderBacktestComparison: unchanged verdict states unchanged in words", () => {
77+
const rendered = renderBacktestComparison(compareBacktestScores(report(), report()));
78+
assert.ok(rendered.includes("Verdict: unchanged"));
79+
});
80+
81+
test("both renderers are byte-identically deterministic for identical input", () => {
82+
const scoreReport = report({ precision: null });
83+
assert.equal(renderBacktestScoreReport(scoreReport), renderBacktestScoreReport(scoreReport));
84+
const comparison = compareBacktestScores(report(), report({ recall: 0.9 }));
85+
assert.equal(renderBacktestComparison(comparison), renderBacktestComparison(comparison));
86+
});

test/unit/backtest-report.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it } from "vitest";
2+
// Direct src-path imports — the coverage-twin pattern test/unit/backtest-corpus.test.ts established for this
3+
// module (the engine's own node:test suite runs against dist, invisible to codecov/patch).
4+
import { compareBacktestScores } from "../../packages/loopover-engine/src/calibration/backtest-compare.js";
5+
import { renderBacktestComparison, renderBacktestScoreReport } from "../../packages/loopover-engine/src/calibration/backtest-report.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("renderBacktestScoreReport (#8088)", () => {
23+
it("renders the exact table for a non-null fixture (snapshot)", () => {
24+
expect(renderBacktestScoreReport(report())).toBe(
25+
[
26+
"### Backtest score: `rule`",
27+
"",
28+
"| Metric | Value |",
29+
"| --- | --- |",
30+
"| Cases scored | 10 |",
31+
"| True positives | 4 |",
32+
"| False positives | 1 |",
33+
"| True negatives | 4 |",
34+
"| False negatives | 1 |",
35+
"| Precision | 0.8 |",
36+
"| Recall | 0.8 |",
37+
"",
38+
].join("\n"),
39+
);
40+
});
41+
42+
it("renders null precision/recall as the literal N/A", () => {
43+
const rendered = renderBacktestScoreReport(report({ precision: null, recall: null }));
44+
expect(rendered).toContain("| Precision | N/A |");
45+
expect(rendered).toContain("| Recall | N/A |");
46+
expect(rendered).not.toContain("null");
47+
});
48+
});
49+
50+
describe("renderBacktestComparison (#8088)", () => {
51+
it("keeps regressed and improved axes in visually separate sections and closes with REGRESSED — do not merge", () => {
52+
const rendered = renderBacktestComparison(compareBacktestScores(report(), report({ precision: 0.95, recall: 0.6 })));
53+
expect(rendered).toContain("REGRESSED");
54+
expect(rendered).toContain("do not merge");
55+
const regressedSection = rendered.slice(rendered.indexOf("**Regressed**"), rendered.indexOf("**Improved**"));
56+
expect(regressedSection).toContain("recall");
57+
expect(regressedSection).not.toContain("- precision");
58+
});
59+
60+
it("renders an improved comparison with no Regressed section at all", () => {
61+
const rendered = renderBacktestComparison(compareBacktestScores(report(), report({ precision: 0.9, recall: 0.85 })));
62+
expect(rendered).not.toContain("**Regressed**");
63+
expect(rendered).toContain("Verdict: improved");
64+
});
65+
66+
it("omits a null-excluded axis from both sections entirely", () => {
67+
const rendered = renderBacktestComparison(compareBacktestScores(report({ precision: null }), report({ precision: null, recall: 0.9 })));
68+
expect(rendered).not.toContain("precision");
69+
expect(rendered).toContain("- recall: 0.8 → 0.9");
70+
expect(rendered).toContain("Verdict: improved");
71+
});
72+
73+
it("states the unchanged verdict in words and is byte-identically deterministic", () => {
74+
const comparison = compareBacktestScores(report(), report());
75+
const first = renderBacktestComparison(comparison);
76+
expect(first).toContain("Verdict: unchanged");
77+
expect(renderBacktestComparison(comparison)).toBe(first);
78+
});
79+
});

0 commit comments

Comments
 (0)