@trillboards/edge-sdk 0.2.80 → 0.2.82

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.
@@ -23,7 +23,8 @@ import urllib.error
23
23
  import urllib.request
24
24
  from dataclasses import dataclass
25
25
 
26
- from trillboards_detector.errors import DetectorNotReady
26
+ from trillboards_detector.errors import DetectorNotReady, UnknownClasses
27
+ from trillboards_detector.labels import RFDETR_MEDIUM, ClassIndex, ModelVocabulary
27
28
  from trillboards_detector.server import DetectorServer, MAX_BODY_BYTES
28
29
 
29
30
  JPEG = bytes([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46])
@@ -40,27 +41,57 @@ class FakeDetection:
40
41
 
41
42
 
42
43
  class FakeBatch:
43
- def __init__(self, detections, inference_ms=63.4):
44
+ def __init__(self, detections, inference_ms=63.4, models=("rfdetr_medium",)):
44
45
  self.detections = detections
45
46
  self.inference_ms = inference_ms
46
47
  self.degenerate_dropped = 0
47
48
  self.unnamed_class_dropped = 0
49
+ #: WHICH MODEL(S) ANSWERED. On a single-model box this is one name and
50
+ #: the emitted payload is byte-identical to what it was before the
51
+ #: library existed; the wire's `model` field is now derived from it
52
+ #: rather than from a loader-wide constant, because the answering set is
53
+ #: a property of the REQUEST once several models are resident.
54
+ self.models = list(models)
48
55
 
49
56
 
50
57
  class FakeLoader:
51
- """Everything `server.py` is allowed to know about the model."""
58
+ """Everything `server.py` is allowed to know about the models.
59
+
60
+ The class index is the REAL one over the REAL shipped vocabulary — only the
61
+ session is faked. Canonicalisation and the unknown-class refusal are the
62
+ behaviour under test here, so stubbing them would test the stub.
63
+ """
52
64
 
53
65
  model_name = "rfdetr_medium"
54
66
 
55
- def __init__(self, batch=None, state="ready", raises=None):
67
+ def __init__(self, batch=None, state="ready", raises=None, vocabularies=(RFDETR_MEDIUM,)):
56
68
  self._batch = batch if batch is not None else FakeBatch([])
57
69
  self.state = state
58
70
  self._raises = raises
59
71
  self.calls: list[tuple] = []
72
+ self._index = ClassIndex(list(vocabularies))
73
+ self.resolve_calls: list[list[str]] = []
60
74
 
61
75
  def info(self):
62
76
  return {"state": self.state, "model": self.model_name, "model_path": "/stub"}
63
77
 
78
+ def class_index(self):
79
+ return self._index
80
+
81
+ def resolve_classes(self, requested):
82
+ self.resolve_calls.append(list(requested))
83
+ canonical, unknown = self._index.resolve(requested)
84
+ if unknown:
85
+ raise UnknownClasses(
86
+ unknown,
87
+ self._index.class_names,
88
+ f"requested classes not in this box's vocabulary: {unknown}. "
89
+ f"Known classes: {sorted(self._index.class_names)}",
90
+ )
91
+ if not canonical:
92
+ raise UnknownClasses([], self._index.class_names, "`classes` resolved to nothing requestable")
93
+ return canonical
94
+
64
95
  def detect(self, jpeg, min_confidence, wanted):
65
96
  self.calls.append((jpeg, min_confidence, wanted))
66
97
  if self._raises is not None:
@@ -180,6 +211,99 @@ class TestHealth(ServerCase):
180
211
  self.assertEqual(status, 405)
181
212
 
182
213
 
