Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/loopover-engine/src/ams-policy-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export type AmsPolicySpec = {
* -- no additions beyond the always-on OS-registry/git-remote defaults. INERT until #7857's OS-level
* enforcement mechanism is built; see {@link AmsNetworkAllowlist}'s own doc comment. */
networkAllowlist: AmsNetworkAllowlist;
/** Whether the min-rank skip threshold may self-adjust from backtest evidence (#8187, epic #8172). The
* FIRST of the double gates: with this OFF (the default) the apply/revert commands refuse and any
* previously-applied override reads as absent; the second gate is the per-apply `--approve` flag. */
minRankAutotuneEnabled: boolean;
};

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

const MAX_AMS_POLICY_SPEC_BYTES = 8_192;
Expand All @@ -144,13 +149,21 @@ function cloneDefaultAmsPolicySpec(): AmsPolicySpec {
ecosystems: [...DEFAULT_AMS_POLICY_SPEC.networkAllowlist.ecosystems],
extraHosts: [...DEFAULT_AMS_POLICY_SPEC.networkAllowlist.extraHosts],
},
minRankAutotuneEnabled: DEFAULT_AMS_POLICY_SPEC.minRankAutotuneEnabled,
};
}

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

function normalizeBooleanFlag(value: unknown, field: string, fallback: boolean, warnings: string[]): boolean {
if (value === undefined || value === null) return fallback;
if (typeof value === "boolean") return value;
warnings.push(`AmsPolicySpec field "${field}" must be a boolean; falling back to ${fallback}.`);
return fallback;
}

function normalizeSubmissionMode(value: unknown, fallback: AmsSubmissionMode, warnings: string[]): AmsSubmissionMode {
if (value === undefined || value === null) return fallback;
if (value === "observe" || value === "enforce") return value;
Expand Down Expand Up @@ -307,7 +320,8 @@ function hasConfiguredPolicyFields(spec: AmsPolicySpec): boolean {
// means the operator configured something, so length alone is the right "differs from default" check;
// no need to compare contents.
spec.networkAllowlist.ecosystems.length > 0 ||
spec.networkAllowlist.extraHosts.length > 0
spec.networkAllowlist.extraHosts.length > 0 ||
spec.minRankAutotuneEnabled !== DEFAULT_AMS_POLICY_SPEC.minRankAutotuneEnabled
);
}

