Skip to content

Commit d615317

Browse files
committed
feat(calibration): per-repo loosening loop — repos earn overrides on their own evidence (#8217)
Epic #8211 track B capstone: on the same calibration tick, each live knob's per-repo pass evaluates every repo whose OWN labeled slice clears the knob's sample floors (computeRepoCorpusDensity — same split, same minimums) from the repo's CURRENT resolved value, and applies to the repo-scoped key under the identical discipline as the global loop: smallest candidate step, visible improved + held-out non-regressed on the REPO slice, hard minimum, double flag gating, one error-level alert per applied step (ev stays per-knob, repo in the body — the Sentry-fingerprint discipline). Sparse repos inherit global untouched. Bounded work per tick: at most 10 eligible repos in deterministic order with a rotating system_flags cursor, so a large-fleet future never turns the tick into a stampede. Fail-safe per repo AND per tick, including non-Error throws. 100% line+branch on the module. Closes #8217
1 parent 904ec7d commit d615317

3 files changed

Lines changed: 234 additions & 5 deletions

File tree

src/queue/job-dispatch.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { isPrReconciliationEnabled, resolvePrReconciliationManifestOverride, run
3131
import { isActiveReviewReconciliationEnabled, resolveActiveReviewReconciliationManifestOverride, runActiveReviewReconciliation } from "../review/active-review-reconciliation";
3232
import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire";
3333
import { isSatisfactionFloorAutotuneEnabled, runScheduledSatisfactionFloorLoosening } from "../services/satisfaction-floor-loosening-run";
34-
import { GENERIC_LIVE_KNOBS, isConfigDriftSentinelEnabled, isKnobAutotuneEnabled, runConfigDriftSentinel, runScheduledKnobLoosening } from "../services/knob-loosening-run";
34+
import { GENERIC_LIVE_KNOBS, isConfigDriftSentinelEnabled, isKnobAutotuneEnabled, runConfigDriftSentinel, runPerRepoKnobLoosening, runScheduledKnobLoosening } from "../services/knob-loosening-run";
3535
import { runSelfTuneBreaker } from "../review/outcomes-wire";
3636
import { isRagEnabled } from "../review/rag-wire";
3737
import { processSubmitDraft } from "../services/draft";
@@ -352,7 +352,12 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
352352
// #8176: every LATER live registry knob rides the same tick through the generic runner — each knob
353353
// is double-gated on its OWN wrangler var, so an un-flagged knob does zero work here.
354354
for (const knob of GENERIC_LIVE_KNOBS) {
355-
if (isKnobAutotuneEnabled(env, knob)) await runScheduledKnobLoosening(env, knob);
355+
if (isKnobAutotuneEnabled(env, knob)) {
356+
await runScheduledKnobLoosening(env, knob);
357+
// #8217: repos whose own labeled slice clears the floors earn repo-scoped steps; sparse repos
358+
// inherit global. Bounded per tick with a rotating cursor; fail-safe internally.
359+
await runPerRepoKnobLoosening(env, knob);
360+
}
356361
}
357362
// #8213: the drift sentinel rides the same calibration tick, behind its own default-off flag.
358363
// Alert-only — it never writes a knob value — and internally fail-safe per knob.

src/services/knob-loosening-run.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@
1212
// • the override write is NOT best-effort (an unrecorded change is worse than none) — the audit trail is;
1313
// • one structured error-level alert per applied step, never re-alerting (the next run starts from the
1414
// already-loosened value and proposes nothing until the corpus justifies another step).
15-
import { buildBacktestCorpus, computeReliabilityCurve, deriveThresholdSuggestion, type ReliabilityCurve } from "@loopover/engine";
15+
import {
16+
buildBacktestCorpus,
17+
computeReliabilityCurve,
18+
computeRepoCorpusDensity,
19+
deriveThresholdSuggestion,
20+
sliceCorpusByRepo,
21+
type ReliabilityCurve,
22+
} from "@loopover/engine";
1623
import { createSignalStore } from "../review/signal-tracking-wire";
1724
import { recordAuditEvent } from "../db/repositories";
1825
import { evaluateKnobDrift, evaluateKnobLoosening, LOOSENABLE_KNOBS, type KnobDriftReport, type KnobLooseningProposal, type LoosenableKnob } from "./loosening-knobs";
@@ -161,6 +168,111 @@ export async function runScheduledKnobLoosening(env: Env, knob: LoosenableKnob):
161168
}
162169
}
163170

