@trillboards/edge-sdk 0.2.111 → 0.2.113

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.
@@ -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"])
@@ -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"],
@@ -29,7 +29,10 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "bench")
29
29
 
30
30
  from regrade_control_selection import ( # noqa: E402
31
31
  PRESERVE_ON_BLOCKING,
32
+ RESCORE_SCHEMAS,
32
33
  UNRESOLVED,
34
+ apply_rescore,
35
+ load_rescores,
33
36
  rateable_controls,
34
37
  regrade_capability,
35
38
  )
@@ -116,5 +119,110 @@ class TestRegradeControlSelection(unittest.TestCase):
116
119
  self.assertEqual(out["status"], "not_affected")
117
120
 
118
121
 
122
+
123
+
124
+ class RescoreReachesTheLadder(unittest.TestCase):
125
+ """A control added AFTER the screen ran must be able to settle a verdict.
126
+
127
+ Without this the tool has no input for a set the original run never scored,
128
+ so a capability whose verdict a later measurement settles stays `UNRESOLVED`
129
+ for ever while the registry publishes a model — two stores disagreeing about
130
+ whether a capability is sellable, which is the state this input exists to
131
+ end. Proved empirically first: with `fire_firesense` graded `good` and
132
+ visible to it, the tool still returned UNRESOLVED, because the new control
133
+ appears in no candidate's `per_control`.
134
+ """
135
+
136
+ RESCORE = {("m1", "fire"): {"control": "fresh", "n": 72, "rate_025": 0.82,
137
+ "rate_at_quiet_t": 0.86, "quiet_t": 0.30,
138
+ "source_file": "rescore.json"}}
139
+ CTLQ3 = dict(CTLQ, fresh={"grade": "good"})
140
+
141
+ def test_a_rescore_settles_an_unresolved(self):
142
+ r = regrade_capability(_cap(UNRESOLVED), self.CTLQ3, BAR, self.RESCORE)
143
+ self.assertEqual(r["regraded_decision"], "PUBLISH")
144
+ self.assertEqual(r["winner"]["control"], "fresh")
145
+ self.assertTrue(r["rescored_controls"], "the rescore must be recorded, not silently used")
146
+
147
+ def test_a_rescore_below_the_committed_threshold_is_REFUSED(self):
148
+ """`off_target_mean` is INHERITED, and only bounds upward.
149
+
150
+ It is `(scores >= t).mean()`, monotone non-increasing in t, so a value
151
+ measured at a LOWER threshold is an upper bound at a higher one. Below
152
+ the committed threshold the bound runs the wrong way and there is no
153
+ off-target number at that operating point — passing the row would assert
154
+ one nobody measured.
155
+ """
156
+ low = {("m1", "fire"): dict(self.RESCORE[("m1", "fire")], quiet_t=0.10)}
157
+ r = regrade_capability(_cap(UNRESOLVED), self.CTLQ3, BAR, low)
158
+ self.assertEqual(r["regraded_decision"], UNRESOLVED)
159
+ self.assertTrue(any("refused" in n for n in r["rescored_controls"]))
160
+
161
+ def test_an_ungraded_rescored_control_is_refused_not_trusted(self):
162
+ """An ungraded set falls through both `liveness_only` and `unusable`."""
163
+ with self.assertRaises(SystemExit):
164
+ apply_rescore(_cap(UNRESOLVED)["candidates"][0], self.RESCORE, CTLQ)
165
+
166
+ def test_a_rescore_never_overturns_a_measured_failure_into_silence(self):
167
+ """A passing rescore may raise a failure to PUBLISH — that is the point —
168
+ but it must do so through the ladder, with a winner and a recorded source."""
169
+ r = regrade_capability(_cap("NO MODEL CLEARS BAR"), self.CTLQ3, BAR, self.RESCORE)
170
+ self.assertEqual(r["regraded_decision"], "PUBLISH")
171
+ self.assertEqual(r["rescored_controls"][0]["source_file"], "rescore.json")
172
+
173
+ def test_an_unreadable_rescore_schema_is_refused_not_skipped(self):
174
+ import json as _json
175
+ import tempfile
176
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
177
+ _json.dump({"schema": "something.else/1"}, fh)
178
+ bad = fh.name
179
+ with self.assertRaises(SystemExit):
180
+ load_rescores([bad])
181
+
182
+ def test_a_stale_threshold_control_may_not_carry_the_verdict(self):
183
+ """THE RATE BELONGS TO THE THRESHOLD IT WAS MEASURED AT.
184
+
185
+ A rescore raises the candidate's `quiet_t` but re-measures only its OWN
186
+ control. Every other rate in `per_control` is still the value at the
187
+ committed, LOWER threshold, where `(scores >= t).mean()` is an UPPER
188
+ BOUND — so reading it as the recall at the new operating point overstates
189
+ recall. Here `big` is stale at 0.90 and the rescored control is a genuine
190
+ 0.30 failure: without per-control threshold tracking `argmax(rate_025)`
191
+ picks `big` and manufactures a PUBLISH out of a number measured somewhere
192
+ else.
193
+ """
194
+ cap = _cap(UNRESOLVED, with_elided=False)
195
+ cap["candidates"][0]["per_control"]["big"] = {
196
+ "n": 9, "rate_025": 0.90, "rate_at_quiet_t": 0.90}
197
+ weak = {("m1", "fire"): dict(self.RESCORE[("m1", "fire")],
198
+ rate_025=0.30, rate_at_quiet_t=0.30)}
199
+ r = regrade_capability(cap, self.CTLQ3, BAR, weak)
200
+ self.assertNotEqual(
201
+ r["regraded_decision"], "PUBLISH",
202
+ "a rate measured at the committed threshold was used to clear the bar "
203
+ "at the higher rescored threshold")
204
+ stale = r["stale_threshold_controls"]
205
+ self.assertTrue(stale, "the stale-threshold refusal must be stated, not silent")
206
+ self.assertEqual(stale[0]["control"], "big")
207
+ self.assertEqual((stale[0]["measured_at_t"], stale[0]["applied_t"]), (0.25, 0.30))
208
+
209
+ def test_a_control_at_the_applied_threshold_still_carries_the_verdict(self):
210
+ """The positive half: the rescored control IS at the applied threshold,
211
+ so it decides — this is the path fire and smoke actually take."""
212
+ r = regrade_capability(_cap(UNRESOLVED, with_elided=False),
213
+ self.CTLQ3, BAR, self.RESCORE)
214
+ self.assertEqual(r["regraded_decision"], "PUBLISH")
215
+ self.assertEqual(r["winner"]["control"], "fresh")
216
+
217
+ def test_without_a_rescore_no_control_is_stamped_stale(self):
218
+ """The untouched path must be byte-identical — nothing is stamped, so
219
+ every control remains admissible."""
220
+ a = regrade_capability(_cap("NO MODEL CLEARS BAR", rateable_rate=0.8), CTLQ, BAR)
221
+ self.assertEqual(a["regraded_decision"], "PUBLISH")
222
+
223
+ def test_the_known_schema_list_is_not_empty(self):
224
+ self.assertTrue(RESCORE_SCHEMAS)
225
+
226
+
119
227
  if __name__ == "__main__":
