@trillboards/edge-sdk 0.2.111 → 0.2.116

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.
Files changed (37) hide show
  1. package/deploy/detector/bench/regrade_control_selection.py +291 -28
  2. package/deploy/detector/bench/run_candidate.py +87 -2
  3. package/deploy/detector/bench/venue_adjudication.py +13 -2
  4. package/deploy/detector/tests/test_class_ownership.py +126 -0
  5. package/deploy/detector/tests/test_regrade_control_selection.py +108 -0
  6. package/deploy/detector/trillboards_detector/labels.py +60 -3
  7. package/dist/AudienceAnalyzeClient.d.ts +11 -0
  8. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  9. package/dist/AudienceAnalyzeClient.js +7 -0
  10. package/dist/AudienceAnalyzeClient.js.map +1 -1
  11. package/dist/ChannelProber.d.ts +6 -1
  12. package/dist/ChannelProber.d.ts.map +1 -1
  13. package/dist/ChannelProber.js +49 -9
  14. package/dist/ChannelProber.js.map +1 -1
  15. package/dist/CloudSensingManager.d.ts +20 -0
  16. package/dist/CloudSensingManager.d.ts.map +1 -1
  17. package/dist/CloudSensingManager.js +30 -0
  18. package/dist/CloudSensingManager.js.map +1 -1
  19. package/dist/EdgeAgent.js +1 -1
  20. package/dist/SensingProfileClient.d.ts +10 -0
  21. package/dist/SensingProfileClient.d.ts.map +1 -1
  22. package/dist/SensingProfileClient.js +16 -0
  23. package/dist/SensingProfileClient.js.map +1 -1
  24. package/dist/diagnose.d.ts +1 -1
  25. package/dist/diagnose.js +1 -1
  26. package/dist/discovery.d.ts +1 -1
  27. package/dist/discovery.d.ts.map +1 -1
  28. package/dist/discovery.js +15 -4
  29. package/dist/discovery.js.map +1 -1
  30. package/dist/localSensing.d.ts +70 -0
  31. package/dist/localSensing.d.ts.map +1 -1
  32. package/dist/localSensing.js +182 -1
  33. package/dist/localSensing.js.map +1 -1
  34. package/dist/setupMode.d.ts.map +1 -1
  35. package/dist/setupMode.js +5 -0
  36. package/dist/setupMode.js.map +1 -1
  37. package/package.json +1 -1
@@ -37,6 +37,7 @@ from __future__ import annotations
37
37
  import argparse
38
38
  import json
39
39
  import os
40
+ import time
40
41
 
41
42
  #: Grades whose own note says the set cannot rate a class. Kept in step with
42
43
  #: `venue_adjudication.py::LIVENESS_ONLY_GRADES`; the test pins them equal.
@@ -67,7 +68,124 @@ def rateable_controls(per_control: dict, ctlq: dict) -> dict:
67
68
  return out
68
69
 
69
70
 