214
+ class TestHealthReportsResidency(ServerCase):
215
+ """`/health` must say WHICH models are resident, and still load nothing.
216
+
217
+ `sensing-model-library.md` §3 turns the loader into a name -> session map,
218
+ so "is the detector up" stops being one word. The top-level `state` still
219
+ describes the pinned general gate — every existing reader parses it, and
220
+ `LocalDetectorClient.isHealthy()` turns anything but 2xx into "send the
221
+ frame to the VLM" — and the per-model detail is additive.
222
+ """
223
+
224
+ def test_health_passes_the_residency_report_through_untouched(self):
225
+ class ResidencyLoader(FakeLoader):
226
+ def info(self):
227
+ return {
228
+ "state": "ready",
229
+ "model": "rfdetr_medium",
230
+ "model_path": "/stub",
231
+ "models": {
232
+ "rfdetr_medium": {"state": "ready", "resident": True, "pinned": True},
233
+ "ppe": {"state": "idle", "resident": False, "pinned": False},
234
+ },
235
+ "resident_models": ["rfdetr_medium"],
236
+ "ram_budget_bytes": 1610612736,
237
+ "ram_resident_bytes": 662700032,
238
+ }
239
+
240
+ loader = ResidencyLoader()
241
+ url = self.serve(loader)
242
+ status, body = self.request(f"{url}/health")
243
+ self.assertEqual(status, 200)
244
+ self.assertEqual(body["resident_models"], ["rfdetr_medium"])
245
+ self.assertEqual(body["models"]["ppe"]["state"], "idle")
246
+ self.assertEqual(body["ram_budget_bytes"], 1610612736)
247
+ # THE ASSERTION THAT MATTERS, restated for the multi-model payload: a
248
+ # bigger report must not have become a probe.
249
+ self.assertEqual(loader.calls, [])
250
+ self.assertEqual(loader.resolve_calls, [])
251
+
252
+
253
+ class TestTheAnsweringSetIsReported(ServerCase):
254
+ """§3: "The response's `model` field reports the set that answered." """
255
+
256
+ def test_two_models_are_joined_into_the_one_model_string(self):
257
+ """`LocalDetectorClient` types `model` as `string | null` and never
258
+ branches on it (`model: typeof parsed?.model === 'string' && parsed.model
259
+ ? parsed.model : null`), so a joined string is contract-safe — checked
260
+ against the client, not assumed."""
261
+ batch = FakeBatch(
262
+ [FakeDetection("person", 0.9, 1, 1, 5, 5), FakeDetection("glove", 0.8, 2, 2, 4, 4)],
263
+ models=("rfdetr_medium", "ppe"),
264
+ )
265
+ url = self.serve(FakeLoader(batch))
266
+ status, body = self.detect(url)
267
+ self.assertEqual(status, 200)
268
+ self.assertEqual(body["model"], "rfdetr_medium+ppe")
269
+ self.assertEqual([d["label"] for d in body["detections"]], ["person", "glove"])
270
+
271
+ def test_one_model_still_emits_exactly_the_payload_that_shipped(self):
272
+ batch = FakeBatch([FakeDetection("person", 0.91, 10.0, 20.0, 30.0, 40.0)])
273
+ url = self.serve(FakeLoader(batch))
274
+ _, body = self.detect(url)
275
+ self.assertEqual(body["model"], "rfdetr_medium")
276
+
277
+ def test_an_unattributed_batch_is_refused_not_emitted(self):
278
+ """A result naming no model is a detection nobody can attribute when a
279
+ specialist later turns out to be wrong. `model` would serialise as `""`,
280
+ which the client reads as null — plausible, and permanently lossy. 500,
281
+ a status that cannot be mistaken for a quiet venue."""
282
+ batch = FakeBatch([FakeDetection("person", 0.9, 1, 1, 5, 5)], models=())
283
+ url = self.serve(FakeLoader(batch))
284
+ status, body = self.detect(url)
285
+ self.assertEqual(status, 500)
286
+ self.assertNotIn("detections", body)
287
+
288
+
289
+ class TestABudgetRefusalIsNotAnEmptyFrame(ServerCase):
290
+ """A set that will not fit the RAM budget is 503, never a thinner answer."""
291
+
292
+ def test_a_portfolio_refusal_reaches_the_client_as_down(self):
293
+ loader = FakeLoader(
294
+ state="ready",
295
+ raises=DetectorNotReady(
296
+ "this request needs ['ppe', 'small_object'] resident at once, which with "
297
+ "['rfdetr_medium'] comes to 1500000000 B against a 1073741824 B portfolio budget"
298
+ ),
299
+ )
300
+ url = self.serve(loader)
301
+ status, body = self.detect(url, classes=["person"])
302
+ self.assertEqual(status, 503)
303
+ self.assertNotIn("detections", body)
304
+ self.assertIn("portfolio budget", body["error"])
305
+
306
+
183
307
  class TestHealthCannotLoadTheModel(unittest.TestCase):
