Skip to content

Commit 5553138

Browse files
authored
feat(engine): render backtest score and comparison reports as Markdown (#8088) (#8116)
BacktestScoreReport (#8085) and BacktestComparison (#8086) are plain data with no human-readable rendering. Add calibration/backtest-report.ts: renderBacktestScoreReport (a Markdown table of the rule ID, case count, all four confusion-matrix counts, and precision/recall) and renderBacktestComparison (regressed axes under a Regressed heading, improved axes under a visually separate Improved heading -- an empty section is omitted entirely so nothing ever reads as regressed when it isn't -- plus a closing verdict line). Null precision/recall render as the literal N/A, never 0 or the word null, mirroring the reports' own null-is-not-zero discipline. The regressed closing line is exactly "Verdict: REGRESSED — do not merge." so the follow-up CI wiring can detect it by string match without re-implementing the comparison. Both functions are pure and byte-identical for identical input. Barrel export added directly after the backtest-compare line, per the issue's placement requirement. Tests in both suites (engine node:test deliverable + root vitest for the coverage gate): snapshot-exact table render, N/A for both null axes, the literal REGRESSED + do-not-merge line with section ordering asserted, improved-only with no regressed claim, unchanged with neither section, and byte-identical determinism for both renderers.
1 parent c52d862 commit 5553138

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Markdown rendering for backtest score/comparison data (#8088, part of the #8082 rule-precision
2+
// backtest epic). BacktestScoreReport (#8085) and BacktestComparison (#8086) are plain data; this is the
3+
// human-readable "receipt" a maintainer (and, per the parent epic, eventually an advisory CI comment)
4+
// reads directly -- a deterministic pure function producing stable Markdown, not ad-hoc console logging.
5+
//
6+
// SELF-CONTAINED, PURE: string in, string out -- no IO, no wall-clock reads, byte-identical output for
7+
// byte-identical input, the same posture as the rest of this calibration directory.
8+
9+
import type { BacktestComparison } from "./backtest-compare.js";
10+
import type { BacktestScoreReport } from "./backtest-score.js";
11+
12+
/** Render null precision/recall as the literal `N/A` -- never 0, the word null, or an empty cell,
13+
* mirroring the null-is-not-zero discipline BacktestScoreReport itself establishes (#8085). */
14+
function formatAxisValue(value: number | null): string {
15+
return value === null ? "N/A" : String(value);
16+
}
17+
18+
/**
19+
* Render one {@link BacktestScoreReport} as a Markdown table: the rule ID, case count, all four
20+
* confusion-matrix counts, and precision/recall (null rendered as `N/A`).
21+
*/
22+
export function renderBacktestScoreReport(report: BacktestScoreReport): string {
23+
return [
24+
`### Backtest score: \`${report.ruleId}\``,
25+
"",
26+
"| Metric | Value |",
27+
"| --- | --- |",
28+
`| Cases scored | ${report.caseCount} |`,
29+
`| True positives | ${report.truePositive} |`,
30+
`| False positives | ${report.falsePositive} |`,
31+
`| True negatives | ${report.trueNegative} |`,
32+
`| False negatives | ${report.falseNegative} |`,
33+
`| Precision | ${formatAxisValue(report.precision)} |`,
34+
`| Recall | ${formatAxisValue(report.recall)} |`,
35+
"",
36+
].join("\n");
37+
}
38+
39+
/**
40+
* Render one {@link BacktestComparison} as Markdown: regressed axes under a "Regressed" heading,
41+
* improved axes under a visually separate "Improved" heading (a section with no axes is omitted
42+
* entirely, so nothing ever reads as regressed when it isn't), and a closing verdict line. The
43+
* `"regressed"` closing line contains the literal word `REGRESSED` and states the change should not be
44+
* merged -- exact wording a future automated consumer (the follow-up CI wiring) detects by string match
45+
* without re-implementing the comparison logic.
46+
*/
47+
export function renderBacktestComparison(comparison: BacktestComparison): string {
48+
const lines: string[] = [`### Backtest comparison: \`${comparison.ruleId}\``, ""];
49+
if (comparison.regressedAxes.length > 0) {
50+
lines.push("**Regressed**");
51+
for (const axis of comparison.regressedAxes) {
52+
lines.push(`- ${axis}: ${formatAxisValue(comparison.baseline[axis])}${formatAxisValue(comparison.candidate[axis])}`);
53+
}
54+
lines.push("");
55+
}
56+
if (comparison.improvedAxes.length > 0) {
57+
lines.push("**Improved**");
58+
for (const axis of comparison.improvedAxes) {
59+
lines.push(`- ${axis}: ${formatAxisValue(comparison.baseline[axis])}${formatAxisValue(comparison.candidate[axis])}`);
60+
}
61+
lines.push("");
62+
}
63+
if (comparison.verdict === "regressed") {
64+
lines.push("Verdict: REGRESSED — do not merge.");
65+
} else if (comparison.verdict === "improved") {
66+
lines.push("Verdict: improved — no axis regressed.");
67+
} else {
68+
lines.push("Verdict: unchanged — no comparable axis moved.");
69+
}
70+
lines.push("");
71+
return lines.join("\n");
72+
}

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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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("barrel: the public entrypoint re-exports both backtest renderers (#8088)", () => {
38+
assert.equal(typeof renderBacktestScoreReport, "function");
39+
assert.equal(typeof renderBacktestComparison, "function");
40+
});
41+
42+
test("renderBacktestScoreReport: renders every count and both non-null axes, snapshot-exact", () => {
43+
const rendered = renderBacktestScoreReport(report());
44+
assert.equal(
45+
rendered,
46+
[
47+
"### Backtest score: `missing_linked_issue`",
48+
"",
49+
"| Metric | Value |",
50+
"| --- | --- |",
51+
"| Cases scored | 4 |",
52+
"| True positives | 1 |",
53+
"| False positives | 1 |",
54+
"| True negatives | 1 |",
55+
"| False negatives | 1 |",
56+
"| Precision | 0.5 |",
57+
"| Recall | 0.5 |",
58+
"",
59+
].join("\n"),
60+
);
61+
});
62+
63+
test("renderBacktestScoreReport: null precision/recall render as N/A, never 0 or the word null", () => {
64+
const rendered = renderBacktestScoreReport(report({ precision: null, recall: null }));
65+
assert.ok(rendered.includes("| Precision | N/A |"));
66+
assert.ok(rendered.includes("| Recall | N/A |"));
67+
assert.ok(!rendered.includes("null"));
68+
});
69+
70+
test("renderBacktestComparison: a regressed verdict contains the literal REGRESSED and a do-not-merge line", () => {
71+
const rendered = renderBacktestComparison(
72+
comparison({ regressedAxes: ["recall"], improvedAxes: ["precision"], verdict: "regressed", candidate: report({ precision: 0.9, recall: 0.4 }) }),
73+
);
74+
assert.ok(rendered.includes("REGRESSED"));
75+
assert.ok(rendered.includes("do not merge"));
76+
assert.ok(rendered.includes("**Regressed**"));
77+
assert.ok(rendered.includes("- recall: 0.5 → 0.4"));
78+
});
79+
80+
test("renderBacktestComparison: improved-only output claims no regressed axis", () => {
81+
const rendered = renderBacktestComparison(comparison());
82+
assert.ok(rendered.includes("**Improved**"));
83+
assert.ok(rendered.includes("- precision: 0.5 → 0.75"));
84+
assert.ok(!rendered.includes("**Regressed**"));
85+
assert.ok(rendered.includes("Verdict: improved"));
86+
});
87+
88+
test("renderBacktestComparison / renderBacktestScoreReport: byte-identical output for identical input", () => {
89+
assert.equal(renderBacktestScoreReport(report()), renderBacktestScoreReport(report()));
90+
assert.equal(renderBacktestComparison(comparison()), renderBacktestComparison(comparison()));
91+
});

test/unit/backtest-report.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { BacktestComparison } from "../../packages/loopover-engine/src/calibration/backtest-compare";
3+
import { renderBacktestComparison, renderBacktestScoreReport } from "../../packages/loopover-engine/src/calibration/backtest-report";
4+
import type { BacktestScoreReport } from "../../packages/loopover-engine/src/calibration/backtest-score";
5+
6+
function report(overrides: Partial<BacktestScoreReport> = {}): BacktestScoreReport {
7+
return {
8+
ruleId: "missing_linked_issue",
9+
caseCount: 4,
10+
truePositive: 1,
11+
falsePositive: 1,
12+
trueNegative: 1,
13+
falseNegative: 1,
14+
precision: 0.5,
15+
recall: 0.5,
16+
...overrides,
17+
};
18+
}
19+
20+
function comparison(overrides: Partial<BacktestComparison> = {}): BacktestComparison {
21+
return {
22+
ruleId: "missing_linked_issue",
23+
baseline: report(),
24+
candidate: report({ precision: 0.75 }),
25+
regressedAxes: [],
26+
improvedAxes: ["precision"],
27+
verdict: "improved",
28+
...overrides,
29+
};
30+
}
31+
32+
describe("renderBacktestScoreReport (#8088)", () => {
33+
it("renders every count and both non-null axes, snapshot-exact", () => {
34+
expect(renderBacktestScoreReport(report())).toBe(
35+
[
36+
"### Backtest score: `missing_linked_issue`",
37+
"",
38+
"| Metric | Value |",
39+
"| --- | --- |",
40+
"| Cases scored | 4 |",
41+
"| True positives | 1 |",
42+
"| False positives | 1 |",
43+
"| True negatives | 1 |",
44+
"| False negatives | 1 |",
45+
"| Precision | 0.5 |",
46+
"| Recall | 0.5 |",
47+
"",
48+
].join("\n"),
49+
);
50+
});
51+
52+
it("renders null precision/recall as N/A — never 0, never the word null", () => {
53+
const rendered = renderBacktestScoreReport(report({ precision: null, recall: null }));
54+
expect(rendered).toContain("| Precision | N/A |");
55+
expect(rendered).toContain("| Recall | N/A |");
56+
expect(rendered).not.toContain("null");
57+
});
58+
59+
it("is byte-identical for identical input", () => {
60+
expect(renderBacktestScoreReport(report())).toBe(renderBacktestScoreReport(report()));
61+
});
62+
});
63+
64+
describe("renderBacktestComparison (#8088)", () => {
65+
it("renders a regressed verdict with the literal REGRESSED, a do-not-merge line, and the regressed axis sectioned", () => {
66+
const rendered = renderBacktestComparison(
67+
comparison({
68+
regressedAxes: ["recall"],
69+
improvedAxes: ["precision"],
70+
verdict: "regressed",
71+
candidate: report({ precision: 0.9, recall: 0.4 }),
72+
}),
73+
);
74+
expect(rendered).toContain("Verdict: REGRESSED — do not merge.");
75+
expect(rendered).toContain("**Regressed**");
76+
expect(rendered).toContain("- recall: 0.5 → 0.4");
77+
expect(rendered).toContain("**Improved**");
78+
expect(rendered).toContain("- precision: 0.5 → 0.9");
79+
// The regressed axis never bleeds into the improved section and vice versa.
80+
expect(rendered.indexOf("**Regressed**")).toBeLessThan(rendered.indexOf("- recall:"));
81+
expect(rendered.indexOf("- recall:")).toBeLessThan(rendered.indexOf("**Improved**"));
82+
});
83+
84+
it("renders improved-only output without claiming any regressed axis", () => {
85+
const rendered = renderBacktestComparison(comparison());
86+
expect(rendered).toContain("**Improved**");
87+
expect(rendered).toContain("Verdict: improved — no axis regressed.");
88+
expect(rendered).not.toContain("**Regressed**");
89+
});
90+
91+
it("renders an unchanged verdict with neither axis section", () => {
92+
const rendered = renderBacktestComparison(comparison({ improvedAxes: [], verdict: "unchanged", candidate: report() }));
93+
expect(rendered).toContain("Verdict: unchanged — no comparable axis moved.");
94+
expect(rendered).not.toContain("**Regressed**");
95+
expect(rendered).not.toContain("**Improved**");
96+
});
97+
98+
it("renders N/A for a null axis endpoint inside a section line", () => {
99+
const rendered = renderBacktestComparison(
100+
comparison({
101+
baseline: report({ recall: 0.5 }),
102+
candidate: report({ recall: null, precision: 0.75 }),
103+
regressedAxes: [],
104+
improvedAxes: ["precision"],
105+
verdict: "improved",
106+
}),
107+
);
108+
expect(rendered).toContain("- precision: 0.5 → 0.75");
109+
});
110+
111+
it("is byte-identical for identical input", () => {
112+
expect(renderBacktestComparison(comparison())).toBe(renderBacktestComparison(comparison()));
113+
});
114+
});

0 commit comments

Comments
 (0)