70
- def regrade_capability(cap: dict, ctlq: dict, bar: dict) -> dict:
71
+ #: Schemas this tool will read a NEW control rate out of. A rescore artifact is
72
+ #: the ONLY way a control the original screen never scored can reach a verdict
73
+ #: here, and it is admissible for exactly one reason: it was produced by
74
+ #: `venue_adjudication.py`'s OWN rule -- same GRID, same QUIET_MAX_FIRE, same
75
+ #: worst-valid-bucket quiet_t, same bar -- against a control that carries a
76
+ #: contact-sheet grade. Anything else would be an assertion wearing a filename.
77
+ RESCORE_SCHEMAS = ("trillboards.fire-smoke-rescore/1",)
78
+
79
+
80
+ def load_rescores(paths) -> dict:
81
+ """Committed rescore artifacts, keyed by (model, class).
82
+
83
+ Returns {(model, class): row}. A file whose schema is not in
84
+ `RESCORE_SCHEMAS` is REFUSED rather than skipped: a rescore this tool cannot
85
+ read is a number somebody expected to count, and silently ignoring it is how
86
+ a verdict stays stale while looking re-derived.
87
+ """
88
+ out = {}
89
+ for path in paths or []:
90
+ doc = json.load(open(path))
91
+ if doc.get("schema") not in RESCORE_SCHEMAS:
92
+ raise SystemExit(
93
+ "%s has schema %r, which this tool cannot read (known: %s). Refusing rather "
94
+ "than skipping -- a rescore that is silently ignored leaves the verdict stale "
95
+ "while the run reports success."
96
+ % (path, doc.get("schema"), ", ".join(RESCORE_SCHEMAS)))
97
+ model = (doc.get("artifact") or {}).get("model_id")
98
+ if not model:
99
+ raise SystemExit("%s names no artifact.model_id, so its rates belong to nobody" % path)
100
+ # A rescore may carry caveats that survive its own verdict -- "this clears
101
+ # the bar AND no live route reaches it" is one fact, not two, and the
102
+ # record of record is where a reader decides whether to SELL. Carried
103
+ # rather than restated so there is one home for the measurement.
104
+ caveats = doc.get("sellability_caveats") or []
105
+ for cls, e in (doc.get("per_class") or {}).items():
106
+ out[(model, cls)] = {
107
+ "control": e["control_set"],
108
+ "n": e["control_frames"],
109
+ "rate_025": e["control_rate_025"],
110
+ "rate_at_quiet_t": e["control_rate_at_quiet_t"],
111
+ "quiet_t": e["quiet_t"],
112
+ "source_file": os.path.basename(path),
113
+ "sellability_caveats": caveats,
114
+ }
115
+ return out
116
+
117
+
118
+ def apply_rescore(cand: dict, rescores: dict, ctlq: dict):
119
+ """Merge a rescored control into one candidate's `per_control`.
120
+
121
+ Returns (candidate, note). The candidate is a COPY when anything changed.
122
+
123
+ THE OFF-TARGET BOUND IS THE WHOLE CARE HERE. `decide()`'s third gate is
124
+ `off_target_mean <= bar`, and a rescore does not recompute it -- the
125
+ off-target control sets the original screen fired against are gone. What it
126
+ can do is INHERIT the committed value, and only in the direction where that
127
+ is sound: `off_target` is `(scores >= quiet_t).mean()`, which is monotone
128
+ NON-INCREASING in the threshold, so a value measured at a LOWER quiet_t is a
129
+ valid UPPER BOUND at a higher one. If the rescore's threshold is BELOW the
130
+ committed one the bound runs the wrong way, and the row is refused rather
131
+ than passed on a number nobody measured at that operating point.
132
+ """
133
+ key = (cand.get("model"), cand.get("class"))
134
+ r = (rescores or {}).get(key)
135
+ if not r:
136
+ return cand, None
137
+ grade = (ctlq.get(r["control"]) or {}).get("grade")
138
+ if grade is None:
139
+ raise SystemExit(
140
+ "rescore %s supplies control %r for %s/%s, which has no contact-sheet grade. "
141
+ "An ungraded set is neither `unusable` nor `liveness_only`, so it would be "
142
+ "eligible to carry a verdict on nobody's inspection."
143
+ % (r["source_file"], r["control"], key[0], key[1]))
144
+ committed_t = cand.get("quiet_t")
145
+ if committed_t is not None and r["quiet_t"] < committed_t:
146
+ return cand, {
147
+ "model": key[0], "class": key[1], "control": r["control"],
148
+ "refused": ("rescore quiet_t %s is BELOW the committed %s, so the committed "
149
+ "off_target_mean is not an upper bound at the rescored operating "
150
+ "point and no off-target number exists for it"
151
+ % (r["quiet_t"], committed_t)),
152
+ "source_file": r["source_file"],
153
+ }
154
+ merged = dict(cand)
155
+ # A RATE BELONGS TO THE THRESHOLD IT WAS MEASURED AT. Raising the candidate's
156
+ # `quiet_t` does not re-measure the controls already in `per_control` -- their
157
+ # `rate_at_quiet_t` is still the value at the COMMITTED threshold. Because
158
+ # `(scores >= t).mean()` is monotone non-increasing in t, that number is an
159
+ # UPPER BOUND at the higher threshold, so reading it as the recall at the new
160
+ # operating point OVERSTATES recall and can manufacture a PUBLISH. Every entry
161
+ # is therefore stamped with the threshold that produced it, and selection
162
+ # admits only the ones measured at the threshold actually applied.
163
+ merged["per_control"] = {
164
+ name: dict(row, measured_at_t=row.get("measured_at_t", committed_t))
165
+ for name, row in (cand.get("per_control") or {}).items()
166
+ }
167
+ merged["per_control"][r["control"]] = {
168
+ "n": r["n"], "rate_025": r["rate_025"], "rate_at_quiet_t": r["rate_at_quiet_t"],
169
+ "measured_at_t": r["quiet_t"],
170
+ }
171
+ merged["quiet_t"] = r["quiet_t"]
172
+ note = {
173
+ "model": key[0], "class": key[1], "control": r["control"], "grade": grade,
174
+ "n": r["n"], "rate_at_quiet_t": r["rate_at_quiet_t"],
175
+ "quiet_t_committed": committed_t, "quiet_t_rescored": r["quiet_t"],
176
+ "off_target_mean_inherited": cand.get("off_target_mean"),
177
+ "off_target_bound_holds_because": (
178
+ "off_target is (scores >= t).mean(), monotone non-increasing in t, and the "
179
+ "rescored t (%s) is >= the committed t (%s), so the committed value is an "
180
+ "upper bound at the rescored operating point"
181
+ % (r["quiet_t"], committed_t)),
182
+ "source_file": r["source_file"],
183
+ "sellability_caveats": r.get("sellability_caveats") or [],
184
+ }
185
+ return merged, note
186
+
187
+
188
+ def regrade_capability(cap: dict, ctlq: dict, bar: dict, rescores: dict = None) -> dict:
71
189
  """Re-apply the bar to one capability using only rateable controls."""
