@trillboards/edge-sdk 0.2.85 → 0.2.91

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.
@@ -24,7 +24,14 @@ import urllib.request
24
24
  from dataclasses import dataclass
25
25
 
26
26
  from trillboards_detector.errors import DetectorNotReady, UnknownClasses
27
- from trillboards_detector.labels import RFDETR_MEDIUM, ClassIndex, ModelVocabulary
27
+ from trillboards_detector.labels import (
28
+ ANCHOR_4_PLUS_NC,
29
+ BOX_UNITS_PIXELS,
30
+ RFDETR_MEDIUM,
31
+ ClassIndex,
32
+ ModelVocabulary,
33
+ VariantContract,
34
+ )
28
35
  from trillboards_detector.server import DetectorServer, MAX_BODY_BYTES
29
36
 
30
37
  JPEG = bytes([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46])
@@ -494,6 +501,7 @@ class TestClassFilter(ServerCase):
494
501
  slot_names={0: "glove", 1: "hairnet"},
495
502
  logit_slots=2,
496
503
  approx_ram_bytes=400 * 1024 * 1024,
504
+ onnx_variant=VariantContract(ANCHOR_4_PLUS_NC, BOX_UNITS_PIXELS, 1.0),
497
505
  )
498
506
  loader = FakeLoader(vocabularies=(RFDETR_MEDIUM, ppe))
499
507
  url = self.serve(loader)
@@ -12,17 +12,34 @@ needs no third-party package at all and is the half that must always run.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import ast
16
+ import pathlib
15
17
  import unittest
16
18
 
17
19
  try:
18
20
  import numpy as np
19
21
 
20
- from trillboards_detector.labels import RFDETR_MEDIUM
22
+ from trillboards_detector.labels import (
23
+ ANCHOR_4_PLUS_NC,
24
+ BOX_UNITS_NORMALIZED,
25
+ BOX_UNITS_PIXELS,
26
+ END2END_N_6,
27
+ RFDETR_MEDIUM,
28
+ ModelVocabulary,
29
+ VariantContract,
30
+ )
31
+ from trillboards_detector.decode import (
32
+ LetterboxGeometry,
33
+ decode_anchor_4_plus_nc,
34
+ )
21
35
  from trillboards_detector.session import (
22
36
  ModelContractError,
23
37
  _read_graph_spec,
24
38
  _sigmoid,
39
+ _to_wire,
25
40
  postprocess,
41
+ preprocess_for_layout,
42
+ preprocess_letterbox,
26
43
  )
27
44
 
28
45
  HAVE_NUMPY = True
@@ -38,6 +55,18 @@ def _logits(pairs, num_queries=3, num_classes=91):
38
55
  return out
39
56
 
40
57
 
58
+ def _solid_jpeg(width, height, colour=(200, 50, 50)):
59
+ """A real JPEG of a solid colour. Small enough to be free, real enough to
60
+ decode — the same reasoning as `loader._warmup_jpeg`."""
61
+ import io as _io
62
+
63
+ from PIL import Image as _Image
64
+
65
+ buf = _io.BytesIO()
66
+ _Image.new("RGB", (width, height), colour).save(buf, format="JPEG", quality=95)
67
+ return buf.getvalue()
68
+
69
+
41
70
  def _boxes(rows, num_queries=3):
42
71
  out = np.zeros((1, num_queries, 4), dtype=np.float32)
43
72
  for q, box in rows.items():
@@ -256,6 +285,378 @@ class TestGraphContract(unittest.TestCase):
256
285
  with self.assertRaisesRegex(ModelContractError, "one graph input"):
257
286
  _read_graph_spec(_FakeSession(two, self.GOOD_OUT), RFDETR_MEDIUM)
258
287
 
