@trillboards/edge-sdk 0.2.107 → 0.2.110
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/regrade_control_selection.py +371 -0
- package/deploy/detector/bench/venue_adjudication.py +182 -13
- 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 +32 -1
- package/deploy/windows/setup-litertlm.ps1 +35 -20
- 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,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Re-apply the venue adjudication's own bar with the control-selection fix.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS
|
|
5
|
+
---------------
|
|
6
|
+
`venue_adjudication.py` chose the control a verdict rests on with
|
|
7
|
+
`argmax(rate_025)` over every set listed for the class. That is a selector with no
|
|
8
|
+
access to the contact-sheet grade, so it reliably picks the set that flatters the
|
|
9
|
+
model. On 2026-08-26 it picked `fire_generic` -- n=5, graded `good_small_n`, whose
|
|
10
|
+
own note reads "n=5 so only a coarse liveness signal" -- over `fire_cctv`, n=39,
|
|
11
|
+
graded `good`, "realistic scale for a venue camera", scored in the same run against
|
|
12
|
+
the same artifact at the same threshold. `fire` and `smoke` were awarded PUBLISH on
|
|
13
|
+
the 5-frame set.
|
|
14
|
+
|
|
15
|
+
The tool is fixed. Re-running it end to end needs the per-frame score matrices
|
|
16
|
+
(`<alias>.npz` + `combo.json`) from the eval box, which are not committed. This
|
|
17
|
+
re-applies the SAME bar to the numbers the artifact already carries, so the
|
|
18
|
+
corrected verdict is derivable from what is in the repo -- and, crucially, marks
|
|
19
|
+
each capability `proven` or `needs_remeasure` rather than pretending the elided
|
|
20
|
+
rows are known.
|
|
21
|
+
|
|
22
|
+
WHAT IT CANNOT DO, AND SAYS SO
|
|
23
|
+
------------------------------
|
|
24
|
+
`venue_adjudication.py` elides `per_control` for candidates ranked 5+ that carry no
|
|
25
|
+
shipped bytes. For those rows only the WINNING set's rate survives. Where the
|
|
26
|
+
winning set was liveness-only, that candidate's rateable-control rate is unknown
|
|
27
|
+
and the capability is reported `needs_remeasure` with the candidate named. An
|
|
28
|
+
unknown is not a pass and it is not a fail; it is the reason to re-run.
|
|
29
|
+
|
|
30
|
+
python3 regrade_control_selection.py \
|
|
31
|
+
--adjudication trillboard-api/scripts/ai/eval-baselines/venue-adjudication-2026-08-26.json \
|
|
32
|
+
--control-quality trillboard-api/scripts/ai/eval-baselines/positive-control-quality-2026-08-25.json \
|
|
33
|
+
--out trillboard-api/scripts/ai/eval-baselines/venue-adjudication-control-regrade-2026-08-26.json
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
#: Grades whose own note says the set cannot rate a class. Kept in step with
|
|
42
|
+
#: `venue_adjudication.py::LIVENESS_ONLY_GRADES`; the test pins them equal.
|
|
43
|
+
LIVENESS_ONLY_GRADES = ("good_small_n", "weak")
|
|
44
|
+
UNUSABLE_GRADES = ("DISCARDED", "POOR", "CONTAMINATED")
|
|
45
|
+
|
|
46
|
+
#: What a capability becomes when the PUBLISH is provably withdrawn but what
|
|
47
|
+
#: replaces it is not. NOT a verdict a model may ship on, and NOT a failure: the
|
|
48
|
+
#: elided rows could still clear the bar, and writing "NO MODEL CLEARS BAR" over
|
|
49
|
+
#: them would be the same mistake in the other direction -- a definitive claim
|
|
50
|
+
#: standing on evidence that was never committed.
|
|
51
|
+
UNRESOLVED = "UNRESOLVED PENDING REMEASURE"
|
|
52
|
+
|
|
53
|
+
#: Priors that an unknown may hold open. A PUBLISH because it is exactly what this
|
|
54
|
+
#: tool exists to withdraw; `UNRESOLVED` because this tool writes it, and a run
|
|
55
|
+
#: over its own output must not harden "not known" into "known bad".
|
|
56
|
+
PRESERVE_ON_BLOCKING = ("PUBLISH", UNRESOLVED)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def rateable_controls(per_control: dict, ctlq: dict) -> dict:
|
|
60
|
+
"""The controls that may carry a verdict: graded, usable, not liveness-only."""
|
|
61
|
+
out = {}
|
|
62
|
+
for name, row in (per_control or {}).items():
|
|
63
|
+
grade = (ctlq.get(name) or {}).get("grade")
|
|
64
|
+
if grade in LIVENESS_ONLY_GRADES or grade in UNUSABLE_GRADES:
|
|
65
|
+
continue
|
|
66
|
+
out[name] = dict(row, grade=grade)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def regrade_capability(cap: dict, ctlq: dict, bar: dict) -> dict:
|
|
71
|
+
"""Re-apply the bar to one capability using only rateable controls."""
|
|
72
|
+
recall_bar = bar["recall_retention"]
|
|
73
|
+
off_bar = bar["off_target_mean"]
|
|
74
|
+
prior = cap["decision"].get("decision")
|
|
75
|
+
|
|
76
|
+
# `decide()` resolves two branches BEFORE it ever looks at a control, and both
|
|
77
|
+
# are properties of the negative set. Re-deriving them here would only
|
|
78
|
+
# reproduce them from less information, and getting them subtly wrong is how a
|
|
79
|
+
# regrade starts reporting movement it did not cause -- the first run of this
|
|
80
|
+
# tool "moved" eight capabilities that have no control involved at all.
|
|
81
|
+
if not cap["candidates"] or prior in ("NO CANDIDATE", "NOT SCREENABLE HERE"):
|
|
82
|
+
return {
|
|
83
|
+
"capability": cap["capability"],
|
|
84
|
+
"prior_decision": prior,
|
|
85
|
+
"regraded_decision": prior,
|
|
86
|
+
"changed": False,
|
|
87
|
+
"status": "not_affected",
|
|
88
|
+
"why": "resolved before any control is read: %s" % (prior,),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
rows, unknown = [], []
|
|
92
|
+
for cand in cap["candidates"]:
|
|
93
|
+
elided = "_elided" in (cand.get("by_bucket") or {})
|
|
94
|
+
rate = rateable_controls(cand.get("per_control"), ctlq)
|
|
95
|
+
if not rate:
|
|
96
|
+
# No per-control detail survived the elision. If the set this row was
|
|
97
|
+
# decided on is itself rateable, the surviving number IS the rateable
|
|
98
|
+
# number and the row is knowable; otherwise it is not.
|
|
99
|
+
best = cand.get("best_control")
|
|
100
|
+
bg = (ctlq.get(best) or {}).get("grade") if best else None
|
|
101
|
+
if best and bg not in LIVENESS_ONLY_GRADES and bg not in UNUSABLE_GRADES:
|
|
102
|
+
rows.append({
|
|
103
|
+
"model": cand["model"], "class": cand["class"],
|
|
104
|
+
"control": best, "control_n": cand.get("control_n"),
|
|
105
|
+
"grade": bg,
|
|
106
|
+
"rate_at_quiet_t": cand.get("control_rate_at_quiet_t"),
|
|
107
|
+
"rate_025": cand.get("control_rate_025"),
|
|
108
|
+
"off_target_mean": cand.get("off_target_mean"),
|
|
109
|
+
"quiet_t": cand.get("quiet_t"),
|
|
110
|
+
"n_valid_negatives": cand.get("n_valid_negatives"),
|
|
111
|
+
"source": "winning set was already rateable",
|
|
112
|
+
})
|
|
113
|
+
else:
|
|
114
|
+
unknown.append({
|
|
115
|
+
"model": cand["model"], "class": cand["class"],
|
|
116
|
+
"decided_on": best, "grade": bg,
|
|
117
|
+
"why_unknown": ("per_control elided for this rank and the set it was "
|
|
118
|
+
"decided on cannot rate a class, so its rateable-control "
|
|
119
|
+
"rate was never committed"),
|
|
120
|
+
"elided": elided,
|
|
121
|
+
})
|
|
122
|
+
continue
|
|
123
|
+
# 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]
|
|
126
|
+
rows.append({
|
|
127
|
+
"model": cand["model"], "class": cand["class"],
|
|
128
|
+
"control": name, "control_n": row["n"], "grade": row["grade"],
|
|
129
|
+
"rate_at_quiet_t": row["rate_at_quiet_t"], "rate_025": row["rate_025"],
|
|
130
|
+
"off_target_mean": cand.get("off_target_mean"),
|
|
131
|
+
"quiet_t": cand.get("quiet_t"),
|
|
132
|
+
"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"]}
|
|
135
|
+
for k, v in (cand.get("per_control") or {}).items() if k != name},
|
|
136
|
+
"source": "recomputed from committed per_control",
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
live = [r for r in rows if (r["rate_025"] or 0) > 0]
|
|
140
|
+
retained = [r for r in live if (r["rate_at_quiet_t"] or 0) >= recall_bar]
|
|
141
|
+
ok = [r for r in retained
|
|
142
|
+
if r["off_target_mean"] is not None and r["off_target_mean"] <= off_bar]
|
|
143
|
+
|
|
144
|
+
if ok:
|
|
145
|
+
# The artifact's own ordering is preserved: candidates arrive sorted.
|
|
146
|
+
win = ok[0]
|
|
147
|
+
verdict = "PUBLISH"
|
|
148
|
+
why = ("clears the bar on %s (n=%d, grade %s): keeps %.1f%% at conf %s"
|
|
149
|
+
% (win["control"], win["control_n"], win["grade"],
|
|
150
|
+
100 * (win["rate_at_quiet_t"] or 0), win["quiet_t"]))
|
|
151
|
+
elif live:
|
|
152
|
+
best = max(live, key=lambda r: (r["rate_at_quiet_t"] or 0))
|
|
153
|
+
verdict = "NO MODEL CLEARS BAR"
|
|
154
|
+
why = ("no candidate keeps >=%.0f%% of a control that can rate a class. Best is "
|
|
155
|
+
"%s / '%s' at %.1f%% of %s (n=%d, grade %s)."
|
|
156
|
+
% (100 * recall_bar, best["model"], best["class"],
|
|
157
|
+
100 * (best["rate_at_quiet_t"] or 0), best["control"],
|
|
158
|
+
best["control_n"], best["grade"]))
|
|
159
|
+
win = None
|
|
160
|
+
else:
|
|
161
|
+
verdict, why, win = "UNINFORMATIVE", "no candidate fires on a rateable control", None
|
|
162
|
+
|
|
163
|
+
# An unknown row can only ever ADD a passing candidate, so it can overturn a
|
|
164
|
+
# failing verdict and can never overturn a PUBLISH.
|
|
165
|
+
blocking = unknown if verdict != "PUBLISH" else []
|
|
166
|
+
|
|
167
|
+
# ...and a verdict an unknown could overturn must not be stated as if it could
|
|
168
|
+
# not. Withdrawing the PUBLISH is CERTAIN -- it rested on a set that cannot
|
|
169
|
+
# rate a class, and no re-measurement changes that. Replacing it with a
|
|
170
|
+
# definitive failure is NOT certain, because an elided candidate could still
|
|
171
|
+
# clear the bar. Two different claims; only the first is proved here, and
|
|
172
|
+
# asserting the second would be the same error in the other direction.
|
|
173
|
+
#
|
|
174
|
+
# Gated on the PRIOR. Where the prior was already a MEASURED failure, the
|
|
175
|
+
# original run reached it with MORE controls and better data than this tool
|
|
176
|
+
# has for the elided rows, so overwriting it with `UNRESOLVED` would be this
|
|
177
|
+
# tool asserting its own blindness over a measurement. Those rows still carry
|
|
178
|
+
# `status: needs_remeasure`; their verdict is left alone.
|
|
179
|
+
#
|
|
180
|
+
# `UNRESOLVED` is in the set because THIS TOOL WRITES IT. Without it, running
|
|
181
|
+
# `--apply` twice would see `prior == UNRESOLVED`, skip this branch, and
|
|
182
|
+
# overwrite its own careful "not known" with a definitive `NO MODEL CLEARS
|
|
183
|
+
# BAR` -- the exact claim the first run refused to make, on the same
|
|
184
|
+
# evidence. The guard would not catch it either: it audits PUBLISH rows only.
|
|
185
|
+
# `--selftest` pins the idempotence.
|
|
186
|
+
if blocking and verdict == "NO MODEL CLEARS BAR" and prior in PRESERVE_ON_BLOCKING:
|
|
187
|
+
why = ("the prior verdict is WITHDRAWN -- it rested on a control that cannot rate a "
|
|
188
|
+
"class, which no re-measurement changes. What REPLACES it is not yet known: "
|
|
189
|
+
+ why
|
|
190
|
+
+ (" %d candidate(s) had their rateable-control rate elided and could still "
|
|
191
|
+
"clear the bar (%s). Re-run venue_adjudication.py against the score "
|
|
192
|
+
"matrices to settle it."
|
|
193
|
+
% (len(blocking), ", ".join("%s/%s" % (b["model"], b["class"])
|
|
194
|
+
for b in blocking))))
|
|
195
|
+
verdict = UNRESOLVED
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"capability": cap["capability"],
|
|
199
|
+
"prior_decision": prior,
|
|
200
|
+
"prior_control": cap["decision"].get("control"),
|
|
201
|
+
"prior_control_n": cap["decision"].get("control_n"),
|
|
202
|
+
"prior_control_grade": (ctlq.get(cap["decision"].get("control")) or {}).get("grade"),
|
|
203
|
+
"prior_events_lost": (cap["decision"].get("gate") or {}).get("events_lost"),
|
|
204
|
+
"regraded_decision": verdict,
|
|
205
|
+
"changed": bool(prior and prior != verdict),
|
|
206
|
+
"status": "needs_remeasure" if blocking else "proven",
|
|
207
|
+
"why": why,
|
|
208
|
+
"winner": win,
|
|
209
|
+
"candidates_regraded": len(rows),
|
|
210
|
+
"candidates_unknown": blocking,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _selftest() -> None:
|
|
215
|
+
"""Pin the two things a reader cannot see by reading the ladder.
|
|
216
|
+
|
|
217
|
+
1. IDEMPOTENCE. `--apply` writes `UNRESOLVED` into the artifact it reads. A
|
|
218
|
+
second run must reproduce it, not harden it into a failure.
|
|
219
|
+
2. A MEASURED failure is left alone. A prior that was already
|
|
220
|
+
`NO MODEL CLEARS BAR` came from a run with more controls than this tool
|
|
221
|
+
holds for an elided row, and must not be softened to `UNRESOLVED` either.
|
|
222
|
+
"""
|
|
223
|
+
ctlq = {"big": {"grade": "good"}, "tiny": {"grade": "good_small_n"}}
|
|
224
|
+
bar = {"recall_retention": 0.5, "off_target_mean": 0.1}
|
|
225
|
+
|
|
226
|
+
def cap(prior):
|
|
227
|
+
return {
|
|
228
|
+
"capability": "fire",
|
|
229
|
+
"decision": {"decision": prior, "control": "tiny", "control_n": 5},
|
|
230
|
+
"candidates": [
|
|
231
|
+
{ # a candidate that fails on the rateable set
|
|
232
|
+
"model": "m1", "class": "fire", "quiet_t": 0.25,
|
|
233
|
+
"off_target_mean": 0.0, "n_valid_negatives": 100,
|
|
234
|
+
"by_bucket": {}, "best_control": "big", "control_n": 9,
|
|
235
|
+
"control_rate_025": 0.4, "control_rate_at_quiet_t": 0.4,
|
|
236
|
+
"per_control": {"big": {"n": 9, "rate_025": 0.4, "rate_at_quiet_t": 0.4},
|
|
237
|
+
"tiny": {"n": 5, "rate_025": 0.9, "rate_at_quiet_t": 0.9}},
|
|
238
|
+
},
|
|
239
|
+
{ # ...and one whose rateable rate was elided
|
|
240
|
+
"model": "m2", "class": "fire", "quiet_t": 0.25,
|
|
241
|
+
"off_target_mean": 0.0, "n_valid_negatives": 100,
|
|
242
|
+
"by_bucket": {"_elided": "summary row"},
|
|
243
|
+
"best_control": "tiny", "control_n": 5,
|
|
244
|
+
"control_rate_025": 0.9, "control_rate_at_quiet_t": 0.9,
|
|
245
|
+
"per_control": {},
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
first = regrade_capability(cap("PUBLISH"), ctlq, bar)
|
|
251
|
+
assert first["regraded_decision"] == UNRESOLVED, first["regraded_decision"]
|
|
252
|
+
assert first["candidates_unknown"], "the elided row must be reported"
|
|
253
|
+
|
|
254
|
+
second = regrade_capability(cap(first["regraded_decision"]), ctlq, bar)
|
|
255
|
+
assert second["regraded_decision"] == UNRESOLVED, (
|
|
256
|
+
"re-running over this tool's own output hardened an unknown into %r"
|
|
257
|
+
% second["regraded_decision"])
|
|
258
|
+
assert second["changed"] is False, "a settled UNRESOLVED must not read as changed"
|
|
259
|
+
|
|
260
|
+
measured = regrade_capability(cap("NO MODEL CLEARS BAR"), ctlq, bar)
|
|
261
|
+
assert measured["regraded_decision"] == "NO MODEL CLEARS BAR", measured["regraded_decision"]
|
|
262
|
+
|
|
263
|
+
print("selftest OK: idempotent on UNRESOLVED; a measured failure is left alone")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main() -> None:
|
|
267
|
+
ap = argparse.ArgumentParser()
|
|
268
|
+
if "--selftest" in __import__("sys").argv[1:]:
|
|
269
|
+
_selftest()
|
|
270
|
+
return
|
|
271
|
+
ap.add_argument("--adjudication", required=True)
|
|
272
|
+
ap.add_argument("--control-quality", required=True)
|
|
273
|
+
ap.add_argument("--out", required=True)
|
|
274
|
+
ap.add_argument("--apply", action="store_true",
|
|
275
|
+
help="also write the corrected verdict back into --adjudication. The "
|
|
276
|
+
"adjudication is the record; leaving a verdict in it that its own "
|
|
277
|
+
"numbers refute is a second source of truth, not a safer one.")
|
|
278
|
+
args = ap.parse_args()
|
|
279
|
+
|
|
280
|
+
adj = json.load(open(args.adjudication))
|
|
281
|
+
ctlq = json.load(open(args.control_quality))
|
|
282
|
+
bar = adj["bar"]
|
|
283
|
+
|
|
284
|
+
rows = [regrade_capability(c, ctlq, bar) for c in adj["capabilities"]]
|
|
285
|
+
changed = [r for r in rows if r["changed"]]
|
|
286
|
+
|
|
287
|
+
# The regrade artifact is a record OF A TRANSITION. Once `--apply` has run, the
|
|
288
|
+
# adjudication no longer holds the verdict that moved, so a second run reads
|
|
289
|
+
# nothing to change and would write an empty `changed_list` over the record of
|
|
290
|
+
# what did. The adjudication itself is idempotent (proved by `--selftest` and
|
|
291
|
+
# by re-running `--apply`, which leaves it byte-identical); this file is not,
|
|
292
|
+
# and erasing it silently would leave the corrected artifact with nothing
|
|
293
|
+
# beside it saying what it was corrected FROM.
|
|
294
|
+
if not changed and os.path.exists(args.out):
|
|
295
|
+
try:
|
|
296
|
+
prev = json.load(open(args.out))
|
|
297
|
+
except (OSError, ValueError):
|
|
298
|
+
prev = {}
|
|
299
|
+
if (prev.get("summary") or {}).get("changed"):
|
|
300
|
+
raise SystemExit(
|
|
301
|
+
"refusing to overwrite %s: it records %d capability transition(s) and this "
|
|
302
|
+
"run found none, because the adjudication it read has ALREADY been corrected. "
|
|
303
|
+
"That is the expected result of re-running, not a reason to erase the record. "
|
|
304
|
+
"Point --out somewhere else, or re-run against an uncorrected adjudication."
|
|
305
|
+
% (args.out, (prev["summary"] or {})["changed"]))
|
|
306
|
+
|
|
307
|
+
json.dump({
|
|
308
|
+
"schema": "trillboards.venue-adjudication-control-regrade/1",
|
|
309
|
+
"regrades": adj["schema"] + " @ " + adj["negative_set"],
|
|
310
|
+
"_what": "the SAME bar, re-applied with the control-selection fix: a verdict may "
|
|
311
|
+
"only rest on a control whose contact-sheet grade can rate a class.",
|
|
312
|
+
"_not_a_re_run": "the per-frame scores are unchanged and are NOT re-derived here. "
|
|
313
|
+
"This re-reads the numbers the adjudication already committed. A "
|
|
314
|
+
"capability marked `needs_remeasure` has a candidate whose "
|
|
315
|
+
"rateable-control rate was elided and must come from a re-run.",
|
|
316
|
+
"liveness_only_grades": list(LIVENESS_ONLY_GRADES),
|
|
317
|
+
"unusable_grades": list(UNUSABLE_GRADES),
|
|
318
|
+
"bar": bar,
|
|
319
|
+
"summary": {
|
|
320
|
+
"capabilities": len(rows),
|
|
321
|
+
"changed": len(changed),
|
|
322
|
+
"changed_list": [{"capability": r["capability"],
|
|
323
|
+
"was": r["prior_decision"], "now": r["regraded_decision"],
|
|
324
|
+
"status": r["status"]} for r in changed],
|
|
325
|
+
},
|
|
326
|
+
"capabilities": rows,
|
|
327
|
+
}, open(args.out, "w"), indent=1)
|
|
328
|
+
|
|
329
|
+
if args.apply and changed:
|
|
330
|
+
by_cap = {r["capability"]: r for r in changed}
|
|
331
|
+
for cap in adj["capabilities"]:
|
|
332
|
+
r = by_cap.get(cap["capability"])
|
|
333
|
+
if not r:
|
|
334
|
+
continue
|
|
335
|
+
was = dict(cap["decision"])
|
|
336
|
+
cap["decision"] = {
|
|
337
|
+
"decision": r["regraded_decision"],
|
|
338
|
+
"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.
|
|
342
|
+
"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
|
+
},
|
|
357
|
+
}
|
|
358
|
+
cap["changed"] = bool(cap.get("prior_decision_taxi_negatives")
|
|
359
|
+
and cap["prior_decision_taxi_negatives"]
|
|
360
|
+
!= r["regraded_decision"])
|
|
361
|
+
json.dump(adj, open(args.adjudication, "w"), indent=1)
|
|
362
|
+
|
|
363
|
+
print(json.dumps({"capabilities": len(rows), "changed": len(changed),
|
|
364
|
+
"applied": bool(args.apply and changed),
|
|
365
|
+
"changed_list": [(r["capability"], r["prior_decision"],
|
|
366
|
+
r["regraded_decision"], r["status"])
|
|
367
|
+
for r in changed]}, indent=1))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
main()
|
|
@@ -15,8 +15,8 @@ industrial logistics yard, a hospital hand-wash basin, a big-box retail aisle an
|
|
|
15
15
|
an office/lab room. The positive controls are unchanged -- only the negative half
|
|
16
16
|
of every number moves, which is exactly the variable under test.
|
|
17
17
|
|
|
18
|
-
THE
|
|
19
|
-
|
|
18
|
+
THE THREE RULES THIS FILE ENFORCES
|
|
19
|
+
---------------------------------
|
|
20
20
|
1. "Quiet" is UNEVENTFUL, not EMPTY. The industrial footage contains people
|
|
21
21
|
working and a running forklift. A forklift frame is a POSITIVE for forklift and
|
|
22
22
|
a negative only for other classes, so validity is decided PER CLASS PER BUCKET
|
|
@@ -31,6 +31,21 @@ THE TWO RULES THIS FILE ENFORCES
|
|
|
31
31
|
at 0.22 s between frames -- `source_span_hours` in the composition is what
|
|
32
32
|
stops a frame count being read as an independence claim.
|
|
33
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
|
+
|
|
34
49
|
SCORED AS A CASCADE GATE, NOT A DETECTOR. The ranking inverts: what matters is
|
|
35
50
|
calls removed against events lost at the production empty-frame prior, not raw
|
|
36
51
|
precision. `cctv-detector-tier.md` is the authority on why.
|
|
@@ -98,7 +113,13 @@ CAPS = {
|
|
|
98
113
|
# 'PROD' is dropped: it meant the 1,500 rideshare frames, and a positive control
|
|
99
114
|
# drawn from the domain under test is exactly the confound this run removes.
|
|
100
115
|
CLASS_CONTROL = {
|
|
101
|
-
|
|
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"],
|
|
102
123
|
"helmet": ["helmet", "n_heavy"], "hardhat": ["helmet", "n_heavy"], "hard_hat": ["helmet", "n_heavy"],
|
|
103
124
|
"hat": ["helmet", "ppe_full"],
|
|
104
125
|
"vest": ["helmet", "n_heavy", "n_cone", "ppe_full"], "safety vest": ["helmet", "n_heavy", "n_cone", "ppe_full"],
|
|
@@ -131,6 +152,63 @@ CLASS_CONTROL = {
|
|
|
131
152
|
"cigarette": ["n_smoking"], "Cigarette": ["n_smoking"], "smoking": ["n_smoking"],
|
|
132
153
|
}
|
|
133
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
|
+
|
|
134
212
|
def normalise_class(name: str) -> str:
|
|
135
213
|
"""`labels.py::normalize_class_name`: lower, and `-`/`_` folded to space.
|
|
136
214
|
|
|
@@ -310,9 +388,21 @@ def main() -> None:
|
|
|
310
388
|
ap.add_argument("--combo", required=True, help="combo.json written by vm_prep2.py")
|
|
311
389
|
ap.add_argument("--grading", required=True, help="venue absence grading json")
|
|
312
390
|
ap.add_argument("--plan", required=True, help="scripts/sensing/quiet-hour-fleet-plan.json")
|
|
313
|
-
ap.add_argument("--control-quality",
|
|
314
|
-
|
|
315
|
-
|
|
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
|
+
)
|
|
316
406
|
ap.add_argument("--out", required=True)
|
|
317
407
|
ap.add_argument("--markdown", help="also write the runbook table here")
|
|
318
408
|
args = ap.parse_args()
|
|
@@ -320,7 +410,16 @@ def main() -> None:
|
|
|
320
410
|
combo = json.load(open(args.combo))
|
|
321
411
|
grading = json.load(open(args.grading))
|
|
322
412
|
plan = json.load(open(args.plan))
|
|
323
|
-
|
|
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
|
|
324
423
|
index = json.load(open(os.path.join(args.out_dir, "_index.json")))
|
|
325
424
|
|
|
326
425
|
meta = combo["meta"]
|
|
@@ -328,6 +427,7 @@ def main() -> None:
|
|
|
328
427
|
sets = np.array([m["set"] for m in meta])
|
|
329
428
|
is_neg = sets == "venue_src"
|
|
330
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)
|
|
331
431
|
|
|
332
432
|
# Positive-control sets whose contact-sheet grade forbids citing them.
|
|
333
433
|
# CONTAMINATED belongs here and was missed on the first pass: `wc_spill` is
|
|
@@ -336,10 +436,26 @@ def main() -> None:
|
|
|
336
436
|
# citing it would have given `spill` a recall number off a set that mostly
|
|
337
437
|
# does not contain a spill. Read the grades, do not enumerate the bad ones
|
|
338
438
|
# from memory.
|
|
339
|
-
UNUSABLE_GRADES = ("DISCARDED", "POOR", "CONTAMINATED")
|
|
340
439
|
unusable = {k for k, v in ctlq.items()
|
|
341
440
|
if isinstance(v, dict) and v.get("grade") in UNUSABLE_GRADES}
|
|
342
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
|
+
|
|
343
459
|
# ---- composition, stated rather than assumed ----------------------------
|
|
344
460
|
# `source_span_hours` is what keeps the frame count honest. 480 industrial
|
|
345
461
|
# frames spread over 10.77 h are 81 s apart and independent; 400 retail frames
|
|
@@ -566,7 +682,14 @@ def main() -> None:
|
|
|
566
682
|
continue
|
|
567
683
|
r25 = float((col[m] >= 0.25).mean())
|
|
568
684
|
rqt = float((col[m] >= quiet_t).mean()) if quiet_t is not None else None
|
|
569
|
-
per_ctl[s] = {"n": int(m.sum()), "rate_025": r25, "rate_at_quiet_t": rqt
|
|
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
|
|
570
693
|
if best_rate is None or r25 > best_rate:
|
|
571
694
|
best_ctl, best_rate, best_at_qt = s, r25, rqt
|
|
572
695
|
|
|
@@ -621,7 +744,13 @@ def main() -> None:
|
|
|
621
744
|
"quiet_fire_by_bucket": quiet_by_bucket,
|
|
622
745
|
"best_control": best_ctl, "control_n": (per_ctl.get(best_ctl) or {}).get("n"),
|
|
623
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,
|
|
624
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"]),
|
|
625
754
|
"off_target_mean": off_mean,
|
|
626
755
|
"off_target_n_sets": len(off),
|
|
627
756
|
"off_target_worst": ({"set": off_worst[0], "rate": off_worst[1]} if off_worst else None),
|
|
@@ -666,9 +795,25 @@ def main() -> None:
|
|
|
666
795
|
"bar": {
|
|
667
796
|
"quiet_max_fire": QUIET_MAX_FIRE,
|
|
668
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.",
|
|
669
802
|
"off_target_mean": OFF_TARGET_BAR,
|
|
670
803
|
"why": "identical to the 2026-08-25 screen so the only variable is the negative set",
|
|
671
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
|
+
},
|
|
672
817
|
"composition": comp,
|
|
673
818
|
"export_reproducibility": repro,
|
|
674
819
|
"venue_empty_frame_prior": venue_prior,
|
|
@@ -729,6 +874,24 @@ def _short(v: str) -> str:
|
|
|
729
874
|
"NO MODEL CLEARS BAR": "NO MODEL CLEARS BAR"}.get(v, v)
|
|
730
875
|
|
|
731
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
|
+
|
|
732
895
|
def decide(cap: str, cands: list) -> dict:
|
|
733
896
|
"""PUBLISH / NO MODEL CLEARS BAR / NOT SCREENABLE, with the reason.
|
|
734
897
|
|
|
@@ -776,13 +939,19 @@ def decide(cap: str, cands: list) -> dict:
|
|
|
776
939
|
return {"decision": "PUBLISH", "model": win["model"], "class": win["class"],
|
|
777
940
|
"operating_conf": win["quiet_t"],
|
|
778
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()},
|
|
779
947
|
"n_valid_negatives": win["n_valid_negatives"],
|
|
780
948
|
"gate": win["gate"],
|
|
781
|
-
"why": "fires on %s (%s frames) at %.0f%%, keeps %.0f%% of that at
|
|
782
|
-
"where it fires on <1%% of %d hand-declared venue negatives"
|
|
783
|
-
% (win["best_control"], win["control_n"],
|
|
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),
|
|
784
953
|
100 * (win["control_rate_at_quiet_t"] or 0), win["quiet_t"] or 0,
|
|
785
|
-
win["n_valid_negatives"]),
|
|
954
|
+
win["n_valid_negatives"], _other_controls_clause(win)),
|
|
786
955
|
"runner_up": ok[1]["model"] if len(ok) > 1 else None}
|
|
787
956
|
|
|
788
957
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""A regrade that writes `UNRESOLVED` must reproduce it, not harden it.
|
|
2
|
+
|
|
3
|
+
`regrade_control_selection.py --apply` writes its verdict back into the
|
|
4
|
+
adjudication it read. That makes the tool its own input, and the branch that
|
|
5
|
+
emits `UNRESOLVED PENDING REMEASURE` was originally gated on
|
|
6
|
+
`prior == "PUBLISH"` — so a second run saw `prior == "UNRESOLVED …"`, took the
|
|
7
|
+
gate as false, and replaced a careful "we do not know" with a definitive
|
|
8
|
+
`NO MODEL CLEARS BAR`, on exactly the same evidence the first run had refused to
|
|
9
|
+
conclude from. Nothing downstream would have caught it: the `positive-control-grade`
|
|
10
|
+
guard audits PUBLISH rows only, and both states are non-PUBLISH.
|
|
11
|
+
|
|
12
|
+
The other half is the mirror error. A prior that was ALREADY a measured
|
|
13
|
+
`NO MODEL CLEARS BAR` came out of a run that scored more controls than this tool
|
|
14
|
+
holds for an elided row, so softening it to `UNRESOLVED` would be the tool
|
|
15
|
+
asserting its own blindness over a measurement.
|
|
16
|
+
|
|
17
|
+
STDLIB ONLY — the detector CI lane runs `python3 -m unittest discover` with no
|
|
18
|
+
pip install, and `regrade_control_selection.py` imports nothing but the stdlib
|
|
19
|
+
for that reason.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import pathlib
|
|
25
|
+
import sys
|
|
26
|
+
import unittest
|
|
27
|
+
|
|
28
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "bench"))
|
|
29
|
+
|
|
30
|
+
from regrade_control_selection import ( # noqa: E402
|
|
31
|
+
PRESERVE_ON_BLOCKING,
|
|
32
|
+
UNRESOLVED,
|
|
33
|
+
rateable_controls,
|
|
34
|
+
regrade_capability,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
CTLQ = {
|
|
38
|
+
"big": {"grade": "good"},
|
|
39
|
+
"tiny": {"grade": "good_small_n"},
|
|
40
|
+
"junk": {"grade": "DISCARDED"},
|
|
41
|
+
}
|
|
42
|
+
BAR = {"recall_retention": 0.5, "off_target_mean": 0.1}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cap(prior, *, rateable_rate=0.4, with_elided=True):
|
|
46
|
+
"""One capability whose rateable control FAILS and whose 2nd row is elided."""
|
|
47
|
+
cands = [{
|
|
48
|
+
"model": "m1", "class": "fire", "quiet_t": 0.25,
|
|
49
|
+
"off_target_mean": 0.0, "n_valid_negatives": 100,
|
|
50
|
+
"by_bucket": {}, "best_control": "big", "control_n": 9,
|
|
51
|
+
"control_rate_025": rateable_rate, "control_rate_at_quiet_t": rateable_rate,
|
|
52
|
+
"per_control": {
|
|
53
|
+
"big": {"n": 9, "rate_025": rateable_rate, "rate_at_quiet_t": rateable_rate},
|
|
54
|
+
"tiny": {"n": 5, "rate_025": 0.9, "rate_at_quiet_t": 0.9},
|
|
55
|
+
},
|
|
56
|
+
}]
|
|
57
|
+
if with_elided:
|
|
58
|
+
cands.append({
|
|
59
|
+
"model": "m2", "class": "fire", "quiet_t": 0.25,
|
|
60
|
+
"off_target_mean": 0.0, "n_valid_negatives": 100,
|
|
61
|
+
"by_bucket": {"_elided": "summary row"},
|
|
62
|
+
"best_control": "tiny", "control_n": 5,
|
|
63
|
+
"control_rate_025": 0.9, "control_rate_at_quiet_t": 0.9,
|
|
64
|
+
"per_control": {},
|
|
65
|
+
})
|
|
66
|
+
return {"capability": "fire",
|
|
67
|
+
"decision": {"decision": prior, "control": "tiny", "control_n": 5},
|
|
68
|
+
"candidates": cands}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestRegradeControlSelection(unittest.TestCase):
|
|
72
|
+
|
|
73
|
+
def test_liveness_only_and_unusable_grades_cannot_rate_a_class(self):
|
|
74
|
+
per = {"big": {"n": 9}, "tiny": {"n": 5}, "junk": {"n": 26}}
|
|
75
|
+
self.assertEqual(sorted(rateable_controls(per, CTLQ)), ["big"])
|
|
76
|
+
|
|
77
|
+
def test_a_publish_on_a_liveness_only_set_is_withdrawn_not_failed(self):
|
|
78
|
+
out = regrade_capability(_cap("PUBLISH"), CTLQ, BAR)
|
|
79
|
+
self.assertEqual(out["regraded_decision"], UNRESOLVED)
|
|
80
|
+
self.assertTrue(out["candidates_unknown"], "the elided row must be named")
|
|
81
|
+
self.assertTrue(out["changed"])
|
|
82
|
+
|
|
83
|
+
def test_rerunning_over_its_own_output_reproduces_unresolved(self):
|
|
84
|
+
first = regrade_capability(_cap("PUBLISH"), CTLQ, BAR)
|
|
85
|
+
second = regrade_capability(_cap(first["regraded_decision"]), CTLQ, BAR)
|
|
86
|
+
self.assertEqual(
|
|
87
|
+
second["regraded_decision"], UNRESOLVED,
|
|
88
|
+
"a second --apply hardened an unknown into a definitive verdict")
|
|
89
|
+
self.assertFalse(second["changed"], "a settled UNRESOLVED must not read as changed")
|
|
90
|
+
|
|
91
|
+
def test_a_measured_failure_is_left_alone(self):
|
|
92
|
+
out = regrade_capability(_cap("NO MODEL CLEARS BAR"), CTLQ, BAR)
|
|
93
|
+
self.assertEqual(out["regraded_decision"], "NO MODEL CLEARS BAR")
|
|
94
|
+
self.assertFalse(out["changed"])
|
|
95
|
+
|
|
96
|
+
def test_with_no_elided_row_the_verdict_is_definitive(self):
|
|
97
|
+
out = regrade_capability(_cap("PUBLISH", with_elided=False), CTLQ, BAR)
|
|
98
|
+
self.assertEqual(out["regraded_decision"], "NO MODEL CLEARS BAR")
|
|
99
|
+
self.assertEqual(out["status"], "proven")
|
|
100
|
+
|
|
101
|
+
def test_a_candidate_that_clears_on_the_rateable_set_still_publishes(self):
|
|
102
|
+
out = regrade_capability(_cap("PUBLISH", rateable_rate=0.8), CTLQ, BAR)
|
|
103
|
+
self.assertEqual(out["regraded_decision"], "PUBLISH")
|
|
104
|
+
self.assertEqual(out["winner"]["control"], "big")
|
|
105
|
+
self.assertEqual(out["winner"]["control_n"], 9)
|
|
106
|
+
|
|
107
|
+
def test_the_preserve_set_names_both_states_and_nothing_else(self):
|
|
108
|
+
self.assertEqual(sorted(PRESERVE_ON_BLOCKING), sorted(["PUBLISH", UNRESOLVED]))
|
|
109
|
+
|
|
110
|
+
def test_branches_resolved_before_a_control_is_read_are_untouched(self):
|
|
111
|
+
for prior in ("NO CANDIDATE", "NOT SCREENABLE HERE"):
|
|
112
|
+
out = regrade_capability(
|
|
113
|
+
{"capability": "x", "decision": {"decision": prior}, "candidates": []},
|
|
114
|
+
CTLQ, BAR)
|
|
115
|
+
self.assertEqual(out["regraded_decision"], prior)
|
|
116
|
+
self.assertEqual(out["status"], "not_affected")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
unittest.main()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""An ungraded control must stop the run, not quietly become eligible to decide.
|
|
2
|
+
|
|
3
|
+
`liveness_only` and `unusable` are both built by MEMBERSHIP of a grade set, so a
|
|
4
|
+
control with no grade at all falls through both and is treated as able to rate a
|
|
5
|
+
class — the flattering direction. The committed-artifact guard catches it
|
|
6
|
+
downstream, but by then a wrong number exists and may have been quoted.
|
|
7
|
+
|
|
8
|
+
The same asymmetry is why `--control-quality` defaults to the COMPLETE sorted set
|
|
9
|
+
rather than to one file: the grades live in dated files (the 2026-08-25 record is
|
|
10
|
+
pinned by the score cards that cite it), and a run reading a subset would give the
|
|
11
|
+
sets graded in the omitted file exactly that benefit of the doubt.
|
|
12
|
+
|
|
13
|
+
STDLIB ONLY — this lane runs `python3 -m unittest discover` with no pip install,
|
|
14
|
+
and `venue_adjudication.py` imports numpy at module scope, so it is loaded here
|
|
15
|
+
with `importlib` under a guard and the whole module is SKIPPED if numpy is absent
|
|
16
|
+
rather than erroring the lane during discovery.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib.util
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import unittest
|
|
25
|
+
|
|
26
|
+
BENCH = pathlib.Path(__file__).resolve().parent.parent / "bench"
|
|
27
|
+
MODULE_PATH = BENCH / "venue_adjudication.py"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load():
|
|
31
|
+
spec = importlib.util.spec_from_file_location("venue_adjudication_under_test", MODULE_PATH)
|
|
32
|
+
module = importlib.util.module_from_spec(spec)
|
|
33
|
+
spec.loader.exec_module(module)
|
|
34
|
+
return module
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
VA = _load()
|
|
39
|
+
except ImportError: # numpy absent on this runner
|
|
40
|
+
VA = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@unittest.skipIf(VA is None, "numpy is not installed on this runner")
|
|
44
|
+
class TestControlGradeCompleteness(unittest.TestCase):
|
|
45
|
+
|
|
46
|
+
def test_an_ungraded_control_that_is_used_refuses_the_run(self):
|
|
47
|
+
with self.assertRaises(SystemExit) as caught:
|
|
48
|
+
VA.assert_every_control_is_graded(
|
|
49
|
+
["fire_cctv", "fire_firesense"], {"fire_firesense": {"grade": "good"}})
|
|
50
|
+
self.assertIn("fire_cctv", str(caught.exception))
|
|
51
|
+
|
|
52
|
+
def test_a_fully_graded_set_passes(self):
|
|
53
|
+
VA.assert_every_control_is_graded(
|
|
54
|
+
["fire_cctv", "fire_firesense"],
|
|
55
|
+
{"fire_cctv": {"grade": "good"}, "fire_firesense": {"grade": "good"}})
|
|
56
|
+
|
|
57
|
+
def test_a_set_no_class_references_is_not_required_to_be_graded(self):
|
|
58
|
+
# `pos_sets` carries every scored set, including off-target ones that no
|
|
59
|
+
# CLASS_CONTROL entry names. Those cannot become `best_control`, so
|
|
60
|
+
# demanding a grade for them would refuse runs for no reason.
|
|
61
|
+
VA.assert_every_control_is_graded(["some_off_target_set"], {})
|
|
62
|
+
|
|
63
|
+
def test_discovery_finds_every_dated_grade_file(self):
|
|
64
|
+
found = [os.path.basename(p) for p in VA.discover_control_quality()]
|
|
65
|
+
self.assertTrue(found, "no positive-control-quality-*.json discovered")
|
|
66
|
+
self.assertEqual(found, sorted(found), "discovery must be sorted so later files win")
|
|
67
|
+
for name in found:
|
|
68
|
+
self.assertTrue(name.startswith("positive-control-quality-"))
|
|
69
|
+
self.assertTrue(name.endswith(".json"))
|
|
70
|
+
# The two the repo carries today. A third is fine; fewer is not.
|
|
71
|
+
self.assertIn("positive-control-quality-2026-08-25.json", found)
|
|
72
|
+
self.assertIn("positive-control-quality-2026-08-27.json", found)
|
|
73
|
+
|
|
74
|
+
def test_discovery_of_a_missing_directory_is_empty_not_an_exception(self):
|
|
75
|
+
self.assertEqual(VA.discover_control_quality("/nonexistent/grade/dir"), [])
|
|
76
|
+
|
|
77
|
+
def test_the_grade_lists_stay_disjoint(self):
|
|
78
|
+
# A grade in both lists would make the refusal order-dependent.
|
|
79
|
+
self.assertFalse(set(VA.LIVENESS_ONLY_GRADES) & set(VA.UNUSABLE_GRADES))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
unittest.main()
|
|
@@ -544,7 +544,37 @@ FALL_1CLS_11X = ModelVocabulary(
|
|
|
544
544
|
),
|
|
545
545
|
)
|
|
546
546
|
|
|
547
|
-
|
|
547
|
+
#: Heavy Machinery Detector (venue-adjudicated) — published 2026-08-27 by
|
|
548
|
+
#: scripts/sensing/publish-sensing-model.js. The class list is the artifact's own
|
|
549
|
+
#: (graph metadata key 'names'); the footprint is MEASURED, 593.3125 MiB after a
|
|
550
|
+
#: probe inference via rusage-maxrss, not an estimate from the file size.
|
|
551
|
+
#: Coordinate space MEASURED, not assumed: max|box channel| = 1825.6333 over 120 real frames; / imgsz 640 = 2.8526. pixels at >= 0.2, normalized at <= 0.02, refused in between.
|
|
552
|
+
GH_PPE_VINAYAK_L_CLASS_NAMES: "MappingProxyType[int, str]" = MappingProxyType({
|
|
553
|
+
0: "hardhat",
|
|
554
|
+
1: "mask",
|
|
555
|
+
2: "no hardhat",
|
|
556
|
+
3: "no mask",
|
|
557
|
+
4: "no safety vest",
|
|
558
|
+
5: "person",
|
|
559
|
+
6: "safety cone",
|
|
560
|
+
7: "safety vest",
|
|
561
|
+
8: "machinery",
|
|
562
|
+
9: "vehicle",
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
GH_PPE_VINAYAK_L = ModelVocabulary(
|
|
566
|
+
model_name="gh_ppe_vinayak_l",
|
|
567
|
+
slot_names=GH_PPE_VINAYAK_L_CLASS_NAMES,
|
|
568
|
+
logit_slots=10,
|
|
569
|
+
approx_ram_bytes=622133248,
|
|
570
|
+
onnx_variant=VariantContract(
|
|
571
|
+
output_layout="anchor_4_plus_nc",
|
|
572
|
+
box_units="pixels",
|
|
573
|
+
box_scale_to_pixels=1.0,
|
|
574
|
+
),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
BUILTIN_VOCABULARIES: "tuple[ModelVocabulary, ...]" = (RFDETR_MEDIUM, GH_FORKLIFT_COMBINE, PPE_VYRA_14CLS_V8M, PPE_CONSTRUCTION_17CLS_V8N, PPE_KILLUMINATI_19CLS_V8S, FALL_1CLS_11X, GH_PPE_VINAYAK_L,)
|
|
548
578
|
|
|
549
579
|
|
|
550
580
|
class ClassIndex:
|
|
@@ -638,6 +668,7 @@ class ClassIndex:
|
|
|
638
668
|
|
|
639
669
|
|
|
640
670
|
__all__ = [
|
|
671
|
+
"GH_PPE_VINAYAK_L",
|
|
641
672
|
"FALL_1CLS_11X",
|
|
642
673
|
"PPE_KILLUMINATI_19CLS_V8S",
|
|
643
674
|
"PPE_CONSTRUCTION_17CLS_V8N",
|
|
@@ -211,10 +211,11 @@ function Write-LitertlmConfig {
|
|
|
211
211
|
# /v1/models answers without ever building an engine, so it cannot tell a working
|
|
212
212
|
# GPU encoder from one that fails on first use.
|
|
213
213
|
function Test-LitertlmVision {
|
|
214
|
+
param([string]$InferenceModel = "$ModelId,gpu")
|
|
214
215
|
try {
|
|
215
216
|
$dataUri = "data:image/jpeg;base64," + $script:LitertlmProbeJpegB64
|
|
216
217
|
$body = @{
|
|
217
|
-
model =
|
|
218
|
+
model = $InferenceModel
|
|
218
219
|
messages = @(@{ role = "user"; content = @(
|
|
219
220
|
@{ type = "text"; text = "Reply with the single word: ok" },
|
|
220
221
|
@{ type = "image_url"; image_url = @{ url = $dataUri } }
|
|
@@ -227,7 +228,7 @@ function Test-LitertlmVision {
|
|
|
227
228
|
-TimeoutSec 180 -UseBasicParsing
|
|
228
229
|
return ($resp.StatusCode -eq 200)
|
|
229
230
|
} catch {
|
|
230
|
-
Log "vision probe did not answer: $_"
|
|
231
|
+
Log "vision probe for '$InferenceModel' did not answer: $_"
|
|
231
232
|
return $false
|
|
232
233
|
}
|
|
233
234
|
}
|
|
@@ -367,23 +368,32 @@ function Get-LitertLmVersion {
|
|
|
367
368
|
}
|
|
368
369
|
}
|
|
369
370
|
|
|
370
|
-
# A HEALTHY
|
|
371
|
+
# A HEALTHY CONTROL ROUTE IS NOT ENOUGH TO SKIP PROVISIONING.
|
|
371
372
|
#
|
|
372
373
|
# `Test-ServeHealthy` substring-matches the bare model id, and every litert-lm
|
|
373
374
|
# version answers that identically - including the ones that reject the `,gpu`
|
|
374
|
-
# model id the GPU delegate is selected with
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
# healthy serve never reaches.
|
|
375
|
+
# model id the GPU delegate is selected with, and without building an inference
|
|
376
|
+
# engine. So this fast path may run only after BOTH the pinned version check and
|
|
377
|
+
# a real image inference. Otherwise a failed first install can leave /v1/models
|
|
378
|
+
# answering, and a reinstall falsely declares success without re-testing the
|
|
379
|
+
# operation that just failed.
|
|
380
380
|
$installedLitertLmVersion = Get-LitertLmVersion
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
381
|
+
$serveHealthy = Test-ServeHealthy
|
|
382
|
+
if ($serveHealthy -and ($installedLitertLmVersion -eq $LitertLmVersion)) {
|
|
383
|
+
if (Test-LitertlmVision -InferenceModel "$ModelId,gpu") {
|
|
384
|
+
Complete-Setup
|
|
385
|
+
Stop-SetupLock
|
|
386
|
+
exit 0
|
|
387
|
+
}
|
|
388
|
+
if (Test-LitertlmVision -InferenceModel $ModelId) {
|
|
389
|
+
Log "local AI healthy on the previously configured full CPU fallback (slower, but serving)"
|
|
390
|
+
Complete-Setup
|
|
391
|
+
Stop-SetupLock
|
|
392
|
+
exit 0
|
|
393
|
+
}
|
|
394
|
+
Log "WARN: the pinned serve answers /v1/models but not a real GPU or CPU image inference; reprovisioning local AI"
|
|
385
395
|
}
|
|
386
|
-
if (
|
|
396
|
+
if ($serveHealthy -and ($installedLitertLmVersion -ne $LitertLmVersion)) {
|
|
387
397
|
Log "serve is healthy but litert-lm is $(if ($installedLitertLmVersion) { $installedLitertLmVersion } else { 'unknown' }) not pinned $LitertLmVersion; reinstalling and restarting the serve"
|
|
388
398
|
}
|
|
389
399
|
|
|
@@ -686,7 +696,7 @@ if (-not $healthy) {
|
|
|
686
696
|
# never builds an engine -- so it cannot see a GPU vision encoder that fails on
|
|
687
697
|
# first USE. If the probe inference does not answer we fall back to the CPU
|
|
688
698
|
# encoder rather than leave this host unable to sense at all.
|
|
689
|
-
if (-not (Test-LitertlmVision)) {
|
|
699
|
+
if (-not (Test-LitertlmVision -InferenceModel "$ModelId,gpu")) {
|
|
690
700
|
Log "WARN: GPU vision encoder did not answer a probe inference; falling back to the CPU encoder"
|
|
691
701
|
Write-LitertlmConfig | Out-Null
|
|
692
702
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $registration -LitertLmPath $litertlmPath -Port $Port *>> $LogPath
|
|
@@ -700,7 +710,9 @@ if (-not (Test-LitertlmVision)) {
|
|
|
700
710
|
Stop-SetupLock
|
|
701
711
|
exit 1
|
|
702
712
|
}
|
|
703
|
-
# RE-PROBE,
|
|
713
|
+
# RE-PROBE the CPU-vision tier, first keeping GPU inference for hosts where
|
|
714
|
+
# only the image encoder failed, then using the same bare-model CPU fallback
|
|
715
|
+
# that LiteRtVlmProvider uses at runtime when the main GPU delegate fails.
|
|
704
716
|
#
|
|
705
717
|
# Test-ServeHealthy above only proves /v1/models answers, and this file's own
|
|
706
718
|
# comment six lines up says that route never builds an engine. So before this
|
|
@@ -710,15 +722,18 @@ if (-not (Test-LitertlmVision)) {
|
|
|
710
722
|
# install: the cascade detected the failure, downgraded a tier, and then
|
|
711
723
|
# verified the downgrade with a check that cannot fail for this reason.
|
|
712
724
|
#
|
|
713
|
-
# A CPU fallback that cannot infer is not a slower tier. It is a broken
|
|
725
|
+
# A full CPU fallback that cannot infer is not a slower tier. It is a broken
|
|
714
726
|
# install, and the operator has to hear so at setup time rather than discover
|
|
715
727
|
# it as silence from a camera weeks later.
|
|
716
|
-
if (-
|
|
717
|
-
|
|
728
|
+
if (Test-LitertlmVision -InferenceModel "$ModelId,gpu") {
|
|
729
|
+
Log "local AI healthy on the CPU vision encoder with GPU inference (slower, but serving)"
|
|
730
|
+
} elseif (Test-LitertlmVision -InferenceModel $ModelId) {
|
|
731
|
+
Log "local AI healthy on the full CPU fallback (slower, but serving)"
|
|
732
|
+
} else {
|
|
733
|
+
Fail-LocalAi "Gemma is installed and the local AI service is answering, but it could not analyze a test image with either GPU inference or the full CPU fallback. See $LogPath."
|
|
718
734
|
Stop-SetupLock
|
|
719
735
|
exit 1
|
|
720
736
|
}
|
|
721
|
-
Log "local AI healthy on the CPU vision encoder (slower, but serving)"
|
|
722
737
|
} else {
|
|
723
738
|
Log "local AI healthy with the GPU vision encoder"
|
|
724
739
|
}
|
package/dist/EdgeAgent.js
CHANGED
|
@@ -44,7 +44,7 @@ const edge_ads_1 = require("@trillboards/edge-ads");
|
|
|
44
44
|
const CommandRouter_1 = require("./CommandRouter");
|
|
45
45
|
const config_1 = require("./config");
|
|
46
46
|
const dataDir_1 = require("./dataDir");
|
|
47
|
-
const SDK_VERSION = '0.2.
|
|
47
|
+
const SDK_VERSION = '0.2.110';
|
|
48
48
|
class EdgeAgent extends events_1.EventEmitter {
|
|
49
49
|
constructor(config, deps) {
|
|
50
50
|
super();
|
package/dist/diagnose.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface DiagnosticReport {
|
|
|
31
31
|
skipped: number;
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
-
declare const SDK_VERSION = "0.2.
|
|
34
|
+
declare const SDK_VERSION = "0.2.110";
|
|
35
35
|
declare function checkNodeVersion(): CheckResult;
|
|
36
36
|
declare function checkPlatform(): CheckResult;
|
|
37
37
|
declare function checkMemory(): CheckResult;
|
package/dist/diagnose.js
CHANGED
|
@@ -63,7 +63,7 @@ const localNetworkAccess_1 = require("./localNetworkAccess");
|
|
|
63
63
|
// ---------------------------------------------------------------------------
|
|
64
64
|
// SDK version — mirrors package.json; kept in sync by CI
|
|
65
65
|
// ---------------------------------------------------------------------------
|
|
66
|
-
const SDK_VERSION = '0.2.
|
|
66
|
+
const SDK_VERSION = '0.2.110';
|
|
67
67
|
exports._SDK_VERSION = SDK_VERSION;
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
69
|
// ANSI helpers (no external deps)
|
package/package.json
CHANGED