@trillboards/edge-sdk 0.2.106 → 0.2.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,959 @@
1
+ #!/usr/bin/env python3
2
+ """Re-adjudicate every screened detector against a VENUE-MATCHED negative set.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ The 2026-08-25 screen scored 96 artifacts across 35 capabilities against 1,500
7
+ "production" frames. 1,494 of those 1,500 came from one screen type,
8
+ `taxi_rideshare_tv` -- rideshare cabin interiors, 44% of them darker than mean
9
+ pixel 50. Its own report says three times "publishable pending a venue-matched
10
+ negative set". So every "no model clears the bar" verdict and every published
11
+ model's operating threshold describes a car at night, and we sell a CCTV product.
12
+
13
+ The negative set here is the Phase-3 quiet-hour corpus: real fixed cameras in an
14
+ industrial logistics yard, a hospital hand-wash basin, a big-box retail aisle and
15
+ an office/lab room. The positive controls are unchanged -- only the negative half
16
+ of every number moves, which is exactly the variable under test.
17
+
18
+ THE THREE RULES THIS FILE ENFORCES
19
+ ---------------------------------
20
+ 1. "Quiet" is UNEVENTFUL, not EMPTY. The industrial footage contains people
21
+ working and a running forklift. A forklift frame is a POSITIVE for forklift and
22
+ a negative only for other classes, so validity is decided PER CLASS PER BUCKET
23
+ and a rate on an invalid cell is reported as a FIRING rate, never a false-alarm
24
+ rate. Where `scripts/sensing/quiet-hour-fleet-plan.json` declares a class, that
25
+ declaration wins; everything else falls back to the hand grading, and a class
26
+ with neither is reported `no_absence_declaration`.
27
+ 2. Never blended. Every cell carries its own bucket, its own n and its own exact
28
+ Clopper-Pearson interval. A pooled rate over four scene types describes no
29
+ promise we sell. Each cell ALSO carries a box-level count, because
30
+ Clopper-Pearson assumes independent trials and the retail bucket violates that
31
+ at 0.22 s between frames -- `source_span_hours` in the composition is what
32
+ stops a frame count being read as an independence claim.
33
+
34
+ 3. A VERDICT RESTS ON A CONTROL THAT CAN RATE A CLASS. Every positive control
35
+ carries a contact-sheet grade, and two of those grades -- `good_small_n` and
36
+ `weak` -- exist precisely to say "this set can show the model is alive, it
37
+ cannot tell you how often it is right". Those sets are still scored and still
38
+ reported; what they may not do is decide. The selector used to be
39
+ `argmax(rate_at_conf_0.25)` over every control, which has no access to the
40
+ grade and therefore reliably picks whichever set flatters the model -- and the
41
+ smallest set flatters most, because five frames cannot disagree with much.
42
+ On 2026-08-26 that awarded `fire` and `smoke` PUBLISH on `fire_generic` (n=5,
43
+ `good_small_n`, "n=5 so only a coarse liveness signal") while `fire_cctv`
44
+ (n=39, `good`, "realistic scale for a venue camera") was scored in the same
45
+ run and said 41.0% and 46.2% -- both under this file's own 50% recall floor.
46
+ Note that `RECALL_RETENTION_BAR` is an ABSOLUTE floor on recall at the
47
+ operating threshold despite its name; it is not a ratio.
48
+
49
+ SCORED AS A CASCADE GATE, NOT A DETECTOR. The ranking inverts: what matters is
50
+ calls removed against events lost at the production empty-frame prior, not raw
51
+ precision. `cctv-detector-tier.md` is the authority on why.
52
+ """
53
+ from __future__ import annotations
54
+
55
+ import argparse
56
+ import glob
57
+ import json
58
+ import math
59
+ import os
60
+ from collections import Counter
61
+
62
+ import numpy as np
63
+
64
+ # --- sellable capability -> the class names that would carry it ---------------
65
+ # Verbatim from the 2026-08-25 screen so the two adjudications are comparable;
66
+ # changing it would make the before/after a different question.
67
+ CAPS = {
68
+ "hard hat present": ["helmet", "hardhat", "hard_hat", "hat", "Helmet", "Hardhat", "Hard_hat"],
69
+ "hard hat ABSENT": ["no-helmet", "no_helmet", "no helmet", "no-hardhat", "nohat", "No-Helmet", "NO-Hardhat", "No_Helmet"],
70
+ "hi-vis vest present": ["vest", "safety vest", "safety_vest", "safety-vest", "Vest", "Safety Vest"],
71
+ "hi-vis vest ABSENT": ["no-vest", "no vest", "no_vest", "novest", "no-safety vest", "no_safety_vest", "No-Vest", "NO-Safety Vest"],
72
+ "face mask present": ["mask", "Mask", "with_mask", "face-mask-medical"],
73
+ "face mask ABSENT": ["no-mask", "no_mask", "without_mask", "NO-Mask", "No-Mask"],
74
+ "gloves present": ["gloves", "glove", "Glove", "Gloves", "glove_hand", "gloved_hand"],
75
+ "gloves ABSENT": ["no-gloves", "no-glove", "no_glove", "No-Glove", "bare_hand"],
76
+ "goggles / eye pro": ["goggles", "glass", "glasses", "Glass", "Goggles"],
77
+ "safety boots": ["boots", "Boots", "safety shoes", "safety-boot", "shoes"],
78
+ "apron": ["Apron", "apron"],
79
+ "hairnet": ["Hairnet", "hairnet"],
80
+ "fire": ["fire", "Fire"],
81
+ "smoke": ["smoke", "Smoke"],
82
+ "smoking (cigarette)": ["Cigarette", "cigarette", "smoking"],
83
+ "gun": ["gun", "Gun", "pistol", "handgun", "rifle", "shotgun", "Pistol", "Handgun", "Shotgun", "firearm"],
84
+ "knife": ["knife", "Knife"],
85
+ "weapon (generic)": ["weapon", "Weapon"],
86
+ "person down / fall": ["fall", "Fall", "fallen", "Fallen", "fall-detected", "Fall-Detected", "down"],
87
+ "forklift": ["forklift", "Forklift", "forklift-partial", "Forklift-Partial", "Yoto", "yoto"],
88
+ "heavy machinery": ["machinery", "machine", "excavator", "excavators", "wheel loader", "wheel_loader",
89
+ "dozer", "backhoe_loader", "mobile_crane", "tower_crane", "compactor",
90
+ "concrete_mixer_truck", "grader", "dump truck", "dump_truck"],
91
+ "pallet": ["pallet", "Pallet"],
92
+ "person / head count": ["person", "Person", "human", "Human", "head", "Head", "worker", "Worker"],
93
+ "vehicle / queue": ["vehicle", "vehicle queue", "Vehicle"],
94
+ "shopping cart": ["cart", "Cart", "trolley", "shopping_cart"],
95
+ "spill": ["Spill", "spill"],
96
+ "shoplifting / theft": ["shoplifting", "theft", "Shoplifting", "Theft"],
97
+ "door": ["door", "Door"],
98
+ "window": ["window", "Window"],
99
+ "phone use": ["phone", "phone_call", "Phone", "cell phone"],
100
+ # `mouse` is carried from the 2026-08-25 table so the two adjudications stay
101
+ # comparable, and it is SEMANTICALLY WRONG for COCO: category 74 is a COMPUTER
102
+ # mouse. It cannot win here -- it would have to clear >=50% recall on n_rodent,
103
+ # a set of rodent photographs -- but if a COCO-vocabulary model ever takes the
104
+ # `rodent` row, that is the reason and the row is void.
105
+ "rodent": ["mouse", "Mouse", "rat", "rodent"],
106
+ "shelf gap / OOS": ["void", "Void", "gap", "empty"],
107
+ "traffic cone": ["safety cone", "Safety Cone", "safety_cone"],
108
+ "fire extinguisher": ["fire_extinguisher", "Fire_Extinguisher"],
109
+ "ladder": ["ladder", "Ladder"],
110
+ }
111
+
112
+ # --- class name -> positive-control set(s) that should make that class fire ----
113
+ # 'PROD' is dropped: it meant the 1,500 rideshare frames, and a positive control
114
+ # drawn from the domain under test is exactly the confound this run removes.
115
+ CLASS_CONTROL = {
116
+ # `fire_firesense` (72 frames / 9 scenes) and `smoke_firesense` (78 / 10) are the
117
+ # sets that can rate these classes; `fire_cctv` (39) is kept because it is the
118
+ # only other in-domain CCTV set, and `fire_generic` (5, `good_small_n`) is kept
119
+ # so its number stays VISIBLE in `per_control` — it is the set that carried
120
+ # both PUBLISH verdicts on 2026-08-26 and the reader should see what it said.
121
+ "fire": ["fire_firesense", "fire_cctv", "fire_generic"],
122
+ "smoke": ["smoke_firesense", "fire_cctv", "fire_generic"],
123
+ "helmet": ["helmet", "n_heavy"], "hardhat": ["helmet", "n_heavy"], "hard_hat": ["helmet", "n_heavy"],
124
+ "hat": ["helmet", "ppe_full"],
125
+ "vest": ["helmet", "n_heavy", "n_cone", "ppe_full"], "safety vest": ["helmet", "n_heavy", "n_cone", "ppe_full"],
126
+ "safety_vest": ["helmet", "n_heavy", "n_cone", "ppe_full"], "safety-vest": ["helmet", "n_heavy", "n_cone", "ppe_full"],
127
+ "mask": ["ppe_full"], "with_mask": ["ppe_full"], "face-mask-medical": ["ppe_full"],
128
+ "goggles": ["ppe_full"], "glass": ["ppe_full"], "glasses": ["ppe_full"],
129
+ "boots": ["ppe_full"], "safety shoes": ["ppe_full"], "safety-boot": ["ppe_full"], "shoes": ["ppe_full"],
130
+ "gloves": ["ppe_full", "wc_glove"], "glove": ["ppe_full", "wc_glove"], "glove_hand": ["wc_glove", "ppe_full"],
131
+ "apron": ["wc_apron"], "Apron": ["wc_apron"], "hairnet": ["n_hairnet"], "Hairnet": ["n_hairnet"],
132
+ "safety cone": ["n_cone"], "safety_cone": ["n_cone"],
133
+ "fire_extinguisher": ["n_extinguisher"], "ladder": ["n_ladder"],
134
+ "person": ["helmet", "ppe_full", "n_crowd"], "worker": ["ppe_full", "helmet", "n_crowd"],
135
+ "human": ["helmet", "ppe_full", "n_crowd"], "head": ["helmet", "n_crowd"],
136
+ "forklift": ["forklift"], "forklift-partial": ["forklift"], "Forklift-Partial": ["forklift"], "yoto": ["forklift"],
137
+ "pallet": ["n_pallet"], "machinery": ["n_heavy"], "machine": ["n_heavy"],
138
+ "excavator": ["n_heavy"], "excavators": ["n_heavy"], "dozer": ["n_heavy"],
139
+ "wheel loader": ["n_heavy"], "wheel_loader": ["n_heavy"], "backhoe_loader": ["n_heavy"],
140
+ "mobile_crane": ["n_heavy"], "tower_crane": ["n_heavy"], "compactor": ["n_heavy"],
141
+ "concrete_mixer_truck": ["n_heavy"], "grader": ["n_heavy"], "dump truck": ["n_heavy"], "dump_truck": ["n_heavy"],
142
+ "vehicle": ["n_traffic"], "vehicle queue": ["n_traffic"],
143
+ "gun": ["gun_clean", "weapon_cctv"], "pistol": ["gun_clean", "weapon_cctv"], "handgun": ["gun_clean", "weapon_cctv"],
144
+ "rifle": ["gun_clean"], "shotgun": ["gun_clean"], "weapon": ["weapon_cctv", "gun_clean"],
145
+ "firearm": ["gun_clean", "weapon_cctv"], "knife": ["knife_cctv", "weapon_cctv", "n_knife"],
146
+ "fall": ["fall_cctv"], "fallen": ["fall_cctv"], "fall-detected": ["fall_cctv"],
147
+ "Fall-Detected": ["fall_cctv"], "down": ["fall_cctv"],
148
+ "door": ["n_door"], "window": ["n_window"], "phone": ["n_phone"], "cell phone": ["n_phone"],
149
+ "phone_call": ["n_phone"], "mouse": ["n_rodent"], "void": ["n_shelfgap"],
150
+ "cart": ["wc_cart", "n_trolley"], "trolley": ["wc_cart", "n_trolley"], "shopping_cart": ["wc_cart", "n_trolley"],
151
+ "spill": ["wc_spill", "n_spill"], "Spill": ["wc_spill", "n_spill"],
152
+ "cigarette": ["n_smoking"], "Cigarette": ["n_smoking"], "smoking": ["n_smoking"],
153
+ }
154
+
155
+ #: Contact-sheet grades that forbid CITING a set at all. `wc_spill` is graded
156
+ #: CONTAMINATED -- "search pulled 'oil spill' -- an overturned truck -- a cathedral
157
+ #: corridor, building exteriors and abstract cream textures" -- and citing it would
158
+ #: have given `spill` a recall number off a set that mostly does not contain one.
159
+ UNUSABLE_GRADES = ("DISCARDED", "POOR", "CONTAMINATED")
160
+
161
+ #: Grades whose OWN note says the set cannot rate a class. These are not unusable:
162
+ #: they are real frames, they are scored, and every one of them is reported in
163
+ #: `per_control`. What they may not be is the set a PUBLISH rests on.
164
+ #:
165
+ #: MODULE SCOPE ON PURPOSE. `check-positive-control-grade.js` refuses the same
166
+ #: grades and its fixture test reads this tuple out of this file, so a guard and a
167
+ #: tool that disagreed would let a file through that the other would never emit.
168
+ #: While these lived inside `main()` nothing could assert on them from either side.
169
+ LIVENESS_ONLY_GRADES = ("good_small_n", "weak")
170
+
171
+ #: Where the dated grade files live, relative to the repo root. Derived from this
172
+ #: file's own location so the tool has no cwd dependency.
173
+ CONTROL_QUALITY_DIR = os.path.join(
174
+ os.path.dirname(os.path.abspath(__file__)),
175
+ *([os.pardir] * 6), "trillboard-api", "scripts", "ai", "eval-baselines")
176
+
177
+
178
+ def discover_control_quality(directory: str = CONTROL_QUALITY_DIR) -> list:
179
+ """Every `positive-control-quality-*.json`, sorted, so a run cannot read a subset.
180
+
181
+ `check-positive-control-grade.js` merges all of them. If this tool read fewer,
182
+ the two would disagree about which sets are graded -- and the disagreement is
183
+ not symmetric: an ungraded set is absent from `liveness_only` AND from
184
+ `unusable`, so it silently becomes eligible to carry a verdict.
185
+ """
186
+ if not os.path.isdir(directory):
187
+ return []
188
+ return sorted(os.path.join(directory, n) for n in os.listdir(directory)
189
+ if n.startswith("positive-control-quality-") and n.endswith(".json"))
190
+
191
+
192
+ def assert_every_control_is_graded(pos_sets, ctlq) -> None:
193
+ """Refuse to adjudicate on a control set that has no contact-sheet grade.
194
+
195
+ The committed-artifact guard catches this downstream, but by then a wrong
196
+ number has been produced, written and possibly quoted. A set nobody has looked
197
+ at is not evidence, and the failure mode is silent in the flattering
198
+ direction: ungraded means not-liveness-only and not-unusable, which means
199
+ eligible to be `best_control`.
200
+ """
201
+ graded = set(ctlq)
202
+ used = {name for names in CLASS_CONTROL_N.values() for name in names}
203
+ missing = sorted(s for s in pos_sets if s in used and s not in graded)
204
+ if missing:
205
+ raise SystemExit(
206
+ "ungraded positive control(s) scored in this run: %s. Grade them on a contact "
207
+ "sheet and add them to a positive-control-quality-*.json, or drop them from "
208
+ "CLASS_CONTROL. An ungraded set is neither `unusable` nor `liveness_only`, so it "
209
+ "would be treated as able to rate a class." % ", ".join(missing))
210
+
211
+
212
+ def normalise_class(name: str) -> str:
213
+ """`labels.py::normalize_class_name`: lower, and `-`/`_` folded to space.
214
+
215
+ The control lookup MUST go through this. A case-sensitive lookup misses
216
+ `Fire_Extinguisher` against a table keyed `fire_extinguisher` and the
217
+ capability then reports UNINFORMATIVE -- "no usable positive control exists"
218
+ -- when the control is sitting right there. That reads as a finding about the
219
+ world and is a finding about a dict.
220
+ """
221
+ return name.strip().lower().replace("_", " ").replace("-", " ")
222
+
223
+
224
+ #: CLASS_CONTROL re-keyed by normalised name, built once.
225
+ CLASS_CONTROL_N = {}
226
+ for _k, _v in CLASS_CONTROL.items():
227
+ CLASS_CONTROL_N.setdefault(normalise_class(_k), []).extend(_v)
228
+ CLASS_CONTROL_N = {k: sorted(set(v), key=v.index) for k, v in CLASS_CONTROL_N.items()}
229
+
230
+
231
+ # --- capability -> the plan file's own class, where one covers it -------------
232
+ # The plan declares `expectedOnQuiet` for nine classes. Where it speaks, it wins.
233
+ CAP_TO_PLAN_CLASS = {
234
+ "person / head count": "person_count",
235
+ "person down / fall": "person_on_the_ground",
236
+ "spill": "spill_on_floor",
237
+ "fire": "fire_or_smoke_visible",
238
+ "smoke": "fire_or_smoke_visible",
239
+ "forklift": "forklift_present",
240
+ "hard hat ABSENT": "person_missing_required_ppe",
241
+ "hi-vis vest ABSENT": "person_missing_required_ppe",
242
+ "face mask ABSENT": "person_missing_required_ppe",
243
+ "gloves ABSENT": "person_missing_required_ppe",
244
+ "phone use": "person_using_phone",
245
+ }
246
+
247
+ # --- capability -> the hand-grading key, for classes the plan does not cover ---
248
+ CAP_TO_GRADE_CLASS = {
249
+ "hard hat present": "hard_hat", "hi-vis vest present": "hi_vis_vest",
250
+ "face mask present": "face_mask", "gloves present": "gloves",
251
+ "goggles / eye pro": "goggles", "safety boots": "safety_boots",
252
+ "apron": "apron", "hairnet": "hairnet", "smoking (cigarette)": "cigarette",
253
+ "gun": "gun", "knife": "knife", "weapon (generic)": "weapon_generic",
254
+ "heavy machinery": "heavy_machinery", "pallet": "pallet",
255
+ "vehicle / queue": "vehicle", "shopping cart": "shopping_cart",
256
+ "shoplifting / theft": "shoplifting", "door": "door", "window": "window",
257
+ "rodent": "rodent", "shelf gap / OOS": "shelf_gap",
258
+ "traffic cone": "traffic_cone", "fire extinguisher": "fire_extinguisher",
259
+ "ladder": "ladder",
260
+ }
261
+
262
+
263
+ # --- what the 2026-08-25 taxi-negative screen decided, for the before/after ----
264
+ # Historical fact, embedded so the comparison is reproducible from this file
265
+ # alone; the source `ranking.json` was a scratch artifact and is not committed.
266
+ PRIOR_VERDICT = {
267
+ 'hard hat present': ('PUBLISH', 'ppe_vyra_14cls_v8m'),
268
+ 'hard hat ABSENT': ('NOT SCREENABLE HERE', None),
269
+ 'hi-vis vest present': ('NO MODEL CLEARS BAR', 'ppe_boots_goggles_v8s'),
270
+ 'hi-vis vest ABSENT': ('NOT SCREENABLE HERE', None),
271
+ 'face mask present': ('NO MODEL CLEARS BAR', 'ppe_mask_3cls_v8n'),
272
+ 'face mask ABSENT': ('NOT SCREENABLE HERE', None),
273
+ 'gloves present': ('NO MODEL CLEARS BAR', 'ppe_sh17_17cls_11x'),
274
+ 'gloves ABSENT': ('NOT SCREENABLE HERE', None),
275
+ 'goggles / eye pro': ('NO MODEL CLEARS BAR', 'ppe_vyra_14cls_v8m'),
276
+ 'safety boots': ('NO MODEL CLEARS BAR', 'ppe_sh17_17cls_11x'),
277
+ 'apron': ('UNINFORMATIVE', None),
278
+ 'hairnet': ('UNINFORMATIVE', None),
279
+ 'fire': ('NO MODEL CLEARS BAR', 'fire_smoke_weslleyskah_11n'),
280
+ 'smoke': ('AMBIGUOUS NEGATIVE SET', 'fire_smoke_weslleyskah_11n'),
281
+ 'smoking (cigarette)': ('UNINFORMATIVE', None),
282
+ 'gun': ('NO MODEL CLEARS BAR', 'gh_weapon_activeshooter_11'),
283
+ 'knife': ('NO MODEL CLEARS BAR', 'gh_weapon_nihal_11s'),
284
+ 'weapon (generic)': ('NO MODEL CLEARS BAR', 'weapon_generic_v8m'),
285
+ 'person down / fall': ('PUBLISH', 'gh_fall_tonlong'),
286
+ 'forklift': ('PUBLISH', 'gh_forklift_combine'),
287
+ 'heavy machinery': ('PUBLISH', 'ppe_construction_17cls_v8n'),
288
+ 'pallet': ('NO MODEL CLEARS BAR', 'gh_forklift_pallet_11s'),
289
+ 'person / head count': ('NOT SCREENABLE HERE', None),
290
+ 'vehicle / queue': ('AMBIGUOUS NEGATIVE SET', 'gh_ppe_vinayak_l'),
291
+ 'shopping cart': ('NO CANDIDATE', None),
292
+ 'spill': ('UNINFORMATIVE', None),
293
+ 'shoplifting / theft': ('UNINFORMATIVE', None),
294
+ 'door': ('NOT SCREENABLE HERE', None),
295
+ 'window': ('NOT SCREENABLE HERE', None),
296
+ 'phone use': ('NOT SCREENABLE HERE', None),
297
+ 'rodent': ('NO MODEL CLEARS BAR', 'rodent_pulkit'),
298
+ 'shelf gap / OOS': ('NO MODEL CLEARS BAR', 'hf_shelfgap'),
299
+ 'traffic cone': ('AMBIGUOUS NEGATIVE SET', 'gh_ppe_vinayak_l'),
300
+ 'fire extinguisher': ('PUBLISH', 'ppe_killuminati_19cls_v8s'),
301
+ 'ladder': ('UNINFORMATIVE', None),
302
+ }
303
+
304
+ #: Artifacts the shipped service REFUSES to load, and what replaces them. An
305
+ #: adjudication that names an unloadable artifact is worse than one that omits
306
+ #: it: the row looks shippable and the model cannot be published.
307
+ #: `gh_fall_tonlong` declares `Violence` at slot 0 and `violence` at slot 6;
308
+ #: `labels.py::normalize_class_name` folds them to one name and ModelVocabulary
309
+ #: raises at construction. `gh_fall_tonlong_v2` is the same weights re-exported
310
+ #: with the slots disambiguated by index, so the numbers are unchanged and only
311
+ #: the name the table prints moves.
312
+ SUPERSEDED = {"gh_fall_tonlong": "gh_fall_tonlong_v2"}
313
+
314
+
315
+ GRID = np.round(np.arange(0.05, 0.9751, 0.0125), 4)
316
+ QUIET_MAX_FIRE = 0.01 # the 2026-08-25 bar: <1% firing on valid negatives
317
+ RECALL_RETENTION_BAR = 0.50 # "keeps >=50% of positive-control detections"
318
+ OFF_TARGET_BAR = 0.10 # "<=10% mean firing on control sets built for other things"
319
+ PRODUCTION_EMPTY_PRIOR = 0.847 # cctv-detector-tier.md's production frame prior
320
+
321
+
322
+ def clopper_pearson(k: int, n: int, alpha: float = 0.05):
323
+ """Exact binomial interval. A rate on 14 frames and a rate on 480 are not the
324
+ same claim, and only the interval says so."""
325
+ if n == 0:
326
+ return [None, None]
327
+ from math import inf
328
+ def beta_ppf(p, a, b):
329
+ # bisection on the regularised incomplete beta; no scipy on the box
330
+ if a <= 0:
331
+ return 0.0
332
+ if b <= 0:
333
+ return 1.0
334
+ lo, hi = 0.0, 1.0
335
+ for _ in range(200):
336
+ mid = (lo + hi) / 2
337
+ if _betainc(a, b, mid) < p:
338
+ lo = mid
339
+ else:
340
+ hi = mid
341
+ return (lo + hi) / 2
342
+ lo = 0.0 if k == 0 else beta_ppf(alpha / 2, k, n - k + 1)
343
+ hi = 1.0 if k == n else beta_ppf(1 - alpha / 2, k + 1, n - k)
344
+ return [round(lo, 6), round(hi, 6)]
345
+
346
+
347
+ def _betainc(a: float, b: float, x: float) -> float:
348
+ """Regularised incomplete beta via the continued fraction (Lentz)."""
349
+ if x <= 0:
350
+ return 0.0
351
+ if x >= 1:
352
+ return 1.0
353
+ lbeta = math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b)
354
+ front = math.exp(math.log(x) * a + math.log(1 - x) * b - lbeta) / a
355
+ f, c, d = 1.0, 1.0, 0.0
356
+ for i in range(0, 300):
357
+ m = i // 2
358
+ if i == 0:
359
+ num = 1.0
360
+ elif i % 2 == 0:
361
+ num = (m * (b - m) * x) / ((a + 2 * m - 1) * (a + 2 * m))
362
+ else:
363
+ num = -((a + m) * (a + b + m) * x) / ((a + 2 * m) * (a + 2 * m + 1))
364
+ d = 1.0 + num * d
365
+ d = 1e-30 if abs(d) < 1e-30 else d
366
+ d = 1.0 / d
367
+ c = 1.0 + num / c
368
+ c = 1e-30 if abs(c) < 1e-30 else c
369
+ f *= c * d
370
+ if abs(1.0 - c * d) < 1e-12:
371
+ break
372
+ r = front * (f - 1.0)
373
+ if x < (a + 1) / (a + b + 2):
374
+ return r
375
+ # `_betainc(b, a, 1 - x)` is the SAME CALL when a == b and x == 0.5, so the
376
+ # reflection recursed forever -- and `beta_ppf` starts its bisection at exactly
377
+ # 0.5, so any Clopper-Pearson interval with symmetric beta parameters (midpoint
378
+ # successes in an odd-sized sample) raised RecursionError instead of returning.
379
+ # I(0.5; a, a) = 0.5 by symmetry; take it directly.
380
+ if a == b and abs(x - 0.5) < 1e-15:
381
+ return 0.5
382
+ return 1.0 - _betainc(b, a, 1 - x)
383
+
384
+
385
+ def main() -> None:
386
+ ap = argparse.ArgumentParser()
387
+ ap.add_argument("--out-dir", required=True, help="directory of <alias>.npz + _index.json")
388
+ ap.add_argument("--combo", required=True, help="combo.json written by vm_prep2.py")
389
+ ap.add_argument("--grading", required=True, help="venue absence grading json")
390
+ ap.add_argument("--plan", required=True, help="scripts/sensing/quiet-hour-fleet-plan.json")
391
+ ap.add_argument("--control-quality", nargs="+", default=None,
392
+ help="positive-control-quality-*.json. DEFAULTS to every such file in "
393
+ "trillboard-api/scripts/ai/eval-baselines/, sorted, merged with "
394
+ "later files winning. The grades live in DATED files -- the "
395
+ "2026-08-25 record is pinned by the score cards that cite it, so a "
396
+ "set graded later lands in a new file rather than editing that one "
397
+ "-- and a run that read only some of them would give an UNGRADED "
398
+ "control the benefit of the doubt: `liveness_only` and `unusable` "
399
+ "are both built by membership, so a set with no grade falls through "
400
+ "both and can carry a PUBLISH. `discover_control_quality` is "
401
+ "therefore the default and an explicit list is an override for "
402
+ "replaying an old run, not the normal path. Either way "
403
+ "`assert_every_control_is_graded` refuses the run rather than "
404
+ "adjudicating on a set nobody looked at."
405
+ )
406
+ ap.add_argument("--out", required=True)
407
+ ap.add_argument("--markdown", help="also write the runbook table here")
408
+ args = ap.parse_args()
409
+
410
+ combo = json.load(open(args.combo))
411
+ grading = json.load(open(args.grading))
412
+ plan = json.load(open(args.plan))
413
+ quality_paths = (discover_control_quality() if args.control_quality is None
414
+ else list(args.control_quality))
415
+ if not quality_paths:
416
+ raise SystemExit("no positive-control-quality-*.json found; a run with no grades "
417
+ "cannot tell a control that can rate a class from one that cannot")
418
+ ctlq = {}
419
+ for _q in quality_paths:
420
+ for _k, _v in json.load(open(_q)).items():
421
+ if isinstance(_v, dict) and isinstance(_v.get("grade"), str):
422
+ ctlq[_k] = _v
423
+ index = json.load(open(os.path.join(args.out_dir, "_index.json")))
424
+
425
+ meta = combo["meta"]
426
+ buckets = np.array([m.get("bucket") or "" for m in meta])
427
+ sets = np.array([m["set"] for m in meta])
428
+ is_neg = sets == "venue_src"
429
+ pos_sets = sorted({m["pos_set"] for m in meta if m.get("pos_set")})
430
+ assert_every_control_is_graded(pos_sets, ctlq)
431
+
432
+ # Positive-control sets whose contact-sheet grade forbids citing them.
433
+ # CONTAMINATED belongs here and was missed on the first pass: `wc_spill` is
434
+ # graded CONTAMINATED ("search pulled 'oil spill' — an overturned truck — a
435
+ # cathedral corridor, building exteriors and abstract cream textures"), and
436
+ # citing it would have given `spill` a recall number off a set that mostly
437
+ # does not contain a spill. Read the grades, do not enumerate the bad ones
438
+ # from memory.
439
+ unusable = {k for k, v in ctlq.items()
440
+ if isinstance(v, dict) and v.get("grade") in UNUSABLE_GRADES}
441
+
442
+ # Grades whose OWN note says the set cannot rate a class. These are not
443
+ # unusable -- they are real frames, they are scored, and every one of them is
444
+ # reported in `per_control`. What they may not be is the set a PUBLISH rests
445
+ # on, which is a different question from whether they may be cited at all.
446
+ #
447
+ # This exists because `fire` and `smoke` were awarded PUBLISH on `fire_generic`,
448
+ # n=5, graded `good_small_n` with the note "n=5 so only a coarse liveness
449
+ # signal" -- while `fire_cctv`, n=39, graded `good`, "realistic scale for a
450
+ # venue camera", was scored in the same run and says something else. The
451
+ # selector was `argmax(rate_025)` over every control, so the SMALLEST and most
452
+ # flattering set won and its n became the `control_n` the verdict quoted. A
453
+ # selector that cannot see the grade will keep choosing the set that flatters
454
+ # it; the grade file was already loaded three lines up and only consulted for
455
+ # the DISCARDED list.
456
+ liveness_only = {k for k, v in ctlq.items()
457
+ if isinstance(v, dict) and v.get("grade") in LIVENESS_ONLY_GRADES}
458
+
459
+ # ---- composition, stated rather than assumed ----------------------------
460
+ # `source_span_hours` is what keeps the frame count honest. 480 industrial
461
+ # frames spread over 10.77 h are 81 s apart and independent; 400 retail frames
462
+ # drawn from 72 SECONDS are ~5 frames per second of one aisle. Same n, very
463
+ # different evidence, and only the span says which you are holding.
464
+ src_index = {}
465
+ src_path = os.path.join(os.path.dirname(args.combo), "srcframes_index.json")
466
+ if os.path.exists(src_path):
467
+ for r in json.load(open(src_path)):
468
+ src_index.setdefault(r["bucket"], {})[r["box"]] = r.get("src_duration_sec") or 0
469
+
470
+ comp = {"negative_set": {}, "positive_controls": {}}
471
+ for b in sorted(set(buckets[is_neg].tolist())):
472
+ m = is_neg & (buckets == b)
473
+ boxes = sorted({meta[i]["box"] for i in np.where(m)[0]})
474
+ span = sum(src_index.get(b, {}).values()) / 3600.0
475
+ comp["negative_set"][b] = {
476
+ "frames": int(m.sum()), "boxes": len(boxes),
477
+ "source_span_hours": round(span, 4),
478
+ "seconds_between_frames": (round(span * 3600 / int(m.sum()), 2)
479
+ if m.sum() and span else None),
480
+ "corpora": sorted({meta[i]["corpus"] for i in np.where(m)[0]}),
481
+ "box_ids": boxes,
482
+ }
483
+ for s in pos_sets:
484
+ m = sets == ("pos:" + s)
485
+ g = (ctlq.get(s) or {})
486
+ comp["positive_controls"][s] = {
487
+ "frames": int(m.sum()), "grade": g.get("grade"),
488
+ "domain": g.get("domain"), "citable": s not in unusable,
489
+ }
490
+
491
+ def validity(cap: str, bucket: str):
492
+ """Is this bucket a valid NEGATIVE for this capability?
493
+
494
+ Returns (verdict, source). `valid` and `weak` may carry a false-alarm
495
+ rate; `invalid` and `none` may carry only a firing rate.
496
+ """
497
+ pc = CAP_TO_PLAN_CLASS.get(cap)
498
+ if pc:
499
+ # the plan declares three buckets; retail and office are the two
500
+ # halves of its `indoor_retail_and_office`, so its declaration
501
+ # covers both. Splitting them is a refinement, not a contradiction.
502
+ pb = {"indoor_retail": "indoor_retail_and_office",
503
+ "indoor_office_lab": "indoor_retail_and_office"}.get(bucket, bucket)
504
+ d = ((plan["classes"].get(pc) or {}).get("expectedOnQuiet") or {}).get(pb)
505
+ if d == "never":
506
+ return "valid", f"plan:{pc}=never"
507
+ if d == "possible":
508
+ return "invalid", f"plan:{pc}=possible"
509
+ gk = CAP_TO_GRADE_CLASS.get(cap)
510
+ if gk:
511
+ d = ((grading.get("per_class") or {}).get(gk) or {}).get(bucket)
512
+ if d == "absent":
513
+ return "valid", f"graded:{gk}=absent"
514
+ if d == "not_observed":
515
+ return "weak", f"graded:{gk}=not_observed"
516
+ if d in ("PRESENT", "AMBIGUOUS"):
517
+ return "invalid", f"graded:{gk}={d}"
518
+ return "none", "no_absence_declaration"
519
+
520
+ models = {}
521
+ for alias, info in index.items():
522
+ if not info.get("ok"):
523
+ continue
524
+ p = os.path.join(args.out_dir, alias + ".npz")
525
+ if not os.path.exists(p):
526
+ continue
527
+ d = np.load(p)
528
+ ms = d["max_scores"]
529
+ names = info.get("names")
530
+ if names:
531
+ names = {int(k): v for k, v in names.items()}
532
+ else: # RF-DETR: COCO category ids
533
+ coco = json.load(open(os.path.join(os.path.dirname(args.combo), "coco91.json")))
534
+ names = {int(k): v for k, v in coco.items()}
535
+ models[alias] = {"info": info, "ms": ms, "names": names,
536
+ "lat_p50_ms": float(np.percentile(d["lat"], 50))}
537
+
538
+ # ---- the empty-frame prior, MEASURED on venue footage -------------------
539
+ # Every `vlm_calls_saved` in this file is reweighted to 0.847 empty frames,
540
+ # which came off the RIDESHARE fleet. A parked cab with nobody in it is empty
541
+ # most of the time; a shop is not. The gate's own person detector says so:
542
+ # `rfdetr_medium` (100% rollout, the artifact actually deciding this today)
543
+ # sees a person in half to nearly all venue frames depending on the bucket.
544
+ #
545
+ # Reported, never substituted into the gate arithmetic above, because
546
+ # "detector sees no person" is a proxy for "empty" and not a label -- these
547
+ # frames carry no person ground truth (`person_count` is `possible` in all
548
+ # three plan buckets, so there is no valid negative for it here either).
549
+ # It is a MEASURED CEILING on what a person-gated cascade could save in a
550
+ # venue, and it is far below what the taxi prior implies.
551
+ venue_prior = {"_what": "share of venue frames with NO person detected by "
552
+ "rfdetr_medium, per bucket, at three confidences",
553
+ "_caveat": "a detector verdict, not a label. Reported beside the "
554
+ "carried 0.847 taxi prior, never substituted for it.",
555
+ "_source": None, "by_bucket": {}}
556
+ rf = models.get("PUB__rfdetr_medium") or models.get("rfdetr_medium")
557
+ if rf is not None:
558
+ venue_prior["_source"] = ("PUB__rfdetr_medium" if "PUB__rfdetr_medium" in models
559
+ else "rfdetr_medium")
560
+ person_idx = next((i for i, n in rf["names"].items() if n == "person"), None)
561
+ if person_idx is not None:
562
+ col = rf["ms"][:, person_idx]
563
+ for b in sorted(set(buckets[is_neg].tolist())):
564
+ m = is_neg & (buckets == b)
565
+ venue_prior["by_bucket"][b] = {
566
+ "n": int(m.sum()),
567
+ "empty_at_0.25": float((col[m] < 0.25).mean()),
568
+ "empty_at_0.50": float((col[m] < 0.50).mean()),
569
+ "empty_at_0.70": float((col[m] < 0.70).mean()),
570
+ }
571
+ allm = is_neg
572
+ venue_prior["all_venue"] = {
573
+ "n": int(allm.sum()),
574
+ "empty_at_0.50": float((col[allm] < 0.50).mean()),
575
+ "taxi_prior_carried_in_gate_arithmetic": PRODUCTION_EMPTY_PRIOR,
576
+ }
577
+
578
+ # ---- control: do the VM re-exports describe the SHIPPED artifacts? -----
579
+ # Four specialists and rfdetr are run twice, once from a re-export of the
580
+ # upstream weights and once from `pubonnx/` -- the exact bytes in
581
+ # sensing_model_registry. A re-export of the same .pt is NOT guaranteed to be
582
+ # the same artifact (fp32 export moved YOLOE's recall 28 points, §3), so the
583
+ # pair decides whether this whole adjudication may be read as describing what
584
+ # is deployed. Compared on the SAME frames through the SAME decode: only the
585
+ # artifact differs.
586
+ repro = {}
587
+ for alias, M in models.items():
588
+ if not alias.startswith("PUB__"):
589
+ continue
590
+ base = alias[len("PUB__"):]
591
+ other = models.get(base)
592
+ if other is None:
593
+ repro[base] = {"paired": False,
594
+ "why": "no re-export of this artifact on the box"}
595
+ continue
596
+ a, b = M["ms"], other["ms"]
597
+ if a.shape != b.shape:
598
+ repro[base] = {"paired": False, "why": f"shape {a.shape} vs {b.shape}"}
599
+ continue
600
+ d = np.abs(a - b)
601
+ # a firing decision is what we actually consume, so disagreement is
602
+ # counted at the threshold, not only as a residual
603
+ dis = {}
604
+ for t in (0.25, 0.50, 0.70):
605
+ dis[f"decision_disagreement_{t:.2f}"] = float(((a >= t) != (b >= t)).mean())
606
+ repro[base] = {"paired": True, "n_frames": int(a.shape[0]),
607
+ "n_classes": int(a.shape[1]),
608
+ "max_abs_score_delta": float(d.max()),
609
+ "mean_abs_score_delta": float(d.mean()), **dis}
610
+
611
+ rows = []
612
+ for cap, wanted in CAPS.items():
613
+ cands = []
614
+ for alias, M in models.items():
615
+ if alias in SUPERSEDED and SUPERSEDED[alias] in models:
616
+ continue
617
+ hit = [(ci, cn) for ci, cn in M["names"].items() if cn in wanted]
618
+ if not hit:
619
+ continue
620
+ ci, cn = hit[0]
621
+ col = M["ms"][:, ci]
622
+
623
+ # -------- negative side, per bucket, never blended ---------------
624
+ by_bucket, valid_mask = {}, np.zeros(len(col), dtype=bool)
625
+ for b in sorted(set(buckets[is_neg].tolist())):
626
+ m = is_neg & (buckets == b)
627
+ v, src = validity(cap, b)
628
+ sc = col[m]
629
+ k50 = int((sc >= 0.50).sum())
630
+ # A BOX-LEVEL view beside the frame-level one. Clopper-Pearson
631
+ # assumes independent trials and the retail bucket flatly violates
632
+ # that -- 400 frames drawn 0.22 s apart from one aisle. Reporting
633
+ # how many distinct SOURCE BOXES fire, out of how many exist, gives
634
+ # the reader the conservative unit without pretending there is one
635
+ # correct n. Neither number is "the" rate; the pair is.
636
+ fb = [meta[i]["box"] for i in np.where(m)[0]]
637
+ fired_boxes = {fb[t] for t in range(len(fb)) if sc[t] >= 0.50}
638
+ by_bucket[b] = {
639
+ "n": int(m.sum()), "validity": v, "validity_source": src,
640
+ "fire_050": float((sc >= 0.50).mean()),
641
+ "fire_050_ci95": clopper_pearson(k50, int(m.sum())),
642
+ "ci95_assumes_independent_frames": True,
643
+ "boxes_firing_050": len(fired_boxes),
644
+ "boxes": len(set(fb)),
645
+ "max_score": float(sc.max()) if sc.size else 0.0,
646
+ "metric": ("false_alarm_rate" if v in ("valid", "weak")
647
+ else "firing_rate_only"),
648
+ }
649
+ if v in ("valid", "weak"):
650
+ valid_mask |= m
651
+
652
+ n_valid = int(valid_mask.sum())
653
+ # THE THRESHOLD MUST HOLD IN EVERY VALID BUCKET, NOT IN THEIR UNION.
654
+ # Choosing it from the pooled mask is the "never blended" rule broken by
655
+ # the tool that states it: the hospital sink is 320 frames and the
656
+ # industrial yard 480, so a class firing 3% on the sink and 0% elsewhere
657
+ # pools to 0.8% and passes, having failed the advertised bar for that
658
+ # scene type. `quiet_fire_rate` is correspondingly the WORST bucket's
659
+ # rate at that threshold, never the average.
660
+ quiet_t, quiet_fire, quiet_by_bucket = None, None, None
661
+ valid_names = [b for b, v in by_bucket.items()
662
+ if v["validity"] in ("valid", "weak")]
663
+ if n_valid and valid_names:
664
+ cols = {b: col[is_neg & (buckets == b)] for b in valid_names}
665
+ for t in GRID:
666
+ rates = {b: float((sc >= t).mean()) for b, sc in cols.items()}
667
+ if max(rates.values()) < QUIET_MAX_FIRE:
668
+ quiet_t = float(t)
669
+ quiet_fire = max(rates.values())
670
+ quiet_by_bucket = {b: r for b, r in sorted(rates.items())}
671
+ break
672
+
673
+ # -------- positive side: same artifact, same preprocessing -------
674
+ ctl_names = CLASS_CONTROL_N.get(normalise_class(cn), [])
675
+ best_ctl, best_rate, best_at_qt = None, None, None
676
+ per_ctl = {}
677
+ for s in ctl_names:
678
+ if s in unusable or ("pos:" + s) not in combo["ranges"]:
679
+ continue
680
+ m = sets == ("pos:" + s)
681
+ if not m.sum():
682
+ continue
683
+ r25 = float((col[m] >= 0.25).mean())
684
+ rqt = float((col[m] >= quiet_t).mean()) if quiet_t is not None else None
685
+ per_ctl[s] = {"n": int(m.sum()), "rate_025": r25, "rate_at_quiet_t": rqt,
686
+ "grade": (ctlq.get(s) or {}).get("grade"),
687
+ "can_rate_a_class": s not in liveness_only}
688
+ # The set a verdict rests on must be one that can rate a class.
689
+ # A liveness-only set still appears above, with its n and its
690
+ # grade, so the reader sees what it said and why it did not decide.
691
+ if s in liveness_only:
692
+ continue
693
+ if best_rate is None or r25 > best_rate:
694
+ best_ctl, best_rate, best_at_qt = s, r25, rqt
695
+
696
+ # -------- off-target: control sets built for OTHER things --------
697
+ off = {}
698
+ for s in pos_sets:
699
+ if s in ctl_names or s in unusable:
700
+ continue
701
+ m = sets == ("pos:" + s)
702
+ if not m.sum() or quiet_t is None:
703
+ continue
704
+ off[s] = float((col[m] >= quiet_t).mean())
705
+ off_mean = (sum(off.values()) / len(off)) if off else None
706
+ off_worst = max(off.items(), key=lambda kv: kv[1]) if off else None
707
+ # Keep the mean, the worst and anything materially firing; drop the
708
+ # long tail of zeros. 24 control sets x 290 candidate rows of "0.0" is
709
+ # ~7,000 lines of committed noise, and the diagnostic question --
710
+ # WHICH other thing does this model confuse for its own class -- is
711
+ # answered by the non-zero entries alone.
712
+ off_notable = {k: v for k, v in off.items() if v >= 0.05}
713
+
714
+ # -------- cascade gate, at the production empty-frame prior ------
715
+ gate = None
716
+ if quiet_t is not None and best_rate is not None:
717
+ recall = best_at_qt or 0.0
718
+ gate = {
719
+ "empty_prior": PRODUCTION_EMPTY_PRIOR,
720
+ "empty_prior_provenance": (
721
+ "cctv-detector-tier.md's production frame prior, measured on the "
722
+ "rideshare fleet. NOT measured for a venue; it is carried here only "
723
+ "so the two adjudications are comparable."),
724
+ "operating_conf": quiet_t,
725
+ "vlm_calls_saved": PRODUCTION_EMPTY_PRIOR * (1.0 - quiet_fire),
726
+ "events_lost": 1.0 - recall,
727
+ "total_calls_removed": (PRODUCTION_EMPTY_PRIOR * (1.0 - quiet_fire)
728
+ + (1.0 - PRODUCTION_EMPTY_PRIOR) * (1.0 - recall)),
729
+ }
730
+
731
+ cands.append({
732
+ "model": alias, "class": cn, "class_index": int(ci),
733
+ # `M["info"]`, NOT the bare `info` -- that name leaks out of the
734
+ # loop that BUILT `models` and still points at whichever index
735
+ # entry it saw last, so every candidate read as shipped_bytes
736
+ # True. Python's loop-variable scope, caught by an elision that
737
+ # elided nothing.
738
+ "shipped_bytes": bool(M["info"].get("shipped_bytes")),
739
+ "by_bucket": by_bucket,
740
+ "n_valid_negatives": n_valid,
741
+ "quiet_t": quiet_t,
742
+ "quiet_fire_rate": quiet_fire,
743
+ "quiet_fire_rate_is": "the WORST valid bucket at quiet_t, not a pooled rate",
744
+ "quiet_fire_by_bucket": quiet_by_bucket,
745
+ "best_control": best_ctl, "control_n": (per_ctl.get(best_ctl) or {}).get("n"),
746
+ "control_rate_025": best_rate, "control_rate_at_quiet_t": best_at_qt,
747
+ "control_grade": (ctlq.get(best_ctl) or {}).get("grade") if best_ctl else None,
748
+ "per_control": per_ctl,
749
+ # Scored, reported, and NOT allowed to decide. Named here so a
750
+ # reader can see that a smaller set said something friendlier and
751
+ # why it was not the one quoted.
752
+ "liveness_only_controls": sorted(k for k in per_ctl
753
+ if not per_ctl[k]["can_rate_a_class"]),
754
+ "off_target_mean": off_mean,
755
+ "off_target_n_sets": len(off),
756
+ "off_target_worst": ({"set": off_worst[0], "rate": off_worst[1]} if off_worst else None),
757
+ "off_target_notable": off_notable,
758
+ "gate": gate,
759
+ "lat_p50_ms": M["lat_p50_ms"],
760
+ })
761
+
762
+ # Tie-break TOWARD the shipped bytes. A specialist appears twice -- once
763
+ # as `PUB__<id>` (the artifact in sensing_model_registry) and once as a
764
+ # re-export -- and they agree to ~1e-06, so an arbitrary tie-break would
765
+ # name whichever the dict happened to yield. Naming the deployed artifact
766
+ # is the useful half of the pair.
767
+ cands.sort(key=lambda c: (-(c["control_rate_at_quiet_t"] or 0), c["quiet_t"] or 1,
768
+ not c["shipped_bytes"], c["model"]))
769
+ # Per-bucket detail for the candidates the decision could turn on -- the
770
+ # top five and every shipped artifact -- and summary rows for the rest.
771
+ # All 290 candidate rows at full detail are 795 KB of committed JSON, 45%
772
+ # of it per-bucket cells for models that lost by 60 points. The verdict
773
+ # stays auditable: every candidate keeps its quiet threshold, its control
774
+ # recall and its valid-negative count, which is everything `decide` reads.
775
+ for rank, c in enumerate(cands):
776
+ if rank >= 5 and not c["shipped_bytes"]:
777
+ c["by_bucket"] = {"_elided": "summary row; rerun the tool for "
778
+ "per-bucket cells on every candidate"}
779
+ c["per_control"] = {}
780
+ c["gate"] = None
781
+
782
+ now = decide(cap, cands)
783
+ was, was_model = PRIOR_VERDICT.get(cap, (None, None))
784
+ rows.append({"capability": cap, "n_candidates": len(cands),
785
+ "prior_decision_taxi_negatives": was,
786
+ "prior_model": was_model,
787
+ "changed": bool(was is not None and was != now["decision"]),
788
+ "decision": now, "candidates": cands})
789
+
790
+ json.dump({
791
+ "schema": "trillboards.venue-adjudication/1",
792
+ "negative_set": "venue-phase3-source-2026-08-26",
793
+ "supersedes": "the 1,500-frame `production` negative set of 2026-08-25, "
794
+ "1,494 of which were taxi_rideshare_tv cabin interiors",
795
+ "bar": {
796
+ "quiet_max_fire": QUIET_MAX_FIRE,
797
+ "recall_retention": RECALL_RETENTION_BAR,
798
+ "recall_retention_is": "an ABSOLUTE floor on recall at the operating "
799
+ "threshold, not a ratio against the 0.25 rate. A "
800
+ "candidate keeping 41% of its control at quiet_t "
801
+ "fails it however flat its own curve is.",
802
+ "off_target_mean": OFF_TARGET_BAR,
803
+ "why": "identical to the 2026-08-25 screen so the only variable is the negative set",
804
+ },
805
+ "control_selection": {
806
+ "rule": "the control a verdict rests on must be one whose contact-sheet "
807
+ "grade can rate a class; %s are scored and reported but may not "
808
+ "decide" % (", ".join(LIVENESS_ONLY_GRADES),),
809
+ "liveness_only_grades": list(LIVENESS_ONLY_GRADES),
810
+ "unusable_grades": list(UNUSABLE_GRADES),
811
+ "among_rateable": "argmax(rate_at_conf_0.25), unchanged",
812
+ "read_per_control_not_control_n": "`control_n` is ONE set's n. Every set "
813
+ "scored for that class is in `per_control` "
814
+ "with its own n, grade and rate. Never quote "
815
+ "`control_n` without them.",
816
+ },
817
+ "composition": comp,
818
+ "export_reproducibility": repro,
819
+ "venue_empty_frame_prior": venue_prior,
820
+ "capabilities": rows,
821
+ }, open(args.out, "w"), indent=1)
822
+
823
+ if args.markdown:
824
+ with open(args.markdown, "w") as fh:
825
+ fh.write(render_markdown(rows, comp))
826
+
827
+ print(json.dumps({"capabilities": len(rows),
828
+ "models_adjudicated": len(models),
829
+ "negative_frames": int(is_neg.sum()),
830
+ "buckets": dict(Counter(buckets[is_neg].tolist()))}, indent=1))
831
+
832
+
833
+ def render_markdown(rows: list, comp: dict) -> str:
834
+ """The runbook table, emitted rather than hand-typed.
835
+
836
+ Hand-copying a 35-row table into prose is how a runbook starts disagreeing
837
+ with its own artifact -- this file already carries a twin count that was
838
+ wrong in nine places for a day.
839
+ """
840
+ out = []
841
+ out.append(" capability was (taxi negatives) now (venue negatives) model / why")
842
+ out.append(" " + "-" * 108)
843
+ for r in rows:
844
+ d = r["decision"]
845
+ was = r["prior_decision_taxi_negatives"] or "-"
846
+ now = d["decision"]
847
+ mark = "*" if r["changed"] else " "
848
+ who = d.get("model") or d.get("best_effort") or ""
849
+ extra = ""
850
+ if now == "PUBLISH":
851
+ c = next((c for c in r["candidates"] if c["model"] == who), None)
852
+ if c:
853
+ extra = (" conf %.3f, keeps %.0f%% of %s (n=%s), <1%% on %d venue negs"
854
+ % (c["quiet_t"] or 0, 100 * (c["control_rate_at_quiet_t"] or 0),
855
+ c["best_control"], c["control_n"], c["n_valid_negatives"]))
856
+ out.append(" %s%-21s %-22s %-22s %s%s"
857
+ % (mark, r["capability"], _short(was), _short(now), who, extra))
858
+ out.append("")
859
+ out.append(" * = the verdict moved when the negative set changed.")
860
+ out.append("")
861
+ out.append(" negative set composition (span is the source video the frames came from --")
862
+ out.append(" a frame count is not an independence claim):")
863
+ for b, v in sorted(comp["negative_set"].items(),
864
+ key=lambda kv: -kv[1]["source_span_hours"]):
865
+ out.append(" %-26s %4d frames %2d boxes %6.2f h span 1 per %7s s %s"
866
+ % (b, v["frames"], v["boxes"], v["source_span_hours"],
867
+ v["seconds_between_frames"], ",".join(v["corpora"])))
868
+ return "\n".join(out) + "\n"
869
+
870
+
871
+ def _short(v: str) -> str:
872
+ return {"NOT SCREENABLE HERE": "NOT SCREENABLE",
873
+ "AMBIGUOUS NEGATIVE SET": "AMBIGUOUS NEGSET",
874
+ "NO MODEL CLEARS BAR": "NO MODEL CLEARS BAR"}.get(v, v)
875
+
876
+
877
+ def _other_controls_clause(win: dict) -> str:
878
+ """Every OTHER control this artifact was scored against, in the `why` string.
879
+
880
+ The verdict line is what gets pasted into a deck. If the only number in it is
881
+ the winning set's, the reader has no way to know a bigger set was scored and
882
+ disagreed -- which is exactly how `fire` was quoted as "60% on 5 frames" while
883
+ a 39-frame in-domain set in the same run said 41%.
884
+ """
885
+ others = {k: v for k, v in (win.get("per_control") or {}).items()
886
+ if k != win.get("best_control")}
887
+ if not others:
888
+ return ""
889
+ parts = ["%s %.0f%% (n=%d, %s)" % (k, 100 * (v["rate_at_quiet_t"] or 0), v["n"],
890
+ "rateable" if v["can_rate_a_class"] else "liveness-only")
891
+ for k, v in sorted(others.items(), key=lambda kv: -kv[1]["n"])]
892
+ return "; the same artifact at that threshold keeps " + ", ".join(parts)
893
+
894
+
895
+ def decide(cap: str, cands: list) -> dict:
896
+ """PUBLISH / NO MODEL CLEARS BAR / NOT SCREENABLE, with the reason.
897
+
898
+ Same ladder as the 2026-08-25 screen. Only the negative set differs.
899
+ """
900
+ if not cands:
901
+ return {"decision": "NO CANDIDATE",
902
+ "why": "no sourced artifact declares a class for this capability"}
903
+ if all(c["n_valid_negatives"] == 0 for c in cands):
904
+ return {"decision": "NOT SCREENABLE HERE",
905
+ "why": "no bucket of the venue corpus is a valid negative for this class -- "
906
+ "either the plan file declares it `possible` everywhere, or the corpus "
907
+ "was hand-graded as containing it. Firing here can be correct, so no "
908
+ "false-alarm rate exists to compute."}
909
+ live = [c for c in cands if (c["control_rate_025"] or 0) > 0]
910
+ if not live:
911
+ if all(c["best_control"] is None for c in cands):
912
+ return {"decision": "UNINFORMATIVE",
913
+ "why": "no usable positive control exists for this class, so no firing "
914
+ "rate here is a finding"}
915
+ return {"decision": "NO MODEL CLEARS BAR",
916
+ "why": "no candidate fires on its positive control"}
917
+ retained = [c for c in live if (c["control_rate_at_quiet_t"] or 0) >= RECALL_RETENTION_BAR]
918
+ if not retained:
919
+ best = max(live, key=lambda c: (c["control_rate_at_quiet_t"] or 0))
920
+ r = best["control_rate_at_quiet_t"]
921
+ return {"decision": "NO MODEL CLEARS BAR", "best_effort": best["model"],
922
+ "why": "candidates are alive but collapse when forced quiet: the best (%s, "
923
+ "class '%s') keeps only %s of its positive-control detections at the "
924
+ "confidence needed for <1%% firing on the venue negatives"
925
+ % (best["model"], best["class"],
926
+ ("%.0f%%" % (100 * r)) if r is not None else "n/a")}
927
+ ok = [c for c in retained
928
+ if c["off_target_mean"] is not None and c["off_target_mean"] <= OFF_TARGET_BAR]
929
+ if not ok:
930
+ b = min(retained, key=lambda c: (c["off_target_mean"] if c["off_target_mean"] is not None else 9))
931
+ w = b["off_target_worst"] or {}
932
+ return {"decision": "NO MODEL CLEARS BAR", "best_effort": b["model"],
933
+ "why": "candidates stay quiet on venue footage but are indiscriminate "
934
+ "elsewhere: the best (%s) still fires on %.0f%% of off-target control "
935
+ "frames at its operating confidence (worst: %.0f%% of %s)"
936
+ % (b["model"], 100 * (b["off_target_mean"] or 0),
937
+ 100 * w.get("rate", 0), w.get("set", "?"))}
938
+ win = ok[0]
939
+ return {"decision": "PUBLISH", "model": win["model"], "class": win["class"],
940
+ "operating_conf": win["quiet_t"],
941
+ "control": win["best_control"], "control_n": win["control_n"],
942
+ "control_grade": win.get("control_grade"),
943
+ "all_controls": {k: {"n": v["n"], "grade": v["grade"],
944
+ "rate_at_quiet_t": v["rate_at_quiet_t"],
945
+ "can_rate_a_class": v["can_rate_a_class"]}
946
+ for k, v in (win.get("per_control") or {}).items()},
947
+ "n_valid_negatives": win["n_valid_negatives"],
948
+ "gate": win["gate"],
949
+ "why": "fires on %s (%s frames, grade %s) at %.0f%%, keeps %.0f%% of that at "
950
+ "conf %.3f, where it fires on <1%% of %d hand-declared venue negatives%s"
951
+ % (win["best_control"], win["control_n"], win.get("control_grade"),
952
+ 100 * (win["control_rate_025"] or 0),
953
+ 100 * (win["control_rate_at_quiet_t"] or 0), win["quiet_t"] or 0,
954
+ win["n_valid_negatives"], _other_controls_clause(win)),
955
+ "runner_up": ok[1]["model"] if len(ok) > 1 else None}
956
+
957
+
958
+ if __name__ == "__main__":
959
+ main()