@trillboards/edge-sdk 0.2.81 → 0.2.84

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.
Files changed (41) hide show
  1. package/deploy/detector/README.md +108 -21
  2. package/deploy/detector/bench/verify_against_reference.py +6 -1
  3. package/deploy/detector/pyproject.toml +1 -1
  4. package/deploy/detector/tests/test_loader.py +188 -0
  5. package/deploy/detector/tests/test_modelpath.py +48 -3
  6. package/deploy/detector/tests/test_registry.py +880 -0
  7. package/deploy/detector/tests/test_server.py +163 -7
  8. package/deploy/detector/tests/test_session.py +29 -10
  9. package/deploy/detector/trillboards_detector/__init__.py +1 -1
  10. package/deploy/detector/trillboards_detector/cli.py +49 -2
  11. package/deploy/detector/trillboards_detector/detections.py +98 -0
  12. package/deploy/detector/trillboards_detector/errors.py +38 -0
  13. package/deploy/detector/trillboards_detector/labels.py +239 -24
  14. package/deploy/detector/trillboards_detector/loader.py +186 -148
  15. package/deploy/detector/trillboards_detector/modelpath.py +32 -0
  16. package/deploy/detector/trillboards_detector/registry.py +836 -0
  17. package/deploy/detector/trillboards_detector/server.py +59 -40
  18. package/deploy/detector/trillboards_detector/session.py +84 -55
  19. package/dist/EdgeAgent.js +1 -1
  20. package/dist/SensingProfileClient.d.ts +1 -1
  21. package/dist/SensingProfileClient.d.ts.map +1 -1
  22. package/dist/SensingProfileClient.js +20 -0
  23. package/dist/SensingProfileClient.js.map +1 -1
  24. package/dist/autoSense.d.ts +6 -5
  25. package/dist/autoSense.d.ts.map +1 -1
  26. package/dist/autoSense.js +12 -2
  27. package/dist/autoSense.js.map +1 -1
  28. package/dist/cli.d.ts.map +1 -1
  29. package/dist/cli.js +11 -6
  30. package/dist/cli.js.map +1 -1
  31. package/dist/diagnose.d.ts +1 -1
  32. package/dist/diagnose.js +1 -1
  33. package/dist/modelOta.d.ts +274 -0
  34. package/dist/modelOta.d.ts.map +1 -0
  35. package/dist/modelOta.js +492 -0
  36. package/dist/modelOta.js.map +1 -0
  37. package/dist/sensingRunMode.d.ts +21 -0
  38. package/dist/sensingRunMode.d.ts.map +1 -1
  39. package/dist/sensingRunMode.js +32 -7
  40. package/dist/sensingRunMode.js.map +1 -1
  41. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  # `trillboards-detector` — the on-device object-detector service
2
2
 
3
- RF-DETR Medium, fp32 ONNX, served over loopback on **127.0.0.1:9380**. It is the
4
- cascade gate that sits in front of the on-device VLM: at the ANALYZE cadence it
5
- removes ~84.7% of 5,645 ms VLM calls for ~2.1% of events.
3
+ A LIBRARY of detectors, served over loopback on **127.0.0.1:9380**. One is
4
+ shipped today — RF-DETR Medium, fp32 ONNX — and it is the cascade gate that sits
5
+ in front of the on-device VLM: at the ANALYZE cadence it removes ~84.7% of
6
+ 5,645 ms VLM calls for ~2.1% of events.
6
7
 
7
8
  The decision record — which model, why not the alternatives, and every number
8
9
  above — is [`docs/runbooks/cctv-detector-tier.md`](../../../../../docs/runbooks/cctv-detector-tier.md).
@@ -21,37 +22,116 @@ POST /detect { image_b64, classes?, min_confidence }
21
22
  200 { detections: [{ label, confidence, box: {x,y,width,height} }],
22
23
  inference_ms, model }
23
24
  400 the request cannot be acted on (bad base64, empty body,
24
- min_confidence outside [0,1], every requested class unknown)
25
+ min_confidence outside [0,1], ANY requested class unknown)
25
26
  413 body over 32 MiB