72
190
  recall_bar = bar["recall_retention"]
73
191
  off_bar = bar["off_target_mean"]
@@ -88,8 +206,11 @@ def regrade_capability(cap: dict, ctlq: dict, bar: dict) -> dict:
88
206
  "why": "resolved before any control is read: %s" % (prior,),
89
207
  }
90
208
 
91
- rows, unknown = [], []
209
+ rows, unknown, rescored, stale_t = [], [], [], []
92
210
  for cand in cap["candidates"]:
211
+ cand, note = apply_rescore(cand, rescores, ctlq)
212
+ if note:
213
+ rescored.append(note)
93
214
  elided = "_elided" in (cand.get("by_bucket") or {})
94
215
  rate = rateable_controls(cand.get("per_control"), ctlq)
95
216
  if not rate:
@@ -120,9 +241,39 @@ def regrade_capability(cap: dict, ctlq: dict, bar: dict) -> dict:
120
241
  "elided": elided,
121
242
  })
122
243
  continue
244
+ # Only a control measured at the threshold this candidate is DECIDED at may
245
+ # carry the verdict. Absent a rescore nothing is stamped and every control
246
+ # is at the committed threshold, so this is a no-op on the untouched path.
247
+ applied_t = cand.get("quiet_t")
248
+ fresh = {k: v for k, v in rate.items()
249
+ if v.get("measured_at_t") is None or v["measured_at_t"] == applied_t}
250
+ stale = {k: v for k, v in rate.items() if k not in fresh}
251
+ for k, v in sorted(stale.items()):
252
+ stale_t.append({
253
+ "model": cand["model"], "class": cand["class"], "control": k,
254
+ "measured_at_t": v.get("measured_at_t"), "applied_t": applied_t,
255
+ "rate_at_measured_t": v.get("rate_at_quiet_t"),
256
+ "why_excluded": ("its rate is an upper bound at the applied threshold, "
257
+ "not a measurement of it, so it may not carry a verdict"),
258
+ })
259
+ if not fresh:
260
+ unknown.append({
261
+ "model": cand["model"], "class": cand["class"],
262
+ "decided_on": cand.get("best_control"),
263
+ "grade": (ctlq.get(cand.get("best_control")) or {}).get("grade"),
264
+ "why_unknown": ("a rescore moved quiet_t to %s and every rateable control "
265
+ "here was measured at a lower threshold (%s), where its rate "
266
+ "is only an upper bound -- no control has a rate AT the "
267
+ "applied operating point"
268
+ % (applied_t,
269
+ ", ".join(sorted(str(v.get("measured_at_t"))
270
+ for v in stale.values())))),
271
+ "elided": elided,
272
+ })
273
+ continue
123
274
  # argmax(rate_025) among the rateable, exactly as the fixed tool does.
