Skip to content

Commit 211f32d

Browse files
authored
feat(miner): AMS min-rank calibration loop over the local event ledger (#8184, #8185, #8186, #8187) (#8270)
The full #8172 arc in one coherent change, transposing the ORB backtest discipline onto the miner's own ledgers with the engine primitives reused untouched: - engine: buildAmsRankCorpus + runAmsMinRankBacktest (fixed-seed split, symmetric Pareto floor via compareBacktestScores; closed take = reversed, merged = confirmed, rank score as replay confidence) - pr_outcome rows now carry the claimed issueNumber so the corpus can join discovery-time rank records to realized outcomes (older rows never join) - calibration backtest-threshold --candidate: advisory replay, shared renderer output, persisted ams_threshold_backtest_run events; exit never reflects verdict - calibration report: REGRESSED-verdict track record (shared engine aggregation) + backtest-cleared proposals with the explicit no-autonomy line; doctor mirrors the proposals; the Phase-7 snapshot payload gains an optional backtestTrackRecord section - double-gated min-rank self-adjustment: .loopover-ams.yml minRankAutotuneEnabled (default OFF) AND per-apply --approve, hard bounds (0, 0.5] validated on every read, evidence required per apply, typed apply/revert ledger events, one-command revert; consumed at discover's single enqueue point, fail-open to shipped
1 parent 3c1f07c commit 211f32d

23 files changed

Lines changed: 1382 additions & 17 deletions

packages/loopover-engine/src/ams-policy-spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ export type AmsPolicySpec = {
105105
* -- no additions beyond the always-on OS-registry/git-remote defaults. INERT until #7857's OS-level
106106
* enforcement mechanism is built; see {@link AmsNetworkAllowlist}'s own doc comment. */
107107
networkAllowlist: AmsNetworkAllowlist;
108+
/** Whether the min-rank skip threshold may self-adjust from backtest evidence (#8187, epic #8172). The
109+
* FIRST of the double gates: with this OFF (the default) the apply/revert commands refuse and any
110+
* previously-applied override reads as absent; the second gate is the per-apply `--approve` flag. */
111+
minRankAutotuneEnabled: boolean;
108112
};
109113

110114
/** The tolerant parser result for `.loopover-ams.yml`. Mirrors `ParsedMinerGoalSpec`'s present/warnings shape. */
@@ -127,6 +131,7 @@ export const DEFAULT_AMS_POLICY_SPEC: Readonly<AmsPolicySpec> = Object.freeze({
127131
maxTurnsPerIteration: 6,
128132
selfLoopAutonomy: "auto",
129133
networkAllowlist: Object.freeze({ ecosystems: [], extraHosts: [] }),
134+
minRankAutotuneEnabled: false,
130135
});
131136

132137
const MAX_AMS_POLICY_SPEC_BYTES = 8_192;
@@ -144,13 +149,21 @@ function cloneDefaultAmsPolicySpec(): AmsPolicySpec {
144149
ecosystems: [...DEFAULT_AMS_POLICY_SPEC.networkAllowlist.ecosystems],
145150
extraHosts: [...DEFAULT_AMS_POLICY_SPEC.networkAllowlist.extraHosts],
146151
},
152+
minRankAutotuneEnabled: DEFAULT_AMS_POLICY_SPEC.minRankAutotuneEnabled,
147153
};
148154
}
149155

150156
function emptyAmsPolicySpec(warnings: string[] = []): ParsedAmsPolicySpec {
151157
return { present: false, spec: cloneDefaultAmsPolicySpec(), warnings };
152158
}
153159

160+
function normalizeBooleanFlag(value: unknown, field: string, fallback: boolean, warnings: string[]): boolean {
161+
if (value === undefined || value === null) return fallback;
162+
if (typeof value === "boolean") return value;
163+
warnings.push(`AmsPolicySpec field "${field}" must be a boolean; falling back to ${fallback}.`);
164+
return fallback;
165+
}
166+
154167
function normalizeSubmissionMode(value: unknown, fallback: AmsSubmissionMode, warnings: string[]): AmsSubmissionMode {
155168
if (value === undefined || value === null) return fallback;
156169
if (value === "observe" || value === "enforce") return value;
@@ -307,7 +320,8 @@ function hasConfiguredPolicyFields(spec: AmsPolicySpec): boolean {
307320
// means the operator configured something, so length alone is the right "differs from default" check;
308321
// no need to compare contents.
309322
spec.networkAllowlist.ecosystems.length > 0 ||
310-
spec.networkAllowlist.extraHosts.length > 0
323+
spec.networkAllowlist.extraHosts.length > 0 ||
324+
spec.minRankAutotuneEnabled !== DEFAULT_AMS_POLICY_SPEC.minRankAutotuneEnabled
311325
);
312326
}
313327

