@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.
- package/deploy/detector/bench/litert-twin-parity.json +27 -1
- package/deploy/detector/bench/regrade_control_selection.py +371 -0
- package/deploy/detector/bench/venue_adjudication.py +959 -0
- package/deploy/detector/bench/verify_litert_twin_parity.py +34 -10
- package/deploy/detector/tests/test_regrade_control_selection.py +120 -0
- package/deploy/detector/tests/test_venue_adjudication_grades.py +83 -0
- package/deploy/detector/trillboards_detector/labels.py +54 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -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()
|