Expand Down Expand Up @@ -356,6 +370,12 @@ export function parseAmsPolicySpec(raw: unknown): ParsedAmsPolicySpec {
warnings,
),
networkAllowlist: normalizeNetworkAllowlist(record.networkAllowlist, DEFAULT_AMS_POLICY_SPEC.networkAllowlist, warnings),
minRankAutotuneEnabled: normalizeBooleanFlag(
record.minRankAutotuneEnabled,
"minRankAutotuneEnabled",
DEFAULT_AMS_POLICY_SPEC.minRankAutotuneEnabled,
warnings,
),
};
if (!hasConfiguredPolicyFields(spec)) {
warnings.push("AmsPolicySpec contained no recognized non-default policy fields; falling back to safe defaults.");
Expand Down
107 changes: 107 additions & 0 deletions packages/loopover-engine/src/calibration/ams-rank-corpus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// AMS min-rank corpus + advisory backtest (#8184, epic #8172 phase 2) -- the miner-side twin of the ORB
// threshold backtest (#8138), over the miner's OWN taken-opportunity history instead of ORB signal events.
// The opportunity ranker's score is an equal-weight clamped product in [0, 1] (opportunity-ranker.ts -- no
// scalar weights), so the SKIP THRESHOLD is the tunable, and the counterfactual question is: "had the
// min-rank floor been X, which taken opportunities would have been skipped, and were those the ones that
// went badly?" Labels come from realized outcomes: a MERGED take was a good take (label "confirmed"); a
// CLOSED take was a bad one -- skipping it would have been right (label "reversed"). That polarity lines
// up exactly with buildConfidenceThresholdClassifier's positive class ("predicted reversed" when the score
// sits below the threshold), so the whole ORB replay stack -- splitBacktestCorpus, runThresholdBacktest's
// scoreBacktest + compareBacktestScores Pareto floor -- is reused untouched, zero new math.
//
// Same purity contract as the rest of this module family: no IO, no randomness, no wall-clock reads.

import type { BacktestCase } from "./backtest-corpus.js";
import type { BacktestComparison } from "./backtest-compare.js";
import { runThresholdBacktest } from "./backtest-threshold.js";
import { splitBacktestCorpus } from "./backtest-split.js";

/** The synthetic rule id AMS min-rank replay cases carry (namespaced away from every ORB rule id). */
export const AMS_MIN_RANK_RULE_ID = "ams_min_rank_skip";

// The #8121 split discipline transposed: a fixed seed so held-out membership never reshuffles between
// evaluations, and never-on-noise sample floors sized like the satisfaction floor's (the miner's local
// history is closer to that corpus's scale than to the AI knob's firehose).
export const AMS_MIN_RANK_SPLIT_SEED = "ams-min-rank-skip-v1";
export const AMS_MIN_RANK_HELD_OUT_FRACTION = 0.25;
export const AMS_MIN_RANK_MIN_VISIBLE_CASES = 20;
export const AMS_MIN_RANK_MIN_HELD_OUT_CASES = 5;

/** One taken opportunity with a realized terminal outcome -- the join of a `discovered_issue` rank record
* and the miner's own `pr_outcome` for the PR that issue produced. Assembled miner-side (the ledger join
* lives in @loopover/miner's ams-calibration module); this module only replays. */
export type AmsTakenOpportunity = {
repoFullName: string;
issueNumber: number;
/** The ranker's clamped-product score at discovery time, in [0, 1]. */
rankScore: number;
realizedDecision: "merged" | "closed";
/** When the opportunity was discovered/ranked (ISO). */
discoveredAt: string;
/** When the terminal outcome was recorded (ISO). */
decidedAt: string;
};

/**
* Shape taken opportunities into {@link BacktestCase}s for the min-rank replay: `metadata.confidence`
* carries the rank score (the value the threshold classifier replays against), a CLOSED take labels
* "reversed" (skipping would have been right), a MERGED take labels "confirmed". Records with a
* non-finite or out-of-[0,1] rank score are dropped -- a case the classifier cannot honestly replay must
* not default to confidence 1. Deterministic order (repo#issue, then discoveredAt), so downstream splits
* see a stable corpus.
*/
export function buildAmsRankCorpus(takes: readonly AmsTakenOpportunity[]): BacktestCase[] {
const cases: BacktestCase[] = [];
for (const take of takes) {
if (!Number.isFinite(take.rankScore) || take.rankScore < 0 || take.rankScore > 1) continue;
cases.push({
ruleId: AMS_MIN_RANK_RULE_ID,
targetKey: `${take.repoFullName}#issue-${take.issueNumber}`,
outcome: "take",
label: take.realizedDecision === "closed" ? "reversed" : "confirmed",
firedAt: take.discoveredAt,
decidedAt: take.decidedAt,
metadata: { confidence: take.rankScore },
});
}
cases.sort((left, right) => {
const key = left.targetKey.localeCompare(right.targetKey);
return key !== 0 ? key : left.firedAt.localeCompare(right.firedAt);
});
return cases;
}

export type AmsMinRankBacktestResult = {
ruleId: string;
currentThreshold: number;
candidateThreshold: number;
visibleCases: number;
heldOutCases: number;
visible: BacktestComparison;
heldOut: BacktestComparison;
};

/**
* Advisory replay of a candidate min-rank skip threshold against the taken-opportunity corpus -- the
* #8138 discipline verbatim: the fixed-seed split, then {@link runThresholdBacktest} (scoreBacktest +
* the symmetric compareBacktestScores Pareto floor, per #8184's required pattern) on EACH slice. Null --
* never a guess -- when either slice misses its sample floor. Report-only by construction: this function
* returns comparisons; it never moves a knob.
*/
export function runAmsMinRankBacktest(
cases: readonly BacktestCase[],
currentThreshold: number,
candidateThreshold: number,
): AmsMinRankBacktestResult | null {
const { visible, heldOut } = splitBacktestCorpus(cases, AMS_MIN_RANK_HELD_OUT_FRACTION, AMS_MIN_RANK_SPLIT_SEED);
if (visible.length < AMS_MIN_RANK_MIN_VISIBLE_CASES || heldOut.length < AMS_MIN_RANK_MIN_HELD_OUT_CASES) return null;
return {
ruleId: AMS_MIN_RANK_RULE_ID,
currentThreshold,
candidateThreshold,
visibleCases: visible.length,
heldOutCases: heldOut.length,
visible: runThresholdBacktest(AMS_MIN_RANK_RULE_ID, visible, currentThreshold, candidateThreshold),
heldOut: runThresholdBacktest(AMS_MIN_RANK_RULE_ID, heldOut, currentThreshold, candidateThreshold),
};
}
1 change: 1 addition & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export * from "./calibration/signal-tracking.js";
export * from "./calibration/backtest-corpus.js";
export * from "./calibration/repo-corpus-slice.js";
export * from "./calibration/ams-prediction-corpus.js";
export * from "./calibration/ams-rank-corpus.js";
export * from "./calibration/counterfactual-contract.js";
export * from "./calibration/counterfactual-fixtures.js";
export * from "./calibration/backtest-score.js";
Expand Down
30 changes: 30 additions & 0 deletions packages/loopover-engine/test/ams-rank-corpus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { AMS_MIN_RANK_RULE_ID, buildAmsRankCorpus, runAmsMinRankBacktest, type AmsTakenOpportunity } from "../dist/index.js";

function take(issueNumber: number, rankScore: number, realizedDecision: "merged" | "closed"): AmsTakenOpportunity {
return {
repoFullName: "acme/widgets",
issueNumber,
rankScore,
realizedDecision,
discoveredAt: "2026-07-01T00:00:00.000Z",
decidedAt: "2026-07-02T00:00:00.000Z",
};
}

test("barrel: the public entrypoint re-exports the AMS min-rank corpus + backtest (#8184)", () => {
assert.equal(typeof buildAmsRankCorpus, "function");
assert.equal(typeof runAmsMinRankBacktest, "function");
assert.equal(AMS_MIN_RANK_RULE_ID, "ams_min_rank_skip");
});

test("runAmsMinRankBacktest: a raise that skips exactly the bad takes is improved on both slices", () => {
const takes = Array.from({ length: 60 }, (_, i) => take(i + 1, 0.15, "closed"));
takes.push(take(101, 0.4, "merged"), take(102, 0.4, "merged"));
const result = runAmsMinRankBacktest(buildAmsRankCorpus(takes), 0, 0.2);
assert.ok(result);
assert.equal(result.visible.verdict, "improved");
assert.equal(result.heldOut.verdict, "improved");
});
24 changes: 24 additions & 0 deletions packages/loopover-miner/docs/miner-selfimprove-calibration.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ From any contributor-safe tool in this batch:
auto-tune hold-only flag, or any autonomy configuration.
- **No de-anonymizing or write-capable telemetry** — local-only or HMAC-anonymized, read-only.

## The min-rank calibration loop (#8172: #8184-#8187)

The first knob to graduate past measure-only: the **min-rank skip threshold** (portfolio-discovery's
`minRankScore`, shipped default 0). The loop is the ORB backtest discipline transposed onto the miner's own
event ledger, end to end:

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

## The neighborhood

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