184
308
  """The strongest form of "/health does not load the model": it CANNOT.
185
309
 
@@ -317,12 +441,72 @@ class TestClassFilter(ServerCase):
317
441
  self.assertEqual(status, 400)
318
442
  self.assertIn("shoplifter", body["error"])
319
443
 
320
- def test_a_partly_unknown_list_proceeds_on_the_known_half(self):
444
+ def test_a_partly_unknown_list_is_ALSO_a_loud_400(self):
445
+ """The partial case is the dangerous one, and it used to return 200.
446
+
447
+ `["person", "shoplifter"]` proceeded on `person` and dropped
448
+ `shoplifter` silently, so a profile asking about shoplifting got person
449
+ counts and no signal that its actual question was discarded. That is the
450
+ same `yolov8_nano` shape the test above is written against — a capability
451
+ offered and not delivered — only wearing a 200 instead of a 0%.
452
+
453
+ It becomes the COMMON case now that specialist models are EXPRESSIBLE: a
454
+ PPE profile asks for `glove` and `hairnet` alongside `person`, and on a
455
+ box holding only the general detector two thirds of the question would
456
+ vanish.
457
+
458
+ Refusing costs the `person` detections too. That is the right trade —
459
+ which models are resident is decided upstream by the profile push, so a
460
+ class the box cannot answer is a configuration error, and the client maps
461
+ 400 to `DetectorUnavailableError`, which shadow mode records as
462
+ `unavailable`. Visible beats quieter.
463
+
464
+ THE REFUSAL ITSELF MOVED to `registry.resolve_classes`, because which
465
+ classes exist is now a property of the models on this box rather than a
466
+ constant. What this case pins is the half the WIRE owns: an
467
+ `UnknownClasses` becomes a 400 and the detector is never asked.
468
+ """
469
+ loader = FakeLoader()
470
+ url = self.serve(loader)
471
+ status, body = self.detect(url, classes=["person", "shoplifter"])
472
+ self.assertEqual(status, 400)
473
+ self.assertIn("shoplifter", body["error"])
474
+ # And the detector was never asked — we refuse before inferring.
475
+ self.assertEqual(loader.calls, [])
476
+
477
+ def test_a_fully_known_list_still_reaches_the_detector(self):
478
+ """The refusal above must not have made every class request fail."""
321
479
  loader = FakeLoader()
322
480
  url = self.serve(loader)
323
- status, _ = self.detect(url, classes=["person", "shoplifter"])
481
+ status, _ = self.detect(url, classes=["person", "cell phone"])
324
482
  self.assertEqual(status, 200)
325
- self.assertEqual(loader.calls[0][2], {"person"})
483
+ self.assertEqual(loader.calls[0][2], {"person", "cell phone"})
484
+
485
+ def test_a_specialists_class_is_requestable_once_its_model_is_in_the_index(self):
486
+ """THE PIECE THAT MAKES A SPECIALIST EXPRESSIBLE AT THE WIRE.
487
+
488
+ Against the hardcoded COCO-91 `KNOWN_CLASS_NAMES` this was a 400 no
489
+ matter what was on the box, so publishing a PPE model would have changed
490
+ nothing here. The vocabulary is now the union of what the loader holds.
491
+ """
492
+ ppe = ModelVocabulary(
493
+ model_name="ppe",
494
+ slot_names={0: "glove", 1: "hairnet"},
495
+ logit_slots=2,
496
+ approx_ram_bytes=400 * 1024 * 1024,
497
+ )
498
+ loader = FakeLoader(vocabularies=(RFDETR_MEDIUM, ppe))
499
+ url = self.serve(loader)
500
+ status, _ = self.detect(url, classes=["person", "Glove"])
501
+ self.assertEqual(status, 200)
502
+ self.assertEqual(loader.calls[0][2], {"person", "glove"})
503
+
504
+ # And the COCO-only box still refuses it, so the widening is a property
505
+ # of what is loaded and not of the build.
506
+ coco_only = FakeLoader()
507
+ status, body = self.detect(self.serve(coco_only), classes=["person", "Glove"])
508
+ self.assertEqual(status, 400)
509
+ self.assertIn("Glove", body["error"])
326
510
 
327
511
 
328
512
  class TestRequestRefusals(ServerCase):
@@ -17,6 +17,7 @@ import unittest
17
17
  try:
18
18
  import numpy as np
19
19
 
20
+ from trillboards_detector.labels import RFDETR_MEDIUM
20
21
  from trillboards_detector.session import (
21
22
  ModelContractError,
22
23
  _read_graph_spec,
@@ -54,6 +55,7 @@ class TestPostprocess(unittest.TestCase):
54
55
  frame_w=640,
55
56
  frame_h=480,
56
57
  min_confidence=0.25,
58
+ vocabulary=RFDETR_MEDIUM,
57
59
  )
58
60
  self.assertEqual(len(batch.detections), 1)
59
61
  d = batch.detections[0]
@@ -71,6 +73,7 @@ class TestPostprocess(unittest.TestCase):
71
73
  frame_w=100,
72
74
  frame_h=100,
73
75
  min_confidence=0.25,
76
+ vocabulary=RFDETR_MEDIUM,
74
77
  )
75
78
  d = batch.detections[0]
76
79
  self.assertEqual((d.x, d.y), (0.0, 0.0))
@@ -84,6 +87,7 @@ class TestPostprocess(unittest.TestCase):
84
87
  frame_w=200,
85
88
  frame_h=200,
86
89
  min_confidence=0.25,
90
+ vocabulary=RFDETR_MEDIUM,
87
91
  )
88
92
  self.assertEqual(sorted(d.label for d in batch.detections), ["car", "person"])
89
93
 
@@ -92,8 +96,12 @@ class TestPostprocess(unittest.TestCase):
92
96
  is OUT, and that has to match: the operating point in the runbook was
93
97
  chosen against this comparison, not a >=."""
