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
19 changes: 19 additions & 0 deletions src/review/loosening-recs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// advisor's apply path. PURE — no IO; the caller supplies the loop's state.
import type { TuningRec } from "./auto-tune";
import type { SatisfactionFloorLooseningProposal } from "../services/satisfaction-floor-loosening";
import type { KnobLooseningProposal } from "../services/loosening-knobs";

/** The advisor list is per-project elsewhere; the satisfaction floor is deployment-global, so its recs use
* this fixed pseudo-project label rather than impersonating any repo. */
Expand Down Expand Up @@ -59,3 +60,21 @@ export function buildSatisfactionFloorLooseningRecs(input: SatisfactionFloorRecI
}
return recs;
}

/**
* Recs for REPORT-ONLY registry knobs (#8159): the evidence surfaces exactly like a live knob's proposal,
* but the action line states plainly that this knob's apply is not wired — enabling it is a per-knob,
* reviewed decision (its consumption plumbing changes real authority), never a flag flip. Same hard
* boundary: no overridePayload, ever.
*/
export function buildReportOnlyKnobRecs(proposals: readonly KnobLooseningProposal[]): TuningRec[] {
return proposals.map((proposal) => ({
project: `global:${proposal.knobId}`,
severity: "good" as const,
message:
`Backtest-cleared LOOSENING evidence for ${proposal.knobId} (report-only): ${proposal.currentValue} → ${proposal.proposedValue}. ` +
`Visible split ${proposal.visible.verdict} (${proposal.visibleCases} case(s), precision ${pct(proposal.visible.baseline.precision)} → ${pct(proposal.visible.candidate.precision)}); ` +
`held-out split ${proposal.heldOut.verdict} (${proposal.heldOutCases} case(s)). ` +
"This knob has no override consumer yet — applying requires shipping its consumption plumbing as its own reviewed change.",
}));
}
11 changes: 8 additions & 3 deletions src/review/selftune-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { errorMessage } from "../utils/json";
import { computeTuningRecommendations, type GateEvalReport, type GateEvalRow } from "./auto-tune";
import { buildSatisfactionFloorLooseningRecs } from "./loosening-recs";
import { loadSatisfactionFloorRecState } from "../services/satisfaction-floor-loosening-run";
import { buildReportOnlyKnobRecs, buildSatisfactionFloorLooseningRecs } from "./loosening-recs";
import { loadReportOnlyKnobProposals, loadSatisfactionFloorRecState } from "../services/satisfaction-floor-loosening-run";
import { runAutoApplyRecommendations, type StorageEnv } from "./auto-apply";