26
27
  500 inference failed, or a detection could not be put on the wire
27
28
  503 no usable model — NEVER a 200 with an empty array
28
29
 
29
- GET /health 200 when a load completed AND a probe inference succeeded
30
- 503 otherwise, with the reason. Runs no inference.
30
+ GET /health 200 when the PINNED model's load completed AND its probe
31
+ inference succeeded; 503 otherwise, with the reason. Also
32
+ reports which models are resident. Runs no inference.
31
33
  ```
32
34
 
33
35
  * `label` is the class **name**, never an index.
34
36
  * `box` is in **pixels of the submitted frame**, not normalised.
35
37
  * `classes` filters at the **service**, so the wire stays small. Absent means
36
38
  every class; an empty array is a 400, never a synonym for "all".
39
+ * `model` names the **set that answered** — `rfdetr_medium`, or
40
+ `rfdetr_medium+ppe` when a request spans two. The client types it
41
+ `string | null` and does not branch on it, so a joined string is contract-safe.
37
42
 
38
- Three properties are load-bearing rather than stylistic:
43
+ ## The model library
39
44
 
40
- **Down is never empty.** A missing model is 503 and a failed inference is 500.
41
- Neither may become `200 []`. A cascade that reads "detector broken" as "venue
42
- quiet" suppresses every alert while every health surface stays green.
45
+ [`docs/runbooks/sensing-model-library.md`](../../../../../docs/runbooks/sensing-model-library.md)
46
+ is the architecture; §3 is what this service implements. **The wire contract
47
+ carries no model selector and it stays that way** — the connector must not hold
48
+ the catalogue, and `classes` already expresses the request completely, so the
49
+ service owns class → model routing because it is the only component that knows
50
+ what is resident on that box right now.
43
51
 
44
- **We refuse what the client would refuse.** `build_detect_response` re-checks
45
- every detection against the client's own fifteen rules and answers 500 rather
46
- than emitting a payload the client will reject — because a rejected payload
47
- costs the whole frame *and* is indistinguishable, from the client's side, from
48
- the service being down.
52
+ ```
53
+ labels.py one committed ModelVocabulary per model (slots -> class names,
54
+ logit width, measured RAM). ClassIndex is the UNION over them.
55
+ registry.py name -> session map, the class index, lazy load, LRU eviction
56
+ under a RAM budget, and the merge. STDLIB ONLY.
57
+ loader.py the poll loop + the real ORT session factory and probe frame.
58
+ ```
59
+
60
+ * **A class routes to the FIRST catalogue model that names it.** Adding a
61
+ specialist can never move an existing class off the model it was scored on —
62
+ `person` stays on `rfdetr_medium` even after a PPE model that also emits it.
63
+ * **A committed vocabulary advertises nothing until its artifact is on disk.**
64
+ `ClassIndex` is built over models whose file is present, so a vocabulary added
65
+ before its ONNX ships contributes no requestable class. That is the
66
+ `yolov8_nano` failure made unreachable rather than merely forbidden.
67
+ * **The pinned model is eager and never evicted; specialists are lazy.**
68
+ `/health` has always meant "a load completed and a probe inference succeeded",
69
+ and `LocalDetectorClient.isHealthy()` turns anything else into "send the frame
70
+ to the VLM" — so the general gate has to be resident for the service to claim
71
+ it is up. One 632 MiB model fits any budget that can hold anything.
72
+ * **Every model runs a probe inference before it may answer**, on its first
73
+ request and on every reload. A session that constructs is not a session that
74
+ infers.
75
+ * **A request spanning two models runs both and merges**, in catalogue order.
76
+ `inference_ms` is the SUM, because they run serially on one box.
77
+
78
+ ### The RAM ceiling is INJECTED — this service owns no RAM policy
79
+
80
+ `--ram-budget-mb`, or `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB`. **There is no
81
+ default and there must not be one.**
82
+
83
+ There is already an allocation authority and it is server-side:
84
+ `sensingProfileService.selectModelForDeviceTier` picks a model from
85
+ `deviceRamMb` + `deviceTier`, and `registerInternalControlSurface` gates each
86
+ model on `ramMb >= model.min_ram_mb`. A ceiling chosen *here* would be a second
87
+ authority that cannot see the first — the server deciding what a device gets
88
+ while the service independently decides what it keeps. That is the
89
+ `yolov8_nano` shape: advertised by one component, unloadable by another, for six
90
+ months, with every surface green.
91
+
92
+ So the ceiling is a parameter, and **absent is a state, not a value to fill in**:
93
+
94
+ | ceiling | what happens |
95
+ | --- | --- |
96
+ | injected | the resident set must fit it; over it, a request is **refused with what did not fit**, never answered with the half that did |
97
+ | **not injected** | the pinned model still loads and answers — the server already chose it, and holding what we were told to hold is the mechanism doing its job — and **any second model is refused**, naming who supplies the ceiling |
98
+
99
+ `/health` carries `ram_ceiling_injected` so a reader can see which of those two
100
+ states the box is in. The service does not exit when nothing was injected: all
101
+ three supervisors restart on a non-zero exit, and this file's own rule for a
102
+ required input that has not arrived is *start, and report the reason*.
103
+
104
+ **Eviction is a last-resort safety valve, not the allocator.** It logs a WARNING
105
+ naming the box as oversubscribed against what it was allocated, and `/health`
106
+ carries a top-level `evictions` count — because a service quietly thrashing
107
+ under a ceiling looks exactly like one working fine.
108
+
109
+ ### The footprint column the server does not have
110
+
111
+ `min_ram_mb` is a **gate**. `model_size_mb` is a **file size**. `gemma_4_e2b`
112
+ carries 8000 and 2580 against a *measured working set* of 3.31 GB — three
113
+ different quantities, and the one portfolio admission needs, **resident
114
+ footprint**, is in none of them. Summing `min_ram_mb` over-rejects; summing
115
+ `model_size_mb` under-rejects.
116
+
117
+ So each load is measured, after the probe (a session that has not run a forward
118
+ pass has not allocated its working buffers), and published per model:
119
+
120
+ ```
121
+ rss_delta_bytes bytes the process grew across this model's load
122
+ rss_source proc-statm Linux, CURRENT rss, exact
123
+ psapi-workingset Windows, CURRENT working set, exact
124
+ rusage-maxrss macOS/fallback, a HIGH-WATER MARK — the
125
+ delta is a LOWER BOUND and 0 is legitimate
126
+ ```
49
127
 
50
- **Health reports a load that actually happened.** `GET /v1/models` on litert-lm
51
- answers 200 without ever building an engine, which is how a UTF-8 BOM in its
52
- config 500'd every frame on every Windows connector while the installer's health
53
- check *and* its CPU-fallback re-check both passed. So this loader builds the
54
- session, runs a real probe inference, and `/health` reports that outcome.
128
+ The instrument is named with the number because the three platforms do not
129
+ answer the same question, and a reader that cannot tell them apart will average
130
+ a high-water mark into a current reading.
131
+
132
+ **It is reported and never used for admission.** Admission follows the
133
+ *committed* `approx_ram_bytes`; admitting on numbers only this service can see
134
+ would be the second authority again, by a different route.
55
135
 
56
136
  ## Where it runs
57
137
 
@@ -87,6 +167,11 @@ reclaims the old bytes. There is no GC for abandoned model dirs anywhere on this
87
167
  path and the existing OTA already orphans 752 MiB per update; a version-named
88
168
  directory would add to that.
89
169
 
170
+ **One directory per model NAME is also what makes the library work**: each
171
+ catalogue entry resolves its own `models/<name>/model.onnx`, a specialist's
172
+ artifact arriving later widens the requestable vocabulary within one poll
173
+ interval with no restart, and its disappearing narrows it again.
174
+
90
175
  ## Tests
91
176
 
92
177
  ```bash