@@ -356,6 +370,12 @@ export function parseAmsPolicySpec(raw: unknown): ParsedAmsPolicySpec {
356370
warnings,
357371
),
358372
networkAllowlist: normalizeNetworkAllowlist(record.networkAllowlist, DEFAULT_AMS_POLICY_SPEC.networkAllowlist, warnings),
373+
minRankAutotuneEnabled: normalizeBooleanFlag(
374+
record.minRankAutotuneEnabled,
375+
"minRankAutotuneEnabled",
376+
DEFAULT_AMS_POLICY_SPEC.minRankAutotuneEnabled,
377+
warnings,
378+
),
359379
};
360380
if (!hasConfiguredPolicyFields(spec)) {
361381
warnings.push("AmsPolicySpec contained no recognized non-default policy fields; falling back to safe defaults.");
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// AMS min-rank corpus + advisory backtest (#8184, epic #8172 phase 2) -- the miner-side twin of the ORB
2+
// threshold backtest (#8138), over the miner's OWN taken-opportunity history instead of ORB signal events.
3+
// The opportunity ranker's score is an equal-weight clamped product in [0, 1] (opportunity-ranker.ts -- no
4+
// scalar weights), so the SKIP THRESHOLD is the tunable, and the counterfactual question is: "had the
5+
// min-rank floor been X, which taken opportunities would have been skipped, and were those the ones that
6+
// went badly?" Labels come from realized outcomes: a MERGED take was a good take (label "confirmed"); a
7+
// CLOSED take was a bad one -- skipping it would have been right (label "reversed"). That polarity lines
8+
// up exactly with buildConfidenceThresholdClassifier's positive class ("predicted reversed" when the score
9+
// sits below the threshold), so the whole ORB replay stack -- splitBacktestCorpus, runThresholdBacktest's
10+
// scoreBacktest + compareBacktestScores Pareto floor -- is reused untouched, zero new math.
11+
//
12+
// Same purity contract as the rest of this module family: no IO, no randomness, no wall-clock reads.
13+
14+
import type { BacktestCase } from "./backtest-corpus.js";
15+
import type { BacktestComparison } from "./backtest-compare.js";
16+
import { runThresholdBacktest } from "./backtest-threshold.js";
17+
import { splitBacktestCorpus } from "./backtest-split.js";
18+
19+
/** The synthetic rule id AMS min-rank replay cases carry (namespaced away from every ORB rule id). */
20+
export const AMS_MIN_RANK_RULE_ID = "ams_min_rank_skip";
21+
22+
// The #8121 split discipline transposed: a fixed seed so held-out membership never reshuffles between
23+
// evaluations, and never-on-noise sample floors sized like the satisfaction floor's (the miner's local
24+
// history is closer to that corpus's scale than to the AI knob's firehose).
25+
export const AMS_MIN_RANK_SPLIT_SEED = "ams-min-rank-skip-v1";
26+
export const AMS_MIN_RANK_HELD_OUT_FRACTION = 0.25;
27+
export const AMS_MIN_RANK_MIN_VISIBLE_CASES = 20;
28+
export const AMS_MIN_RANK_MIN_HELD_OUT_CASES = 5;
29+
30+
/** One taken opportunity with a realized terminal outcome -- the join of a `discovered_issue` rank record
31+
* and the miner's own `pr_outcome` for the PR that issue produced. Assembled miner-side (the ledger join
32+
* lives in @loopover/miner's ams-calibration module); this module only replays. */
33+
export type AmsTakenOpportunity = {
34+
repoFullName: string;
35+
issueNumber: number;
36+
/** The ranker's clamped-product score at discovery time, in [0, 1]. */
37+
rankScore: number;
38+
realizedDecision: "merged" | "closed";
39+
/** When the opportunity was discovered/ranked (ISO). */
40+
discoveredAt: string;
41+
/** When the terminal outcome was recorded (ISO). */
42+
decidedAt: string;
43+
};
44+
45+
/**
46+
* Shape taken opportunities into {@link BacktestCase}s for the min-rank replay: `metadata.confidence`
47+
* carries the rank score (the value the threshold classifier replays against), a CLOSED take labels
48+
* "reversed" (skipping would have been right), a MERGED take labels "confirmed". Records with a
49+
* non-finite or out-of-[0,1] rank score are dropped -- a case the classifier cannot honestly replay must
50+
* not default to confidence 1. Deterministic order (repo#issue, then discoveredAt), so downstream splits
51+
* see a stable corpus.
52+
*/
53+
export function buildAmsRankCorpus(takes: readonly AmsTakenOpportunity[]): BacktestCase[] {
54+
const cases: BacktestCase[] = [];
55+
for (const take of takes) {
56+
if (!Number.isFinite(take.rankScore) || take.rankScore < 0 || take.rankScore > 1) continue;
57+
cases.push({
58+
ruleId: AMS_MIN_RANK_RULE_ID,
59+
targetKey: `${take.repoFullName}#issue-${take.issueNumber}`,
60+
outcome: "take",
61+
label: take.realizedDecision === "closed" ? "reversed" : "confirmed",
62+
firedAt: take.discoveredAt,
63+
decidedAt: take.decidedAt,
64+
metadata: { confidence: take.rankScore },
65+
});
66+
}
67+
cases.sort((left, right) => {
68+
const key = left.targetKey.localeCompare(right.targetKey);
69+
return key !== 0 ? key : left.firedAt.localeCompare(right.firedAt);
70+
});
71+
return cases;
72+
}
73+
74+
export type AmsMinRankBacktestResult = {
75+
ruleId: string;
76+
currentThreshold: number;
77+
candidateThreshold: number;
78+
visibleCases: number;
79+
heldOutCases: number;
80+
visible: BacktestComparison;
81+
heldOut: BacktestComparison;
82+
};
83+
84+
/**
85+
* Advisory replay of a candidate min-rank skip threshold against the taken-opportunity corpus -- the
86+
* #8138 discipline verbatim: the fixed-seed split, then {@link runThresholdBacktest} (scoreBacktest +
87+
* the symmetric compareBacktestScores Pareto floor, per #8184's required pattern) on EACH slice. Null --
88+
* never a guess -- when either slice misses its sample floor. Report-only by construction: this function
89+
* returns comparisons; it never moves a knob.
90+
*/
91+
export function runAmsMinRankBacktest(
92+
cases: readonly BacktestCase[],
93+
currentThreshold: number,
94+
candidateThreshold: number,
95+
): AmsMinRankBacktestResult | null {
96+
const { visible, heldOut } = splitBacktestCorpus(cases, AMS_MIN_RANK_HELD_OUT_FRACTION, AMS_MIN_RANK_SPLIT_SEED);
97+
if (visible.length < AMS_MIN_RANK_MIN_VISIBLE_CASES || heldOut.length < AMS_MIN_RANK_MIN_HELD_OUT_CASES) return null;
98+
return {
99+
ruleId: AMS_MIN_RANK_RULE_ID,
100+
currentThreshold,
101+
candidateThreshold,
102+
visibleCases: visible.length,
103+
heldOutCases: heldOut.length,
104+
visible: runThresholdBacktest(AMS_MIN_RANK_RULE_ID, visible, currentThreshold, candidateThreshold),
105+
heldOut: runThresholdBacktest(AMS_MIN_RANK_RULE_ID, heldOut, currentThreshold, candidateThreshold),
106+
};
107+
}

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/repo-corpus-slice.js";
168168
export * from "./calibration/ams-prediction-corpus.js";
169+
export * from "./calibration/ams-rank-corpus.js";
169170
export * from "./calibration/counterfactual-contract.js";
170171
export * from "./calibration/counterfactual-fixtures.js";
171172
export * from "./calibration/backtest-score.js";
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import { AMS_MIN_RANK_RULE_ID, buildAmsRankCorpus, runAmsMinRankBacktest, type AmsTakenOpportunity } from "../dist/index.js";
5+
6+
function take(issueNumber: number, rankScore: number, realizedDecision: "merged" | "closed"): AmsTakenOpportunity {
7+
return {
8+
repoFullName: "acme/widgets",
9+
issueNumber,
10+
rankScore,
11+
realizedDecision,
12+
discoveredAt: "2026-07-01T00:00:00.000Z",
13+
decidedAt: "2026-07-02T00:00:00.000Z",
14+
};
15+
}
16+
17+
test("barrel: the public entrypoint re-exports the AMS min-rank corpus + backtest (#8184)", () => {
18+
assert.equal(typeof buildAmsRankCorpus, "function");
19+
assert.equal(typeof runAmsMinRankBacktest, "function");
20+
assert.equal(AMS_MIN_RANK_RULE_ID, "ams_min_rank_skip");
21+
});
22+
23+
test("runAmsMinRankBacktest: a raise that skips exactly the bad takes is improved on both slices", () => {
24+
const takes = Array.from({ length: 60 }, (_, i) => take(i + 1, 0.15, "closed"));
25+
takes.push(take(101, 0.4, "merged"), take(102, 0.4, "merged"));
26+
const result = runAmsMinRankBacktest(buildAmsRankCorpus(takes), 0, 0.2);
27+
assert.ok(result);
28+
assert.equal(result.visible.verdict, "improved");
29+
assert.equal(result.heldOut.verdict, "improved");
30+
});

packages/loopover-miner/docs/miner-selfimprove-calibration.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,30 @@ From any contributor-safe tool in this batch:
110110
auto-tune hold-only flag, or any autonomy configuration.
111111
- **No de-anonymizing or write-capable telemetry** — local-only or HMAC-anonymized, read-only.
112112

113+
## The min-rank calibration loop (#8172: #8184-#8187)
114+
115+
The first knob to graduate past measure-only: the **min-rank skip threshold** (portfolio-discovery's
116+
`minRankScore`, shipped default 0). The loop is the ORB backtest discipline transposed onto the miner's own
117+
event ledger, end to end:
118+
119+
1. **Corpus**`discovered_issue` rank records joined to the miner's own `pr_outcome` events by
120+
`(repo, issueNumber)` (the pairing rides on outcome rows written since #8184; older rows never join). A
121+
MERGED take labels `confirmed`, a CLOSED take labels `reversed` — "skipping it would have been right".
122+
2. **Advisory backtest**`loopover-miner calibration backtest-threshold --candidate <x>`: fixed-seed
123+
held-out split, Pareto floor via `compareBacktestScores`, rendered with the shared comparison renderer,
124+
persisted as an `ams_threshold_backtest_run` ledger event. Exit code never reflects the verdict.
125+
3. **Track record + proposals** — the calibration report prints the REGRESSED-verdict track record over
126+
every persisted run (`computeRegressedVerdictTrackRecord`, the same aggregation ORB uses) and any
127+
backtest-cleared proposals; `doctor` mirrors the proposals line. Display only.
128+
4. **Double-gated self-adjustment (#8187)**`calibration apply-min-rank --candidate <x> --approve`
129+
moves the knob ONLY when: `.loopover-ams.yml` sets `minRankAutotuneEnabled: true` (gate one, default
130+
OFF), the per-apply `--approve` is passed (gate two), the candidate sits inside the hard bounds
131+
`(0, 0.5]` declared next to the shipped constant, AND a recent persisted run actually cleared that exact
132+
candidate — evidence is not optional. Every apply/revert is a typed ledger event carrying the evidence;
133+
`calibration revert-min-rank --approve` is the one-command revert. Consumption happens at exactly one
134+
point (discover's enqueue), re-validating bounds and the flag on every read, so flipping the flag off
135+
restores the shipped default on the very next run.
136+
113137
## The neighborhood
114138

115139
- Contributor-safe (this batch): drift detector, calibration dashboard + extension panel, prediction ledger, metrics

0 commit comments

Comments
 (0)