@trillboards/edge-sdk 0.2.92 → 0.2.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deploy/detector/bench/litert-twin-parity.json +313 -0
- package/deploy/detector/bench/twin_parity_contract.py +113 -0
- package/deploy/detector/bench/verify_litert_twin_parity.py +188 -0
- package/deploy/detector/tests/test_litert_twin_parity_contract.py +165 -0
- package/deploy/detector/tests/test_onnx_artifact_probe.py +618 -0
- package/deploy/detector/trillboards_detector/labels.py +181 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +9 -2
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""`scripts/sensing/onnx_artifact_probe.py` — the DECLARED layout, VERIFIED.
|
|
2
|
+
|
|
3
|
+
WHY THIS SUITE LIVES HERE AND NOT NEXT TO THE SCRIPT
|
|
4
|
+
----------------------------------------------------
|
|
5
|
+
The probe is Python, and the only lane in this repo that executes Python is
|
|
6
|
+
`detector-service`, which discovers `test_*.py` under `deploy/detector`. Putting
|
|
7
|
+
these cases anywhere else would make them the shape the probe itself is written
|
|
8
|
+
against: a check that exists and never runs. `test_session.py`'s
|
|
9
|
+
`TestEveryVocabularyConstructionDeclaresItsVariant` already reaches out of this
|
|
10
|
+
package into `scripts/sensing` for the same reason, and it did so because the
|
|
11
|
+
first version of that guard scanned only `deploy/detector` and passed while the
|
|
12
|
+
probe held a broken `ModelVocabulary` construction.
|
|
13
|
+
|
|
14
|
+
STDLIB ONLY, so it runs on that lane's bare interpreter — no onnxruntime, no
|
|
15
|
+
numpy, no Pillow, no ~12 MiB artifact. Everything under test here is arithmetic
|
|
16
|
+
over shapes an exporter wrote down, so a fake output descriptor with a `.name`
|
|
17
|
+
and a `.shape` is not a weaker input than a real `NodeArg`; it is the same two
|
|
18
|
+
fields. What it cannot reach — that a graph this accepts also RUNS — is covered
|
|
19
|
+
by the probe's own measurement child, which loads the artifact and infers.
|
|
20
|
+
|
|
21
|
+
WHAT IS BEING PROTECTED
|
|
22
|
+
-----------------------
|
|
23
|
+
Three failures, none of which raises anything at runtime:
|
|
24
|
+
|
|
25
|
+
* a DEFAULT that moved. The probe answers `--artifact` alone exactly as it did
|
|
26
|
+
before layouts were declarable, which is the only reason `rfdetr_medium`'s
|
|
27
|
+
published probe result is still reproducible. A new flag that quietly widened
|
|
28
|
+
that path would republish a different number under the same provenance.
|
|
29
|
+
* a layout DECLARED that the graph cannot speak. `[1, 300, 6]` and
|
|
30
|
+
`[1, 6, 8400]` are both rank-3 with a 6 in them, and the wrong one decodes
|
|
31
|
+
into a confident, meaningless table.
|
|
32
|
+
* a `4 + nc` check counting the wrong `nc`. Distinct NAMES and LOGIT SLOTS are
|
|
33
|
+
different numbers on real artifacts in both directions, and using the name
|
|
34
|
+
count rejects healthy graphs.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import contextlib
|
|
40
|
+
import importlib.util
|
|
41
|
+
import io
|
|
42
|
+
import json
|
|
43
|
+
import pathlib
|
|
44
|
+
import sys
|
|
45
|
+
import tempfile
|
|
46
|
+
import unittest
|
|
47
|
+
|
|
48
|
+
from trillboards_detector import labels as labels_mod
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_probe():
|
|
52
|
+
"""Import the probe BY PATH, or return None on a checkout that has none.
|
|
53
|
+
|
|
54
|
+
`deploy/` ships inside the npm tarball, so on an installed connector
|
|
55
|
+
`scripts/sensing` is simply absent. The module is loaded rather than asserted
|
|
56
|
+
for exactly that reason — the same concession `test_session.py` makes about
|
|
57
|
+
the repo roots it scans.
|
|
58
|
+
"""
|
|
59
|
+
here = pathlib.Path(__file__).resolve().parent.parent
|
|
60
|
+
for parent in here.parents:
|
|
61
|
+
# A git WORKTREE's `.git` is a file, not a directory.
|
|
62
|
+
if not (parent / ".git").exists():
|
|
63
|
+
continue
|
|
64
|
+
candidate = parent / "scripts" / "sensing" / "onnx_artifact_probe.py"
|
|
65
|
+
if not candidate.is_file():
|
|
66
|
+
return None
|
|
67
|
+
spec = importlib.util.spec_from_file_location("onnx_artifact_probe_under_test", candidate)
|
|
68
|
+
module = importlib.util.module_from_spec(spec)
|
|
69
|
+
spec.loader.exec_module(module)
|
|
70
|
+
return module
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
PROBE = _load_probe()
|
|
75
|
+
NO_PROBE = "scripts/sensing/onnx_artifact_probe.py is not in this checkout"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Out:
|
|
79
|
+
"""An ORT output descriptor, which is a `.name` and a `.shape` and nothing else.
|
|
80
|
+
|
|
81
|
+
A dynamic axis arrives from onnxruntime as a symbolic STRING rather than an
|
|
82
|
+
int, so several cases here pass one — that is a real artifact property and
|
|
83
|
+
the probe's branches turn on it.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, name: str, shape: "list"):
|
|
87
|
+
self.name = name
|
|
88
|
+
self.shape = shape
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str: # pragma: no cover - only reached in a failure message
|
|
91
|
+
return f"Out({self.name!r}, {self.shape!r})"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
DETS_LABELS_91 = [Out("dets", [1, 300, 4]), Out("labels", [1, 300, 91])]
|
|
95
|
+
#: `ppe_vyra_14cls_v8m.onnx`, measured: 14 classes, so 4 + 14 = 18 channels.
|
|
96
|
+
ANCHOR_14 = [Out("output0", [1, 18, 8400])]
|
|
97
|
+
#: `gh_fall_tonlong.onnx`, measured: 11 channels over 8400 anchors.
|
|
98
|
+
ANCHOR_FALL = [Out("output0", [1, 11, 8400])]
|
|
99
|
+
END2END = [Out("output0", [1, 300, 6])]
|
|
100
|
+
|
|
101
|
+
#: `gh_fall_tonlong.onnx`'s own `names` metadata, byte for byte. SEVEN slots,
|
|
102
|
+
#: and `Violence`/`violence` fold to ONE canonical name.
|
|
103
|
+
FALL_NAMES = {0: "Violence", 1: "fall", 2: "fire", 3: "sit", 4: "sleep", 5: "standing", 6: "violence"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@contextlib.contextmanager
|
|
107
|
+
def _captured_stdout():
|
|
108
|
+
buf = io.StringIO()
|
|
109
|
+
original = sys.stdout
|
|
110
|
+
sys.stdout = buf
|
|
111
|
+
try:
|
|
112
|
+
yield buf
|
|
113
|
+
finally:
|
|
114
|
+
sys.stdout = original
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ProbeCase(unittest.TestCase):
|
|
118
|
+
"""Shared helpers: the probe refuses by writing JSON and exiting 1."""
|
|
119
|
+
|
|
120
|
+
def args(self, *argv):
|
|
121
|
+
"""A parsed `Namespace` through the REAL CLI, never a hand-built stand-in.
|
|
122
|
+
|
|
123
|
+
Every case that pretends to be a command line has to go through the
|
|
124
|
+
parser the command line goes through, or the suite stops covering the
|
|
125
|
+
surface it claims to.
|
|
126
|
+
"""
|
|
127
|
+
return PROBE.parse_args(["--artifact", "unused.onnx", *argv])
|
|
128
|
+
|
|
129
|
+
def assertRefuses(self, fn, *args, **kwargs):
|
|
130
|
+
"""Run something expected to `_fail`, and return its refusal payload.
|
|
131
|
+
|
|
132
|
+
Asserts the WHOLE contract of a refusal, not merely that it happened:
|
|
133
|
+
`{"ok": false, "step": ..., "reason": ...}` on stdout and exit 1. The
|
|
134
|
+
publisher reads `reason` off the LAST stdout line of a non-zero exit, so
|
|
135
|
+
a refusal that raised instead — or exited 0, or wrote prose — would reach
|
|
136
|
+
it as "the probe failed" with a stack trace attached.
|
|
137
|
+
"""
|
|
138
|
+
with _captured_stdout() as buf:
|
|
139
|
+
with self.assertRaises(SystemExit) as raised:
|
|
140
|
+
fn(*args, **kwargs)
|
|
141
|
+
self.assertEqual(raised.exception.code, 1)
|
|
142
|
+
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
|
|
143
|
+
self.assertTrue(lines, "a refusal must write JSON on stdout")
|
|
144
|
+
payload = json.loads(lines[-1])
|
|
145
|
+
self.assertIs(payload["ok"], False)
|
|
146
|
+
self.assertTrue(payload["step"])
|
|
147
|
+
self.assertTrue(payload["reason"])
|
|
148
|
+
return payload
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@unittest.skipIf(PROBE is None, NO_PROBE)
|
|
152
|
+
class TestTheDefaultDidNotMove(ProbeCase):
|
|
153
|
+
"""`--artifact` alone must answer exactly as it did before layouts existed.
|
|
154
|
+
|
|
155
|
+
`rfdetr_medium` was published by a run that passed nothing else, and its
|
|
156
|
+
`coco_classes`, `resident_mb` and graph contract are committed against that
|
|
157
|
+
run. A widened default would republish different numbers under the same
|
|
158
|
+
provenance, which is worse than a refusal because nothing would notice.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def test_no_declaration_is_the_rfdetr_contract(self):
|
|
162
|
+
contract, source = PROBE._resolve_variant(labels_mod, self.args())
|
|
163
|
+
self.assertEqual(contract.output_layout, labels_mod.RFDETR_DETS_LABELS)
|
|
164
|
+
self.assertEqual(contract.box_units, labels_mod.BOX_UNITS_PIXELS)
|
|
165
|
+
self.assertEqual(contract.box_scale_to_pixels, 1.0)
|
|
166
|
+
self.assertIn("default", source)
|
|
167
|
+
|
|
168
|
+
def test_a_graph_with_no_labels_output_is_still_refused(self):
|
|
169
|
+
reason = self.assertRefuses(
|
|
170
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.RFDETR_DETS_LABELS, ANCHOR_14,
|
|
171
|
+
)["reason"]
|
|
172
|
+
# The refusal that has always been here, and the fact that identifies it.
|
|
173
|
+
self.assertIn("`labels`", reason)
|
|
174
|
+
# It now also says what to do about it, because there IS something.
|
|
175
|
+
self.assertIn("--variant-from-card", reason)
|
|
176
|
+
self.assertIn("output0[1, 18, 8400]", reason)
|
|
177
|
+
|
|
178
|
+
def test_the_rfdetr_class_axis_is_still_read_off_labels(self):
|
|
179
|
+
self.assertEqual(
|
|
180
|
+
PROBE._graph_class_axis(labels_mod, labels_mod.RFDETR_DETS_LABELS, DETS_LABELS_91),
|
|
181
|
+
91,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def test_a_dynamic_rfdetr_class_axis_is_still_refused(self):
|
|
185
|
+
outputs = [Out("dets", [1, 300, 4]), Out("labels", [1, 300, "num_classes"])]
|
|
186
|
+
reason = self.assertRefuses(
|
|
187
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.RFDETR_DETS_LABELS, outputs,
|
|
188
|
+
)["reason"]
|
|
189
|
+
self.assertIn("fixed class axis", reason)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@unittest.skipIf(PROBE is None, NO_PROBE)
|
|
193
|
+
class TestDeclaredLayoutIsVerified(ProbeCase):
|
|
194
|
+
"""Each layout accepted on a shape it can speak, refused on one it cannot.
|
|
195
|
+
|
|
196
|
+
The refusals name the DECLARED layout and the ACTUAL outputs, because those
|
|
197
|
+
two facts are what tells an operator whether the artifact is wrong or the
|
|
198
|
+
card is.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def test_anchor_accepted_and_its_class_axis_is_channels_minus_four(self):
|
|
202
|
+
self.assertEqual(
|
|
203
|
+
PROBE._graph_class_axis(labels_mod, labels_mod.ANCHOR_4_PLUS_NC, ANCHOR_14), 14,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def test_end2end_accepted_and_declares_no_class_axis(self):
|
|
207
|
+
# `None`, not 0 and not a guess: the class index rides in column 5, so
|
|
208
|
+
# the graph says nothing at all about how many classes this model has.
|
|
209
|
+
self.assertIsNone(
|
|
210
|
+
PROBE._graph_class_axis(labels_mod, labels_mod.END2END_N_6, END2END),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def test_anchor_refused_on_an_end2end_shape(self):
|
|
214
|
+
# THE DISAMBIGUATOR. `[1, 300, 6]` read as an anchor head is 296 classes
|
|
215
|
+
# over 6 anchors and decodes without raising.
|
|
216
|
+
reason = self.assertRefuses(
|
|
217
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.ANCHOR_4_PLUS_NC, END2END,
|
|
218
|
+
)["reason"]
|
|
219
|
+
self.assertIn(labels_mod.ANCHOR_4_PLUS_NC, reason)
|
|
220
|
+
self.assertIn("output0[1, 300, 6]", reason)
|
|
221
|
+
self.assertIn("296 classes over 6 anchors", reason)
|
|
222
|
+
|
|
223
|
+
def test_end2end_refused_on_an_anchor_shape(self):
|
|
224
|
+
reason = self.assertRefuses(
|
|
225
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.END2END_N_6, ANCHOR_FALL,
|
|
226
|
+
)["reason"]
|
|
227
|
+
self.assertIn(labels_mod.END2END_N_6, reason)
|
|
228
|
+
self.assertIn("output0[1, 11, 8400]", reason)
|
|
229
|
+
|
|
230
|
+
def test_rfdetr_refused_on_an_anchor_shape(self):
|
|
231
|
+
reason = self.assertRefuses(
|
|
232
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.RFDETR_DETS_LABELS, ANCHOR_FALL,
|
|
233
|
+
)["reason"]
|
|
234
|
+
self.assertIn(labels_mod.RFDETR_DETS_LABELS, reason)
|
|
235
|
+
|
|
236
|
+
def test_a_single_output_layout_refuses_a_two_output_graph(self):
|
|
237
|
+
outputs = [Out("output0", [1, 18, 8400]), Out("proto", [1, 32, 160, 160])]
|
|
238
|
+
reason = self.assertRefuses(
|
|
239
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.ANCHOR_4_PLUS_NC, outputs,
|
|
240
|
+
)["reason"]
|
|
241
|
+
self.assertIn("exactly ONE graph output", reason)
|
|
242
|
+
self.assertIn("proto", reason)
|
|
243
|
+
|
|
244
|
+
def test_anchor_refuses_a_rank_two_output(self):
|
|
245
|
+
reason = self.assertRefuses(
|
|
246
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.ANCHOR_4_PLUS_NC, [Out("output0", [18, 8400])],
|
|
247
|
+
)["reason"]
|
|
248
|
+
self.assertIn("rank-3", reason)
|
|
249
|
+
|
|
250
|
+
def test_anchor_refuses_a_batch_that_is_not_one(self):
|
|
251
|
+
reason = self.assertRefuses(
|
|
252
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.ANCHOR_4_PLUS_NC, [Out("output0", [4, 18, 8400])],
|
|
253
|
+
)["reason"]
|
|
254
|
+
self.assertIn("batch-1", reason)
|
|
255
|
+
|
|
256
|
+
def test_anchor_refuses_a_dynamic_class_axis(self):
|
|
257
|
+
# The class count is one of the two numbers a detector arm is meaningless
|
|
258
|
+
# without, and a symbolic axis does not carry it.
|
|
259
|
+
reason = self.assertRefuses(
|
|
260
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.ANCHOR_4_PLUS_NC,
|
|
261
|
+
[Out("output0", [1, "channels", 8400])],
|
|
262
|
+
)["reason"]
|
|
263
|
+
self.assertIn("dynamic", reason)
|
|
264
|
+
|
|
265
|
+
def test_anchor_refuses_box_channels_with_no_class_left(self):
|
|
266
|
+
reason = self.assertRefuses(
|
|
267
|
+
PROBE._graph_class_axis, labels_mod, labels_mod.ANCHOR_4_PLUS_NC, [Out("output0", [1, 4, 8400])],
|
|
268
|
+
)["reason"]
|
|
269
|
+
self.assertIn("at least one class", reason)
|
|
270
|
+
|
|
271
|
+
def test_a_dynamic_anchor_count_does_not_block_a_real_head(self):
|
|
272
|
+
# `anchors > channels` is skipped rather than guessed when the axis is
|
|
273
|
+
# symbolic. The tensor ORT actually returns is still checked by
|
|
274
|
+
# `decode_anchor_4_plus_nc`'s own `expected_classes` refusal.
|
|
275
|
+
self.assertEqual(
|
|
276
|
+
PROBE._graph_class_axis(
|
|
277
|
+
labels_mod, labels_mod.ANCHOR_4_PLUS_NC, [Out("output0", [1, 18, "anchors"])],
|
|
278
|
+
),
|
|
279
|
+
14,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@unittest.skipIf(PROBE is None, NO_PROBE)
|
|
284
|
+
class TestFourPlusNcCountsLogitSlots(ProbeCase):
|
|
285
|
+
"""`4 + nc` where nc is the LOGIT WIDTH — never the number of distinct names.
|
|
286
|
+
|
|
287
|
+
This is the case that would silently invert the check. Both directions are
|
|
288
|
+
live on real artifacts:
|
|
289
|
+
|
|
290
|
+
* COCO-91 names 80 classes on a 91-wide axis (gaps in the category ids);
|
|
291
|
+
* `gh_fall_tonlong` declares SEVEN slots of which `Violence` and `violence`
|
|
292
|
+
fold to one canonical name, so it has 7 logit slots and 6 names.
|
|
293
|
+
|
|
294
|
+
Its graph is `[1, 11, 8400]`. `4 + 7` is 11 and matches; `4 + 6` is 10 and
|
|
295
|
+
would reject a perfectly healthy head.
|
|
296
|
+
|
|
297
|
+
`_verify_class_axis_width` passes by construction on a real run — the width it
|
|
298
|
+
is handed was read off the same graph — so these cases are what EXERCISES it.
|
|
299
|
+
That is the point of it: it is a tripwire on the derivation, and a tripwire
|
|
300
|
+
nothing pulls is indistinguishable from one that is wired wrong.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def test_the_fall_artifact_has_seven_slots_and_six_names(self):
|
|
304
|
+
slot_map = PROBE._canonical_slot_map(labels_mod, FALL_NAMES)
|
|
305
|
+
self.assertEqual(len(slot_map), 7)
|
|
306
|
+
self.assertEqual(len(set(slot_map.values())), 6)
|
|
307
|
+
self.assertEqual(slot_map[0], slot_map[6])
|
|
308
|
+
|
|
309
|
+
def test_the_logit_width_matches_the_real_graph(self):
|
|
310
|
+
# No refusal: this is the healthy case, and it must stay healthy.
|
|
311
|
+
PROBE._verify_class_axis_width(
|
|
312
|
+
labels_mod, labels_mod.ANCHOR_4_PLUS_NC, ANCHOR_FALL, 7,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def test_the_distinct_name_count_would_reject_the_same_graph(self):
|
|
316
|
+
reason = self.assertRefuses(
|
|
317
|
+
PROBE._verify_class_axis_width,
|
|
318
|
+
labels_mod, labels_mod.ANCHOR_4_PLUS_NC, ANCHOR_FALL, 6,
|
|
319
|
+
)["reason"]
|
|
320
|
+
self.assertIn("11 wide", reason)
|
|
321
|
+
self.assertIn("needs 10", reason)
|
|
322
|
+
|
|
323
|
+
def test_a_committed_vocabulary_narrower_than_the_graph_is_refused(self):
|
|
324
|
+
reason = self.assertRefuses(
|
|
325
|
+
PROBE._verify_class_axis_width,
|
|
326
|
+
labels_mod, labels_mod.ANCHOR_4_PLUS_NC, ANCHOR_14, 13,
|
|
327
|
+
)["reason"]
|
|
328
|
+
self.assertIn("18 wide", reason)
|
|
329
|
+
self.assertIn("needs 17", reason)
|
|
330
|
+
|
|
331
|
+
def test_rfdetr_width_is_checked_against_the_labels_axis(self):
|
|
332
|
+
PROBE._verify_class_axis_width(
|
|
333
|
+
labels_mod, labels_mod.RFDETR_DETS_LABELS, DETS_LABELS_91, 91,
|
|
334
|
+
)
|
|
335
|
+
reason = self.assertRefuses(
|
|
336
|
+
PROBE._verify_class_axis_width,
|
|
337
|
+
labels_mod, labels_mod.RFDETR_DETS_LABELS, DETS_LABELS_91, 80,
|
|
338
|
+
)["reason"]
|
|
339
|
+
# COCO-91 names EIGHTY classes on a NINETY-ONE-wide axis, so the name
|
|
340
|
+
# count is the wrong number here too — in the other direction.
|
|
341
|
+
self.assertIn("91 wide", reason)
|
|
342
|
+
|
|
343
|
+
def test_end2end_has_no_width_to_check(self):
|
|
344
|
+
# Not an omission: the class index is a column, so any width is
|
|
345
|
+
# consistent with the graph and a check would be theatre.
|
|
346
|
+
PROBE._verify_class_axis_width(labels_mod, labels_mod.END2END_N_6, END2END, 3)
|
|
347
|
+
PROBE._verify_class_axis_width(labels_mod, labels_mod.END2END_N_6, END2END, 91)
|
|
348
|
+
|
|
349
|
+
def test_a_vocabulary_with_two_spellings_of_one_word_is_unpublishable(self):
|
|
350
|
+
# The other half of the same artifact: the SHAPE is fine, the LIST is
|
|
351
|
+
# not, and `ModelVocabulary` refuses it at construction on a device too.
|
|
352
|
+
with self.assertRaises(ValueError) as raised:
|
|
353
|
+
PROBE._build_vocabulary(
|
|
354
|
+
labels_mod,
|
|
355
|
+
"fall_specialist",
|
|
356
|
+
PROBE._canonical_slot_map(labels_mod, FALL_NAMES),
|
|
357
|
+
7,
|
|
358
|
+
labels_mod.VariantContract(
|
|
359
|
+
labels_mod.ANCHOR_4_PLUS_NC, labels_mod.BOX_UNITS_PIXELS, 1.0,
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
self.assertIn("two slots share one class name", str(raised.exception))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@unittest.skipIf(PROBE is None, NO_PROBE)
|
|
366
|
+
class TestBoxUnitsAreRecordedNeverInvented(ProbeCase):
|
|
367
|
+
"""A coordinate space is declared or the run is refused. It is never guessed.
|
|
368
|
+
|
|
369
|
+
`session.py` multiplies by `box_scale_to_pixels` and draws, without ever
|
|
370
|
+
re-deriving from the tensor, so a defaulted value is a 640x error that raises
|
|
371
|
+
nothing on an unpadded frame. RF-DETR is the one exemption and it is
|
|
372
|
+
structural: its branch calls `postprocess`, which does its own
|
|
373
|
+
normalised-to-frame scaling and never reads the contract.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
def test_a_non_rfdetr_layout_without_box_units_is_refused(self):
|
|
377
|
+
reason = self.assertRefuses(
|
|
378
|
+
PROBE._resolve_variant, labels_mod, self.args("--output-layout", labels_mod.ANCHOR_4_PLUS_NC),
|
|
379
|
+
)["reason"]
|
|
380
|
+
self.assertIn("--box-units", reason)
|
|
381
|
+
self.assertIn("640x too small", reason)
|
|
382
|
+
|
|
383
|
+
def test_end2end_without_box_units_is_refused_too(self):
|
|
384
|
+
self.assertRefuses(
|
|
385
|
+
PROBE._resolve_variant, labels_mod, self.args("--output-layout", labels_mod.END2END_N_6),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def test_normalized_without_a_scale_is_refused(self):
|
|
389
|
+
# 1.0 IS positive, so `VariantContract` would accept the default and the
|
|
390
|
+
# boxes would be silently wrong. The refusal has to happen before it.
|
|
391
|
+
reason = self.assertRefuses(
|
|
392
|
+
PROBE._resolve_variant, labels_mod,
|
|
393
|
+
self.args("--output-layout", labels_mod.ANCHOR_4_PLUS_NC, "--box-units", "normalized"),
|
|
394
|
+
)["reason"]
|
|
395
|
+
self.assertIn("--box-scale-to-pixels", reason)
|
|
396
|
+
|
|
397
|
+
def test_normalized_with_a_scale_is_carried_through(self):
|
|
398
|
+
contract, source = PROBE._resolve_variant(labels_mod, self.args(
|
|
399
|
+
"--output-layout", labels_mod.ANCHOR_4_PLUS_NC,
|
|
400
|
+
"--box-units", "normalized", "--box-scale-to-pixels", "640",
|
|
401
|
+
))
|
|
402
|
+
self.assertEqual(contract.box_units, labels_mod.BOX_UNITS_NORMALIZED)
|
|
403
|
+
self.assertEqual(contract.box_scale_to_pixels, 640.0)
|
|
404
|
+
self.assertIn("--output-layout", source)
|
|
405
|
+
|
|
406
|
+
def test_pixels_with_a_scale_that_is_not_one_is_refused(self):
|
|
407
|
+
reason = self.assertRefuses(
|
|
408
|
+
PROBE._resolve_variant, labels_mod,
|
|
409
|
+
self.args("--output-layout", labels_mod.ANCHOR_4_PLUS_NC,
|
|
410
|
+
"--box-units", "pixels", "--box-scale-to-pixels", "640"),
|
|
411
|
+
)["reason"]
|
|
412
|
+
self.assertIn("already pixels", reason)
|
|
413
|
+
|
|
414
|
+
def test_a_scale_without_units_is_refused_rather_than_overwritten(self):
|
|
415
|
+
# RF-DETR is the one layout whose units may be omitted, so it is also the
|
|
416
|
+
# one place a declared scale could be silently replaced by the 1.0 that
|
|
417
|
+
# fallback supplies. A declaration accepted and discarded is worse than a
|
|
418
|
+
# refusal: nothing downstream can tell it happened.
|
|
419
|
+
reason = self.assertRefuses(
|
|
420
|
+
PROBE._resolve_variant, labels_mod,
|
|
421
|
+
self.args("--output-layout", labels_mod.RFDETR_DETS_LABELS, "--box-scale-to-pixels", "640"),
|
|
422
|
+
)["reason"]
|
|
423
|
+
self.assertIn("no box_units", reason)
|
|
424
|
+
self.assertIn("640", reason)
|
|
425
|
+
|
|
426
|
+
def test_units_without_a_layout_are_a_claim_about_nothing(self):
|
|
427
|
+
reason = self.assertRefuses(
|
|
428
|
+
PROBE._resolve_variant, labels_mod, self.args("--box-units", "pixels"),
|
|
429
|
+
)["reason"]
|
|
430
|
+
self.assertIn("--output-layout", reason)
|
|
431
|
+
|
|
432
|
+
def test_an_unknown_layout_is_refused_by_name(self):
|
|
433
|
+
reason = self.assertRefuses(
|
|
434
|
+
PROBE._resolve_variant, labels_mod, self.args("--output-layout", "yolo_v8_raw"),
|
|
435
|
+
)["reason"]
|
|
436
|
+
self.assertIn("yolo_v8_raw", reason)
|
|
437
|
+
|
|
438
|
+
def test_an_unknown_box_unit_is_refused_by_name(self):
|
|
439
|
+
reason = self.assertRefuses(
|
|
440
|
+
PROBE._resolve_variant, labels_mod,
|
|
441
|
+
self.args("--output-layout", labels_mod.ANCHOR_4_PLUS_NC, "--box-units", "pixel"),
|
|
442
|
+
)["reason"]
|
|
443
|
+
self.assertIn("'pixel'", reason)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@unittest.skipIf(PROBE is None, NO_PROBE)
|
|
447
|
+
class TestVariantFromCard(ProbeCase):
|
|
448
|
+
"""The card is the primary path: a MEASUREMENT with provenance, not a claim."""
|
|
449
|
+
|
|
450
|
+
def _card(self, variant, **extra):
|
|
451
|
+
handle = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
|
|
452
|
+
blob = {"schema": "detector-score-card/1", "verdict": {"publishable": True}}
|
|
453
|
+
blob.update(extra)
|
|
454
|
+
if variant is not None:
|
|
455
|
+
blob["variant"] = variant
|
|
456
|
+
json.dump(blob, handle)
|
|
457
|
+
handle.close()
|
|
458
|
+
self.addCleanup(lambda: pathlib.Path(handle.name).unlink(missing_ok=True))
|
|
459
|
+
return handle.name
|
|
460
|
+
|
|
461
|
+
#: The `variant` block `score-detector-candidate.js` wrote for every one of
|
|
462
|
+
#: the five staged specialists, verbatim in shape.
|
|
463
|
+
MEASURED = {
|
|
464
|
+
"format": "onnx",
|
|
465
|
+
"output_layout": "anchor_4_plus_nc",
|
|
466
|
+
"end2end": False,
|
|
467
|
+
"box_units": "pixels",
|
|
468
|
+
"box_scale_to_pixels": 1,
|
|
469
|
+
"box_units_evidence": "max|box channel| = 640.0000 over 110 real frames",
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
def test_the_three_values_are_read_off_the_card(self):
|
|
473
|
+
contract, source = PROBE._resolve_variant(
|
|
474
|
+
labels_mod, self.args("--variant-from-card", self._card(self.MEASURED)),
|
|
475
|
+
)
|
|
476
|
+
self.assertEqual(contract.output_layout, labels_mod.ANCHOR_4_PLUS_NC)
|
|
477
|
+
self.assertEqual(contract.box_units, labels_mod.BOX_UNITS_PIXELS)
|
|
478
|
+
# A JSON `1` is a scale of 1.0. `VariantContract` compares it against
|
|
479
|
+
# 1.0 exactly, so the coercion is load-bearing rather than cosmetic.
|
|
480
|
+
self.assertEqual(contract.box_scale_to_pixels, 1.0)
|
|
481
|
+
self.assertIn("--variant-from-card", source)
|
|
482
|
+
|
|
483
|
+
def test_a_card_with_no_variant_block_is_refused(self):
|
|
484
|
+
reason = self.assertRefuses(
|
|
485
|
+
PROBE._resolve_variant, labels_mod, self.args("--variant-from-card", self._card(None)),
|
|
486
|
+
)["reason"]
|
|
487
|
+
self.assertIn("no `variant` block", reason)
|
|
488
|
+
|
|
489
|
+
def test_a_partial_variant_block_is_refused(self):
|
|
490
|
+
partial = {k: v for k, v in self.MEASURED.items() if k != "box_units"}
|
|
491
|
+
reason = self.assertRefuses(
|
|
492
|
+
PROBE._resolve_variant, labels_mod, self.args("--variant-from-card", self._card(partial)),
|
|
493
|
+
)["reason"]
|
|
494
|
+
self.assertIn("variant.box_units", reason)
|
|
495
|
+
|
|
496
|
+
def test_unreadable_json_is_refused_rather_than_raised(self):
|
|
497
|
+
handle = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
|
|
498
|
+
handle.write("{not json")
|
|
499
|
+
handle.close()
|
|
500
|
+
self.addCleanup(lambda: pathlib.Path(handle.name).unlink(missing_ok=True))
|
|
501
|
+
self.assertRefuses(
|
|
502
|
+
PROBE._resolve_variant, labels_mod, self.args("--variant-from-card", handle.name),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def test_a_card_and_a_flag_that_AGREE_are_fine(self):
|
|
506
|
+
contract, source = PROBE._resolve_variant(labels_mod, self.args(
|
|
507
|
+
"--variant-from-card", self._card(self.MEASURED),
|
|
508
|
+
"--output-layout", labels_mod.ANCHOR_4_PLUS_NC,
|
|
509
|
+
))
|
|
510
|
+
self.assertEqual(contract.output_layout, labels_mod.ANCHOR_4_PLUS_NC)
|
|
511
|
+
# A flag that states ONE field must not read as disagreeing with a card
|
|
512
|
+
# that states three — the agreement is checked per field, not per tuple.
|
|
513
|
+
self.assertIn("--variant-from-card", source)
|
|
514
|
+
|
|
515
|
+
def test_a_card_and_a_flag_that_DISAGREE_are_refused(self):
|
|
516
|
+
reason = self.assertRefuses(PROBE._resolve_variant, labels_mod, self.args(
|
|
517
|
+
"--variant-from-card", self._card(self.MEASURED),
|
|
518
|
+
"--output-layout", labels_mod.END2END_N_6,
|
|
519
|
+
))["reason"]
|
|
520
|
+
self.assertIn("DISAGREES", reason)
|
|
521
|
+
self.assertIn(labels_mod.ANCHOR_4_PLUS_NC, reason)
|
|
522
|
+
self.assertIn(labels_mod.END2END_N_6, reason)
|
|
523
|
+
|
|
524
|
+
def test_a_disagreeing_scale_is_refused_too(self):
|
|
525
|
+
normalized = dict(self.MEASURED, box_units="normalized", box_scale_to_pixels=640)
|
|
526
|
+
reason = self.assertRefuses(PROBE._resolve_variant, labels_mod, self.args(
|
|
527
|
+
"--variant-from-card", self._card(normalized),
|
|
528
|
+
"--output-layout", labels_mod.ANCHOR_4_PLUS_NC,
|
|
529
|
+
"--box-units", "normalized", "--box-scale-to-pixels", "320",
|
|
530
|
+
))["reason"]
|
|
531
|
+
self.assertIn("box_scale_to_pixels", reason)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@unittest.skipIf(PROBE is None, NO_PROBE)
|
|
535
|
+
class TestClassAxisWidthWhenTheGraphDeclaresNone(ProbeCase):
|
|
536
|
+
"""`end2end_n_6` has no class axis, so the width comes from the class LIST."""
|
|
537
|
+
|
|
538
|
+
def _classes_file(self, blob):
|
|
539
|
+
handle = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
|
|
540
|
+
json.dump(blob, handle)
|
|
541
|
+
handle.close()
|
|
542
|
+
self.addCleanup(lambda: pathlib.Path(handle.name).unlink(missing_ok=True))
|
|
543
|
+
return handle.name
|
|
544
|
+
|
|
545
|
+
def test_a_dense_list_sets_the_width_to_one_past_its_last_slot(self):
|
|
546
|
+
args = self.args("--classes-file", self._classes_file(["forklift", "pallet", "person"]))
|
|
547
|
+
slot_names, source, width = PROBE._resolve_slot_names(
|
|
548
|
+
labels_mod, args, None, "(no graph metadata)", None,
|
|
549
|
+
)
|
|
550
|
+
self.assertEqual(slot_names, {0: "forklift", 1: "pallet", 2: "person"})
|
|
551
|
+
self.assertIn("--classes-file", source)
|
|
552
|
+
self.assertEqual(width, 3)
|
|
553
|
+
|
|
554
|
+
def test_a_sparse_map_sets_the_width_from_its_highest_slot(self):
|
|
555
|
+
args = self.args("--classes-file", self._classes_file({"0": "forklift", "9": "person"}))
|
|
556
|
+
_, _, width = PROBE._resolve_slot_names(labels_mod, args, None, "(no graph metadata)", None)
|
|
557
|
+
self.assertEqual(width, 10)
|
|
558
|
+
|
|
559
|
+
def test_a_graph_declaring_fewer_names_than_its_axis_is_still_ambiguous(self):
|
|
560
|
+
# The check that DOES depend on a width, unchanged: it fires whenever the
|
|
561
|
+
# graph declares one, and is skipped only when the layout carries none.
|
|
562
|
+
args = self.args()
|
|
563
|
+
reason = self.assertRefuses(
|
|
564
|
+
PROBE._resolve_slot_names, labels_mod, args, ["person", "car"], "graph metadata key 'names'", 91,
|
|
565
|
+
)["reason"]
|
|
566
|
+
self.assertIn("--slot-offset", reason)
|
|
567
|
+
|
|
568
|
+
def test_the_same_list_is_unambiguous_when_the_graph_declares_no_axis(self):
|
|
569
|
+
args = self.args()
|
|
570
|
+
slot_names, _, width = PROBE._resolve_slot_names(
|
|
571
|
+
labels_mod, args, ["person", "car"], "graph metadata key 'names'", None,
|
|
572
|
+
)
|
|
573
|
+
self.assertEqual(slot_names, {0: "person", 1: "car"})
|
|
574
|
+
self.assertEqual(width, 2)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@unittest.skipIf(PROBE is None, NO_PROBE)
|
|
578
|
+
class TestTheCliSurface(ProbeCase):
|
|
579
|
+
"""The flags this script offers, asserted so a rename cannot be silent.
|
|
580
|
+
|
|
581
|
+
`publish-sensing-model.js` builds `--variant-from-card` by hand into an
|
|
582
|
+
`argv` array and reads the probe's stdout back. Nothing type-checks that
|
|
583
|
+
join, so a flag renamed on one side surfaces as "the probe failed" with an
|
|
584
|
+
argparse usage string attached — at publish time, on a real artifact.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
EXPECTED = {
|
|
588
|
+
"-h", "--help", "--mode", "--measure-payload", "--artifact", "--model-id",
|
|
589
|
+
"--classes-file", "--slot-offset",
|
|
590
|
+
"--variant-from-card", "--output-layout", "--box-units", "--box-scale-to-pixels",
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
def test_the_option_strings_are_exactly_these(self):
|
|
594
|
+
offered = set()
|
|
595
|
+
for action in PROBE.build_parser()._actions:
|
|
596
|
+
offered.update(action.option_strings)
|
|
597
|
+
self.assertEqual(offered, self.EXPECTED)
|
|
598
|
+
|
|
599
|
+
def test_the_declaration_flags_all_default_to_none(self):
|
|
600
|
+
# NOT to the RF-DETR values. `_resolve_variant` has to be able to tell
|
|
601
|
+
# "nobody declared a layout" from "somebody declared this one", because
|
|
602
|
+
# only the first may fall back to a default.
|
|
603
|
+
args = self.args()
|
|
604
|
+
self.assertIsNone(args.variant_from_card)
|
|
605
|
+
self.assertIsNone(args.output_layout)
|
|
606
|
+
self.assertIsNone(args.box_units)
|
|
607
|
+
self.assertIsNone(args.box_scale_to_pixels)
|
|
608
|
+
|
|
609
|
+
def test_slot_offset_still_records_whether_it_was_typed(self):
|
|
610
|
+
self.assertFalse(self.args().slot_offset_given)
|
|
611
|
+
self.assertEqual(self.args().slot_offset, 0)
|
|
612
|
+
typed = self.args("--slot-offset", "0")
|
|
613
|
+
self.assertTrue(typed.slot_offset_given)
|
|
614
|
+
self.assertEqual(typed.slot_offset, 0)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
if __name__ == "__main__": # pragma: no cover
|
|
618
|
+
unittest.main()
|