/** True when the self-improvement loop is enabled. Flag-OFF (default) → every export below is a no-op. Truthy
Expand Down Expand Up @@ -161,7 +161,12 @@ export async function runSelfTune(env: Env): Promise<void> {
// design — runAutoApplyRecommendations below only ever consumes recs carrying a TIGHTENING
// overridePayload, so these are report-only here and can never be promoted by the apply path.
// Appended once per pass (deployment-global state), on the first repo's iteration.
if (repoFullName === repos[0]) recs.push(...buildSatisfactionFloorLooseningRecs(await loadSatisfactionFloorRecState(env, nowMs)));
if (repoFullName === repos[0]) {
recs.push(...buildSatisfactionFloorLooseningRecs(await loadSatisfactionFloorRecState(env, nowMs)));
// #8159: report-only registry knobs surface their evidence in the same pass -- payload-less, so
// the apply path below ignores them identically.
recs.push(...buildReportOnlyKnobRecs(await loadReportOnlyKnobProposals(env, nowMs)));
}
// runAutoApplyRecommendations only ever consumes recs that carry a TIGHTENING overridePayload, shadow-
// soaks them, and promotes a soaked override only when isStrictlyTightening + evidence + soak pass.
await runAutoApplyRecommendations(env as unknown as StorageEnv, {
Expand Down
130 changes: 130 additions & 0 deletions src/services/loosening-knobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Bounded loosenable-knob registry (#8159, sub-issue of epic #8121). The #8121 narrow start hardcoded ONE
// loosenable value (the satisfaction floor); this registry generalizes the shape the same way
// KNOWN_THRESHOLDS (threshold-backtest.ts) and KNOWN_LOGIC_RULES (backtest-logic-check-core.ts) declare
// their surfaces: each knob is a declarative entry — rule id, candidate steps, hard bounds, split
// discipline — evaluated by ONE generic function, never per-knob bespoke loops.
//
// Every knob keeps the narrow start's invariants verbatim: smallest-step-first, strictly `improved` on the
// visible split AND non-`regressed` on the deterministic held-out split, a hard safety minimum no evidence
// can cross, and never-on-noise sample floors. A knob additionally declares whether its apply path is LIVE
// (an override consumer exists) or REPORT-ONLY (proposals surface with full evidence, but nothing may be
// written until the consumption plumbing ships — adding a consumer is a deliberate, per-knob decision, not
// a registry edit side effect).
import {
buildConfidenceThresholdClassifier,
compareBacktestScores,
scoreBacktest,
splitBacktestCorpus,
type BacktestCase,
type BacktestComparison,
} from "@loopover/engine";
import { LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR } from "./linked-issue-satisfaction";
import { DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE } from "../rules/advisory";

export type LoosenableKnob = {
/** Stable id — used in override flag keys, audit events, and advisor labels. Never rename. */
knobId: string;
ruleId: string;
shippedValue: number;
/** Candidate loosened values, nearest-to-shipped first — the smallest evidence-cleared step wins. */
candidates: readonly number[];
/** No backtest result, however good, may loosen below this. */
hardMinimum: number;
minVisibleCases: number;
minHeldOutCases: number;
heldOutFraction: number;
/** Fixed per-knob split seed — held-out membership must never reshuffle between evaluations. */
splitSeed: string;
/** `live`: an override consumer exists and the apply path may write. `report_only`: proposals surface
* (advisor/status) but the apply path REFUSES — flipping a knob to live requires shipping its
* consumption plumbing first, reviewed on its own. */
applyMode: "live" | "report_only";
};

export const LOOSENABLE_KNOBS: Readonly<Record<string, LoosenableKnob>> = Object.freeze({
// #8121's approved narrow start — fully live (override consumed by runLoopOverLinkedIssueSatisfaction).
// Values and seed are IDENTICAL to the pre-registry constants: behavior and held-out membership are
// byte-stable across this refactor.
satisfaction_floor: {
knobId: "satisfaction_floor",
ruleId: "linked_issue_scope_mismatch",
shippedValue: LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR,
candidates: [0.45, 0.4, 0.35, 0.3],
hardMinimum: 0.3,
minVisibleCases: 20,
minHeldOutCases: 5,
heldOutFraction: 0.25,
splitSeed: "satisfaction-floor-loosening-v1",
applyMode: "live",
},
// The AI close-confidence floor (#8159's second knob). Its corpus (ai_consensus_defect — including the
// #8157 backfilled decision-level history) is real TODAY, so proposals carry evidence now — but
// loosening it means MORE auto-closes, a direct gate-authority change, so it enters REPORT-ONLY: no
// override consumer exists yet, and the apply path refuses until that plumbing ships as its own
// reviewed change. Tight bounds by design: two small steps, hard floor 0.85.
ai_review_close_confidence: {
knobId: "ai_review_close_confidence",
ruleId: "ai_consensus_defect",
shippedValue: DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE,
candidates: [0.9, 0.85],
hardMinimum: 0.85,
minVisibleCases: 50,
minHeldOutCases: 12,
heldOutFraction: 0.25,
splitSeed: "ai-close-confidence-loosening-v1",
applyMode: "report_only",
},
});

export type KnobLooseningProposal = {
knobId: string;
ruleId: string;
currentValue: number;
proposedValue: number;
visibleCases: number;
heldOutCases: number;
visible: BacktestComparison;
heldOut: BacktestComparison;
};

