Skip to content

fix(review): correct risk-control calibration and the published guarantee's fleet integrity - #9228

Merged
JSONbored merged 4 commits into
mainfrom
fix/9048-9050-9066-9068-risk-control-guarantee
Jul 27, 2026
Merged

fix(review): correct risk-control calibration and the published guarantee's fleet integrity#9228
JSONbored merged 4 commits into
mainfrom
fix/9048-9050-9066-9068-risk-control-guarantee

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Four related fixes to the published statistical guarantee subsystem:

Test plan

Closes #9048
Closes #9050
Closes #9066
Closes #9068

…calibration delta (#9048, #9066)

calibrateActThreshold returned "insufficient_labels" from two branches with different
"have" semantics: a genuine label shortfall (have = total pairs) and a residual
high-confidence stratum too small to certify despite ample total labels (have = that
stratum's size). The latter now returns a distinct "no_certifiable_threshold" status
carrying totalPairs/bestN/bestLambda/bestUpperBound, rendered through its own message
and a distinct risk_control_no_certifiable_threshold audit event so the label
burn-down no longer conflates "needs labels" with "needs a better error rate".

Separately, the ascending-lambda scan reported whichever of K observed-confidence
candidates passed first without correcting for testing K of them — the advertised
1-delta confidence overstated what the scan actually delivered. Each candidate is now
tested at a Bonferroni-split delta/K, so the certified lambda is valid at the full,
originally-advertised delta regardless of which candidate passes. Chosen over a true
fixed-sequence rewrite as the smaller, safer diff against the existing ascending scan.

Also adds AND dr2.action = dal.verdict to the calibration join (latent-risk hardening:
today's data is not mis-joined, but a later HOLD/MERGE record on the same PR could
otherwise shadow the acted CLOSE record a label adjudicates) and tags each calibration
pair's provenance (backfilled vs live, via the backfill's configDigest sentinel) so a
published guarantee can later say how much of its evidence is reconstructed history.
…ction, pool decisionAccuracy (#9068)

orb_signals ingest stored any object-shaped risk_control payload verbatim, so a
registered instance with a stale or misconfigured alpha (or an outright refused
calibration) could reach the public guarantee unchecked. handleOrbIngest now runs
the payload through validateCalibrationPayload (status === "calibrated", alpha/
lambda/coverage in range, nAtLambda clearing the zero-error floor for its own
alpha/delta) before it reaches orb_risk_control_arms at all.

gamingPatternFlags compared each eligible instance against the fleet median, which
is structurally unfireable below 3 eligible instances (an instance IS the median at
n=1) and can never flag "low reversal" once the fleet's own reversal-rate median is
exactly 0 (a common, healthy-fleet case) since a fraction of zero can never be
undercut. computeFleetAnalytics now gates detection on eligible.length >= 3
(surfaced via the new gamingDetectionEligible field) and falls back to an absolute
reversal-rate floor when the fleet median is zero.

fleet.decisionAccuracy published the per-instance MEDIAN while accuracyCiPct
(public-stats.ts) is a Wilson interval over the POOLED counts — different
estimands that only coincide at equal per-instance volumes. fleet.decisionAccuracy
now publishes the pooled proportion directly (the same population the interval
describes); the per-instance median survives as the new decisionAccuracyMedian
diagnostic field.

The underlying per-instance last-writer-wins fleet-key bug this issue also
describes was already fixed by #9177 (orb_risk_control_arms is keyed per
instance_id/arm and public-stats already aggregates across registered instances at
read time) — this change covers the remaining validation-before-publish and
detection-floor gaps.
…date it before serving (#9050)

readGuarantee published "coveragePct" as if it were a share of all closes, adjacent
to a different fleetAccuracy.coveragePct that IS a share of all decided signals --
one word, two denominators. The guarantee's own field is actually the share of the
arm's AI-JUDGED sub-population the threshold covers (loadCalibrationPairs can only
join a confidence to decisions an AI-judgment blocker ran on, a minority of real
closes). Renamed to aiJudgedCoveragePct and the homepage string now names the
sub-population explicitly instead of leaving a bare percentage next to its sibling.

readGuarantee also now re-validates every stored orb_risk_control_arms row through
risk-control.ts's validateCalibrationPayload (defense in depth alongside the
ingest-side check from the companion #9068 fix) and walks all registered rows for
an arm in nAtLambda-descending order instead of trusting only the top one, so a
single malformed or stale peer can no longer hide a good row behind it.

Each calibration pair now carries whether it's backfilled (the 2026-07
calibration-corpus backfill's configDigest sentinel) or live; calibrateActThreshold
surfaces the split as backfilledPairs, and the public guarantee renders it as
backfilledPct so a guarantee resting mostly on reconstructed history says so.

Regenerated apps/loopover-ui/public/openapi.json for the schema rename/nullability.
…ions

npm run ui:lint's format:check caught unformatted lines from the #9050 fleetAccuracy.guaranteed rename in proof-of-power-stats-model.ts and its new test.
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
loopover-ui b89464d Commit Preview URL

Branch Preview URL
Jul 27 2026, 08:06 AM

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 952 bytes (0.01%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
loopover-ui 7.43MB 952 bytes (0.01%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: loopover-ui

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/add-scalar-classes-C7lyb3Lm.js (New) 2.17MB 2.17MB 100.0% 🚀
assets/tanstack-vendor-CU40gYNJ.js (New) 804.04kB 804.04kB 100.0% 🚀
openapi.json 442 bytes 532.1kB 0.08%
assets/docs.fumadocs-spike-api-reference-rSxjBJgD.js (New) 442.88kB 442.88kB 100.0% 🚀
assets/AgentScalarChatInterface.vue-BQzDzcQl.js (New) 201.71kB 201.71kB 100.0% 🚀
assets/modal-CcbYYGKq.js (New) 184.39kB 184.39kB 100.0% 🚀
assets/client-B6J6msoT.js (New) 146.06kB 146.06kB 100.0% 🚀
assets/maintainer-panel-C1ixBXGW.js (New) 79.0kB 79.0kB 100.0% 🚀
assets/routes-Q7O2gpEI.js (New) 35.96kB 35.96kB 100.0% 🚀
assets/owner-panel-ChXpQI-h.js (New) 27.52kB 27.52kB 100.0% 🚀
assets/app-DDj7pzTG.js (New) 25.78kB 25.78kB 100.0% 🚀
assets/ui-vendor-azmdXeTB.js (New) 22.28kB 22.28kB 100.0% 🚀
assets/miner-panel-CU3d3RIa.js (New) 20.24kB 20.24kB 100.0% 🚀
assets/app.runs-CVI9vMG0.js (New) 20.22kB 20.22kB 100.0% 🚀
assets/api._op-CsmVsrdB.js (New) 17.57kB 17.57kB 100.0% 🚀
assets/self-hosting-docs-audit-CskkxX_i.js (New) 16.6kB 16.6kB 100.0% 🚀
assets/docs._slug-B5ulGkWb.js (New) 15.37kB 15.37kB 100.0% 🚀
assets/playground-panel-BREt4XE5.js (New) 14.43kB 14.43kB 100.0% 🚀
assets/fairness-CcDfl1Sc.js (New) 10.73kB 10.73kB 100.0% 🚀
assets/app.audit-BKiMJEVB.js (New) 10.08kB 10.08kB 100.0% 🚀
assets/app.config-generator-D573cG3S.js (New) 10.06kB 10.06kB 100.0% 🚀
assets/maintainers-41DNlhdm.js (New) 8.06kB 8.06kB 100.0% 🚀
assets/miners-BBsxtfut.js (New) 7.91kB 7.91kB 100.0% 🚀
assets/agents-C-CEBvQU.js (New) 7.74kB 7.74kB 100.0% 🚀
assets/commands-panel-CrmyLCUk.js (New) 6.65kB 6.65kB 100.0% 🚀
assets/maintainer-workflow-Dns62Sky.js (New) 6.52kB 6.52kB 100.0% 🚀
assets/digest-panel-BpYRl76k.js (New) 6.15kB 6.15kB 100.0% 🚀
assets/repos._owner._repo.quality-CsUH3Z9s.js (New) 6.14kB 6.14kB 100.0% 🚀
assets/docs-nav-CLSqC3NK.js (New) 5.95kB 5.95kB 100.0% 🚀
assets/docs.index-C6GZnSYw.js (New) 5.95kB 5.95kB 100.0% 🚀
assets/api.index-9UDI4hEo.js (New) 4.7kB 4.7kB 100.0% 🚀
assets/docs-Bf8emTRf.js (New) 2.7kB 2.7kB 100.0% 🚀
assets/api-BijUoLLs.js (New) 2.69kB 2.69kB 100.0% 🚀
assets/docs-page-DUH_t2T8.js (New) 2.1kB 2.1kB 100.0% 🚀
assets/table-BeeIcXFZ.js (New) 1.75kB 1.75kB 100.0% 🚀
assets/app.workbench-DNSrCRg3.js (New) 1.58kB 1.58kB 100.0% 🚀
assets/tabs-DGQQYHWk.js (New) 1.39kB 1.39kB 100.0% 🚀
assets/app.repos-CSKBhYgT.js (New) 1.07kB 1.07kB 100.0% 🚀
assets/input-Av4nSpuF.js (New) 796 bytes 796 bytes 100.0% 🚀
assets/file-cog-C_obDxP7.js (New) 758 bytes 758 bytes 100.0% 🚀
assets/app.maintainer-amI3totg.js (New) 502 bytes 502 bytes 100.0% 🚀
assets/app.owner-BeIL8gMB.js (New) 474 bytes 474 bytes 100.0% 🚀
assets/app.commands-C9roQDJc.js (New) 455 bytes 455 bytes 100.0% 🚀
assets/app.playground-DuXF6ZLb.js (New) 442 bytes 442 bytes 100.0% 🚀
assets/index-xZ8eROcE.js (New) 438 bytes 438 bytes 100.0% 🚀
assets/app.digest-C0NBhMyF.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/eye-off-B1Px2r2P.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/app.miner-BjF0xmUT.js (New) 422 bytes 422 bytes 100.0% 🚀
assets/key-round-B9xcTrqI.js (New) 355 bytes 355 bytes 100.0% 🚀
assets/bot-C2CCRb5z.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/trash-2-DgYL1ub5.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/save-DsLSESc9.js (New) 327 bytes 327 bytes 100.0% 🚀
assets/git-pull-request-arrow-MGihYq-8.js (New) 321 bytes 321 bytes 100.0% 🚀
assets/list-checks-C7FHMEFQ.js (New) 279 bytes 279 bytes 100.0% 🚀
assets/compass-BB6r6fuI.js (New) 251 bytes 251 bytes 100.0% 🚀
assets/history-BgyxzvBS.js (New) 237 bytes 237 bytes 100.0% 🚀
assets/message-square--Y7cspS1.js (New) 233 bytes 233 bytes 100.0% 🚀
assets/lock-PDAGuBk_.js (New) 206 bytes 206 bytes 100.0% 🚀
assets/rotate-cw-iG8QjHTE.js (New) 201 bytes 201 bytes 100.0% 🚀
assets/play-CSGA7_0g.js (New) 190 bytes 190 bytes 100.0% 🚀
assets/circle-check-Cok123j4.js (New) 178 bytes 178 bytes 100.0% 🚀
assets/search-C9SRST_o.js (New) 174 bytes 174 bytes 100.0% 🚀
assets/add-scalar-classes-Cf9AbH3o.js (Deleted) -2.17MB 0 bytes -100.0% 🗑️
assets/tanstack-vendor-AP0TueuY.js (Deleted) -803.86kB 0 bytes -100.0% 🗑️
assets/docs.fumadocs-spike-api-reference-Dy-83ekW.js (Deleted) -442.88kB 0 bytes -100.0% 🗑️
assets/AgentScalarChatInterface.vue-B71bsT07.js (Deleted) -201.71kB 0 bytes -100.0% 🗑️
assets/modal-DDGlOYav.js (Deleted) -184.39kB 0 bytes -100.0% 🗑️
assets/client-CWIGjSRg.js (Deleted) -146.06kB 0 bytes -100.0% 🗑️
assets/maintainer-panel-QeJ45iks.js (Deleted) -79.0kB 0 bytes -100.0% 🗑️
assets/routes-99LpxxrV.js (Deleted) -35.77kB 0 bytes -100.0% 🗑️
assets/owner-panel-BlvSERyf.js (Deleted) -27.52kB 0 bytes -100.0% 🗑️
assets/app-BymTp6KX.js (Deleted) -25.78kB 0 bytes -100.0% 🗑️
assets/ui-vendor-iVpPzx--.js (Deleted) -22.28kB 0 bytes -100.0% 🗑️
assets/miner-panel-CQznKIL6.js (Deleted) -20.24kB 0 bytes -100.0% 🗑️
assets/app.runs-Dh6jXJqL.js (Deleted) -20.22kB 0 bytes -100.0% 🗑️
assets/api._op-DE1BL7kT.js (Deleted) -17.57kB 0 bytes -100.0% 🗑️
assets/self-hosting-docs-audit-BC34eLhy.js (Deleted) -16.6kB 0 bytes -100.0% 🗑️
assets/docs._slug-Be3UwUYx.js (Deleted) -15.37kB 0 bytes -100.0% 🗑️
assets/playground-panel-WTEQ2MdO.js (Deleted) -14.43kB 0 bytes -100.0% 🗑️
assets/fairness-D9gK2DgW.js (Deleted) -10.6kB 0 bytes -100.0% 🗑️
assets/app.audit-_CqsIBKu.js (Deleted) -10.08kB 0 bytes -100.0% 🗑️
assets/app.config-generator-DvSIGuYM.js (Deleted) -10.06kB 0 bytes -100.0% 🗑️
assets/maintainers-DkxBcxuc.js (Deleted) -8.06kB 0 bytes -100.0% 🗑️
assets/miners-BmceDscj.js (Deleted) -7.91kB 0 bytes -100.0% 🗑️
assets/agents-kSVMY__v.js (Deleted) -7.74kB 0 bytes -100.0% 🗑️
assets/commands-panel-Canca6sX.js (Deleted) -6.65kB 0 bytes -100.0% 🗑️
assets/maintainer-workflow-DL7M2qOg.js (Deleted) -6.52kB 0 bytes -100.0% 🗑️
assets/digest-panel-C11cw9oy.js (Deleted) -6.15kB 0 bytes -100.0% 🗑️
assets/repos._owner._repo.quality-BTq-K_VF.js (Deleted) -6.14kB 0 bytes -100.0% 🗑️
assets/docs-nav-UEHgM9kT.js (Deleted) -5.95kB 0 bytes -100.0% 🗑️
assets/docs.index-D7pTVrSg.js (Deleted) -5.95kB 0 bytes -100.0% 🗑️
assets/api.index-E1oJfyBh.js (Deleted) -4.7kB 0 bytes -100.0% 🗑️
assets/docs-BRIu7j4W.js (Deleted) -2.7kB 0 bytes -100.0% 🗑️
assets/api-Dmn22gPI.js (Deleted) -2.69kB 0 bytes -100.0% 🗑️
assets/docs-page-CY2UJeu6.js (Deleted) -2.1kB 0 bytes -100.0% 🗑️
assets/table-OGI4S3_W.js (Deleted) -1.75kB 0 bytes -100.0% 🗑️
assets/app.workbench-CA6183eh.js (Deleted) -1.58kB 0 bytes -100.0% 🗑️
assets/tabs-CJWVgTGA.js (Deleted) -1.39kB 0 bytes -100.0% 🗑️
assets/app.repos-B1E9fn9Y.js (Deleted) -1.07kB 0 bytes -100.0% 🗑️
assets/input-qqx3U2Bi.js (Deleted) -796 bytes 0 bytes -100.0% 🗑️
assets/file-cog-CiLvefOA.js (Deleted) -758 bytes 0 bytes -100.0% 🗑️
assets/app.maintainer-Cb1p7aWP.js (Deleted) -502 bytes 0 bytes -100.0% 🗑️
assets/app.owner-B9kBQyW1.js (Deleted) -474 bytes 0 bytes -100.0% 🗑️
assets/app.commands-B7Xj69cD.js (Deleted) -455 bytes 0 bytes -100.0% 🗑️
assets/app.playground-BDdO0DHa.js (Deleted) -442 bytes 0 bytes -100.0% 🗑️
assets/index-BQ1D46A_.js (Deleted) -438 bytes 0 bytes -100.0% 🗑️
assets/app.digest-Bpza_47A.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/eye-off-toGLBRxx.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/app.miner--TtLeI8t.js (Deleted) -422 bytes 0 bytes -100.0% 🗑️
assets/key-round-wjnZCqYM.js (Deleted) -355 bytes 0 bytes -100.0% 🗑️
assets/bot-Cm_O3LFj.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/trash-2-DYixLXkU.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/save-CPfYPT46.js (Deleted) -327 bytes 0 bytes -100.0% 🗑️
assets/git-pull-request-arrow-BsG0FOqb.js (Deleted) -321 bytes 0 bytes -100.0% 🗑️
assets/list-checks-t55NB8Dm.js (Deleted) -279 bytes 0 bytes -100.0% 🗑️
assets/compass-Big9PXPb.js (Deleted) -251 bytes 0 bytes -100.0% 🗑️
assets/history-P2nDU39B.js (Deleted) -237 bytes 0 bytes -100.0% 🗑️
assets/message-square-BC7gEn_U.js (Deleted) -233 bytes 0 bytes -100.0% 🗑️
assets/lock-B3mwZfV4.js (Deleted) -206 bytes 0 bytes -100.0% 🗑️
assets/rotate-cw-Bj-8zF9o.js (Deleted) -201 bytes 0 bytes -100.0% 🗑️
assets/play-DoJAJzxk.js (Deleted) -190 bytes 0 bytes -100.0% 🗑️
assets/circle-check-ChQ0O352.js (Deleted) -178 bytes 0 bytes -100.0% 🗑️
assets/search-Ccl-znKw.js (Deleted) -174 bytes 0 bytes -100.0% 🗑️

@JSONbored
JSONbored merged commit 194fa65 into main Jul 27, 2026
7 of 8 checks passed
@JSONbored
JSONbored deleted the fix/9048-9050-9066-9068-risk-control-guarantee branch July 27, 2026 08:09
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

❌ 4 Tests Failed:

Tests completed Failed Passed Skipped
22240 4 22236 21
View the top 3 failed test(s) by shortest run time
test/integration/orb-relay.test.ts > POST /v1/orb/relay/pull > REGRESSION (#4995): a DB error inside validateOrbRelayEnrollment's own lookup ALSO returns a clean 503 broker_error, not a framework 500 (the earlier of the two DB-touching calls in this handler)
Stack Traces | 0.00569s run time
AssertionError: expected 500 to be 503 // Object.is equality

- Expected
+ Received

- 503
+ 500

 ❯ test/integration/orb-relay.test.ts:1160:24
test/integration/orb-onboarding.test.ts > Central Orb installation registry routes (/v1/internal/orb/installations) > tolerates a list query that omits results (rows.results ?? [])
Stack Traces | 0.0141s run time
SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON
 ❯ test/integration/orb-onboarding.test.ts:57:14
test/integration/orb-ingest.test.ts > Orb instance registry routes (/v1/internal/orb/instances) > tolerates a list query that omits results (rows.results ?? [])
Stack Traces | 0.015s run time
SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON
 ❯ test/integration/orb-ingest.test.ts:427:14
test/integration/orb-relay.test.ts > POST /v1/orb/relay/register > REGRESSION (#4995): a DB error inside validateOrbRelayEnrollment's own lookup ALSO returns a clean 503 broker_error, not a framework 500 (the earlier of the two DB-touching calls in this handler)
Stack Traces | 0.0717s run time
AssertionError: expected 500 to be 503 // Object.is equality

- Expected
+ Received

- 503
+ 500

 ❯ test/integration/orb-relay.test.ts:155:24

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

loopover-orb Bot pushed a commit that referenced this pull request Jul 28, 2026
…thhold the pooled count at n=2 (#9168) (#9574)

`fleetAccuracy` had a per-instance volume floor (MIN_DECIDED) but nothing bounding
the NUMBER of instances. At the one registered instance live today it publishes one
operator's own outcomes under fleet framing, next to a risk-control guarantee
calibrated by that same instance -- which invites a reader to treat one party's
self-report as two independent sources.

#9228 already fixed the detector half of this issue (gamingFlagsCaught is null, not
0, below GAMING_MIN_ELIGIBLE). This is the framing half, which did not land.

WHAT CHANGES

- FLEET_FRAMING_MIN_INSTANCES, deliberately defined AS GAMING_MIN_ELIGIBLE: the n at
  which a median becomes robust to a single bad contributor is the same n at which
  "this far above the median" becomes satisfiable. Pinning them together means a
  reader never sees fleet framing next to "the detector could not run".
- A `basis` discriminator on the published block: "fleet" or
  "single_instance_self_report". The numbers are real and stay published at every n --
  only the claim about what they are changes.
- The pooled `decidedCount` is withheld (null) at exactly 1 < instanceCount < floor.
  The count is a plain SUM, and this deployment's own volume is already public via
  byProject, so at n=2 a reader recovers the OTHER instance's decision volume by
  subtraction. For a hosted tenant that volume is a business metric -- how many PRs
  they ship, how many get closed -- and not ours to publish. At n=1 there is nothing
  to subtract; at n >= floor the sum no longer isolates anyone. RATES are safe at
  every n, since a proportion carries no volume, so only the count is withheld.
- The median-robustness comment in analytics.ts claimed robustness unconditionally.
  Corrected to state the n it actually requires, since that claim was the reason the
  median was chosen and it was doing no work at n=1.
- The DB-failure fallback fails closed (fleetFramingEligible: false) with the rest of
  that path -- if the fleet tables cannot be read we certainly cannot claim a fleet.

The OpenAPI schema is a hand-maintained zod mirror, so `basis` and the now-nullable
`decidedCount` are added there too, and apps/loopover-ui/public/openapi.json
regenerated -- otherwise the spec silently drifts from the response it documents.

NOT DECIDED HERE: whether a hosted tenant's outcomes should enter the public
aggregate at all, and if so opt-in or opt-out. That is a product/privacy call, not a
code one. This change is safe under either answer -- it withholds the leaky figure by
default rather than presuming consent -- and the issue records the question.

Tests: the outlier and gaming thresholds had never been exercised against
multi-instance fixtures, which #9168 called out. Now covered at n=1, n=2, n=3, with
the eligible-vs-registered distinction (volume and registration both gate the floor)
and the fail-closed path. 0 uncovered changed lines or branches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment