Skip to content

Commit bcdd2f8

Browse files
authored
fix(eval): commit each published score to the corpus a reader can download (#9811)
Verified live on api.loopover.ai: /v1/public/stats publishes ai_consensus_defect at decided 460 / confirmed 287, /v1/public/eval-corpus serves 460 real cases whose checksum re-derives from the downloaded bytes, and /v1/public/eval-scores serves `{"records":[]}`. The commitment came only from a persisted calibration.*_backtest_run audit event. #9639 fixes that writer, but it runs inside a review pass, and hosted review execution is retired (src/index.ts acks-and-drops review-execution jobs off the queue), so on loopover.ai the event never exists. The walkthrough tells an anonymous reader to fetch .records and re-derive recordDigest; they got an empty array, explained by a doc line attributing it to an empty corpus that demonstrably is not empty. Each record now falls back to its OWN rule's published corpus checksum -- the exact bytes /v1/public/eval-corpus serves, over the same window. That is not the placeholder commitment #9215 forbids: it is a hash over an artifact the reader downloads and re-hashes, which is what the `reproducible` tier asserts. A persisted run still wins where one exists, so self-host is unchanged. Resolving per rule also fixes a latent bug: one run's checksum was stamped onto every record, so with more than one published rule every record but one would have committed to a different rule's cases. TRUNCATION HAD TO BE MADE DETECTABLE FIRST. PUBLIC_EVAL_CORPUS_MAX_CASES was 5_000 while the corpus is built from a rule-history read that listAuditEventsByType hard-clamps to 2_000 -- and queryRuleHistory passed no limit at all, so it took the default of 500. The cap could never bind, and `truncated` was structurally always false, while /v1/public/stats counts `decided` with an unbounded SQL COUNT(*). The two surfaces agreed only while the window stayed under 500 cases; at 460 they were 40 from silently diverging, with the corpus serving a prefix and reporting completeness. queryRuleHistory now takes an explicit bound and reports `saturated`, the corpus ORs that into `truncated`, and a truncated corpus is never published as a commitment -- its checksum would cover a prefix of the window the score covers. Closes #9805
1 parent 0c6b441 commit bcdd2f8

16 files changed

Lines changed: 451 additions & 31 deletions

apps/loopover-ui/content/docs/verify-this-review.mdx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,28 @@ transport — recompute `recordDigest` over the record's own remaining fields an
138138
curl -s "https://api.loopover.ai/v1/public/eval-scores" | jq '.records'
139139
```
140140

141-
That array is empty whenever the latest backtest run's corpus is empty. A checksum over zero cases
142-
is byte-identical for every rule and every window, so it commits to nothing a reader could re-derive
143-
the scores from — publishing a record against it would assert a reproducibility that does not exist.
144-
An empty `records` array therefore means *the numbers are not currently committed to a corpus*, never
145-
that the numbers are zero.
141+
Each record's `commitments.corpusChecksum` is the checksum of the corpus you downloaded in step 2 —
142+
the same rule, the same window, the same bytes. So you can close the loop yourself: hash the corpus,
143+
read the record, compare.
144+
145+
```bash
146+
curl -s "https://api.loopover.ai/v1/public/eval-corpus?ruleId=ai_consensus_defect" | jq -r '.checksum'
147+
curl -s "https://api.loopover.ai/v1/public/eval-scores" \
148+
| jq -r '.records[] | select(.workUnit.ruleId == "ai_consensus_defect") | .commitments.corpusChecksum'
149+
```
150+
151+
A rule is **omitted** from `records` — rather than published with a commitment you could not check —
152+
in exactly three cases:
153+
154+
- **its corpus is empty.** A checksum over zero cases is byte-identical for every rule, every window
155+
and every deployment, so it commits to nothing you could re-derive the scores from;
156+
- **its corpus is truncated** (`truncated: true` in step 2). The checksum would then cover a prefix of
157+
the window while the record's `decided`/`confirmed` cover all of it, so re-deriving from the
158+
published cases would give you different numbers than the ones published;
159+
- **the deployment has neither a persisted backtest run nor a usable corpus** for that rule.
160+
161+
An empty `records` array therefore means *these numbers are not currently committed to a corpus*
162+
never that the numbers are zero.
146163

147164
The aggregated run history is readable directly too, again against the deployment's own database
148165
(operator / self-host):

packages/loopover-engine/src/calibration/signal-tracking.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,21 @@ export interface SignalStore {
4949
recordHumanOverride(event: HumanOverrideEvent): Promise<void>;
5050
/** Every fired + override event for `ruleId` at or after `sinceMs` (epoch millis), oldest first. A host MAY
5151
* scope this further (e.g. to one repo) internally; the interface itself is unscoped beyond `ruleId`. */
52-
queryRuleHistory(ruleId: string, sinceMs: number): Promise<{ fired: RuleFiredEvent[]; overrides: HumanOverrideEvent[] }>;
52+
/**
53+
* Read one rule's history since `sinceMs`.
54+
*
55+
* `limit` bounds EACH of the two reads (#9805). It is part of the interface rather than an implementation
56+
* detail because a caller that publishes the result has to know whether it saw the whole window --
57+
* /v1/public/eval-corpus reported `truncated: false` over a read it could not have completed, precisely
58+
* because the bound was invisible from here.
59+
*
60+
* `saturated` is true when either read came back AT its bound, i.e. rows were almost certainly left behind.
61+
*/
62+
queryRuleHistory(
63+
ruleId: string,
64+
sinceMs: number,
65+
limit?: number,
66+
): Promise<{ fired: RuleFiredEvent[]; overrides: HumanOverrideEvent[]; saturated: boolean }>;
5367
}
5468

5569
/** Per-rule confusion-style report over a window: how many times it fired, how many of those got an explicit

packages/loopover-miner/lib/signal-tracking-store.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ export function createSignalTrackingStore(eventLedger: SignalTrackingLedger): Si
100100
payload: toHumanOverridePayload(event),
101101
});
102102
},
103-
async queryRuleHistory(ruleId: string, sinceMs: number): Promise<{ fired: RuleFiredEvent[]; overrides: HumanOverrideEvent[] }> {
103+
// #9805: the interface carries a `limit`/`saturated` pair so a publishing caller can tell a complete read
104+
// from a truncated one. This store reads the WHOLE local event ledger in memory -- there is no bound to
105+
// hit and nothing is ever left behind -- so `limit` is accepted for interface conformance and ignored,
106+
// and `saturated` is unconditionally false. That is a true statement here, not a stub: reporting `true`
107+
// would claim missing rows that do not exist.
108+
async queryRuleHistory(ruleId: string, sinceMs: number): Promise<{ fired: RuleFiredEvent[]; overrides: HumanOverrideEvent[]; saturated: boolean }> {
104109
const sinceIso = new Date(sinceMs).toISOString();
105110
const fired: RuleFiredEvent[] = [];
106111
const overrides: HumanOverrideEvent[] = [];
@@ -127,7 +132,7 @@ export function createSignalTrackingStore(eventLedger: SignalTrackingLedger): Si
127132
});
128133
}
129134
}
130-
return { fired, overrides };
135+
return { fired, overrides, saturated: false };
131136
},
132137
};
133138
}

src/api/routes.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride }
304304
import { isRagEnabled } from "../review/rag-wire";
305305
import { loadDecisionLedgerTip, loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record";
306306
import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records";
307+
import { buildPublicCorpusCommitments } from "../review/public-eval-corpus";
307308
import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor";
308309
import { resolveProofPage } from "../review/proof-summary";
309310
import { renderProofBadgeSvg } from "./proof-badge";
@@ -905,7 +906,13 @@ export function createApp() {
905906
// IO-touching source (the benchmark_run records from #9265) is exactly where real error handling belongs,
906907
// added when that source actually exists, not as untestable defensive code here.
907908
const precision = await loadPublicRulePrecision(c.env);
908-
const records = await buildEvalScoreRecordsFromRulePrecision(precision, new Date().toISOString());
909+
// #9805: the per-rule fallback commitment, when no backtest run is persisted. Loaded HERE rather than
910+
// inside the record builder so that module stays pure -- and loaded through the same
911+
// loadPublicEvalCorpus the /v1/public/eval-corpus route serves, so the checksum a record commits to is by
912+
// construction the one a reader re-derives from the bytes they downloaded, not a parallel computation
913+
// that could drift from it.
914+
const corpusChecksumByRuleId = await buildPublicCorpusCommitments(c.env, precision.rules.map((rule) => rule.ruleId));
915+
const records = await buildEvalScoreRecordsFromRulePrecision(precision, new Date().toISOString(), corpusChecksumByRuleId);
909916
const filtered = filterEvalScoreRecords(records, {
910917
subject: c.req.query("subject"),
911918
since: c.req.query("since"),

src/review/eval-score-records.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ async function finalizeRecord(input: EvalScoreRecordDigestInput): Promise<EvalSc
7676
* a record whose commitments cannot be independently re-derived (no corpus checksum to point at) is not
7777
* publishable, so this deliberately emits nothing rather than a record with a placeholder commitment.
7878
*
79+
* #9805: when no backtest run is persisted, the commitment falls back to `corpusChecksumByRuleId` -- the
80+
* checksum of the corpus `/v1/public/eval-corpus` publishes for that same rule, over the same window. That is
81+
* not a placeholder standing in for a real commitment: it is a hash over an artifact the reader can download
82+
* and re-hash themselves, which is exactly what the `reproducible` trust tier asserts. It exists because a
83+
* deployment with review execution retired (the hosted Worker: see src/index.ts) never persists a backtest
84+
* run at all, so the entire surface was empty while a complete, downloadable corpus sat behind the next
85+
* endpoint over.
86+
*
87+
* The commitment is resolved PER RULE, not once for the whole batch. Each rule's score is computed over its
88+
* own corpus, so stamping one checksum across every record would have every record but one committing to a
89+
* different rule's cases -- latent today only because a single rule clears the publication floor.
90+
*
7991
* Also returns an empty array when the run's checksum is {@link EMPTY_CORPUS_CHECKSUM}. A hash over zero
8092
* cases is the same 32 bytes for every rule, every window, and every deployment, so it points at nothing a
8193
* consumer could re-derive the scores from -- it is a placeholder commitment wearing a real hash's clothes,
@@ -90,14 +102,31 @@ async function finalizeRecord(input: EvalScoreRecordDigestInput): Promise<EvalSc
90102
* concept here, so `0` is the correct value, not a masked null). PURE -- no IO, no clock (the caller
91103
* supplies `issuedAt`).
92104
*/
93-
export async function buildEvalScoreRecordsFromRulePrecision(precision: PublicRulePrecision, issuedAt: string): Promise<EvalScoreRecord[]> {
94-
if (!precision.latestBacktestRun) return [];
95-
const { corpusChecksum } = precision.latestBacktestRun;
96-
if (corpusChecksum === EMPTY_CORPUS_CHECKSUM) return [];
105+
export async function buildEvalScoreRecordsFromRulePrecision(
106+
precision: PublicRulePrecision,
107+
issuedAt: string,
108+
// #9805: per-rule fallback commitments, supplied by the caller so this module stays PURE. Only rules whose
109+
// published corpus is a usable commitment belong in here -- the route drops empty and truncated ones before
110+
// building it, because a truncated corpus's checksum covers a subset of the cases the score covers.
111+
corpusChecksumByRuleId: ReadonlyMap<string, string> = new Map(),
112+
): Promise<EvalScoreRecord[]> {
113+
// A persisted backtest run still wins where one exists, so a deployment that executes reviews keeps exactly
114+
// today's behaviour and this change cannot silently move a self-host commitment.
115+
const runChecksum =
116+
precision.latestBacktestRun && precision.latestBacktestRun.corpusChecksum !== EMPTY_CORPUS_CHECKSUM
117+
? precision.latestBacktestRun.corpusChecksum
118+
: null;
97119
const windowStart = new Date(Date.parse(issuedAt) - precision.windowDays * 24 * 60 * 60 * 1000).toISOString();
98120

121+
// A rule with no usable commitment is OMITTED rather than published with a placeholder -- the #9215
122+
// requirement this module has always enforced, now applied per rule instead of to the whole batch.
123+
const publishable = precision.rules.flatMap((row) => {
124+
const corpusChecksum = runChecksum ?? corpusChecksumByRuleId.get(row.ruleId) ?? null;
125+
return corpusChecksum === null || corpusChecksum === EMPTY_CORPUS_CHECKSUM ? [] : [{ row, corpusChecksum }];
126+
});
127+
99128
const records = await Promise.all(
100-
precision.rules.map((row) =>
129+
publishable.map(({ row, corpusChecksum }) =>
101130
finalizeRecord({
102131
schemaVersion: EVAL_SCORE_RECORD_SCHEMA_VERSION,
103132
subject: { kind: "agent", id: ORB_GATE_SUBJECT_ID },

src/review/public-eval-corpus.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,18 @@
2929
import { buildBacktestCorpus } from "@loopover/engine/calibration/backtest-corpus";
3030
import { canonicalJson, sha256Hex } from "./decision-record";
3131
import { NON_ATTRIBUTABLE_OVERRIDE_PROVENANCES, PUBLIC_PRECISION_WINDOW_DAYS } from "./public-rule-precision";
32-
import { createSignalStore } from "./signal-tracking-wire";
32+
import { createSignalStore, MAX_RULE_HISTORY_LIMIT } from "./signal-tracking-wire";
3333

3434
/** Hard cap on published cases. The window is already bounded, but an unbounded array on an
3535
* unauthenticated route is a footgun the moment a rule gets noisy; a truncated corpus is reported
3636
* honestly via `truncated` rather than silently trimmed. */
37-
export const PUBLIC_EVAL_CORPUS_MAX_CASES = 5_000;
37+
// #9805: was 5_000, which could never bind. The corpus is built from a rule-history read that
38+
// listAuditEventsByType hard-clamps to MAX_RULE_HISTORY_LIMIT rows, so a cap above that ceiling is
39+
// unreachable and `truncated` was structurally always false -- while /v1/public/stats counts `decided` with
40+
// an UNBOUNDED SQL COUNT(*). The two surfaces therefore agreed only while the window stayed under the read
41+
// bound, and would have silently diverged after that: a complete-looking corpus serving a prefix of the
42+
// cases the published precision was computed over. Pinned to the real ceiling so the cap and the read agree.
43+
export const PUBLIC_EVAL_CORPUS_MAX_CASES = MAX_RULE_HISTORY_LIMIT;
3844

3945
/** One published case: a {@link BacktestCase} minus `targetKey`, with `metadata` narrowed to the single
4046
* key the shipped classifier reads. `metadata` is omitted entirely (never `undefined`) when the firing
@@ -124,8 +130,12 @@ export async function loadPublicEvalCorpus(env: Env, ruleId: string, nowMs: numb
124130
const sinceMs = nowMs - PUBLIC_PRECISION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
125131
let fired: Awaited<ReturnType<ReturnType<typeof createSignalStore>["queryRuleHistory"]>>["fired"] = [];
126132
let overrides: Awaited<ReturnType<ReturnType<typeof createSignalStore>["queryRuleHistory"]>>["overrides"] = [];
133+
// A read that came back at its bound almost certainly left rows behind, and a corpus that silently omits
134+
// them must say so -- committing a published score to a prefix, while claiming completeness, is exactly the
135+
// unverifiable-artifact problem this endpoint exists to solve.
136+
let saturated = false;
127137
try {
128-
({ fired, overrides } = await createSignalStore(env).queryRuleHistory(ruleId, sinceMs));
138+
({ fired, overrides, saturated } = await createSignalStore(env).queryRuleHistory(ruleId, sinceMs, MAX_RULE_HISTORY_LIMIT));
129139
} catch {
130140
// Fall through to an empty corpus rather than 500ing an unauthenticated route.
131141
}
@@ -144,8 +154,42 @@ export async function loadPublicEvalCorpus(env: Env, ruleId: string, nowMs: numb
144154
ruleId,
145155
windowDays: PUBLIC_PRECISION_WINDOW_DAYS,
146156
caseCount: cases.length,
147-
truncated,
157+
// Either bound truncates: the cap on the built cases, or the read that fed it. Reporting only the former
158+
// is what made this field always-false.
159+
truncated: truncated || saturated,
148160
checksum: await checksumPublicEvalCorpus(cases),
149161
cases,
150162
};
151163
}
164+
165+
/**
166+
* #9805: the publishable commitment for each of `ruleIds` -- the checksum of the corpus this deployment
167+
* serves at `/v1/public/eval-corpus?ruleId=...`, for rules whose corpus can actually back a claim.
168+
*
169+
* A rule is OMITTED (rather than mapped to a checksum a reader would be misled by) when:
170+
*
171+
* • the corpus is empty -- `checksumPublicEvalCorpus([])` is the same 32 bytes for every rule, every
172+
* window and every deployment, so it commits to nothing re-derivable. This is also where a failed read
173+
* lands, since loadPublicEvalCorpus degrades to an empty corpus rather than throwing a public route;
174+
* • the corpus is TRUNCATED at PUBLIC_EVAL_CORPUS_MAX_CASES -- the checksum would then cover a prefix of
175+
* the window while the record's `decided`/`confirmed` cover all of it. A reader who re-derived scores
176+
* from the published cases would get different numbers and reasonably conclude the published ones were
177+
* wrong. Omitting the record says "not committed"; publishing it would say something false.
178+
*
179+
* Sequential rather than Promise.all: each call is its own D1 read over a 90-day window, and the rule list
180+
* is the handful that clear the publication floor -- fanning them out buys nothing and makes the read
181+
* burst on an unauthenticated route.
182+
*/
183+
export async function buildPublicCorpusCommitments(
184+
env: Env,
185+
ruleIds: readonly string[],
186+
nowMs: number = Date.now(),
187+
): Promise<Map<string, string>> {
188+
const commitments = new Map<string, string>();
189+
for (const ruleId of ruleIds) {
190+
const corpus = await loadPublicEvalCorpus(env, ruleId, nowMs);
191+
if (corpus.caseCount === 0 || corpus.truncated) continue;
192+
commitments.set(ruleId, corpus.checksum);
193+
}
194+
return commitments;
195+
}

src/review/signal-tracking-wire.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ function toHumanOverrideEvent(ruleId: string, row: { targetKey: string | null; m
6969
* are NOT fail-open the same way: a read error propagates, since a caller computing a precision report needs
7070
* to know its input is incomplete rather than silently scoring against a partial (possibly empty) history.
7171
*/
72+
/** listAuditEventsByType's own default, restated so callers that do not pass a limit keep today's behaviour
73+
* explicitly rather than by inheritance (#9805). */
74+
export const DEFAULT_RULE_HISTORY_LIMIT = 500;
75+
76+
/** The most rows one queryRuleHistory read can return: listAuditEventsByType hard-clamps its limit to this,
77+
* so asking for more silently yields this many. Anything built on top of a rule-history read is bounded by
78+
* it, and a cap declared ABOVE it can never be the thing that actually truncates. */
79+
export const MAX_RULE_HISTORY_LIMIT = 2_000;
80+
7281
export function createSignalStore(env: Env): SignalStore {
7382
return {
7483
async recordRuleFired(event: RuleFiredEvent): Promise<void> {
@@ -93,15 +102,28 @@ export function createSignalStore(env: Env): SignalStore {
93102
createdAt: event.occurredAt || nowIso(),
94103
}).catch(() => undefined);
95104
},
96-
async queryRuleHistory(ruleId: string, sinceMs: number): Promise<{ fired: RuleFiredEvent[]; overrides: HumanOverrideEvent[] }> {
105+
// #9805: `limit` is explicit rather than left to listAuditEventsByType's default of 500. The published
106+
// corpus needs to know whether it saw the WHOLE window, and a caller that cannot choose the bound cannot
107+
// tell a complete read from a truncated one. Defaulted so every existing caller is byte-identical.
108+
//
109+
// `saturated` is the honest signal: the row count came back exactly at the bound, so there are almost
110+
// certainly more rows the caller did not see. It is deliberately not "did we hit MAX_CASES" -- the read
111+
// bound is what actually limits the corpus, and conflating the two is how /v1/public/eval-corpus came to
112+
// report `truncated: false` over a read it could not have completed.
113+
async queryRuleHistory(
114+
ruleId: string,
115+
sinceMs: number,
116+
limit: number = DEFAULT_RULE_HISTORY_LIMIT,
117+
): Promise<{ fired: RuleFiredEvent[]; overrides: HumanOverrideEvent[]; saturated: boolean }> {
97118
const sinceIso = new Date(sinceMs).toISOString();
98119
const [firedRows, overrideRows] = await Promise.all([
99-
listAuditEventsByType(env, ruleFiredEventType(ruleId), sinceIso),
100-
listAuditEventsByType(env, humanOverrideEventType(ruleId), sinceIso),
120+
listAuditEventsByType(env, ruleFiredEventType(ruleId), sinceIso, limit),
121+
listAuditEventsByType(env, humanOverrideEventType(ruleId), sinceIso, limit),
101122
]);
102123
return {
103124
fired: firedRows.map((row) => toRuleFiredEvent(ruleId, row)),
104125
overrides: overrideRows.map((row) => toHumanOverrideEvent(ruleId, row)),
126+
saturated: firedRows.length >= limit || overrideRows.length >= limit,
105127
};
106128
},
107129
};

0 commit comments

Comments
 (0)