/**
* Evaluate whether `knob` can be safely loosened from `currentValue` — the generic form of the #8121
* narrow start's gate, parameterized by the registry entry and nothing else: the smallest candidate step
* below `currentValue` (never below the knob's hard minimum) whose backtest verdict is strictly
* `"improved"` on the visible split AND non-`"regressed"` on the held-out split. Null when the corpus is
* too small, no candidate qualifies, or the current value already sits at/below the hard minimum. Pure and
* deterministic — same knob + corpus + value ⇒ same proposal.
*/
export function evaluateKnobLoosening(
knob: LoosenableKnob,
cases: readonly BacktestCase[],
currentValue: number = knob.shippedValue,
): KnobLooseningProposal | null {
const { visible, heldOut } = splitBacktestCorpus(cases, knob.heldOutFraction, knob.splitSeed);
if (visible.length < knob.minVisibleCases || heldOut.length < knob.minHeldOutCases) return null;

for (const candidate of knob.candidates) {
if (candidate >= currentValue || candidate < knob.hardMinimum) continue;
const visibleComparison = compareOnSlice(knob.ruleId, visible, currentValue, candidate);
if (visibleComparison.verdict !== "improved") continue;
const heldOutComparison = compareOnSlice(knob.ruleId, heldOut, currentValue, candidate);
if (heldOutComparison.verdict === "regressed") continue;
return {
knobId: knob.knobId,
ruleId: knob.ruleId,
currentValue,
proposedValue: candidate,
visibleCases: visible.length,
heldOutCases: heldOut.length,
visible: visibleComparison,
heldOut: heldOutComparison,
};
}
return null;
}

function compareOnSlice(ruleId: string, slice: readonly BacktestCase[], currentValue: number, candidate: number): BacktestComparison {
const baseline = scoreBacktest(ruleId, slice, buildConfidenceThresholdClassifier(currentValue));
const proposed = scoreBacktest(ruleId, slice, buildConfidenceThresholdClassifier(candidate));
return compareBacktestScores(baseline, proposed);
}
22 changes: 22 additions & 0 deletions src/services/satisfaction-floor-loosening-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { buildBacktestCorpus } from "@loopover/engine";
import { createSignalStore } from "../review/signal-tracking-wire";
import { recordAuditEvent } from "../db/repositories";
import { evaluateKnobLoosening, LOOSENABLE_KNOBS, type KnobLooseningProposal } from "./loosening-knobs";
import {
evaluateSatisfactionFloorLoosening,
SATISFACTION_FLOOR_HARD_MINIMUM,
Expand Down Expand Up @@ -273,3 +274,24 @@ export async function loadSatisfactionFloorStatus(env: Env): Promise<Satisfactio
applied,
};
}

/**
* Evaluate every REPORT-ONLY registry knob (#8159) against its own corpus — proposals surface with full
* evidence in the advisor, but nothing is ever written for these knobs: their apply stays refused until
* each one's consumption plumbing ships as its own reviewed change (see LOOSENABLE_KNOBS' applyMode doc).
* Fail-safe per knob: a corpus error skips that knob rather than breaking the advisor pass.
*/
export async function loadReportOnlyKnobProposals(env: Env, nowMs: number = Date.now()): Promise<KnobLooseningProposal[]> {
const proposals: KnobLooseningProposal[] = [];
for (const knob of Object.values(LOOSENABLE_KNOBS)) {
if (knob.applyMode !== "report_only") continue;
try {
const { fired, overrides } = await createSignalStore(env).queryRuleHistory(knob.ruleId, nowMs - CORPUS_LOOKBACK_MS);
const proposal = evaluateKnobLoosening(knob, buildBacktestCorpus(knob.ruleId, fired, overrides));
if (proposal) proposals.push(proposal);
} catch {
/* one knob's read blip must not hide the others' proposals */
}
}
return proposals;
}
58 changes: 16 additions & 42 deletions src/services/satisfaction-floor-loosening.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,9 @@
// via the SignalStore). Scoped to exactly this one scalar; generalizing to other loosenable knobs is the
// rest of epic #8121, decomposed separately, and requires its own explicit approval per that epic's
// Boundaries.
import {
buildConfidenceThresholdClassifier,
compareBacktestScores,
scoreBacktest,
splitBacktestCorpus,
type BacktestCase,
type BacktestComparison,
} from "@loopover/engine";
import type { BacktestCase, BacktestComparison } from "@loopover/engine";
import { LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR } from "./linked-issue-satisfaction";
import { evaluateKnobLoosening, LOOSENABLE_KNOBS } from "./loosening-knobs";