120
228
  unittest.main()
@@ -210,6 +210,17 @@ export declare class AudienceAnalyzeClient {
210
210
  * local-vlm rows while the cloud path is 100%.
211
211
  */
212
212
  promptText?: string;
213
+ /**
214
+ * Which fields of `preAnalyzed` a GRANTED SPECIALIST answered, as
215
+ * `field -> modelId`. Everything not named here was answered by the
216
+ * on-device VLM.
217
+ *
218
+ * The two producers write byte-identical values into the same flat
219
+ * object, so this map is the only thing that can separate them — and the
220
+ * server needs it to stamp `local_detector` in `_provenance_map` rather
221
+ * than flattening the whole observation to `local_connector`.
222
+ */
223
+ detectorProduced?: Record<string, string>;
213
224
  }): Promise<AudienceAnalyzeResponse>;
214
225
  /**
215
226
  * POST a FRAMELESS camera-health beacon. This is used when the connector can
@@ -1 +1 @@
1
- {"version":3,"file":"AudienceAnalyzeClient.d.ts","sourceRoot":"","sources":["../src/AudienceAnalyzeClient.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AudienceAnalyzeClient.d.ts","sourceRoot":"","sources":["../src/AudienceAnalyzeClient.ts"],"names":[],"mappings":"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"}
@@ -230,6 +230,13 @@ class AudienceAnalyzeClient {
230
230
  ...(typeof request.promptText === 'string' && request.promptText.trim() !== ''
231
231
  ? { localPromptText: request.promptText }
232
232
  : {}),
233
+ // Per-field producer attribution. Sent only when a specialist actually
234
+ // answered something: an absent key means "no specialist answered on this
235
+ // frame", which is a different fact from an empty map and the server
236
+ // treats them the same way only because it never sees the second one.
237
+ ...(request.detectorProduced && Object.keys(request.detectorProduced).length > 0
238
+ ? { detectorProduced: request.detectorProduced }
239
+ : {}),
233
240
  // Same last-spread override semantics as analyze(); see the note there.
234
241
  ...(request.extra || {}),
235
242
  ...(evidenceId ? { evidenceId } : {}),