124
- name = max(rate, key=lambda k: (rate[k]["rate_025"] or 0))
125
- row = rate[name]
275
+ name = max(fresh, key=lambda k: (fresh[k]["rate_025"] or 0))
276
+ row = fresh[name]
126
277
  rows.append({
127
278
  "model": cand["model"], "class": cand["class"],
128
279
  "control": name, "control_n": row["n"], "grade": row["grade"],
@@ -130,8 +281,13 @@ def regrade_capability(cap: dict, ctlq: dict, bar: dict) -> dict:
130
281
  "off_target_mean": cand.get("off_target_mean"),
131
282
  "quiet_t": cand.get("quiet_t"),
132
283
  "n_valid_negatives": cand.get("n_valid_negatives"),
133
- "also_scored": {k: {"n": v["n"], "grade": (ctlq.get(k) or {}).get("grade"),
134
- "rate_at_quiet_t": v["rate_at_quiet_t"]}
284
+ "also_scored": {k: dict(
285
+ {"n": v["n"], "grade": (ctlq.get(k) or {}).get("grade"),
286
+ "rate_at_quiet_t": v["rate_at_quiet_t"]},
287
+ **({} if v.get("measured_at_t") in (None, applied_t) else {
288
+ "measured_at_t": v["measured_at_t"],
289
+ "upper_bound_only": ("measured at %s, not the applied %s"
290
+ % (v["measured_at_t"], applied_t))}))
135
291
  for k, v in (cand.get("per_control") or {}).items() if k != name},
136
292
  "source": "recomputed from committed per_control",
137
293
  })
@@ -208,6 +364,10 @@ def regrade_capability(cap: dict, ctlq: dict, bar: dict) -> dict:
208
364
  "winner": win,
209
365
  "candidates_regraded": len(rows),
210
366
  "candidates_unknown": blocking,
367
+ "rescored_controls": rescored,
368
+ # Never silent: a control dropped for being measured at another
369
+ # threshold is a stated exclusion, exactly like a withheld model.
370
+ "stale_threshold_controls": stale_t,
211
371
  }
212
372
 
213
373
 
@@ -260,7 +420,35 @@ def _selftest() -> None:
260
420
  measured = regrade_capability(cap("NO MODEL CLEARS BAR"), ctlq, bar)
261
421
  assert measured["regraded_decision"] == "NO MODEL CLEARS BAR", measured["regraded_decision"]
262
422
 
263
- print("selftest OK: idempotent on UNRESOLVED; a measured failure is left alone")
423
+ # 3. A RESCORE SETTLES AN UNRESOLVED. This is the whole point of --rescore: a
424
+ # control added after the screen ran reaches the ladder, and a candidate
425
+ # that clears the bar on it turns "not known" into PUBLISH. The elided row
426
+ # is still unknown and still reported -- it just cannot block any more,
427
+ # which is this tool's own stated monotonicity ("an unknown can only ever
428
+ # ADD a passing candidate").
429
+ ctlq3 = dict(ctlq, fresh={"grade": "good"})
430
+ rescore = {("m1", "fire"): {"control": "fresh", "n": 72, "rate_025": 0.82,
431
+ "rate_at_quiet_t": 0.86, "quiet_t": 0.30,
432
+ "source_file": "rescore.json"}}
433
+ settled = regrade_capability(cap(UNRESOLVED), ctlq3, bar, rescore)
434
+ assert settled["regraded_decision"] == "PUBLISH", settled["regraded_decision"]
435
+ assert settled["winner"]["control"] == "fresh", settled["winner"]
436
+ assert settled["rescored_controls"], "the rescore must be recorded, not silently used"
437
+ assert settled["changed"] is True
438
+
439
+ # 4. A RESCORE AT A LOWER THRESHOLD IS REFUSED. `off_target_mean` is inherited
440
+ # from the committed row and is only an UPPER BOUND when the rescored
441
+ # threshold is >= the committed one. Below it, no off-target number exists
442
+ # at that operating point and passing the row would assert one.
443
+ low = {("m1", "fire"): {"control": "fresh", "n": 72, "rate_025": 0.82,
444
+ "rate_at_quiet_t": 0.86, "quiet_t": 0.10,
445
+ "source_file": "rescore.json"}}
446
+ refused = regrade_capability(cap(UNRESOLVED), ctlq3, bar, low)
447
+ assert refused["regraded_decision"] == UNRESOLVED, refused["regraded_decision"]
448
+ assert any("refused" in r for r in refused["rescored_controls"]), refused["rescored_controls"]
449
+
450
+ print("selftest OK: idempotent on UNRESOLVED; a measured failure is left alone; "
451
+ "a rescore settles an unknown; a rescore below the committed threshold is refused")
264
452
 
265
453
 
266
454
  def main() -> None:
@@ -269,7 +457,24 @@ def main() -> None:
269
457
  _selftest()
270
458
  return
271
459
  ap.add_argument("--adjudication", required=True)