94
98
  args = (_boxes({0: (0.5, 0.5, 0.2, 0.2)}), _logits({(0, 1): 0.5}), 100, 100)
95
- self.assertEqual(len(postprocess(*args, min_confidence=0.4).detections), 1)
96
- self.assertEqual(len(postprocess(*args, min_confidence=0.5).detections), 0)
99
+ self.assertEqual(
100
+ len(postprocess(*args, min_confidence=0.4, vocabulary=RFDETR_MEDIUM).detections), 1
101
+ )
102
+ self.assertEqual(
103
+ len(postprocess(*args, min_confidence=0.5, vocabulary=RFDETR_MEDIUM).detections), 0
104
+ )
97
105
 
98
106
  def test_an_unnamed_class_slot_is_dropped_and_counted_never_labelled(self):
99
107
  """Eleven of the ninety-one slots are COCO id gaps. Slot 12 is one.
@@ -108,6 +116,7 @@ class TestPostprocess(unittest.TestCase):
108
116
  frame_w=100,
109
117
  frame_h=100,
110
118
  min_confidence=0.25,
119
+ vocabulary=RFDETR_MEDIUM,
111
120
  )
112
121
  self.assertEqual(batch.detections, [])
113
122
  self.assertEqual(batch.unnamed_class_dropped, 1)
@@ -122,6 +131,7 @@ class TestPostprocess(unittest.TestCase):
122
131
  frame_w=100,
123
132
  frame_h=100,
124
133
  min_confidence=0.25,
134
+ vocabulary=RFDETR_MEDIUM,
125
135
  )
126
136
  self.assertEqual(len(batch.detections), 1)
127
137
  self.assertEqual(batch.degenerate_dropped, 1)
@@ -134,8 +144,16 @@ class TestPostprocess(unittest.TestCase):
134
144
  100,
135
145
  0.25,
136
146
  )
137
- self.assertEqual([d.label for d in postprocess(*args, wanted={"car"}).detections], ["car"])
138
- self.assertEqual(len(postprocess(*args, wanted=None).detections), 2)
147
+ self.assertEqual(
148
+ [
149
+ d.label
150
+ for d in postprocess(*args, wanted={"car"}, vocabulary=RFDETR_MEDIUM).detections
151
+ ],
152
+ ["car"],
153
+ )
154
+ self.assertEqual(
155
+ len(postprocess(*args, wanted=None, vocabulary=RFDETR_MEDIUM).detections), 2
156
+ )
139
157
 
140
158
  def test_selection_keeps_the_top_300_pairs_before_thresholding(self):
141
159
  """`num_select=300` is applied FIRST, exactly as upstream does.