@@ -94,7 +179,9 @@ directory would add to that.
94
179
  python3 -m unittest discover -s . -p "test_*.py" -v
95
180
  ```
96
181
 
97
- Runs in `guards-fast`. **Everything except the post-processing cases needs
182
+ Runs in `guards-fast`. `test_registry.py` covers the whole residency policy —
183
+ routing, lazy load, eviction, and every refusal — with FAKE sessions, so it runs
184
+ there too. **Everything except the post-processing cases needs
98
185
  nothing but the standard library** — those are the only ones that touch numpy,
99
186
  and they skip themselves with a stated reason when it is absent. That invariant
100
187
  is the durable claim; the counts (45 of 60 on 2026-08-24) are a snapshot and go
@@ -33,6 +33,7 @@ from pathlib import Path
33
33
 
34
34
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
35
35
 
36
+ from trillboards_detector.labels import RFDETR_MEDIUM # noqa: E402
36
37
  from trillboards_detector.session import OnnxDetector # noqa: E402
37
38
 
38
39
 
@@ -75,7 +76,11 @@ def main() -> int:
75
76
  )
76
77
 
77
78
  # ── this service ─────────────────────────────────────────────────────────
78
- detector = OnnxDetector(Path(args.onnx), "rfdetr_medium")
79
+ # The VOCABULARY, not just the name: a session labels its slots from the
80
+ # committed vocabulary it was built with, so the control has to hand over
81
+ # the same one the service would — otherwise it would compare the reference
82
+ # against a differently-labelled run and still report agreement.
83
+ detector = OnnxDetector(Path(args.onnx), RFDETR_MEDIUM)
79
84
  batch = detector.detect(image_path.read_bytes(), args.threshold, None)
80
85
  ours_rows = sorted(
81
86
  (d.label, round(d.confidence, 4), (round(d.x, 2), round(d.y, 2), round(d.x + d.width, 2), round(d.y + d.height, 2)))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "trillboards-detector"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "On-device object-detector service for the Trillboards BYO-CCTV connector (loopback HTTP, ONNX Runtime)."
5
5
  requires-python = ">=3.10"
6
6
 
@@ -0,0 +1,188 @@
1
+ """The half of the loader that needs a real runtime to even import.
2
+
3
+ `loader.py` imports `session.py`, which imports onnxruntime, numpy and Pillow —
4
+ so unlike `test_registry.py` these cases cannot run on a bare interpreter and
5
+ skip themselves with a stated reason, exactly as `test_session.py` does. The
6
+ residency policy itself is deliberately NOT here: it lives in `registry.py`,
7
+ which imports none of that, so every rule about routing, eviction and refusal is
8
+ exercised where the artifact is absent.
9
+
10
+ What is left for this file is the catalogue construction and the ONE-SNAPSHOT
11
+ property of `/health` — both of which are about `DetectorLoader` specifically.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import unittest
17
+
18
+ try:
19
+ from trillboards_detector.labels import RFDETR_MEDIUM, ModelVocabulary
20
+ from trillboards_detector.loader import DetectorLoader, build_catalogue
21
+
22
+ HAVE_RUNTIME = True
23
+ except ImportError: # pragma: no cover - exercised only on a bare interpreter
24
+ HAVE_RUNTIME = False
25
+
26
+ PPE = None
27
+ if HAVE_RUNTIME:
28
+ PPE = ModelVocabulary(
29
+ model_name="ppe",
30
+ slot_names={0: "glove", 1: "hairnet"},
31
+ logit_slots=2,
32
+ approx_ram_bytes=400 * 1024 * 1024,
33
+ )
34
+
35
+
36
+ @unittest.skipUnless(HAVE_RUNTIME, "onnxruntime/numpy/Pillow are not installed")
37
+ class TestBuildCatalogue(unittest.TestCase):
38
+ def test_the_pinned_model_comes_first_so_it_wins_every_class_tie(self):
39
+ """Catalogue order IS the disambiguation rule. `person` must keep coming
40
+ from the model the 98.8/98.8 was measured on, whatever a later
41
+ specialist also emits."""
42
+ entries = build_catalogue("rfdetr_medium", None, (PPE, RFDETR_MEDIUM), {"HOME": "/h"})
43
+ self.assertEqual([e.name for e in entries], ["rfdetr_medium", "ppe"])
44
+ self.assertTrue(entries[0].pinned)
45
+ self.assertFalse(entries[1].pinned)
46
+
47
+ def test_every_entry_resolves_its_own_per_model_directory(self):
48
+ entries = build_catalogue("rfdetr_medium", None, (RFDETR_MEDIUM, PPE), {"HOME": "/h"})
49
+ self.assertEqual(
50
+ [str(e.path) for e in entries],
51
+ ["/h/.trillboards/models/rfdetr_medium/model.onnx",
52
+ "/h/.trillboards/models/ppe/model.onnx"],
53
+ )
54
+
55
+ def test_an_explicit_path_overrides_only_the_pinned_entry(self):
56
+ """`--model` / `TRILLBOARDS_DETECTOR_MODEL` is the bench escape hatch and
57
+ it names ONE artifact, so it must not relocate every specialist onto the
58
+ same file."""
59
+ from pathlib import Path
60
+
61
+ entries = build_catalogue(
62
+ "rfdetr_medium", Path("/bench/rfdetr.onnx"), (RFDETR_MEDIUM, PPE), {"HOME": "/h"}
63
+ )
64
+ self.assertEqual(str(entries[0].path), "/bench/rfdetr.onnx")
65
+ self.assertEqual(str(entries[1].path), "/h/.trillboards/models/ppe/model.onnx")
66
+
67
+ def test_a_default_model_with_no_committed_vocabulary_is_refused(self):
68
+ """A name with no class list is a model nothing can label — `yolov8_nano`
69
+ exactly, and it must fail at start rather than in a venue."""
70
+ with self.assertRaisesRegex(ValueError, "no committed vocabulary"):
71
+ build_catalogue("yolov8_nano", None, (RFDETR_MEDIUM,), {"HOME": "/h"})
72
+
73
+
74
+ @unittest.skipUnless(HAVE_RUNTIME, "onnxruntime/numpy/Pillow are not installed")
75
+ class TestHealthIsOneRead(unittest.TestCase):
76
+ def test_info_takes_exactly_one_snapshot(self):
77
+ """`/health` must describe ONE moment.
78
+
79
+ Reading the top-level `state` from a second snapshot lets the watcher
80
+ thread move between the two, so the payload could answer
81
+ `state: "ready"` next to a `models` map showing the pinned model still
82
+ loading. Neither half would be wrong and the pair would describe no
83
+ moment — the same shape `postgres-tls-uri-override.md` records for two
84
+ scans a minute apart.
85
+ """
86
+ loader = DetectorLoader(
87
+ model_path="/nonexistent/model.onnx",
88
+ model_name="rfdetr_medium",
89
+ vocabularies=(RFDETR_MEDIUM, PPE),
90
+ log=lambda _m: None,
91
+ )
92
+ real = loader.registry.snapshot
93
+ calls = []
94
+
95
+ def counting():
96
+ calls.append(1)
97
+ return real()
98
+
99
+ loader.registry.snapshot = counting
100
+ payload = loader.info()
101
+ self.assertEqual(len(calls), 1, "`/health` read the registry twice; it must describe one moment")
102
+ # And it is still the payload every existing reader parses.
103
+ self.assertEqual(payload["model"], "rfdetr_medium")
104
+ self.assertEqual(sorted(payload["models"]), ["ppe", "rfdetr_medium"])
105
+ self.assertEqual(payload["resident_models"], [])
106
+
107
+ def test_before_the_first_stat_the_answer_is_loading_and_names_no_reason(self):
108
+ """`absent` is a CLAIM about the disk, and at construction nobody has
109
+ looked. The service starts before its watcher thread does, so a probe
110
+ can land in that window — and the single-model loader this replaced
111
+ correctly said `loading` there. Reporting `absent` for a file we have not
112
+ yet stat'd is a fact asserted without evidence, which is the failure
113
+ shape this whole service is written against."""
114
+ loader = DetectorLoader(
115
+ model_path="/nonexistent/model.onnx",
116
+ model_name="rfdetr_medium",
117
+ vocabularies=(RFDETR_MEDIUM, PPE),
118
+ log=lambda _m: None,
119
+ )
120
+ payload = loader.info()
121
+ self.assertEqual(payload["state"], "loading")
122
+ self.assertNotIn("reason", payload)
123
+
124
+ def test_after_the_first_stat_a_missing_artifact_is_unavailable_WITH_a_reason(self):
125
+ """And once we HAVE looked, the answer must carry why — the OTA has not
126
+ landed, which is a different problem from a corrupt artifact and the one
127
+ the installers' post-install probes print."""
128
+ loader = DetectorLoader(
129
+ model_path="/nonexistent/model.onnx",
130
+ model_name="rfdetr_medium",
131
+ vocabularies=(RFDETR_MEDIUM, PPE),
132
+ log=lambda _m: None,
133
+ )
134
+ loader.registry.refresh()
135
+ payload = loader.info()
136
+ self.assertEqual(payload["state"], "unavailable")
137
+ self.assertIn("arrives over the air", payload["reason"])
138
+ self.assertEqual(payload["models"]["rfdetr_medium"]["state"], "absent")
139
+
140
+ def test_the_ceiling_state_and_the_eviction_count_reach_the_payload(self):
141
+ """The registry knowing a fact is not the same as `/health` carrying it.
142
+
143
+ Both of these were computed in `snapshot()` and dropped on the floor by
144
+ `info()` for a commit — the registry was honest and the wire was silent,
145
+ which is the exact shape of a consumer with no producer. A reader has to
146
+ be able to see that NO authority has set a ceiling (distinct from a
147
+ ceiling of some number), and that a box is evicting at all.
148
+ """
149
+ loader = DetectorLoader(
150
+ model_path="/nonexistent/model.onnx",
151
+ model_name="rfdetr_medium",
152
+ vocabularies=(RFDETR_MEDIUM, PPE),
153
+ log=lambda _m: None,
154
+ )
155
+ payload = loader.info()
156
+ self.assertIn("ram_ceiling_injected", payload)
157
+ self.assertFalse(payload["ram_ceiling_injected"])
158
+ self.assertIsNone(payload["ram_budget_bytes"])
159
+ self.assertEqual(payload["evictions"], 0)
160
+
161
+ told = DetectorLoader(
162
+ model_path="/nonexistent/model.onnx",
163
+ model_name="rfdetr_medium",
164
+ ram_budget_bytes=1024 * 1024 * 1024,
165
+ vocabularies=(RFDETR_MEDIUM, PPE),
166
+ log=lambda _m: None,
167
+ )
168
+ self.assertTrue(told.info()["ram_ceiling_injected"])
169
+
170
+ def test_the_top_level_state_stays_the_three_words_that_shipped(self):
171
+ """`idle` and `absent` have no top-level equivalent: every existing
172
+ reader — three installers' post-install probes, the integration test —
173
+ was written against ready/loading/unavailable, and
174
+ `LocalDetectorClient.isHealthy()` turns anything but 2xx into "send the
175
+ frame to the VLM"."""
176
+ for pinned, expected in [
177
+ ("ready", "ready"),
178
+ ("loading", "loading"),
179
+ ("idle", "unavailable"),
180
+ ("absent", "unavailable"),
181
+ ("unavailable", "unavailable"),
182
+ ]:
183
+ with self.subTest(pinned=pinned):
184
+ self.assertEqual(DetectorLoader._top_level_state(pinned), expected)
185
+
186
+
187
+ if __name__ == "__main__":
188
+ unittest.main()
@@ -16,16 +16,25 @@ import unittest
16
16
  from pathlib import Path
17
17
 
18
18
  from trillboards_detector.labels import (
19
+ BUILTIN_VOCABULARIES,
19
20
  COCO91_CLASS_NAMES,
20
21
  EXPECTED_LOGIT_SLOTS,
21
- resolve_requested_classes,
22
+ RFDETR_MEDIUM,
23
+ ClassIndex,
22
24
  )
23
25
  from trillboards_detector.modelpath import (
24
26
  DEFAULT_MODEL_NAME,
25
27
  resolve_data_dir,
26
28
  resolve_model_path,
29
+ resolve_ram_budget_mb,
27
30
  )
28
31
 
32
+ #: The single-model index this file's vocabulary assertions are about. It used
33
+ #: to be a module-level constant (`KNOWN_CLASS_NAMES`), which is exactly what
34
+ #: made a specialist inexpressible; the resolution rules it tests did not
35
+ #: change, only where the vocabulary comes from.
36
+ COCO_INDEX = ClassIndex([RFDETR_MEDIUM])
37
+
29
38
 
30
39
  class TestModelPath(unittest.TestCase):
31
40
  def test_home_wins(self):
@@ -94,15 +103,51 @@ class TestLabelMap(unittest.TestCase):
94
103
  self.assertEqual(len(names), len(set(names)))
95
104
 
96
105
  def test_requested_classes_fold_case_underscores_and_hyphens(self):
97
- wanted, unknown = resolve_requested_classes(["Person", "CELL_PHONE", "dining-table"])
106
+ wanted, unknown = COCO_INDEX.resolve(["Person", "CELL_PHONE", "dining-table"])
98
107
  self.assertEqual(wanted, {"person", "cell phone", "dining table"})
99
108
  self.assertEqual(unknown, [])
100
109
 
101
110
  def test_an_unknown_class_is_returned_never_silently_dropped(self):
102
- wanted, unknown = resolve_requested_classes(["person", "shoplifter"])
111
+ wanted, unknown = COCO_INDEX.resolve(["person", "shoplifter"])
103
112
  self.assertEqual(wanted, {"person"})
104
113
  self.assertEqual(unknown, ["shoplifter"])
105
114
 
115
+ def test_every_committed_class_name_is_already_canonical(self):
116
+ """`_to_wire` puts the DECLARED name on the wire and the filter matches
117
+ the CANONICAL one, so a vocabulary declaring `cell_phone` would emit a
118
+ label the caller's own request had just been folded away from — a class
119
+ that silently matches nothing on a box where everything looks loaded.
120
+ `ModelVocabulary.__post_init__` refuses it at import; this pins that the
121
+ shipped vocabularies satisfy it rather than merely that the check
122
+ exists."""
123
+ for vocabulary in BUILTIN_VOCABULARIES:
124
+ index = ClassIndex([vocabulary])
125
+ for name in vocabulary.class_names:
126
+ with self.subTest(model=vocabulary.model_name, name=name):
127
+ self.assertEqual(index.resolve([name]), ({name}, []))
128
+
129
+
130
+ class TestRamBudgetEnv(unittest.TestCase):
131
+ """The portfolio budget's env override. A bad value must not be ignored.
132
+
133
+ Silently falling back to the default would leave an operator convinced they
134
+ had raised a limit that never moved — an operation reporting success while
135
+ doing nothing, which is the failure shape this tree keeps catching.
136
+ """
137
+
138
+ def test_absent_means_take_the_committed_default(self):
139
+ self.assertIsNone(resolve_ram_budget_mb({}))
140
+ self.assertIsNone(resolve_ram_budget_mb({"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB": " "}))
141
+
142
+ def test_an_integer_is_read_as_mib(self):
143
+ self.assertEqual(resolve_ram_budget_mb({"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB": "2048"}), 2048)
144
+
145
+ def test_a_malformed_value_raises_rather_than_defaulting(self):
146
+ for bad in ("1.5gb", "2048MB", "-1", "0", "lots"):
147
+ with self.subTest(bad=bad):
148
+ with self.assertRaises(ValueError):
149
+ resolve_ram_budget_mb({"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB": bad})
150
+
106
151
 
107
152
  if __name__ == "__main__":
108
153
  unittest.main()