272
- ap.add_argument("--control-quality", required=True)
460
+ ap.add_argument("--control-quality", required=True, nargs="+",
461
+ help="positive-control-quality-*.json. TAKES A LIST and merges them, later "
462
+ "files winning, exactly as venue_adjudication.py does. It read ONE "
463
+ "path until 2026-08-27, which is unsafe for the reason that file "
464
+ "states about itself: `liveness_only` and `unusable` are both built "
465
+ "by MEMBERSHIP, so a set whose grade lives in a file this run did not "
466
+ "read falls through both and is eligible to carry a verdict on "
467
+ "nobody's inspection. The grades live in DATED files because the "
468
+ "2026-08-25 record is pinned by the score cards that cite it, so a "
469
+ "set graded later lands in a new file rather than editing that one -- "
470
+ "which means a single-path reader was guaranteed to go stale.")
471
+ ap.add_argument("--rescore", nargs="+", default=None,
472
+ help="committed rescore artifact(s) supplying a control the ORIGINAL "
473
+ "screen never scored. Admissible only because they are produced by "
474
+ "venue_adjudication.py's own rule -- same GRID, same QUIET_MAX_FIRE, "
475
+ "same worst-valid-bucket quiet_t, same bar. Without this there is no "
476
+ "path for a control added after the screen ran, and a capability "
477
+ "whose verdict a later measurement settles stays UNRESOLVED for ever.")
273
478
  ap.add_argument("--out", required=True)
274
479
  ap.add_argument("--apply", action="store_true",
275
480
  help="also write the corrected verdict back into --adjudication. The "
@@ -278,10 +483,15 @@ def main() -> None:
278
483
  args = ap.parse_args()
279
484
 
280
485
  adj = json.load(open(args.adjudication))
281
- ctlq = json.load(open(args.control_quality))
486
+ ctlq = {}
487
+ for _q in args.control_quality:
488
+ for _k, _v in json.load(open(_q)).items():
489
+ if isinstance(_v, dict) and isinstance(_v.get("grade"), str):
490
+ ctlq[_k] = _v
282
491
  bar = adj["bar"]
283
492
 
284
- rows = [regrade_capability(c, ctlq, bar) for c in adj["capabilities"]]
493
+ rescores = load_rescores(args.rescore)
494
+ rows = [regrade_capability(c, ctlq, bar, rescores) for c in adj["capabilities"]]
285
495
  changed = [r for r in rows if r["changed"]]
286
496
 
287
497
  # The regrade artifact is a record OF A TRANSITION. Once `--apply` has run, the
@@ -313,6 +523,8 @@ def main() -> None:
313
523
  "This re-reads the numbers the adjudication already committed. A "
314
524
  "capability marked `needs_remeasure` has a candidate whose "
315
525
  "rateable-control rate was elided and must come from a re-run.",
526
+ "control_quality_inputs": sorted(os.path.basename(x) for x in args.control_quality),
527
+ "rescore_inputs": sorted({os.path.basename(x) for x in (args.rescore or [])}),
316
528
  "liveness_only_grades": list(LIVENESS_ONLY_GRADES),
317
529
  "unusable_grades": list(UNUSABLE_GRADES),
318
530
  "bar": bar,
@@ -327,34 +539,85 @@ def main() -> None:
327
539
  }, open(args.out, "w"), indent=1)
328
540
 
329
541
  if args.apply and changed:
542
+ # THE WRITER MUST HANDLE BOTH DIRECTIONS. It was built for the WITHDRAWAL
543
+ # only -- it hardcoded the date `2026-08-26`, always wrote `ships: False`,
544
+ # and never recorded a winner, because a withdrawal has none. All three
545
+ # are correct for taking a PUBLISH away and wrong for giving one back,
546
+ # and a restoration written through the withdrawal's fields would land a
547
+ # PUBLISH in the record with no model, no control and `ships: False`
548
+ # beside it -- a verdict a reader cannot act on and a sales claim nobody
549
+ # can trace to an artifact.
550
+ today = time.strftime("%Y-%m-%d", time.gmtime())
330
551
  by_cap = {r["capability"]: r for r in changed}
331
552
  for cap in adj["capabilities"]:
332
553
  r = by_cap.get(cap["capability"])
333
554
  if not r:
334
555
  continue
335
556
  was = dict(cap["decision"])