export const SATISFACTION_FLOOR_RULE_ID = "linked_issue_scope_mismatch";

Expand Down Expand Up @@ -52,42 +46,22 @@ export type SatisfactionFloorLooseningProposal = {
heldOut: BacktestComparison;
};

/**
* Evaluate whether the satisfaction confidence floor can be safely loosened, per #8121's approved gate:
* the smallest candidate step below `currentFloor` (never below {@link SATISFACTION_FLOOR_HARD_MINIMUM})
* whose backtest verdict is strictly `"improved"` on the visible split AND non-`"regressed"` on the
* held-out split. Returns null when the corpus is too small, no candidate qualifies, or `currentFloor` is
* already at/below the hard minimum (a previously-applied loosening never compounds past it). Pure and
* deterministic — same corpus + floor ⇒ same proposal.
*/
export function evaluateSatisfactionFloorLoosening(
cases: readonly BacktestCase[],
currentFloor: number = LINKED_ISSUE_SATISFACTION_CONFIDENCE_FLOOR,
): SatisfactionFloorLooseningProposal | null {
const { visible, heldOut } = splitBacktestCorpus(cases, SATISFACTION_FLOOR_HELD_OUT_FRACTION, SATISFACTION_FLOOR_SPLIT_SEED);
if (visible.length < SATISFACTION_FLOOR_MIN_VISIBLE_CASES || heldOut.length < SATISFACTION_FLOOR_MIN_HELD_OUT_CASES) return null;

for (const candidate of SATISFACTION_FLOOR_LOOSENING_CANDIDATES) {
if (candidate >= currentFloor || candidate < SATISFACTION_FLOOR_HARD_MINIMUM) continue;
const visibleComparison = compareOnSlice(visible, currentFloor, candidate);
if (visibleComparison.verdict !== "improved") continue;
const heldOutComparison = compareOnSlice(heldOut, currentFloor, candidate);
if (heldOutComparison.verdict === "regressed") continue;
return {
ruleId: SATISFACTION_FLOOR_RULE_ID,
currentFloor,
proposedFloor: candidate,
visibleCases: visible.length,
heldOutCases: heldOut.length,
visible: visibleComparison,
heldOut: heldOutComparison,
};
}
return null;
}

function compareOnSlice(slice: readonly BacktestCase[], currentFloor: number, candidate: number): BacktestComparison {
const baseline = scoreBacktest(SATISFACTION_FLOOR_RULE_ID, slice, buildConfidenceThresholdClassifier(currentFloor));
const proposed = scoreBacktest(SATISFACTION_FLOOR_RULE_ID, slice, buildConfidenceThresholdClassifier(candidate));
return compareBacktestScores(baseline, proposed);
// #8159: delegates to the generic knob evaluator with the registry entry whose values/seed are pinned
// (by test) to this module's own legacy constants -- behavior and held-out membership are byte-stable
// across the refactor. This wrapper only re-shapes the field names the #8121 consumers already use.
const proposal = evaluateKnobLoosening(LOOSENABLE_KNOBS.satisfaction_floor!, cases, currentFloor);
if (!proposal) return null;
return {
ruleId: SATISFACTION_FLOOR_RULE_ID,
currentFloor: proposal.currentValue,
proposedFloor: proposal.proposedValue,
visibleCases: proposal.visibleCases,
heldOutCases: proposal.heldOutCases,
visible: proposal.visible,
heldOut: proposal.heldOut,
};
}
Loading