171+
// ── Per-repo loosening loop (#8217, epic #8211 track B capstone) ─────────────────────────────────────────
172+
173+
/** Repos evaluated per tick, hard-capped so a large-fleet future never turns the tick into a stampede.
174+
* Deterministic order + a system_flags cursor make successive ticks cover the whole eligible set. */
175+
export const PER_REPO_LOOSENING_MAX_REPOS_PER_TICK = 10;
176+
177+
const PER_REPO_CURSOR_FLAG_PREFIX = "per_repo_loosening_cursor:";
178+
179+
export type PerRepoLooseningResult = { repoFullName: string; applied: boolean; reason: string };
180+
181+
/**
182+
* Per-repo loosening evaluation (#8217): repos whose OWN labeled slice clears the knob's sample floors
183+
* (computeRepoCorpusDensity — the same split + minimums as every evaluator) earn their own loosening
184+
* step from their CURRENT resolved value; sparse repos keep inheriting the global value untouched.
185+
* Same discipline as the global loop verbatim: smallest candidate step, visible improved + held-out
186+
* non-regressed on the REPO slice, hard minimum, double flag gating, one error-level alert per applied
187+
* step (`ev` stays per-knob; the repo rides the alert body — the Sentry-fingerprint discipline).
188+
* Bounded work: at most {@link PER_REPO_LOOSENING_MAX_REPOS_PER_TICK} eligible repos per tick in
189+
* deterministic order, resuming from a per-knob cursor. Fail-safe per repo.
190+
*/
191+
export async function runPerRepoKnobLoosening(env: Env, knob: LoosenableKnob, nowMs: number = Date.now()): Promise<PerRepoLooseningResult[]> {
192+
if (knob.applyMode !== "live") return [];
193+
if (!isKnobAutotuneEnabled(env, knob)) return [];
194+
const results: PerRepoLooseningResult[] = [];
195+
try {
196+
const { fired, overrides } = await createSignalStore(env).queryRuleHistory(knob.ruleId, nowMs - CORPUS_LOOKBACK_MS);
197+
const cases = buildBacktestCorpus(knob.ruleId, fired, overrides);
198+
const density = computeRepoCorpusDensity(cases, knob.minVisibleCases, knob.minHeldOutCases, knob.heldOutFraction, knob.splitSeed);
199+
const eligible = [...density.entries()]
200+
.filter(([, stats]) => stats.eligible)
201+
.map(([repo]) => repo)
202+
.sort();
203+
if (eligible.length === 0) return results;
204+
205+
const cursorKey = `${PER_REPO_CURSOR_FLAG_PREFIX}${knob.knobId}`;
206+
const cursorRow = await env.DB.prepare("SELECT value FROM system_flags WHERE key = ?").bind(cursorKey).first<{ value: string }>();
207+
const cursor = cursorRow?.value ?? "";
208+
// Rotate: start after the cursor, wrap around, cap the batch.
209+
const startIndex = eligible.findIndex((repo) => repo > cursor);
210+
const rotated = startIndex === -1 ? eligible : [...eligible.slice(startIndex), ...eligible.slice(0, startIndex)];
211+
const batch = rotated.slice(0, PER_REPO_LOOSENING_MAX_REPOS_PER_TICK);
212+
const slices = sliceCorpusByRepo(cases);
213+
214+
for (const repoFullName of batch) {
215+
try {
216+
const currentValue = (await getKnobOverrideForRepo(env, knob, repoFullName)) ?? knob.shippedValue;
217+
if (currentValue <= knob.hardMinimum) {
218+
results.push({ repoFullName, applied: false, reason: "already_applied" });
219+
continue;
220+
}
221+
// Non-null by construction: eligibility derives from the same slicing, so every eligible repo has a slice.
222+
const proposal = evaluateKnobLoosening(knob, slices.get(repoFullName)!, currentValue);
223+
if (!proposal || proposal.proposedValue >= currentValue || proposal.proposedValue < knob.hardMinimum) {
224+
results.push({ repoFullName, applied: false, reason: "no_proposal" });
225+
continue;
226+
}
227+
await env.DB.prepare(
228+
"INSERT INTO system_flags (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
229+
)
230+
.bind(repoKnobOverrideFlagKey(knob, repoFullName), String(proposal.proposedValue))
231+
.run();
232+
await recordAuditEvent(env, {
233+
eventType: knob.looseningEventType,
234+
actor: "loopover",
235+
targetKey: knob.ruleId,
236+
outcome: "completed",
237+
detail: `${knob.knobId} loosened for ${repoFullName}: ${proposal.currentValue} -> ${proposal.proposedValue} (repo-slice backtest-gated)`,
238+
metadata: { proposal, repoFullName, scope: "repo" },
239+
}).catch(() => undefined);
240+
console.error(
241+
JSON.stringify({
242+
level: "error",
243+
event: "calibration_knob_loosened",
244+
ev: knob.knobId,
245+
at: new Date().toISOString(),
246+
scope: "repo",
247+
repoFullName,
248+
currentValue: proposal.currentValue,
249+
proposedValue: proposal.proposedValue,
250+
visibleCases: proposal.visibleCases,
251+
heldOutCases: proposal.heldOutCases,
252+
}),
253+
);
254+
results.push({ repoFullName, applied: true, reason: "applied" });
255+
} catch (error) {
256+
console.warn(
257+
JSON.stringify({ level: "warn", event: "per_repo_loosening_failed", ev: knob.knobId, repoFullName, error: error instanceof Error ? error.message : "unknown error" }),
258+
);
259+
results.push({ repoFullName, applied: false, reason: "error" });
260+
}
261+
}
262+
const lastProcessed = batch[batch.length - 1]!;
263+
await env.DB.prepare(
264+
"INSERT INTO system_flags (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
265+
)
266+
.bind(cursorKey, lastProcessed)
267+
.run();
268+
} catch (error) {
269+
console.warn(
270+
JSON.stringify({ level: "warn", event: "per_repo_loosening_tick_failed", ev: knob.knobId, error: error instanceof Error ? error.message : "unknown error" }),
271+
);
272+
}
273+
return results;
274+
}
275+
164276
// ── Config-drift sentinel (#8213, epic #8211 track A) ────────────────────────────────────────────────────
165277