@@ -152,6 +170,7 @@ class TestPostprocess(unittest.TestCase):
152
170
  frame_w=100,
153
171
  frame_h=100,
154
172
  min_confidence=0.25,
173
+ vocabulary=RFDETR_MEDIUM,
155
174
  )
156
175
  self.assertEqual(len(batch.detections), 300)
157
176
 
@@ -159,7 +178,7 @@ class TestPostprocess(unittest.TestCase):
159
178
  rng = np.random.default_rng(7)
160
179
  logits = rng.normal(0.0, 40.0, size=(1, 300, 91)).astype(np.float32)
161
180
  boxes = np.abs(rng.normal(0.4, 0.2, size=(1, 300, 4))).astype(np.float32)
162
- batch = postprocess(boxes, logits, 640, 480, 0.0)
181
+ batch = postprocess(boxes, logits, 640, 480, 0.0, vocabulary=RFDETR_MEDIUM)
163
182
  for d in batch.detections:
164
183
  self.assertGreaterEqual(d.confidence, 0.0)
165
184
  self.assertLessEqual(d.confidence, 1.0)
@@ -205,7 +224,7 @@ class TestGraphContract(unittest.TestCase):
205
224
  GOOD_OUT = [_FakeIo("dets", [1, 300, 4]), _FakeIo("labels", [1, 300, 91])]
206
225
 
207
226
  def test_accepts_the_shipped_graph(self):
208
- spec = _read_graph_spec(_FakeSession(self.GOOD_IN, self.GOOD_OUT))
227
+ spec = _read_graph_spec(_FakeSession(self.GOOD_IN, self.GOOD_OUT), RFDETR_MEDIUM)
209
228
  self.assertEqual((spec.width, spec.height), (576, 576))
210
229
  self.assertEqual(spec.num_classes, 91)
211
230
  self.assertEqual(spec.input_name, "input")
@@ -213,7 +232,7 @@ class TestGraphContract(unittest.TestCase):
213
232
  def test_refuses_a_class_axis_the_label_map_does_not_describe(self):
214
233
  out = [_FakeIo("dets", [1, 300, 4]), _FakeIo("labels", [1, 300, 80])]
215
234
  with self.assertRaisesRegex(ModelContractError, "80 class slots"):
216
- _read_graph_spec(_FakeSession(self.GOOD_IN, out))
235
+ _read_graph_spec(_FakeSession(self.GOOD_IN, out), RFDETR_MEDIUM)
217
236
 
218
237
  def test_refuses_a_dynamic_resolution(self):
219
238
  """imgsz is one of the two numbers a detector arm is meaningless
@@ -221,12 +240,12 @@ class TestGraphContract(unittest.TestCase):
221
240
  the artifact at all."""
222
241
  dyn = [_FakeIo("input", [1, 3, "height", "width"])]
223
242
  with self.assertRaisesRegex(ModelContractError, "fixed input resolution"):
224
- _read_graph_spec(_FakeSession(dyn, self.GOOD_OUT))
243
+ _read_graph_spec(_FakeSession(dyn, self.GOOD_OUT), RFDETR_MEDIUM)
225
244
 
226
245
  def test_refuses_unexpected_output_names(self):
227
246
  out = [_FakeIo("boxes", [1, 300, 4]), _FakeIo("scores", [1, 300, 91])]
228
247
  with self.assertRaisesRegex(ModelContractError, "dets"):
229
- _read_graph_spec(_FakeSession(self.GOOD_IN, out))
248
+ _read_graph_spec(_FakeSession(self.GOOD_IN, out), RFDETR_MEDIUM)
230
249
 
231
250
  def test_refuses_a_multi_input_graph(self):
232
251
  """An open-vocabulary model would carry a text input. Per the runbook
@@ -235,7 +254,7 @@ class TestGraphContract(unittest.TestCase):
235
254
  the label map belongs to."""