336
- cap["decision"] = {
557
+ win = r.get("winner") or {}
558
+ publishes = r["regraded_decision"] == "PUBLISH"
559
+ if publishes:
560
+ why_corrected = (
561
+ "a control that CAN rate this class was added after the screen ran "
562
+ "(`%s`, n=%s, grade %s) and is committed in %s, which computed its rate "
563
+ "with venue_adjudication.py's OWN rule -- same GRID, same QUIET_MAX_FIRE, "
564
+ "same worst-valid-bucket quiet_t, same bar. Re-applying that bar with the "
565
+ "new control in scope clears it. The elided rows stay unknown and cannot "
566
+ "block: an unknown can only ever ADD a passing candidate."
567
+ % (win.get("control"), win.get("control_n"), win.get("grade"),
568
+ ", ".join(sorted({os.path.basename(x) for x in (args.rescore or [])}))
569
+ or "a rescore artifact"))
570
+ else:
571
+ why_corrected = (
572
+ "the verdict rested on `%s` (n=%s, grade %s), a set whose own "
573
+ "contact-sheet note says it can only show liveness. Re-applying "
574
+ "this run's OWN bar to the controls that can rate a class "
575
+ "overturns it." % (was.get("control"), was.get("control_n"),
576
+ r.get("prior_control_grade")))
577
+ decision = {
337
578
  "decision": r["regraded_decision"],
338
579
  "why": r["why"],
339
- # The gate is a promise computed from a recall this verdict no
340
- # longer accepts. Keeping it would leave an events-lost figure in
341
- # the file with nothing behind it.
580
+ # NOT re-derived here in either direction, and for the same
581
+ # reason: `events_lost` / `vlm_calls_saved` are computed by
582
+ # venue_adjudication.py from the FULL per-frame score matrices
583
+ # against the whole negative set, which this tool does not hold.
584
+ # A withdrawal must not keep a promise computed from a recall it
585
+ # no longer accepts; a restoration must not invent one.
342
586
  "gate": None,
343
- "corrected": {
344
- "on": "2026-08-26",
345
- "by": "deploy/detector/bench/regrade_control_selection.py --apply",
346
- "was": was,
347
- "ships": False,
348
- "why_corrected": (
349
- "the verdict rested on `%s` (n=%s, grade %s), a set whose own "
350
- "contact-sheet note says it can only show liveness. Re-applying "
351
- "this run's OWN bar to the controls that can rate a class "
352
- "overturns it." % (was.get("control"), was.get("control_n"),
353
- r.get("prior_control_grade"))),
354
- "needs_remeasure": r["candidates_unknown"],
355
- "regrade_artifact": args.out,
356
- },
587
+ "gate_is_null_because": (
588
+ "the cascade-gate arithmetic is a function of the full score matrices, "
589
+ "which this tool does not read. A verdict is derivable from committed "
590
+ "rates; a savings promise is not."),
591
+ }
592
+ caveats = [c for n in (r.get("rescored_controls") or [])
593
+ for c in (n.get("sellability_caveats") or [])]
594
+ if caveats:
595
+ # TOP LEVEL, beside the verdict, not buried in `corrected`. This
596
+ # file is quoted in the sales claim sheet, and a PUBLISH whose
597
+ # caveat is one level down is a PUBLISH that gets quoted alone.
598
+ decision["sellability_caveats"] = caveats
599
+ if publishes:
600
+ decision.update({
601
+ "model": win.get("model"),
602
+ "class": win.get("class"),
603
+ "control": win.get("control"),
604
+ "control_n": win.get("control_n"),
605
+ "control_grade": win.get("grade"),
606
+ "operating_conf": win.get("quiet_t"),
607
+ "n_valid_negatives": win.get("n_valid_negatives"),
608
+ "also_scored": win.get("also_scored"),
609
+ })
610
+ decision["corrected"] = {
611
+ "on": today,
612
+ "by": "deploy/detector/bench/regrade_control_selection.py --apply",
613
+ "was": was,
614
+ "ships": publishes,
615
+ "why_corrected": why_corrected,
616
+ "needs_remeasure": r["candidates_unknown"],
617
+ "rescored_controls": r.get("rescored_controls") or [],
618
+ "regrade_artifact": args.out,
357
619
  }
620
+ cap["decision"] = decision
358
621
  cap["changed"] = bool(cap.get("prior_decision_taxi_negatives")
359
622
  and cap["prior_decision_taxi_negatives"]
360
623
  != r["regraded_decision"])
@@ -91,6 +91,7 @@ from box_units import ( # noqa: E402 (sibling module, stdlib-only on purpose)
91
91
  )
92
92
  from trillboards_detector.errors import ModelContractError # noqa: E402
93
93
  from trillboards_detector.labels import ( # noqa: E402
94
+ BOX_UNITS_NORMALIZED,
94
95
  BOX_UNITS_PIXELS,
95
96
  BUILTIN_VOCABULARIES,
96
97
  ModelVocabulary,
@@ -554,7 +555,11 @@ class Frame:
554
555
 
555
556
 
556
557
  def synthesise_vocabulary(
557
- name: str, class_names: "dict[int, str]", output_layout: str
558
+ name: str,
559
+ class_names: "dict[int, str]",
560
+ output_layout: str,
561
+ box_units: str = BOX_UNITS_PIXELS,
562
+ box_scale_to_pixels: float = 1.0,
558
563
  ) -> ModelVocabulary:
559
564
  """A ModelVocabulary over a model's OWN names, canonicalised the service's way.
560
565
 
@@ -602,7 +607,7 @@ def synthesise_vocabulary(
602
607
  # 0..1 boxes as 0..1; what it would lose is per-class detections on
603
608
  # padded frames, which is the signal to read the units verdict before
604
609
  # trusting that candidate's score.
605
- onnx_variant=VariantContract(output_layout, BOX_UNITS_PIXELS, 1.0),
610
+ onnx_variant=VariantContract(output_layout, box_units, box_scale_to_pixels),
606
611
  )
607
612
 
608
613
 
@@ -827,6 +832,75 @@ def main() -> int:
827
832
  if units["refusal"]:
828
833
  raise SystemExit(f"REFUSING: {units['refusal']}")
829
834
 
835
+ # ── MEASURE THE UNITS, THEN DECODE WITH THEM ─────────────────────────────
836
+ #
837
+ # The pass above ran with the identity contract (`pixels`/1.0) ON PURPOSE:
838
+ # it is the only pair that scales nothing, so the graph's own box magnitudes
839
+ # reach `box_abs_max` untouched and the classifier sees what the model
840
+ # actually emitted. What was missing is the second half — USING the answer.
841
+ #
842
+ # For a NORMALISED artifact the identity decode reads 0..1 boxes as pixels,
843
+ # and un-letterboxing then subtracts the pad offset from a sub-pixel box and
844
+ # pushes it out of frame. On a SQUARE source there is no padding and nothing
845
+ # is lost; on any other aspect ratio EVERY detection is dropped, silently,
846
+ # and the run reports a model that fires on nothing.
847
+ #
848
+ # That is not hypothetical. `fall_1cls_11x` is the one normalised ONNX
849
+ # artifact in the registry, and `fall-2026-08-25` scored it on 14 positives
850
+ # that are 1024x1024 SQUARE against 80 negatives that are 640x480. The
851
+ # positives survived, the negatives were all dropped, and the card published
852
+ # `precision 1.0` with `fire_rate_on_labelled_empty: 0` — a measurement of
853
+ # the harness's blind spot wearing the model's name. Measured 2026-08-28:
854
+ # the same 80 frames score up to 0.9400 through the SERVICE's decode, which
855
+ # reads the declared contract, against a published operating threshold of
856
+ # 0.8616873025894165.
857
+ #
858
+ # So a normalised verdict re-runs the scored pass with the measured contract.
859
+ # It costs a second pass on the artifacts that need one and nothing on the
860
+ # ones that do not, and it cannot flatter a candidate: the second pass is the
861
+ # decode the SERVICE would use, and it can only ADD detections the first pass
862
+ # was unable to represent — including on negatives.
863
+ decoded_units = BOX_UNITS_PIXELS
864
+ redecoded = False
865
+ if units["units"] == BOX_UNITS_NORMALIZED and isinstance(runner, ShippedRunner) and declared_names:
866
+ redecoded = True
867
+ runner = ShippedRunner(
868
+ model_path,
869
+ synthesise_vocabulary(
870
+ args.candidate, declared_names, loadability["output_layout"],
871
+ BOX_UNITS_NORMALIZED, float(units["scale_to_pixels"]),
872
+ ),
873
+ )
874
+ detections = {}
875
+ infer_ms = []
876
+ wall_ms = []
877
+ for key in keys:
878
+ frame = Frame(frames_dir / os.path.basename(key))
879
+ started = time.perf_counter()
880
+ per_class, ms, _box_max = runner.run(frame, args.conf_floor)
881
+ wall_ms.append((time.perf_counter() - started) * 1000.0)
882
+ infer_ms.append(ms)
883
+ detections[key] = per_class
884
+ frame.release()
885
+ decoded_units = BOX_UNITS_NORMALIZED
886
+ sys.stderr.write(
887
+ f" re-decoded {len(keys)} frame(s) with the MEASURED contract "
888
+ f"({BOX_UNITS_NORMALIZED} x{units['scale_to_pixels']:g}); the first pass used the identity "
889
+ "to measure the units and could not represent this artifact's boxes\n"
890
+ )
891
+ elif units["units"] == BOX_UNITS_NORMALIZED:
892
+ # No path to a correct decode from here: either the graph declares no
893
+ # names to synthesise a vocabulary from, or the runner is not the shipped
894
+ # one. REFUSE rather than emit a run whose detections were decoded with
895
+ # units the same run reports as wrong — that combination is what produced
896
+ # `fall_1cls_11x`'s card.
897
+ raise SystemExit(
898
+ f"REFUSING: {model_path.name} emits NORMALIZED boxes (ratio {units['ratio']:.4f}) and this "
899
+ "runner cannot be rebuilt with that contract, so every detection on a non-square frame was "
900
+ "silently dropped. Score it through the detector service instead: "
901
+ "node trillboard-api/scripts/sensing/score-venue-corpus.js --model <id> --sweep"
902
+ )
903
+
830
904
  same_source = bool(rss_before and rss_after_probe and rss_before[1] == rss_after_probe[1])
831
905
  rss = {
832
906
  "source": rss_before[1] if rss_before else None,
@@ -855,6 +929,17 @@ def main() -> int:
855
929
  "sha256": sha256_file(model_path),
856
930
  "bytes": model_path.stat().st_size,
857
931
  },
932
+ "decode": {
933
+ "units_measured": units["units"],
934
+ "units_decoded_with": decoded_units,
935
+ "rescored_after_measuring_units": redecoded,
936
+ "why": (
937
+ "The scored pass is decoded with the units the run MEASURED, not with the identity it "
938
+ "measured them with. A normalized artifact decoded as pixels loses every detection on a "
939
+ "letterbox-padded frame and keeps them on a square one, which is a property of the eval "
940
+ "set's aspect ratios and not of the model."
941
+ ),
942
+ },
858
943
  "eval_set_manifest": {
859
944
  "path": str(manifest_path),
860
945
  "sha256": sha256_file(manifest_path),
@@ -151,8 +151,19 @@ CLASS_CONTROL = {
151
151
  "firearm": ["gun_clean", "weapon_cctv"], "knife": ["knife_cctv", "weapon_cctv", "n_knife"],
152
152
  "fall": ["fall_cctv"], "fallen": ["fall_cctv"], "fall-detected": ["fall_cctv"],
153
153
  "Fall-Detected": ["fall_cctv"], "down": ["fall_cctv"],
154
- "door": ["n_door"], "window": ["n_window"], "phone": ["n_phone"], "cell phone": ["n_phone"],
155
- "phone_call": ["n_phone"], "mouse": ["n_rodent"], "void": ["n_shelfgap"],
154
+ "door": ["n_door"], "window": ["n_window"],
155
+ # `phone_use_meva` (22 frames / 17 fixed cameras, `good`) leads because it is the only
156
+ # set that can rate this class at all TODAY: `n_phone` is graded `good` but its FRAMES
157
+ # ARE GONE -- s3://trillboards-media-prod/DEV/regression-corpus/ holds no `n_phone`
158
+ # prefix, measured 2026-08-27, the same as fire_cctv/fire_generic/wc_spill. `n_phone` is
159
+ # KEPT and stays second for the same reason `fire_generic` was kept after it was
160
+ # demoted: its number is in the committed adjudication and deleting the mapping would
161
+ # hide what the earlier screen scored. The two also answer different questions --
162
+ # `n_phone` is web stills of people holding smartphones, `phone_use_meva` is fixed CCTV
163
+ # at 79-690 px of person height, which is the deployment.
164
+ "phone": ["phone_use_meva", "n_phone"], "cell phone": ["phone_use_meva", "n_phone"],
165
+ "phone_call": ["phone_use_meva", "n_phone"],
166
+ "mouse": ["n_rodent"], "void": ["n_shelfgap"],
156
167
  "cart": ["wc_cart", "n_trolley"], "trolley": ["wc_cart", "n_trolley"], "shopping_cart": ["wc_cart", "n_trolley"],
157
168
  "spill": ["wc_spill", "n_spill"], "Spill": ["wc_spill", "n_spill"],
158
169
  "cigarette": ["n_smoking"], "Cigarette": ["n_smoking"], "smoking": ["n_smoking"],