166278
/** Truthy-string flag for the drift sentinel — default off, so a deploy is byte-identical until opted in. */

test/unit/knob-loosening-run.test.ts

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
KNOB_SUGGESTION_TARGET_PRECISION,
1515
loadKnobStatus,
1616
loadLiveKnobStatuses,
17+
runPerRepoKnobLoosening,
18+
PER_REPO_LOOSENING_MAX_REPOS_PER_TICK,
1719
runConfigDriftSentinel,
1820
runKnobLoosening,
1921
runScheduledKnobLoosening,
@@ -48,8 +50,8 @@ async function setOverrideRow(env: Env, key: string, value: string): Promise<voi
4850
// Membership-probe seeding (same technique as the satisfaction suites) sized for the AI knob's stricter
4951
// floors: borderline-confirmed history between the first candidate (0.9) and the shipped 0.93 in both
5052
// slices, plus one genuinely-reversed deep-low firing per slice so precision has a denominator.
51-
async function seedAiLooseningFriendlyHistory(env: Env): Promise<void> {
52-
const pool = Array.from({ length: 400 }, (_, i) => `acme/widgets#${i + 1}`);
53+
async function seedAiLooseningFriendlyHistory(env: Env, repo = "acme/widgets"): Promise<void> {
54+
const pool = Array.from({ length: 400 }, (_, i) => `${repo}#${i + 1}`);
5355
const probe = pool.map((targetKey) => ({
5456
ruleId: AI_KNOB.ruleId,
5557
targetKey,
@@ -388,6 +390,116 @@ describe("runConfigDriftSentinel (#8213)", () => {
388390
});
389391
});
390392

393+
describe("runPerRepoKnobLoosening (#8217)", () => {
394+
it("a dense repo earns its OWN override while a sparse repo inherits global untouched", async () => {
395+
const env = enabledEnv();
396+
await seedAiLooseningFriendlyHistory(env, "acme/dense");
397+
// Sparse repo: a handful of cases, far under the knob's floors.
398+
const store = createSignalStore(env);
399+
for (let i = 1; i <= 3; i += 1) {
400+
await store.recordRuleFired({ ruleId: AI_KNOB.ruleId, targetKey: `acme/sparse#${i}`, outcome: "unaddressed", occurredAt: new Date(Date.now() - 5000).toISOString(), metadata: { confidence: 0.91 } });
401+
await store.recordHumanOverride({ ruleId: AI_KNOB.ruleId, targetKey: `acme/sparse#${i}`, verdict: "confirmed", occurredAt: new Date().toISOString() });
402+
}
403+
404+
const results = await runPerRepoKnobLoosening(env, AI_KNOB);
405+
expect(results).toEqual([{ repoFullName: "acme/dense", applied: true, reason: "applied" }]);
406+
expect(await getKnobOverrideForRepo(env, AI_KNOB, "acme/dense")).toBe(AI_KNOB.candidates[0]);
407+
// Sparse repo: no repo row; resolution falls through to global (none here) -> null.
408+
expect(await getKnobOverrideForRepo(env, AI_KNOB, "acme/sparse")).toBeNull();
409+
// The repo-scoped audit event carries the scope + repo.
410+
const events = await env.DB.prepare("SELECT metadata_json FROM audit_events WHERE event_type = ?").bind(AI_KNOB.looseningEventType).all<{ metadata_json: string }>();
411+
const metadata = JSON.parse(events.results![0]!.metadata_json) as { scope?: string; repoFullName?: string };
412+
expect(metadata).toMatchObject({ scope: "repo", repoFullName: "acme/dense" });
413+
});
414+
415+
it("second tick evaluates the earned repo from ITS value (no proposal left) — never oscillates; already-at-minimum reports as such", async () => {
416+
const env = enabledEnv();
417+
await seedAiLooseningFriendlyHistory(env, "acme/dense");
418+
await runPerRepoKnobLoosening(env, AI_KNOB);
419+
const second = await runPerRepoKnobLoosening(env, AI_KNOB);
420+
expect(second).toEqual([{ repoFullName: "acme/dense", applied: false, reason: "no_proposal" }]);
421+
422+
await setOverrideRow(env, repoKnobOverrideFlagKey(AI_KNOB, "acme/dense"), String(AI_KNOB.hardMinimum));
423+
const third = await runPerRepoKnobLoosening(env, AI_KNOB);
424+
expect(third).toEqual([{ repoFullName: "acme/dense", applied: false, reason: "already_applied" }]);
425+
});
426+
427+
it("gates: report-only knob and flag-off both do nothing; a broken store fails safe with a warn", async () => {
428+
const reportOnly = { ...AI_KNOB, applyMode: "report_only" as const };
429+
expect(await runPerRepoKnobLoosening(enabledEnv(), reportOnly)).toEqual([]);
430+
expect(await runPerRepoKnobLoosening(createTestEnv(), AI_KNOB)).toEqual([]);
431+
432+
const broken = enabledEnv();
433+
broken.DB = { prepare: () => { throw new Error("boom"); } } as never;
434+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
435+
expect(await runPerRepoKnobLoosening(broken, AI_KNOB)).toEqual([]);
436+
expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("per_repo_loosening_tick_failed"))).toBe(true);
437+
});
438+
439+
it("empty eligibility returns cleanly; audit rejection is best-effort; a mid-repo error fails safe per repo; non-Error throws degrade", async () => {
440+
// Enabled but only sparse data -> zero eligible repos -> the early return, no cursor written.
441+
const sparseOnly = enabledEnv();
442+
const store = createSignalStore(sparseOnly);
443+
await store.recordRuleFired({ ruleId: AI_KNOB.ruleId, targetKey: "acme/sparse#1", outcome: "unaddressed", occurredAt: new Date().toISOString(), metadata: { confidence: 0.91 } });
444+
await store.recordHumanOverride({ ruleId: AI_KNOB.ruleId, targetKey: "acme/sparse#1", verdict: "confirmed", occurredAt: new Date().toISOString() });
445+
expect(await runPerRepoKnobLoosening(sparseOnly, AI_KNOB)).toEqual([]);
446+
447+
// Audit write rejection: the override still lands (best-effort trail, never sacrificed writes).
448+
const env = enabledEnv();
449+
await seedAiLooseningFriendlyHistory(env, "acme/dense");
450+
const repositories = await import("../../src/db/repositories");
451+
vi.spyOn(repositories, "recordAuditEvent").mockRejectedValue(new Error("audit down"));
452+
const results = await runPerRepoKnobLoosening(env, AI_KNOB);
453+
expect(results).toEqual([{ repoFullName: "acme/dense", applied: true, reason: "applied" }]);
454+
expect(await getKnobOverrideForRepo(env, AI_KNOB, "acme/dense")).toBe(AI_KNOB.candidates[0]);
455+
vi.restoreAllMocks();
456+
457+
// Mid-repo evaluator throw: that repo reports error, the tick survives.
458+
const env2 = enabledEnv();
459+
await seedAiLooseningFriendlyHistory(env2, "acme/dense");
460+
const looseningKnobsModule = await import("../../src/services/loosening-knobs");
461+
vi.spyOn(looseningKnobsModule, "evaluateKnobLoosening").mockImplementation(() => { throw "evaluator string boom"; });
462+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
463+
const errored = await runPerRepoKnobLoosening(env2, AI_KNOB);
464+
expect(errored).toEqual([{ repoFullName: "acme/dense", applied: false, reason: "error" }]);
465+
expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("per_repo_loosening_failed") && String(c[0]).includes('"error":"unknown error"'))).toBe(true);
466+
vi.restoreAllMocks();
467+
468+
// Inner catch with a REAL Error message, and the outer catch's non-Error arm via a string-throwing store.
469+
const env3 = enabledEnv();
470+
await seedAiLooseningFriendlyHistory(env3, "acme/dense");
471+
const knobsModule = await import("../../src/services/loosening-knobs");
472+
vi.spyOn(knobsModule, "evaluateKnobLoosening").mockImplementation(() => { throw new Error("evaluator real error"); });
473+
const warn3 = vi.spyOn(console, "warn").mockImplementation(() => undefined);
474+
await runPerRepoKnobLoosening(env3, AI_KNOB);
475+
expect(warn3.mock.calls.some((c) => String(c[0]).includes("evaluator real error"))).toBe(true);
476+
vi.restoreAllMocks();
477+
478+
const stringStore = enabledEnv();
479+
stringStore.DB = { prepare: () => { throw "outer string boom"; } } as never;
480+
const warn4 = vi.spyOn(console, "warn").mockImplementation(() => undefined);
481+
expect(await runPerRepoKnobLoosening(stringStore, AI_KNOB)).toEqual([]);
482+
expect(warn4.mock.calls.some((c) => String(c[0]).includes("per_repo_loosening_tick_failed") && String(c[0]).includes('"error":"unknown error"'))).toBe(true);
483+
});
484+
485+
it("caps the batch and rotates the cursor across ticks (deterministic order)", async () => {
486+
expect(PER_REPO_LOOSENING_MAX_REPOS_PER_TICK).toBe(10);
487+
const env = enabledEnv();
488+
// Two dense repos; cursor after tick 1 should sit at the last processed repo. With a batch cap of 10
489+
// both fit in one tick, so pin the cursor bookkeeping rather than the wraparound (covered by the
490+
// rotation arithmetic itself being deterministic on the sorted list).
491+
await seedAiLooseningFriendlyHistory(env, "acme/alpha");
492+
await seedAiLooseningFriendlyHistory(env, "acme/beta");
493+
const results = await runPerRepoKnobLoosening(env, AI_KNOB);
494+
expect(results.map((r) => r.repoFullName)).toEqual(["acme/alpha", "acme/beta"]);
495+
const cursor = await env.DB.prepare("SELECT value FROM system_flags WHERE key = ?").bind(`per_repo_loosening_cursor:${AI_KNOB.knobId}`).first<{ value: string }>();
496+
expect(cursor?.value).toBe("acme/beta");
497+
// Next tick starts AFTER the cursor: wraps to alpha first again (both still eligible).
498+
const second = await runPerRepoKnobLoosening(env, AI_KNOB);
499+
expect(second.map((r) => r.repoFullName)).toEqual(["acme/alpha", "acme/beta"]);
500+
});
501+
});
502+
391503
describe("loadKnobStatus / loadLiveKnobStatuses (#8161 generalized)", () => {
392504
it("reports a lingering override row even with the flag OFF, and the live value only when ON", async () => {
393505
const env = createTestEnv();

0 commit comments

Comments
 (0)