236
255
  two = [_FakeIo("images", [1, 3, 576, 576]), _FakeIo("text", [1, 512])]
237
256
  with self.assertRaisesRegex(ModelContractError, "one graph input"):
238
- _read_graph_spec(_FakeSession(two, self.GOOD_OUT))
257
+ _read_graph_spec(_FakeSession(two, self.GOOD_OUT), RFDETR_MEDIUM)
239
258
 
240
259
 
241
260
  if __name__ == "__main__":
@@ -5,4 +5,4 @@ Its own uv tool, its own venv, its own OS-supervised loopback service on
5
5
  `docs/runbooks/cctv-detector-tier.md` for what it runs and why that model.
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.0"
@@ -18,7 +18,7 @@ import threading
18
18
  from pathlib import Path
19
19
 
20
20
  from .loader import DEFAULT_POLL_INTERVAL_S, DetectorLoader
21
- from .modelpath import DEFAULT_MODEL_NAME, resolve_model_path
21
+ from .modelpath import DEFAULT_MODEL_NAME, resolve_model_path, resolve_ram_budget_mb
22
22
  from .server import DEFAULT_HOST, DEFAULT_PORT, serve
23
23
 
24
24
  LOG = logging.getLogger("trillboards_detector")
@@ -77,6 +77,22 @@ def build_parser() -> argparse.ArgumentParser:
77
77
  "wrong and this exists only to reproduce a bench arm."
78
78
  ),
79
79
  )
80
+ s.add_argument(
81
+ "--ram-budget-mb",
82
+ type=int,
83
+ default=None,
84
+ help=(
85
+ "how much RAM every RESIDENT detector session may occupy TOGETHER, in MiB "
86
+ "(env TRILLBOARDS_DETECTOR_RAM_BUDGET_MB). THERE IS NO DEFAULT AND THERE "
87
+ "MUST NOT BE: the allocation authority is server-side "
88
+ "(sensingProfileService.selectModelForDeviceTier and the model-manifest "
89
+ "min_ram_mb gate), and a ceiling chosen by this service would be a second "
90
+ "authority that cannot see the first. Unset, the service still holds the "
91
+ "model it was told to hold and REFUSES to make any second model resident, "
92
+ "naming why. Over the ceiling, a request is refused with what did not fit — "
93
+ "never answered with the half that did."
94
+ ),
95
+ )
80
96
  s.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_S)
81
97
  s.add_argument("--log-file", default=None, help="also write to this file (Windows has no task stdout)")
82
98
  s.add_argument("--verbose", action="store_true")
@@ -92,13 +108,44 @@ def main(argv: "list[str] | None" = None) -> int:
92
108
  _configure_logging(args.log_file, args.verbose)
93
109
 
94
110
  model_path = Path(args.model) if args.model else resolve_model_path(args.model_name)
95
- LOG.info("[detector] starting on %s:%s, model %s", args.host, args.port, model_path)
111
+
112
+ # PRECEDENCE: flag, then env, then NOTHING. There is deliberately no third
113
+ # rung — an absent ceiling is a state, not a value to fill in.
114
+ budget_mb = args.ram_budget_mb if args.ram_budget_mb is not None else resolve_ram_budget_mb()
115
+ ram_budget_bytes = budget_mb * 1024 * 1024 if budget_mb is not None else None
116
+
117
+ # NOT A FATAL EXIT, and that is deliberate. Every one of the three
118
+ # supervisors restarts this process on a non-zero exit — the crash-loop each
119
+ # of their definitions carries a ThrottleInterval for — and `loader.py`'s
120
+ # header already settles the policy for a required input that has not
121
+ # arrived: START, and report UNHEALTHY with the reason. That is how a missing
122
+ # OTA'd artifact is handled and a missing ceiling is the same shape. The
123
+ # refusal is enforced where it belongs, at admission, per request, loudly.
124
+ if ram_budget_bytes is None:
125
+ LOG.warning(
126
+ "[detector] NO RAM CEILING INJECTED — the pinned model %s will still load and "
127
+ "answer, but NO second model can be made resident and every request that would "
128
+ "need one is refused with the reason. This service does not pick its own ceiling: "
129
+ "the allocation authority is server-side. Pass --ram-budget-mb or set "
130
+ "TRILLBOARDS_DETECTOR_RAM_BUDGET_MB.",
131
+ args.model_name,
132
+ )
133
+
134
+ LOG.info(
135
+ "[detector] starting on %s:%s, pinned model %s at %s, ceiling %s",
136
+ args.host,
137
+ args.port,
138
+ args.model_name,
139
+ model_path,
140
+ f"{ram_budget_bytes // (1024 * 1024)} MiB (injected)" if ram_budget_bytes else "NOT INJECTED",
141
+ )
96
142
 
