Skip to content

Commit 1f99ccc

Browse files
authored
docs(ui): document the backtest/calibration system as a fumadocs page (#8114) (#8153)
#8138 (threshold backtest, shipped ORB-native in #8142) and #8139 (logic backtest CI check, shipped in #8147) are exactly the #3047 failure shape -- new CLI flags, a new CI check with its own trigger/comment behavior, a gate-adjacent pending policy decision (#8105) -- with no docs. Adds content/docs/backtest-calibration.mdx (corpus model, the two backtest mechanisms and why they run where they run, how to read the Pareto-floor comparison, secret_leak's permanent exclusion, the track-record/corpus-export CLIs, and the advisory-only guarantee with #8105 noted as pending) plus the thin route, nav/command-palette entries, and the regenerated route tree. The two pinned docs-route-count tests move 48 -> 49.
1 parent 9c54a1e commit 1f99ccc

8 files changed

Lines changed: 155 additions & 2 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
title: Backtest & calibration
3+
description: How LoopOver measures whether its own review rules are right — and backtests threshold and logic changes against real history before they ship.
4+
---
5+
6+
## What this is
7+
8+
Every time a configured gate blocker fires, LoopOver records it. Every time a human later
9+
overrides that call (or confirms it), LoopOver records that too. Paired together, the two
10+
histories become a labeled corpus: _this rule fired against this PR, and a person later said it
11+
was right ("confirmed") or wrong ("reversed")_.
12+
13+
That corpus makes a rule change **testable before it merges**. A PR that tunes a confidence
14+
threshold or rewrites detection logic gets scored against the real recorded history — the same
15+
targets, the same raw inputs — instead of being eyeballed.
16+
17+
<Callout variant="note">
18+
Everything on this page is advisory-only today. A backtest verdict — even a regression — never
19+
blocks a merge. Whether a REGRESSED verdict should ever gate merges is a tracked, deliberately
20+
separate decision that waits for real production track-record data.
21+
</Callout>
22+
23+
## The corpus
24+
25+
- **Fired events** — recorded when a configured blocker actually carries gate authority, with
26+
bounded raw context: the issue text, PR title/body, and diff the rule evaluated, and (for the
27+
AI-judgment rule `linked_issue_scope_mismatch`) the model's own raw response text.
28+
- **Override events** — recorded when a human reverses an automated call, or confirms one (for
29+
example, an owner closing a PR that was held for low AI confidence confirms the hold).
30+
- **`secret_leak` is permanently excluded from raw-context capture.** Storing the diff that
31+
triggered a leaked-credential finding would store the credential itself in the audit trail.
32+
This rule can therefore never be logic-backtested — by design, not omission.
33+
34+
## Two backtests, two mechanisms
35+
36+
**Threshold changes** run inside LoopOver's own review pass. When a PR's diff changes a known
37+
confidence-threshold constant, the old and new values are replayed as classifiers over the
38+
rule's recorded history, and the comparison is rendered directly into the unified review
39+
comment. No code from the PR is ever executed — a threshold is just a number.
40+
41+
**Logic/regex changes** run in a dedicated CI workflow instead, because honestly verifying a
42+
rewritten detection function requires _executing the PR's own code_ against history — something
43+
the production review service must never do. The workflow checks out both the PR's head and its
44+
base, replays each recorded case's captured raw context through both versions of the detection
45+
function, and posts its own clearly-labeled **"Logic backtest"** PR comment, separate from the
46+
unified review comment. It triggers only for PRs touching the watched detection-logic paths,
47+
skips drafts and forks (fork runs get no secrets), and fails open: an infrastructure problem
48+
produces a skip notice, never a red check.
49+
50+
## Reading the comparison
51+
52+
Both backtests score the same way:
53+
54+
- **"Reversed" is the positive class.** A classifier predicting "reversed" is saying the rule's
55+
original firing was wrong. Precision and recall are computed against the human labels, and
56+
stay `N/A` when there is not enough decided data — unknown is never coerced to zero.
57+
- **The Pareto floor decides the verdict.** A candidate that regresses on _any_ axis is
58+
**REGRESSED**, even if the other axis improved. Trading precision for recall is a regression,
59+
not a net win. Otherwise the verdict is _improved_ (some axis moved up) or _unchanged_.
60+
- Cases recorded before raw-context capture existed are skipped and counted in the comment —
61+
replaying them against empty inputs would bias both sides, so they are never scored.
62+
63+
## The track record
64+
65+
Every backtest run persists its comparison as structured audit data. The accumulated record —
66+
how often runs regress, per rule — is the evidence base for the pending merge-gating decision,
67+
readable at any time with the maintainer CLI:
68+
69+
```bash
70+
npx tsx scripts/backtest-track-record.ts --db loopover --remote
71+
```
72+
73+
The corpus itself can be exported as a versioned, checksummed snapshot:
74+
75+
```bash
76+
npx tsx scripts/backtest-corpus-export.ts --rule-id <ruleId> --output corpus.json --remote
77+
```
78+
79+
Both CLIs are strictly read-only against the database.

apps/loopover-ui/src/components/site/command-palette.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const DEFAULT_ITEMS: PaletteItem[] = [
6161
{ label: "Scoreability", to: "/docs/scoreability", group: "Docs" },
6262
{ label: "Upstream drift", to: "/docs/upstream-drift", group: "Docs" },
6363
{ label: "AI summaries policy", to: "/docs/ai-summaries", group: "Docs" },
64+
{ label: "Backtest & calibration", to: "/docs/backtest-calibration", group: "Docs" },
6465
{ label: "Privacy & security", to: "/docs/privacy-security", group: "Docs" },
6566
{ label: "Troubleshooting", to: "/docs/troubleshooting", group: "Docs" },
6667
{ label: "API reference", to: "/api", group: "Reference" },

apps/loopover-ui/src/components/site/docs-nav.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export const docsNav: DocsGroup[] = [
9999
{ to: "/docs/branch-analysis", label: "Branch analysis" },
100100
{ to: "/docs/scoreability", label: "Scoreability" },
101101
{ to: "/docs/upstream-drift", label: "Upstream drift" },
102+
{ to: "/docs/backtest-calibration", label: "Backtest & calibration" },
102103
],
103104
},
104105
{

apps/loopover-ui/src/lib/docs-source-server-isolation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe("docs.*.tsx routes never import docs-source(.server) directly", () => {
2929
const inScope = docsRouteFiles.filter((name) => !outOfScope.has(name));
3030

3131
it("found the expected set of in-scope docs route files", () => {
32-
expect(inScope.length).toBe(48);
32+
expect(inScope.length).toBe(49);
3333
});
3434

3535
it.each(inScope)(

apps/loopover-ui/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { Route as DocsFederatedFleetIntelligenceRouteImport } from './routes/doc
6363
import { Route as DocsCapacityRouteImport } from './routes/docs.capacity'
6464
import { Route as DocsBranchAnalysisRouteImport } from './routes/docs.branch-analysis'
6565
import { Route as DocsBetaOnboardingRouteImport } from './routes/docs.beta-onboarding'
66+
import { Route as DocsBacktestCalibrationRouteImport } from './routes/docs.backtest-calibration'
6667
import { Route as DocsAmsUnattendedSchedulingRouteImport } from './routes/docs.ams-unattended-scheduling'
6768
import { Route as DocsAmsSizingRouteImport } from './routes/docs.ams-sizing'
6869
import { Route as DocsAmsOperationsRunbookRouteImport } from './routes/docs.ams-operations-runbook'
@@ -376,6 +377,11 @@ const DocsBetaOnboardingRoute = DocsBetaOnboardingRouteImport.update({
376377
path: '/beta-onboarding',
377378
getParentRoute: () => DocsRoute,
378379
} as any)
380+
const DocsBacktestCalibrationRoute = DocsBacktestCalibrationRouteImport.update({
381+
id: '/backtest-calibration',
382+
path: '/backtest-calibration',
383+
getParentRoute: () => DocsRoute,
384+
} as any)
379385
const DocsAmsUnattendedSchedulingRoute =
380386
DocsAmsUnattendedSchedulingRouteImport.update({
381387
id: '/ams-unattended-scheduling',
@@ -554,6 +560,7 @@ export interface FileRoutesByFullPath {
554560
'/docs/ams-operations-runbook': typeof DocsAmsOperationsRunbookRoute
555561
'/docs/ams-sizing': typeof DocsAmsSizingRoute
556562
'/docs/ams-unattended-scheduling': typeof DocsAmsUnattendedSchedulingRoute
563+
'/docs/backtest-calibration': typeof DocsBacktestCalibrationRoute
557564
'/docs/beta-onboarding': typeof DocsBetaOnboardingRoute
558565
'/docs/branch-analysis': typeof DocsBranchAnalysisRoute
559566
'/docs/capacity': typeof DocsCapacityRoute
@@ -633,6 +640,7 @@ export interface FileRoutesByTo {
633640
'/docs/ams-operations-runbook': typeof DocsAmsOperationsRunbookRoute
634641
'/docs/ams-sizing': typeof DocsAmsSizingRoute
635642
'/docs/ams-unattended-scheduling': typeof DocsAmsUnattendedSchedulingRoute
643+
'/docs/backtest-calibration': typeof DocsBacktestCalibrationRoute
636644
'/docs/beta-onboarding': typeof DocsBetaOnboardingRoute
637645
'/docs/branch-analysis': typeof DocsBranchAnalysisRoute
638646
'/docs/capacity': typeof DocsCapacityRoute
@@ -717,6 +725,7 @@ export interface FileRoutesById {
717725
'/docs/ams-operations-runbook': typeof DocsAmsOperationsRunbookRoute
718726
'/docs/ams-sizing': typeof DocsAmsSizingRoute
719727
'/docs/ams-unattended-scheduling': typeof DocsAmsUnattendedSchedulingRoute
728+
'/docs/backtest-calibration': typeof DocsBacktestCalibrationRoute
720729
'/docs/beta-onboarding': typeof DocsBetaOnboardingRoute
721730
'/docs/branch-analysis': typeof DocsBranchAnalysisRoute
722731
'/docs/capacity': typeof DocsCapacityRoute
@@ -802,6 +811,7 @@ export interface FileRouteTypes {
802811
| '/docs/ams-operations-runbook'
803812
| '/docs/ams-sizing'
804813
| '/docs/ams-unattended-scheduling'
814+
| '/docs/backtest-calibration'
805815
| '/docs/beta-onboarding'
806816
| '/docs/branch-analysis'
807817
| '/docs/capacity'
@@ -881,6 +891,7 @@ export interface FileRouteTypes {
881891
| '/docs/ams-operations-runbook'
882892
| '/docs/ams-sizing'
883893
| '/docs/ams-unattended-scheduling'
894+
| '/docs/backtest-calibration'
884895
| '/docs/beta-onboarding'
885896
| '/docs/branch-analysis'
886897
| '/docs/capacity'
@@ -964,6 +975,7 @@ export interface FileRouteTypes {
964975
| '/docs/ams-operations-runbook'
965976
| '/docs/ams-sizing'
966977
| '/docs/ams-unattended-scheduling'
978+
| '/docs/backtest-calibration'
967979
| '/docs/beta-onboarding'
968980
| '/docs/branch-analysis'
969981
| '/docs/capacity'
@@ -1405,6 +1417,13 @@ declare module '@tanstack/react-router' {
14051417
preLoaderRoute: typeof DocsBetaOnboardingRouteImport
14061418
parentRoute: typeof DocsRoute
14071419
}
1420+
'/docs/backtest-calibration': {
1421+
id: '/docs/backtest-calibration'
1422+
path: '/backtest-calibration'
1423+
fullPath: '/docs/backtest-calibration'
1424+
preLoaderRoute: typeof DocsBacktestCalibrationRouteImport
1425+
parentRoute: typeof DocsRoute
1426+
}
14081427
'/docs/ams-unattended-scheduling': {
14091428
id: '/docs/ams-unattended-scheduling'
14101429
path: '/ams-unattended-scheduling'
@@ -1658,6 +1677,7 @@ interface DocsRouteChildren {
16581677
DocsAmsOperationsRunbookRoute: typeof DocsAmsOperationsRunbookRoute
16591678
DocsAmsSizingRoute: typeof DocsAmsSizingRoute
16601679
DocsAmsUnattendedSchedulingRoute: typeof DocsAmsUnattendedSchedulingRoute
1680+
DocsBacktestCalibrationRoute: typeof DocsBacktestCalibrationRoute
16611681
DocsBetaOnboardingRoute: typeof DocsBetaOnboardingRoute
16621682
DocsBranchAnalysisRoute: typeof DocsBranchAnalysisRoute
16631683
DocsCapacityRoute: typeof DocsCapacityRoute
@@ -1711,6 +1731,7 @@ const DocsRouteChildren: DocsRouteChildren = {
17111731
DocsAmsOperationsRunbookRoute: DocsAmsOperationsRunbookRoute,
17121732
DocsAmsSizingRoute: DocsAmsSizingRoute,
17131733
DocsAmsUnattendedSchedulingRoute: DocsAmsUnattendedSchedulingRoute,
1734+
DocsBacktestCalibrationRoute: DocsBacktestCalibrationRoute,
17141735
DocsBetaOnboardingRoute: DocsBetaOnboardingRoute,
17151736
DocsBranchAnalysisRoute: DocsBranchAnalysisRoute,
17161737
DocsCapacityRoute: DocsCapacityRoute,

apps/loopover-ui/src/routes/docs-routes-loading-state.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe("docs route Suspense fallback (#6982)", () => {
4545
"docs.tsx",
4646
]);
4747
const inScope = docsRouteFiles.filter((name) => !outOfScope.has(name));
48-
expect(inScope.length).toBe(48);
48+
expect(inScope.length).toBe(49);
4949

5050
for (const file of inScope) {
5151
const source = readFileSync(join(routesDir, file), "utf8");
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createFileRoute, notFound } from "@tanstack/react-router";
2+
import { Suspense } from "react";
3+
4+
import { DocsPage } from "@/components/site/docs-page";
5+
import { LoadingState } from "@/components/site/state-views";
6+
import { docsClientLoader } from "@/lib/docs-client-loader";
7+
import { getDocPage } from "@/lib/docs-source.functions";
8+
9+
// Rendered from content/docs/backtest-calibration.mdx via fumadocs-mdx's browser entry
10+
// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives -- not fumadocs-ui's
11+
// bundled components. See docs-source.server.ts's comment for why the loader below resolves only a
12+
// plain, serializable path string.
13+
export const Route = createFileRoute("/docs/backtest-calibration")({
14+
loader: async () => {
15+
const page = await getDocPage({ data: { slugs: ["backtest-calibration"] } });
16+
if (!page) throw notFound();
17+
return page;
18+
},
19+
head: () => ({
20+
meta: [
21+
{ title: "Backtest & calibration — LoopOver docs" },
22+
{
23+
name: "description",
24+
content:
25+
"How LoopOver measures whether its own review rules are right, and backtests threshold and logic changes against real recorded history before they ship.",
26+
},
27+
{ property: "og:title", content: "Backtest & calibration — LoopOver docs" },
28+
{
29+
property: "og:description",
30+
content:
31+
"How LoopOver measures whether its own review rules are right, and backtests threshold and logic changes against real recorded history before they ship.",
32+
},
33+
{ property: "og:url", content: "/docs/backtest-calibration" },
34+
],
35+
links: [{ rel: "canonical", href: "/docs/backtest-calibration" }],
36+
}),
37+
component: BacktestCalibrationDoc,
38+
});
39+
40+
function BacktestCalibrationDoc() {
41+
const { path, title, description } = Route.useLoaderData();
42+
const Content = docsClientLoader.getComponent(path);
43+
return (
44+
<DocsPage eyebrow="Core concepts" title={title} description={description}>
45+
<Suspense fallback={<LoadingState />}>
46+
<Content />
47+
</Suspense>
48+
</DocsPage>
49+
);
50+
}

apps/loopover-ui/src/routes/docs.index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ const AUDIENCES: Audience[] = [
9494
{ to: "/docs/self-hosting-rees-analyzers", label: "REES analyzers" },
9595
{ to: "/docs/upstream-drift", label: "Upstream drift" },
9696
{ to: "/docs/ai-summaries", label: "AI summaries policy" },
97+
{ to: "/docs/backtest-calibration", label: "Backtest & calibration" },
9798
],
9899
},
99100
{

0 commit comments

Comments
 (0)