288
+ def test_the_shipped_graph_reports_its_layout_and_preprocessing(self):
289
+ """`/health` has to be able to say which decode ran. Two artifacts of
290
+ different layouts produce equally plausible tables."""
291
+ spec = _read_graph_spec(_FakeSession(self.GOOD_IN, self.GOOD_OUT), RFDETR_MEDIUM)
292
+ self.assertEqual(spec.output_layout, "rfdetr_dets_labels")
293
+ self.assertEqual(spec.preprocessing, "imagenet-normalised-stretch")
294
+ self.assertEqual(spec.output_names, ("dets", "labels"))
295
+
296
+
297
+ def _yolo_vocabulary(layout, classes=2, units=None, scale=1.0):
298
+ """A specialist vocabulary for the graph-contract cases.
299
+
300
+ `units` defaults to None and is resolved in the BODY, not in the signature.
301
+ A default argument is evaluated at import time, so naming `BOX_UNITS_PIXELS`
302
+ there raises `NameError` on a bare interpreter where the guarded import at
303
+ the top of this file never ran — which is every CI runner without
304
+ onnxruntime, and would take the whole module down as an ERROR rather than a
305
+ skip. Caught by running the suite with numpy, ORT and Pillow made
306
+ unimportable.
307
+ """
308
+ return ModelVocabulary(
309
+ model_name="ppe",
310
+ slot_names={i: name for i, name in enumerate(["glove", "hairnet", "apron"][:classes])},
311
+ logit_slots=classes,
312
+ approx_ram_bytes=400 * 1024 * 1024,
313
+ onnx_variant=VariantContract(layout, units or BOX_UNITS_PIXELS, scale),
314
+ )
315
+
316
+
317
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
318
+ class TestYoloGraphContract(unittest.TestCase):
319
+ """The declared layout has to survive contact with the graph, or refuse.
320
+
321
+ This is where a mis-declared artifact is caught. `[1, 300, 6]` decoded as
322
+ `[1, 4+nc, A]` reads x1,y1,x2,y2,score,cls as four box channels and two
323
+ classes; the reverse reads 8,400 anchors as 8,400 detections. Neither raises
324
+ anywhere downstream — both produce a full table — so the refusal has to
325
+ happen here, at load, where it takes the model UNAVAILABLE and the request
326
+ becomes a 503 rather than a plausible answer.
327
+ """
328
+
329
+ IN = [_FakeIo("images", [1, 3, 640, 640])]
330
+
331
+ def test_accepts_an_anchor_head_whose_axis_matches_the_vocabulary(self):
332
+ out = [_FakeIo("output0", [1, 6, 8400])]
333
+ spec = _read_graph_spec(_FakeSession(self.IN, out), _yolo_vocabulary(ANCHOR_4_PLUS_NC))
334
+ self.assertEqual(spec.output_layout, ANCHOR_4_PLUS_NC)
335
+ self.assertEqual(spec.output_names, ("output0",))
336
+ self.assertEqual(spec.preprocessing, "ultralytics-letterbox-114-centred")
337
+ self.assertEqual((spec.width, spec.height), (640, 640))
338
+ self.assertEqual(spec.num_classes, 2)
339
+ self.assertEqual(spec.num_queries, 8400)
340
+
341
+ def test_refuses_an_anchor_axis_the_vocabulary_does_not_describe(self):
342
+ """4 box channels + 2 named classes needs 6. A 7-wide axis shifts every
343
+ class name onto its neighbour's scores."""
344
+ out = [_FakeIo("output0", [1, 7, 8400])]
345
+ with self.assertRaisesRegex(ModelContractError, "labelling a channel by position"):
346
+ _read_graph_spec(_FakeSession(self.IN, out), _yolo_vocabulary(ANCHOR_4_PLUS_NC))
347
+
348
+ def test_refuses_an_end2end_graph_declared_as_an_anchor_head(self):
349
+ out = [_FakeIo("output0", [1, 300, 6])]
350
+ with self.assertRaisesRegex(ModelContractError, "labelling a channel by position"):
351
+ _read_graph_spec(_FakeSession(self.IN, out), _yolo_vocabulary(ANCHOR_4_PLUS_NC, 2))
352
+
353
+ def test_accepts_an_end2end_graph(self):
354
+ out = [_FakeIo("output0", [1, 300, 6])]
355
+ spec = _read_graph_spec(_FakeSession(self.IN, out), _yolo_vocabulary(END2END_N_6))
356
+ self.assertEqual(spec.output_layout, END2END_N_6)
357
+ self.assertEqual(spec.num_queries, 300)
358
+
359
+ def test_refuses_an_anchor_graph_declared_as_end2end(self):
360
+ """The exact inversion the declared layout exists to catch: `[1, 6, 8400]`
361
+ has a 6 in it too, and reading it as rows would return 6 detections whose
362
+ `cls` column is a box coordinate."""
363
+ out = [_FakeIo("output0", [1, 6, 8400])]
364
+ with self.assertRaisesRegex(ModelContractError, r"x1,y1,x2,y2,score,cls"):
365
+ _read_graph_spec(_FakeSession(self.IN, out), _yolo_vocabulary(END2END_N_6))
366
+
367
+ def test_refuses_a_second_output_on_a_single_output_layout(self):
368
+ out = [_FakeIo("output0", [1, 6, 8400]), _FakeIo("proto", [1, 32, 160, 160])]
369
+ with self.assertRaisesRegex(ModelContractError, "exactly one graph output"):
370
+ _read_graph_spec(_FakeSession(self.IN, out), _yolo_vocabulary(ANCHOR_4_PLUS_NC))
371
+
372
+ def test_refuses_a_dynamic_resolution_on_a_yolo_head_too(self):
373
+ dyn = [_FakeIo("images", [1, 3, "height", "width"])]
374
+ out = [_FakeIo("output0", [1, 6, 8400])]
375
+ with self.assertRaisesRegex(ModelContractError, "fixed input resolution"):
376
+ _read_graph_spec(_FakeSession(dyn, out), _yolo_vocabulary(ANCHOR_4_PLUS_NC))
377
+
378
+ def test_a_normalized_variant_loads_the_same_graph(self):
379
+ """The LiteRT twin of these weights is `normalized`/640 and the ONNX one
380
+ is `pixels`/1. The GRAPH is identical, which is the whole trap: nothing
381
+ about the shape distinguishes them."""
382
+ out = [_FakeIo("output0", [1, 6, 8400])]
383
+ spec = _read_graph_spec(
384
+ _FakeSession(self.IN, out),
385
+ _yolo_vocabulary(ANCHOR_4_PLUS_NC, 2, BOX_UNITS_NORMALIZED, 640.0),
386
+ )
387
+ self.assertEqual(spec.output_layout, ANCHOR_4_PLUS_NC)
388
+
389
+
390
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
391
+ class TestLetterboxPreprocessing(unittest.TestCase):
392
+ """The Ultralytics transform, and the padding convention the decode undoes."""
393
+
394
+ def test_aspect_is_preserved_and_the_remainder_is_114_grey(self):
395
+ blob, geometry = preprocess_letterbox(_solid_jpeg(1200, 900), 640, 640)
396
+ self.assertEqual(blob.shape, (1, 3, 640, 640))
397
+ self.assertAlmostEqual(geometry.gain, 640.0 / 1200.0, places=9)
398
+ self.assertEqual((geometry.pad_x, geometry.pad_y), (0.0, 80.0))
399
+ self.assertEqual((geometry.frame_w, geometry.frame_h), (1200, 900))
400
+ # The top row is padding, and 114/255 is what the model saw in training.
401
+ self.assertTrue(np.allclose(blob[0, :, 0, :], 114.0 / 255.0, atol=1e-6))
402
+ # The centre is content, not padding.
403
+ self.assertFalse(np.allclose(blob[0, :, 320, 320], 114.0 / 255.0, atol=1e-3))
404
+
405
+ def test_pixels_are_scaled_by_255_and_not_imagenet_normalised(self):
406
+ """`preprocess` subtracts a mean and divides by a standard deviation and
407
+ this one must not: on a mid-grey frame that is the difference between
408
+ ~0.45 and ~-0.15, and no shape or dtype check can see it."""
409
+ blob, _ = preprocess_letterbox(_solid_jpeg(64, 64, (128, 128, 128)), 640, 640)
410
+ self.assertGreaterEqual(float(blob.min()), 0.0)
411
+ self.assertLessEqual(float(blob.max()), 1.0)
412
+ self.assertAlmostEqual(float(blob[0, 0, 320, 320]), 128.0 / 255.0, places=2)
413
+
414
+ def test_the_padding_convention_matches_ultralytics_scale_boxes_for_every_input(self):
415
+ """`(W - new_w) // 2` here versus `round(dw - 0.1)` in
416
+ `ops.scale_boxes`. They agree for every width because dw is either an
417
+ integer or a half-integer, and both forms floor it — asserted rather than
418
+ argued, since a one-pixel disagreement would shift every box by one pixel
419
+ and nothing would report it."""
420
+ for width in range(0, 641):
421
+ for remainder in (width, 640 - width):
422
+ dw = remainder / 2.0
423
+ self.assertEqual(remainder // 2, round(dw - 0.1), f"remainder={remainder}")
424
+
425
+ def test_a_square_frame_needs_no_padding(self):
426
+ _, geometry = preprocess_letterbox(_solid_jpeg(320, 320), 640, 640)
427
+ self.assertEqual((geometry.pad_x, geometry.pad_y), (0.0, 0.0))
428
+ self.assertAlmostEqual(geometry.gain, 2.0, places=9)
429
+
430
+
431
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
432
+ class TestPreprocessorDispatch(unittest.TestCase):
433
+ """The layout picks the transform, and nothing else may.
434
+
435
+ Handing an Ultralytics export the RF-DETR stretch produces a tensor of the
436
+ right shape and dtype whose every channel is shifted by roughly two standard
437
+ deviations, on an image whose aspect ratio has been changed. It raises
438
+ nothing. A mutation test found this dispatch reachable only through a real
439
+ ORT session — all 178 unit tests stayed green while the two preprocessors
440
+ were swapped — which is why it is a function with its own test rather than
441
+ four lines inside `detect`.
442
+ """
443
+
444
+ def test_a_yolo_layout_gets_the_letterbox_and_keeps_the_frame_size(self):
445
+ for layout in (ANCHOR_4_PLUS_NC, END2END_N_6):
446
+ blob, geometry, frame_w, frame_h = preprocess_for_layout(
447
+ layout, _solid_jpeg(1200, 900), 640, 640
448
+ )
449
+ self.assertIsNotNone(geometry, layout)
450
+ self.assertEqual((frame_w, frame_h), (1200, 900))
451
+ # Padding is present and it is 114-grey, not black and not content.
452
+ self.assertTrue(np.allclose(blob[0, :, 0, :], 114.0 / 255.0, atol=1e-6), layout)
453
+ # /255 only: an ImageNet-normalised tensor goes negative.
454
+ self.assertGreaterEqual(float(blob.min()), 0.0, layout)
455
+
456
+ def test_the_rfdetr_layout_gets_the_stretch_and_no_geometry(self):
457
+ blob, geometry, frame_w, frame_h = preprocess_for_layout(
458
+ "rfdetr_dets_labels", _solid_jpeg(1200, 900), 576, 576
459
+ )
460
+ self.assertIsNone(geometry)
461
+ self.assertEqual((frame_w, frame_h), (1200, 900))
462
+ self.assertEqual(blob.shape, (1, 3, 576, 576))
463
+ # ImageNet normalisation puts a mid-tone below zero; the letterbox path
464
+ # cannot. That sign is the cheapest thing that tells the two apart.
465
+ self.assertLess(float(blob.min()), 0.0)
466
+ # A STRETCH leaves no padding: the top row is content, not 114-grey.
467
+ self.assertFalse(np.allclose(blob[0, :, 0, :], 114.0 / 255.0, atol=1e-3))
468
+
469
+
470
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
471
+ class TestWrongBoxUnitsAtTheWire(unittest.TestCase):
472
+ """What a WRONG `box_units` declaration actually does to a caller.
473
+
474
+ IT DEPENDS ON THE FRAME, NOT ON THE DIRECTION, and that is the whole finding.
475
+ Measured on `ppe_keremberke_v8n` (ONNX), 200 frames of `safety-gloves-v1`,
476
+ with the declaration as the only variable and the frame's aspect ratio as the
477
+ only other:
478
+
479
+ frame as submitted declared on wire degenerate_dropped widths
480
+ letterboxed (pad>0) correct 848 0 33.5-250.5 px
481
+ letterboxed (pad>0) WRONG 0 848 -
482
+ square (pad==0) correct 707 0 18.2-122.0 px
483
+ square (pad==0) WRONG 707 0 0.028-0.191 px
484
+
485
+ On the SQUARE frames the wrong declaration is perfectly silent: same count,
486
+ **zero** label mismatches over all 707, `max |confidence delta| = 0.0`
487
+ EXACTLY, and a width ratio of 639.9987-640.0012 — boxes exactly 640x too
488
+ small and nothing else different. That is the claim `labels.VariantContract`
489
+ makes, and it holds.
490
+
491
+ On the LETTERBOXED frames the same mistake loses everything. `_to_frame_pixels`
492
+ subtracts the padding BEFORE it clips, so boxes 640x too small land at a
493
+ negative offset, clip to zero extent, and `_to_wire` drops all of them. That
494
+ is why a naive check can report a mis-declaration as loud: measure it on
495
+ padded frames and it always is.
496
+
497
+ Both regimes are reachable in production — the connector submits whatever the
498
+ camera gives it — so neither is "the" behaviour, and a mis-declaration cannot
499
+ be relied on to announce itself. It is a declaration checked against a
500
+ measurement (`bench/run_candidate.py`) for exactly that reason.
501
+
502
+ The fixtures below reproduce each regime with a hand-built tensor: an
503
+ identity geometry is the square case, a padded geometry the letterboxed one.
504
+ """
505
+
506
+ @staticmethod
507
+ def _vocabulary():
508
+ return ModelVocabulary(
509
+ model_name="probe",
510
+ slot_names={0: "glove", 1: "hairnet"},
511
+ logit_slots=2,
512
+ approx_ram_bytes=1,
513
+ onnx_variant=VariantContract(ANCHOR_4_PLUS_NC, BOX_UNITS_PIXELS, 1.0),
514
+ )
515
+
516
+ @staticmethod
517
+ def _tensor(cx, cy, w, h):
518
+ """One anchor, one class, whatever units the caller means them to be."""
519
+ grid = np.zeros((1, 6, 1), dtype=np.float32)
520
+ grid[0, 0, 0], grid[0, 1, 0], grid[0, 2, 0], grid[0, 3, 0] = cx, cy, w, h
521
+ grid[0, 4, 0] = 0.9
522
+ return grid
523
+
524
+ def _wire(self, raw, scale, geometry=None):
525
+ if geometry is None: # the SQUARE case: nothing was padded away
526
+ geometry = LetterboxGeometry(1.0, 0.0, 0.0, 640, 640)
527
+ scores, classes, x0, y0, x1, y1 = decode_anchor_4_plus_nc(
528
+ raw, geometry, 0.25, box_scale_to_pixels=scale, expected_classes=2
529
+ )
530
+ return _to_wire(scores, classes, x0, y0, x1, y1, None, self._vocabulary())
531
+
532
+ def test_on_a_square_frame_a_wrong_declaration_is_completely_silent(self):
533
+ """The 640x error survives to the wire with everything else intact.
534
+
535
+ Right count, right label, byte-identical confidence, and a box two
536
+ orders of magnitude too small. Nothing in the payload says it is wrong,
537
+ which is why there is no default `box_units`.
538
+ """
539
+ normalized = self._tensor(0.5, 0.5, 0.1, 0.1)
540
+ good = self._wire(normalized, 640.0)
541
+ self.assertEqual(len(good.detections), 1)
542
+ self.assertAlmostEqual(good.detections[0].width, 64.0, places=3)
543
+
544
+ bad = self._wire(normalized, 1.0)
545
+ self.assertEqual(len(bad.detections), 1)
546
+ self.assertEqual(bad.degenerate_dropped, 0)
547
+ self.assertEqual(bad.detections[0].label, good.detections[0].label)
548
+ self.assertEqual(bad.detections[0].confidence, good.detections[0].confidence)
549
+ self.assertAlmostEqual(
550
+ good.detections[0].width / bad.detections[0].width, 640.0, places=2
551
+ )
552
+
553
+ def test_on_a_letterboxed_frame_the_same_mistake_loses_every_detection(self):
554
+ """Padding is subtracted before the clip, so the sub-pixel box clamps away.
555
+
556
+ Same tensor, same wrong declaration as the test above; only the geometry
557
+ differs. This is the regime a padded camera frame is in, and it is why
558
+ "we would have noticed" is not an argument.
559
+ """
560
+ padded = LetterboxGeometry(0.5, 0.0, 80.0, 1280, 960)
561
+ normalized = self._tensor(0.5, 0.5, 0.1, 0.1)
562
+
563
+ good = self._wire(normalized, 640.0, padded)
564
+ self.assertEqual(len(good.detections), 1)
565
+
566
+ bad = self._wire(normalized, 1.0, padded)
567
+ self.assertEqual(bad.detections, [])
568
+ self.assertEqual(bad.degenerate_dropped, 1)
569
+
570
+ def test_a_pixel_artifact_declared_normalized_loses_every_detection(self):
571
+ """The other direction, on a square frame: 640x too BIG clamps flush."""
572
+ pixels = self._tensor(320.0, 320.0, 64.0, 64.0)
573
+ good = self._wire(pixels, 1.0)
574
+ self.assertEqual(len(good.detections), 1)
575
+ self.assertAlmostEqual(good.detections[0].width, 64.0, places=3)
576
+ self.assertEqual(good.degenerate_dropped, 0)
577
+
578
+ bad = self._wire(pixels, 640.0)
579
+ self.assertEqual(bad.detections, [])
580
+ self.assertEqual(bad.degenerate_dropped, 1)
581
+
582
+
583
+ class TestEveryVocabularyConstructionDeclaresItsVariant(unittest.TestCase):
584
+ """Every `ModelVocabulary(...)` in the tree must supply `onnx_variant`.
585
+
586
+ STDLIB ONLY, ON PURPOSE. The thing this guards lives in `bench/`, which
587
+ imports onnxruntime at module scope and therefore cannot be imported by the
588
+ `detector-service` CI lane at all — so a runtime test would skip in exactly
589
+ the environment that has to catch this, and pass vacuously everywhere else.
590
+ `ast` needs nothing installed, so this runs on the bare interpreter.
591
+
592
+ It exists because making `onnx_variant` REQUIRED broke three call sites in
593
+ `bench/run_candidate.py` — including `probe_loadability`, which decides
594
+ whether a candidate is publishable, and the `ShippedRunner` construction on
595
+ the scoring path — and every one of them failed with a `TypeError` at
596
+ runtime that no test in this suite could reach. A missing required argument
597
+ is a static fact; check it statically.
598
+ """
599
+
600
+ #: `labels.py` DEFINES the dataclass, so its own signature is not a call.
601
+ _EXEMPT = {"labels.py"}
602
+
603
+ def _roots(self):
604
+ """The detector package, plus the repo's own callers WHEN THEY EXIST.
605
+
606
+ The first version of this test scanned only the three directories under
607
+ `deploy/detector` and passed while `scripts/sensing/onnx_artifact_probe.py`
608
+ held a fourth broken construction — the guard's blind spot was the reason
609
+ the bug survived the guard. Anything in the repo that imports the
610
+ detector package is a caller and belongs in scope.
611
+
612
+ `deploy/` ships inside the npm tarball, so on an installed connector the
613
+ repo roots are simply absent; they are added only if present rather than
614
+ asserted, and the detector roots always are.
615
+ """
616
+ here = pathlib.Path(__file__).resolve().parent.parent
617
+ roots = [here / "trillboards_detector", here / "bench", here / "tests"]
618
+ for parent in here.parents:
619
+ if (parent / ".git").exists():
620
+ roots.append(parent / "scripts" / "sensing")
621
+ break
622
+ return roots
623
+
624
+ def test_no_construction_omits_the_variant_contract(self):
625
+ offenders = []
626
+ for root in self._roots():
627
+ if not root.is_dir():
628
+ continue
629
+ for path in sorted(root.rglob("*.py")):
630
+ if path.name in self._EXEMPT:
631
+ continue
632
+ try:
633
+ tree = ast.parse(path.read_text(encoding="utf-8"))
634
+ except SyntaxError as exc: # a file that cannot parse is its own failure
635
+ offenders.append(f"{path.name}: unparseable ({exc})")
636
+ continue
637
+ for node in ast.walk(tree):
638
+ if not isinstance(node, ast.Call):
639
+ continue
640
+ func = node.func
641
+ name = getattr(func, "id", None) or getattr(func, "attr", None)
642
+ if name != "ModelVocabulary":
643
+ continue
644
+ kwargs = {kw.arg for kw in node.keywords if kw.arg}
645
+ has_kwsplat = any(kw.arg is None for kw in node.keywords)
646
+ # 5 positional args reaches `onnx_variant`; `**kwargs` is opaque
647
+ # and is trusted rather than guessed at.
648
+ if "onnx_variant" in kwargs or len(node.args) >= 5 or has_kwsplat:
649
+ continue
650
+ offenders.append(f"{path.name}:{node.lineno}")
651
+ self.assertEqual(
652
+ offenders,
653
+ [],
654
+ "ModelVocabulary requires onnx_variant — a variant whose output layout is "
655
+ "inferred is a variant whose boxes are plausible and wrong. Omitting it is a "
656
+ "TypeError at runtime, and these call sites are not reachable from this "
657
+ f"suite: {offenders}",
658
+ )
659
+
259
660
 
260
661
  if __name__ == "__main__":
261
662
  unittest.main()