97
143
  loader = DetectorLoader(
98
144
  model_path=model_path,
99
145
  model_name=args.model_name,
100
146
  intra_op_threads=args.threads,
101
147
  poll_interval_s=args.poll_interval,
148
+ ram_budget_bytes=ram_budget_bytes,
102
149
  log=LOG.info,
103
150
  )
104
151
  loader.start()
@@ -0,0 +1,98 @@
1
+ """What one frame's answer looks like, and how two models' answers become one.
2
+
3
+ STDLIB ONLY, and that is why it is its own module rather than staying in
4
+ `session.py`. A merged result is produced by `registry.py`, which must be
5
+ testable on a bare interpreter — the ~128 MiB ONNX is OTA'd and is absent on
6
+ every CI runner, so any part of this service that can only be exercised with
7
+ onnxruntime present is a part nothing tests. `session.py` re-exports both
8
+ dataclasses, so nothing that imported them from there had to move.
9
+
10
+ `models` IS PART OF THE RESULT, not decoration. `docs/runbooks/sensing-model-library.md`
11
+ §3: "A request spanning two models runs both and merges. The response's `model`
12
+ field reports the set that answered." A detection whose producer cannot be named
13
+ is a detection that cannot be attributed when a specialist later turns out to be
14
+ wrong, and `cctv-detector-tier.md` §5 rule 2 is a whole scar about exactly that —
15
+ an unattributed class index once scored BUSES as people for an entire eval run
16
+ and the table looked completely plausible.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Detection:
26
+ """One wire-shaped detection. Pixel box, canonical label, [0,1] confidence."""
27
+
28
+ label: str
29
+ confidence: float
30
+ x: float
31
+ y: float
32
+ width: float
33
+ height: float
34
+
35
+
36
+ @dataclass
37
+ class DetectionBatch:
38
+ detections: list[Detection]
39
+ #: The ORT forward pass alone, in ms — directly comparable to the ~63 ms in
40
+ #: `docs/runbooks/cctv-detector-tier.md`. Decode and post-processing are
41
+ #: reported separately (`total_ms`) so a slow JPEG decode cannot hide inside
42
+ #: a number the runbook will later be checked against.
43
+ #:
44
+ #: On a merged batch this is the SUM across the models that answered, which
45
+ #: is the truth: they run serially on one box, so the wall clock the service
46
+ #: spent inferring is their sum, not their max. Comparing a two-model figure
47
+ #: against the single-model ~63 ms is therefore a comparison of different
48
+ #: things — `models` is on the same object so the reader can see how many.
49
+ inference_ms: float = 0.0
50
+ total_ms: float = 0.0
51
+ #: Boxes that survived the threshold but cover no pixels after clamping to
52
+ #: the frame. Counted, never silently swallowed — see `session._to_wire`.
53
+ degenerate_dropped: int = 0
54
+ #: Detections whose argmax landed on a slot the model's vocabulary does not
55
+ #: name (for COCO-91, one of the eleven category-id gaps).
56
+ unnamed_class_dropped: int = 0
57
+ #: The model(s) that produced this, in the order they ran. Exactly one
58
+ #: element for a batch off a single session; N for a merged one.
59
+ models: list[str] = field(default_factory=list)
60
+
61
+
62
+ def merge_batches(batches: "list[DetectionBatch]") -> DetectionBatch:
63
+ """Concatenate several models' answers for ONE frame into one answer.
64
+
65
+ ORDER IS CATALOGUE ORDER, NOT SCORE ORDER. The wire contract carries no
66
+ ordering promise and `LocalDetectorClient` reads the array straight through
67
+ without sorting, so re-ranking across models here would be inventing a
68
+ cross-model calibration nobody measured — two detectors' confidences are not
69
+ on a common scale, and `cctv-detector-tier.md` §2 is the standing warning
70
+ about comparing models on a number that does not mean the same thing in
71
+ both.
72
+
73
+ THERE IS NO PARTIAL MERGE. Every batch handed here is a model that ran to
74
+ completion; a model that failed raises long before this, so a short list can
75
+ never silently become a thinner frame. That is the single collapse this
76
+ whole component exists to prevent.
77
+ """
78
+ if not batches:
79
+ # Never reachable from `registry.detect`, which refuses an empty plan
80
+ # with a 503 before inferring. Raising rather than returning an empty
81
+ # batch keeps it that way: an empty merge is indistinguishable from a
82
+ # quiet venue, which is the one answer that must never be manufactured.
83
+ raise ValueError("merge_batches() was given no batches; an empty result must be a 503, not `[]`")
84
+ if len(batches) == 1:
85
+ return batches[0]
86
+
87
+ merged = DetectionBatch(detections=[])
88
+ for b in batches:
89
+ merged.detections.extend(b.detections)
90
+ merged.inference_ms += b.inference_ms
91
+ merged.total_ms += b.total_ms
92
+ merged.degenerate_dropped += b.degenerate_dropped
93
+ merged.unnamed_class_dropped += b.unnamed_class_dropped
94
+ merged.models.extend(b.models)
95
+ return merged
96
+
97
+
98
+ __all__ = ["Detection", "DetectionBatch", "merge_batches"]
@@ -4,6 +4,10 @@ Its own module, with NO third-party imports, so `server.py` stays importable —
4
4
  and therefore unit-testable against a stub — on a machine with no onnxruntime,
5
5
  no numpy and no Pillow. The wire contract is the load-bearing half of this
6
6
  service; it must not need a 128 MiB model or a 50 MB runtime to be tested.
7
+
8
+ `registry.py` is held to the same rule for the same reason, which is why
9
+ `ModelContractError` lives here rather than in `session.py`: the registry has to
10
+ be able to CLASSIFY a load failure without importing the thing that loads.
7
11
  """
