Skip to content

Commit 353d019

Browse files
feat(review): evidence-weighted reviewer routing, stage 1 — the report-only shadow (#8229) (#8301)
After each ok block-mode dual review, compute what routing WOULD have preferred for this repo from the live per-provider track records (#8228 over the stage-0 reviewer_vote rows) and record it — one audit event (reviewer_routing_shadow) plus a maintainer-recap section — changing NOTHING about the review: - computeWouldHaveRouted: repo-scoped rows only, ROUTING_MIN_DECIDED (10, the AUTOTUNE_MIN_DECIDED never-on-noise bar at reviewer grain) per (provider, repo), null on ties/lone reviewers/any missing density — an absent record MEANS no measurable preference, keeping stage 2's eventual evidence read undiluted - loadLiveProviderTrackRecords reads ONLY the live vote event type (the replay-derived campaign data never enters — the #8278 segregation rule from the consuming side); fail-safe empty on any store error - orchestration hook is best-effort end to end: zero added AI spend, a thrown read or a rejecting/throwing audit write reduces to no record - the weekly recap gains a routing-shadow section (grouped per repo + preferred provider with the mean precision edge, explicit empty line, report-only footer), read back off the audit trail at format time and fail-safe to an absent section Stage 2 (actual weighting behind a default-off flag with hard floors) ships only against this stage's recorded evidence, per the issue. Co-authored-by: loopover-orb[bot] <296761690+loopover-orb[bot]@users.noreply.github.com>
1 parent f791f61 commit 353d019

6 files changed

Lines changed: 435 additions & 2 deletions

File tree

src/queue/ai-review-orchestration.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "./transient-locks";
2222
import { buildPullRequestAdvisory } from "../rules/advisory";
2323
import { recordAuditEvent, getDecryptedRepositoryAiKey, getRepository, listCheckSummaries, listPullRequestFiles } from "../db/repositories";
24+
import { recordRoutingShadow } from "../services/reviewer-routing";
2425
import { createInstallationToken } from "../github/app";
2526
import type { AgentActionMode } from "../settings/agent-execution";
2627
import { buildAiReviewDiff } from "../review/review-diff";
@@ -735,6 +736,16 @@ export async function runAiReviewForAdvisory(
735736
metadata: { repoFullName: args.repoFullName, vote: vote.votedFail ? "fail" : "non_fail" },
736737
}).catch(() => undefined);
737738
}
739+
// #8229 stage 1: the report-only routing shadow — records what evidence-weighted routing WOULD have
740+
// preferred for this repo (audit metadata only; the recap aggregates it). Same best-effort discipline
741+
// as the votes above: internally fail-safe, zero AI spend, and a no-signal review records nothing.
742+
if (result.reviewerVotes.length >= 2) {
743+
await recordRoutingShadow(env, {
744+
repoFullName: args.repoFullName,
745+
prNumber: args.pr.number,
746+
actualProviders: result.reviewerVotes.map((vote) => vote.reviewer),
747+
});
748+
}
738749
const findings: AdvisoryFinding[] = [];
739750
if (result.consensusDefect) {
740751
findings.push({
3 KB
Binary file not shown.

src/services/maintainer-recap.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN, PUBLIC_UNSAFE_PATTERN } from "../signa
1414
import { deliverRecapToDiscord, deliverRecapToSlack } from "./notify-discord";
1515
import type { GatePrecisionReport } from "./gate-precision";
1616
import type { DriftRecapSection } from "./maintainer-recap-drift";
17+
import { buildRoutingRecapSection } from "./maintainer-recap-routing";
18+
import { REVIEWER_ROUTING_SHADOW_EVENT_TYPE, type RoutingShadowDecision } from "./reviewer-routing";
1719
import type { OutcomeCalibration } from "./outcome-calibration";
1820
import type { MaintainerRecapCohortCounts, MaintainerRecapRepo, RecapReport } from "../types";
1921
import { nowIso } from "../utils/json";
@@ -162,7 +164,7 @@ function recapSectionLines(items: string[], fallback: string): string[] {
162164
* (Summary, Totals, Per-repo), mirroring formatWeeklyValueReportMarkdown at weekly-value-report.ts. PURE
163165
* string function — no delivery, no I/O. Every free-text value is routed through {@link redactRecapLine} so no
164166
* reward/trust/score/path term can leak into the digest even if the input report was hand-built. (#2240) */
165-
export function formatMaintainerRecap(report: RecapReport, options: { configDrift?: DriftRecapSection } = {}): string {
167+
export function formatMaintainerRecap(report: RecapReport, options: { configDrift?: DriftRecapSection; routingShadow?: { title: string; lines: string[] } } = {}): string {
166168
const { totals } = report;
167169
const rate = totals.gateFalsePositiveRate !== null ? `${Math.round(totals.gateFalsePositiveRate * 100)}%` : "n/a";
168170
const perRepoLines = report.repos.map(
@@ -194,6 +196,9 @@ export function formatMaintainerRecap(report: RecapReport, options: { configDrif
194196
...(options.configDrift
195197
? ["", `## ${redactRecapLine(options.configDrift.title)}`, ...recapSectionLines(options.configDrift.lines, "_No drift lines for this window._")]
196198
: []),
199+
...(options.routingShadow
200+
? ["", `## ${redactRecapLine(options.routingShadow.title)}`, ...recapSectionLines(options.routingShadow.lines, "_No routing-shadow lines for this window._")]
201+
: []),
197202
];
198203
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
199204
}
@@ -217,6 +222,31 @@ export type RunMaintainerRecapResult =
217222
* never throws, so a single-channel outage does not abort the other. When `enabled === false`, short-circuits
218223
* before any I/O (the flag-OFF arm mirrored by the cron/job processor).
219224
*/
225+
/** Read the window's reviewer_routing_shadow decisions back off the audit trail and build the recap
226+
* section. Null — the section is simply absent — on any read error (fail-safe, like every recap input). */
227+
async function loadRoutingRecapSection(env: Env, windowDays: number, generatedAt: string): Promise<{ title: string; lines: string[] } | null> {
228+
try {
229+
const sinceIso = new Date(Date.parse(generatedAt) - windowDays * 24 * 60 * 60 * 1000).toISOString();
230+
const rows = await env.DB.prepare("SELECT metadata_json FROM audit_events WHERE event_type = ? AND created_at >= ?")
231+
.bind(REVIEWER_ROUTING_SHADOW_EVENT_TYPE, sinceIso)
232+
.all<{ metadata_json: string }>();
233+
const decisions: RoutingShadowDecision[] = [];
234+
/* v8 ignore next -- defined-results guard, the loadKnobStatus convention */
235+
for (const row of rows.results ?? []) {
236+
try {
237+
const metadata = JSON.parse(row.metadata_json) as Partial<RoutingShadowDecision>;
238+
if (typeof metadata.repoFullName !== "string" || typeof metadata.preferredProvider !== "string" || !Array.isArray(metadata.basis)) continue;
239+
decisions.push(metadata as RoutingShadowDecision);
240+
} catch {
241+
/* a corrupt shadow row is not evidence */
242+
}
243+
}
244+
return buildRoutingRecapSection({ decisions, windowDays });
245+
} catch {
246+
return null;
247+
}
248+
}
249+
220250
export async function runMaintainerRecap(
221251
env: Env,
222252
options: {
@@ -238,7 +268,10 @@ export async function runMaintainerRecap(
238268
windowDays: options.windowDays,
239269
repos: options.repos ?? [],
240270
});
241-
const formatted = formatMaintainerRecap(report);
271+
// #8229 stage 1: the routing-shadow section reads the window's recorded decisions straight from the
272+
// audit trail — fail-safe to an absent section (the recap must never break on a read blip).
273+
const routingShadow = await loadRoutingRecapSection(env, report.windowDays, options.generatedAt ?? nowIso());
274+
const formatted = formatMaintainerRecap(report, routingShadow ? { routingShadow } : {});
242275
const [discord, slack] = await Promise.all([
243276
deliverRecapToDiscord(env, report, formatted),
244277
deliverRecapToSlack(env, report, formatted),

src/services/reviewer-routing.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Evidence-weighted reviewer routing, STAGE 1 of #8229 (epic #8211 track F): the report-only shadow.
2+
// After each ok block-mode dual review, compute what routing WOULD have preferred for this repo from the
3+
// live per-provider track records (#8228 over the stage-0 reviewer_vote rows) and record it — one audit
4+
// event plus a maintainer-recap section — while changing NOTHING about the review itself. Stage 2 (actual
5+
// weighting behind a default-off flag, hard floors, instant restore) ships only against this stage's
6+
// recorded evidence, per the issue's two-stage contract.
7+
//
8+
// Invariants the shadow holds (each pinned by a test):
9+
// • ZERO behavior change and ZERO added AI spend — pure DB reads, one best-effort audit write;
10+
// • fail-safe: any read/compute error ⇒ no record, review path byte-identical;
11+
// • never a preference on noise — a per-(provider, repo) decided floor below which NOTHING records
12+
// ({@link ROUTING_MIN_DECIDED}, the AUTOTUNE_MIN_DECIDED never-on-noise bar at reviewer grain);
13+
// • a tie (or a lone provider) records nothing — absence of a record must mean "no measurable
14+
// preference", so the eventual stage-2 evidence read is never diluted by no-signal rows.
15+
import { buildBacktestCorpus, computeProviderTrackRecords, type ProviderReviewSignal, type ProviderTrackRecord } from "@loopover/engine";
16+
import { createSignalStore } from "../review/signal-tracking-wire";
17+
import { recordAuditEvent } from "../db/repositories";
18+
19+
/** Audit event type a shadow decision writes — ONE stable type forever (the #8159 event discipline). */
20+
export const REVIEWER_ROUTING_SHADOW_EVENT_TYPE = "reviewer_routing_shadow";
21+
22+
/** Minimum DECIDED votes per (provider, repo) before a preference may record — AUTOTUNE_MIN_DECIDED's
23+
* never-on-noise bar (auto-tune.ts) applied at reviewer grain, as #8229's own floors clause requires. */
24+
export const ROUTING_MIN_DECIDED = 10;
25+
26+
/** The trailing window the track-record read replays — mirrors the calibration corpus lookback. */
27+
const CORPUS_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000;
28+
29+
export type RoutingShadowDecision = {
30+
repoFullName: string;
31+
preferredProvider: string;
32+
actualProviders: string[];
33+
/** The evidence the preference rests on — repo-scoped decided/precision per actual provider. */
34+
basis: Array<{ provider: string; decided: number; precision: number }>;
35+
};
36+
37+
/**
38+
* PURE: what would evidence-weighted routing have preferred for this repo, given the current track
39+
* records and the providers the review ACTUALLY used? Null — record nothing — unless EVERY actual
40+
* provider has a repo-scoped row at/above the decided floor with a non-null precision, and exactly one
41+
* provider strictly leads. Repo-scoped rows only: the per-(provider, repo) floor is the issue's own
42+
* requirement, and a global rollup preference would smuggle cross-repo behavior into a per-repo call.
43+
*/
44+
export function computeWouldHaveRouted(
45+
records: readonly ProviderTrackRecord[],
46+
repoFullName: string,
47+
actualProviders: readonly string[],
48+
): RoutingShadowDecision | null {
49+
if (actualProviders.length < 2) return null; // a lone reviewer has nothing to route between
50+
const basis: Array<{ provider: string; decided: number; precision: number }> = [];
51+
for (const provider of actualProviders) {
52+
const row = records.find((record) => record.provider === provider && record.repoFullName === repoFullName);
53+
if (!row || row.decided < ROUTING_MIN_DECIDED || row.precision === null) return null;
54+
basis.push({ provider, decided: row.decided, precision: row.precision });
55+
}
56+
const sorted = [...basis].sort((a, b) => b.precision - a.precision);
57+
if (sorted[0]!.precision === sorted[1]!.precision) return null; // a tie is not a preference
58+
return {
59+
repoFullName,
60+
preferredProvider: sorted[0]!.provider,
61+
actualProviders: [...actualProviders],
62+
basis,
63+
};
64+
}
65+
66+
/**
67+
* Load the LIVE provider track records: stage-0 reviewer_vote rows joined to the labeled consensus corpus
68+
* via the #8228 aggregation. Replay-derived signals never enter here — this reads only the live event
69+
* type (the #8278 segregation rule from the consuming side). Fail-safe empty on any store error.
70+
*/
71+
export async function loadLiveProviderTrackRecords(env: Env, nowMs: number = Date.now()): Promise<ProviderTrackRecord[]> {
72+
try {
73+
const votes = await env.DB.prepare(
74+
"SELECT actor, target_key, metadata_json FROM audit_events WHERE event_type = ? AND created_at >= ?",
75+
)
76+
.bind(REVIEWER_VOTE_EVENT_TYPE, new Date(nowMs - CORPUS_LOOKBACK_MS).toISOString())
77+
.all<{ actor: string; target_key: string; metadata_json: string }>();
78+
const signals: ProviderReviewSignal[] = [];
79+
/* v8 ignore next -- defined-results guard, the loadKnobStatus convention */
80+
for (const row of votes.results ?? []) {
81+
let metadata: { repoFullName?: unknown; vote?: unknown } = {};
82+
try {
83+
metadata = JSON.parse(row.metadata_json) as { repoFullName?: unknown; vote?: unknown };
84+
} catch {
85+
continue; // a corrupt vote row is not evidence
86+
}
87+
if (typeof metadata.repoFullName !== "string" || (metadata.vote !== "fail" && metadata.vote !== "non_fail")) continue;
88+
signals.push({
89+
provider: row.actor,
90+
repoFullName: metadata.repoFullName,
91+
targetKey: row.target_key,
92+
vote: metadata.vote === "fail" ? "fail" : "pass",
93+
});
94+
}
95+
const { fired, overrides } = await createSignalStore(env).queryRuleHistory("ai_consensus_defect", nowMs - CORPUS_LOOKBACK_MS);
96+
return computeProviderTrackRecords(signals, buildBacktestCorpus("ai_consensus_defect", fired, overrides));
97+
} catch {
98+
return []; // fail-safe: no records ⇒ downstream records nothing ⇒ byte-identical behavior
99+
}
100+
}
101+
102+
/** The stage-0 vote event type, mirrored here as the ONE consuming-side constant (the orchestration writer
103+
* keeps its literal; the invariant test pins the two spellings together so they can never drift). */
104+
export const REVIEWER_VOTE_EVENT_TYPE = "reviewer_vote";
105+
106+
/**
107+
* The orchestration hook: compute + record this review's shadow decision, best-effort end to end. Never
108+
* throws, never adds an AI call; a null decision (no density / tie / lone reviewer / read error) writes
109+
* NOTHING. Called after the stage-0 vote persistence with the same swap-proof reviewer identities.
110+
*/
111+
export async function recordRoutingShadow(
112+
env: Env,
113+
args: { repoFullName: string; prNumber: number; actualProviders: readonly string[] },
114+
): Promise<RoutingShadowDecision | null> {
115+
try {
116+
const decision = computeWouldHaveRouted(await loadLiveProviderTrackRecords(env), args.repoFullName, args.actualProviders);
117+
if (!decision) return null;
118+
await recordAuditEvent(env, {
119+
eventType: REVIEWER_ROUTING_SHADOW_EVENT_TYPE,
120+
actor: "loopover",
121+
targetKey: `${args.repoFullName}#${args.prNumber}`,
122+
outcome: "completed",
123+
detail: `routing would have preferred ${decision.preferredProvider} (report-only shadow; review used ${decision.actualProviders.join(" + ")})`,
124+
metadata: { ...decision },
125+
}).catch(() => undefined);
126+
return decision;
127+
} catch {
128+
return null; // the shadow must never touch the review path
129+
}
130+
}

0 commit comments

Comments
 (0)