8
12
 
9
13
 
@@ -13,4 +17,38 @@ class DetectorNotReady(RuntimeError):
13
17
  The distinction is the entire reason `LocalDetectorClient` exists: a cascade
14
18
  that reads "detector down" as "nobody there" suppresses every alert while
15
19
  every health surface stays green.
20
+
21
+ IT COVERS "WILL NOT FIT" AS WELL AS "IS NOT THERE", deliberately. A request
22
+ spanning models whose committed RAM cost exceeds the portfolio budget is a
23
+ request this box cannot answer, and answering it with the half that fits
24
+ would be a partial result nobody downstream could distinguish from a
25
+ genuinely emptier frame — `docs/runbooks/sensing-model-library.md` §2:
26
+ "report what it could not fit rather than silently dropping it."
27
+ """
28
+
29
+
30
+ class ModelContractError(RuntimeError):
31
+ """The artifact loaded, but it is not a graph this service can label.
32
+
33
+ Raised by `session.py` against the model's COMMITTED vocabulary. Named here
34
+ so `registry.py` can tell "this artifact is the wrong shape" (a permanent
35
+ fault that will not fix itself on a retry) from "ORT failed to open the
36
+ file" (which a re-download can fix), without importing onnxruntime.
16
37
  """
38
+
39
+
40
+ class UnknownClasses(ValueError):
41
+ """The caller named classes no resident-or-present model can emit.
42
+
43
+ A 400, and a LOUD one — never a quieter answer computed from the half we
44
+ recognised. `docs/runbooks/cctv-detector-tier.md` §5 rule 5 is the scar:
45
+ `yolov8_nano` was offered to ~97 of 114 profile authors while every device
46
+ hardcoded it unloadable, and `vehicle_count` sat at ~0% fleet-wide for six
47
+ months with every surface green.
48
+ """
49
+
50
+ def __init__(self, unknown: "list[str]", known: "frozenset[str]", detail: str = ""):
51
+ self.unknown = list(unknown)
52
+ self.known = frozenset(known)
53
+ self.detail = detail
54
+ super().__init__(detail or f"unknown classes: {self.unknown}")