@trillboards/edge-sdk 0.2.81 → 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.
- package/deploy/detector/README.md +108 -21
- package/deploy/detector/bench/verify_against_reference.py +6 -1
- package/deploy/detector/pyproject.toml +1 -1
- package/deploy/detector/tests/test_loader.py +188 -0
- package/deploy/detector/tests/test_modelpath.py +48 -3
- package/deploy/detector/tests/test_registry.py +880 -0
- package/deploy/detector/tests/test_server.py +163 -7
- package/deploy/detector/tests/test_session.py +29 -10
- package/deploy/detector/trillboards_detector/__init__.py +1 -1
- package/deploy/detector/trillboards_detector/cli.py +49 -2
- package/deploy/detector/trillboards_detector/detections.py +98 -0
- package/deploy/detector/trillboards_detector/errors.py +38 -0
- package/deploy/detector/trillboards_detector/labels.py +239 -24
- package/deploy/detector/trillboards_detector/loader.py +186 -148
- package/deploy/detector/trillboards_detector/modelpath.py +32 -0
- package/deploy/detector/trillboards_detector/registry.py +836 -0
- package/deploy/detector/trillboards_detector/server.py +59 -40
- package/deploy/detector/trillboards_detector/session.py +84 -55
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""Residency, routing and refusal — with FAKE sessions and NO onnxruntime.
|
|
2
|
+
|
|
3
|
+
WHAT THIS SUITE IS FOR. `docs/runbooks/sensing-model-library.md` §3 is the
|
|
4
|
+
specification and every rule in it is a rule about what happens when a box
|
|
5
|
+
cannot hold everything: which model answers a class, which one gets evicted,
|
|
6
|
+
and — the load-bearing half — what the service says instead of answering when it
|
|
7
|
+
cannot. None of that is reachable from the wire suite (which stubs the loader
|
|
8
|
+
whole) or from the session suite (which needs numpy and skips on a bare
|
|
9
|
+
interpreter).
|
|
10
|
+
|
|
11
|
+
STDLIB ONLY, DELIBERATELY, exactly as `test_server.py` argues for the wire. The
|
|
12
|
+
sessions here are two-line fakes and the "artifacts" are files of a few bytes,
|
|
13
|
+
so every case runs on every CI runner with no ~128 MiB ONNX and no 50 MB
|
|
14
|
+
runtime. A residency policy that could only be tested on a box with the models
|
|
15
|
+
present is a residency policy nothing tests — and this one decides whether the
|
|
16
|
+
box OOMs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import unittest
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from trillboards_detector.detections import Detection, DetectionBatch, merge_batches
|
|
27
|
+
from trillboards_detector.errors import DetectorNotReady, ModelContractError, UnknownClasses
|
|
28
|
+
from trillboards_detector.labels import COCO91_CLASS_NAMES, RFDETR_MEDIUM, ModelVocabulary
|
|
29
|
+
from trillboards_detector.registry import (
|
|
30
|
+
STATE_ABSENT,
|
|
31
|
+
STATE_IDLE,
|
|
32
|
+
STATE_READY,
|
|
33
|
+
STATE_UNAVAILABLE,
|
|
34
|
+
ModelEntry,
|
|
35
|
+
ModelRegistry,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
MB = 1024 * 1024
|
|
39
|
+
|
|
40
|
+
#: A general detector standing in for `rfdetr_medium`: same shape, tiny map, so
|
|
41
|
+
#: a fixture that needs `person` and `car` does not carry eighty names.
|
|
42
|
+
GENERAL = ModelVocabulary(
|
|
43
|
+
model_name="general",
|
|
44
|
+
slot_names={1: "person", 3: "car"},
|
|
45
|
+
logit_slots=91,
|
|
46
|
+
approx_ram_bytes=600 * MB,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
#: A PPE specialist — the §3 worked example. It ALSO emits `person`, which is
|
|
50
|
+
#: the ambiguity catalogue order exists to resolve.
|
|
51
|
+
PPE = ModelVocabulary(
|
|
52
|
+
model_name="ppe",
|
|
53
|
+
slot_names={0: "person", 1: "glove", 2: "hairnet"},
|
|
54
|
+
logit_slots=3,
|
|
55
|
+
approx_ram_bytes=400 * MB,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: A third specialist, so eviction has something to choose between.
|
|
59
|
+
SMALL_OBJECT = ModelVocabulary(
|
|
60
|
+
model_name="small_object",
|
|
61
|
+
slot_names={0: "rodent"},
|
|
62
|
+
logit_slots=1,
|
|
63
|
+
approx_ram_bytes=400 * MB,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FakeSpec:
|
|
68
|
+
width = 576
|
|
69
|
+
height = 576
|
|
70
|
+
num_classes = 91
|
|
71
|
+
providers = ["CPUExecutionProvider"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FakeSession:
|
|
75
|
+
"""One resident model. Records what it was asked and by whom."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, vocabulary: ModelVocabulary, raises=None):
|
|
78
|
+
self.vocabulary = vocabulary
|
|
79
|
+
self.model_name = vocabulary.model_name
|
|
80
|
+
self.spec = FakeSpec()
|
|
81
|
+
self.calls: "list[tuple]" = []
|
|
82
|
+
self.closed = False
|
|
83
|
+
self._raises = raises
|
|
84
|
+
|
|
85
|
+
def detect(self, jpeg, min_confidence, wanted):
|
|
86
|
+
self.calls.append((jpeg, min_confidence, wanted))
|
|
87
|
+
if self._raises is not None:
|
|
88
|
+
raise self._raises
|
|
89
|
+
return DetectionBatch(
|
|
90
|
+
detections=[Detection(f"{self.model_name}-hit", 0.9, 1.0, 1.0, 2.0, 2.0)],
|
|
91
|
+
inference_ms=10.0,
|
|
92
|
+
total_ms=12.0,
|
|
93
|
+
models=[self.model_name],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __del__(self): # pragma: no cover - refcount observation only
|
|
97
|
+
self.closed = True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RegistryCase(unittest.TestCase):
|
|
101
|
+
"""Builds a registry over real files in a temp dir, with fake sessions."""
|
|
102
|
+
|
|
103
|
+
def setUp(self):
|
|
104
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
105
|
+
self.addCleanup(self.tmp.cleanup)
|
|
106
|
+
self.root = Path(self.tmp.name)
|
|
107
|
+
self.logs: "list[str]" = []
|
|
108
|
+
self.built: "list[str]" = []
|
|
109
|
+
self.probed: "list[str]" = []
|
|
110
|
+
self.factory_raises: "dict[str, Exception]" = {}
|
|
111
|
+
self.probe_raises: "dict[str, Exception]" = {}
|
|
112
|
+
self.session_raises: "dict[str, Exception]" = {}
|
|
113
|
+
self.sessions: "dict[str, FakeSession]" = {}
|
|
114
|
+
#: A monotonic clock we control, so LRU order is a fact and not a race.
|
|
115
|
+
self.now = 0.0
|
|
116
|
+
|
|
117
|
+
def tick(self, by: float = 1.0) -> None:
|
|
118
|
+
self.now += by
|
|
119
|
+
|
|
120
|
+
def write_artifact(self, name: str, payload: bytes = b"onnx") -> Path:
|
|
121
|
+
path = self.root / "models" / name / "model.onnx"
|
|
122
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
path.write_bytes(payload)
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
def remove_artifact(self, name: str) -> None:
|
|
127
|
+
(self.root / "models" / name / "model.onnx").unlink()
|
|
128
|
+
|
|
129
|
+
def _factory(self, entry: ModelEntry):
|
|
130
|
+
self.built.append(entry.name)
|
|
131
|
+
boom = self.factory_raises.get(entry.name)
|
|
132
|
+
if boom is not None:
|
|
133
|
+
raise boom
|
|
134
|
+
session = FakeSession(entry.vocabulary, self.session_raises.get(entry.name))
|
|
135
|
+
self.sessions[entry.name] = session
|
|
136
|
+
return session
|
|
137
|
+
|
|
138
|
+
def _probe(self, session) -> None:
|
|
139
|
+
self.probed.append(session.model_name)
|
|
140
|
+
boom = self.probe_raises.get(session.model_name)
|
|
141
|
+
if boom is not None:
|
|
142
|
+
raise boom
|
|
143
|
+
|
|
144
|
+
def registry(
|
|
145
|
+
self,
|
|
146
|
+
vocabularies,
|
|
147
|
+
pinned: str,
|
|
148
|
+
budget_mb: "int | None" = 1536,
|
|
149
|
+
) -> ModelRegistry:
|
|
150
|
+
"""Every case INJECTS a ceiling, because the service owns none.
|
|
151
|
+
|
|
152
|
+
`budget_mb=None` is the state where nobody has told it one — a state,
|
|
153
|
+
not a default — and `TestNoCeilingWasInjected` is what covers it.
|
|
154
|
+
"""
|
|
155
|
+
entries = [
|
|
156
|
+
ModelEntry(v, self.root / "models" / v.model_name / "model.onnx", pinned=v.model_name == pinned)
|
|
157
|
+
for v in vocabularies
|
|
158
|
+
]
|
|
159
|
+
return ModelRegistry(
|
|
160
|
+
entries=entries,
|
|
161
|
+
session_factory=self._factory,
|
|
162
|
+
probe=self._probe,
|
|
163
|
+
ram_budget_bytes=None if budget_mb is None else budget_mb * MB,
|
|
164
|
+
log=self.logs.append,
|
|
165
|
+
clock=lambda: self.now,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestTwoModelsAnswerOneRequest(RegistryCase):
|
|
170
|
+
"""§3: "A request spanning two models runs both and merges." """
|
|
171
|
+
|
|
172
|
+
def test_a_request_spanning_two_models_runs_both_and_merges(self):
|
|
173
|
+
self.write_artifact("general")
|
|
174
|
+
self.write_artifact("ppe")
|
|
175
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
176
|
+
reg.refresh()
|
|
177
|
+
|
|
178
|
+
wanted = reg.resolve_classes(["person", "glove"])
|
|
179
|
+
batch = reg.detect(b"jpeg", 0.25, wanted)
|
|
180
|
+
|
|
181
|
+
# BOTH ran, in catalogue order, and both answers are in the result.
|
|
182
|
+
self.assertEqual(batch.models, ["general", "ppe"])
|
|
183
|
+
self.assertEqual([d.label for d in batch.detections], ["general-hit", "ppe-hit"])
|
|
184
|
+
# `person` went to the GENERAL model even though `ppe` also emits it —
|
|
185
|
+
# catalogue order breaks the tie, and the 98.8/98.8 is a claim about
|
|
186
|
+
# the general model's `person`, not about the concept.
|
|
187
|
+
self.assertEqual(self.sessions["general"].calls[0][2], {"person"})
|
|
188
|
+
self.assertEqual(self.sessions["ppe"].calls[0][2], {"glove"})
|
|
189
|
+
|
|
190
|
+
def test_the_merged_timing_is_the_sum_because_they_run_serially(self):
|
|
191
|
+
self.write_artifact("general")
|
|
192
|
+
self.write_artifact("ppe")
|
|
193
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
194
|
+
reg.refresh()
|
|
195
|
+
batch = reg.detect(b"jpeg", 0.25, reg.resolve_classes(["person", "glove"]))
|
|
196
|
+
self.assertEqual(batch.inference_ms, 20.0)
|
|
197
|
+
|
|
198
|
+
def test_the_union_vocabulary_is_what_a_caller_may_ask_for(self):
|
|
199
|
+
"""THE PIECE THAT MAKES A SPECIALIST EXPRESSIBLE. `glove` used to come
|
|
200
|
+
back `unknown` against a hardcoded COCO-91 set, so publishing a PPE
|
|
201
|
+
model would have changed nothing at the service."""
|
|
202
|
+
self.write_artifact("general")
|
|
203
|
+
self.write_artifact("ppe")
|
|
204
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
205
|
+
reg.refresh()
|
|
206
|
+
self.assertEqual(
|
|
207
|
+
reg.class_index().class_names, frozenset({"person", "car", "glove", "hairnet"})
|
|
208
|
+
)
|
|
209
|
+
# And they fold the way a profile writes them: `sensing_profiles.classes`
|
|
210
|
+
# is LLM prose, so `Glove` and `HAIRNET` must reach the same names.
|
|
211
|
+
self.assertEqual(reg.resolve_classes(["Glove", "HAIRNET"]), {"glove", "hairnet"})
|
|
212
|
+
|
|
213
|
+
def test_absent_classes_asks_each_model_only_for_what_it_owns(self):
|
|
214
|
+
""""Every class" must not double-report an overlapping one.
|
|
215
|
+
|
|
216
|
+
Running each model over its whole vocabulary would return two `person`
|
|
217
|
+
boxes for one person — from the general model and from PPE — and nothing
|
|
218
|
+
downstream could tell that from two people.
|
|
219
|
+
"""
|
|
220
|
+
self.write_artifact("general")
|
|
221
|
+
self.write_artifact("ppe")
|
|
222
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
223
|
+
reg.refresh()
|
|
224
|
+
reg.detect(b"jpeg", 0.25, None)
|
|
225
|
+
# The general model owns its whole vocabulary, so it gets `None` — the
|
|
226
|
+
# same object the single-model service always passed.
|
|
227
|
+
self.assertIsNone(self.sessions["general"].calls[0][2])
|
|
228
|
+
# PPE gets everything EXCEPT the `person` the general model owns.
|
|
229
|
+
self.assertEqual(self.sessions["ppe"].calls[0][2], {"glove", "hairnet"})
|
|
230
|
+
|
|
231
|
+
def test_a_partly_unknown_list_is_a_refusal_not_the_known_half(self):
|
|
232
|
+
"""ANY unknown is a refusal, not just all of them.
|
|
233
|
+
|
|
234
|
+
`["person", "shoplifter"]` used to proceed on `person` and drop
|
|
235
|
+
`shoplifter` silently, so a profile asking about shoplifting got person
|
|
236
|
+
counts and no signal that its actual question was discarded. The partial
|
|
237
|
+
case is the dangerous one and it becomes the COMMON one the moment
|
|
238
|
+
specialists exist — a PPE profile naming `glove` alongside `person` on a
|
|
239
|
+
box with no PPE model.
|
|
240
|
+
"""
|
|
241
|
+
self.write_artifact("general")
|
|
242
|
+
self.write_artifact("ppe")
|
|
243
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
244
|
+
reg.refresh()
|
|
245
|
+
with self.assertRaises(UnknownClasses) as caught:
|
|
246
|
+
reg.resolve_classes(["person", "shoplifter"])
|
|
247
|
+
self.assertEqual(caught.exception.unknown, ["shoplifter"])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TestLazyLoadAndEviction(RegistryCase):
|
|
251
|
+
"""§3: lazy, with LRU eviction under a stated RAM budget."""
|
|
252
|
+
|
|
253
|
+
def test_a_specialist_is_not_loaded_until_its_classes_are_asked_for(self):
|
|
254
|
+
self.write_artifact("general")
|
|
255
|
+
self.write_artifact("ppe")
|
|
256
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
257
|
+
reg.refresh()
|
|
258
|
+
# The watcher loaded the PINNED model and nothing else.
|
|
259
|
+
self.assertEqual(self.built, ["general"])
|
|
260
|
+
self.assertEqual(reg.snapshot()["resident_models"], ["general"])
|
|
261
|
+
|
|
262
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
263
|
+
self.assertEqual(self.built, ["general", "ppe"])
|
|
264
|
+
|
|
265
|
+
def test_eviction_under_a_budget_that_forces_it(self):
|
|
266
|
+
"""1,000 MiB holds the pinned 600 plus ONE 400 MiB specialist, never two.
|
|
267
|
+
|
|
268
|
+
The LRU choice is a fact here, not a race: the clock is injected.
|
|
269
|
+
"""
|
|
270
|
+
for name in ("general", "ppe", "small_object"):
|
|
271
|
+
self.write_artifact(name)
|
|
272
|
+
reg = self.registry([GENERAL, PPE, SMALL_OBJECT], pinned="general", budget_mb=1000)
|
|
273
|
+
reg.refresh()
|
|
274
|
+
|
|
275
|
+
self.tick()
|
|
276
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
277
|
+
self.assertEqual(sorted(reg.snapshot()["resident_models"]), ["general", "ppe"])
|
|
278
|
+
|
|
279
|
+
self.tick()
|
|
280
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["rodent"]))
|
|
281
|
+
snap = reg.snapshot()
|
|
282
|
+
# `ppe` was the least recently used non-pinned model, so it went.
|
|
283
|
+
self.assertEqual(sorted(snap["resident_models"]), ["general", "small_object"])
|
|
284
|
+
self.assertEqual(snap["models"]["ppe"]["state"], STATE_IDLE)
|
|
285
|
+
self.assertEqual(snap["models"]["ppe"]["evictions"], 1)
|
|
286
|
+
self.assertLessEqual(snap["ram_resident_bytes"], snap["ram_budget_bytes"])
|
|
287
|
+
|
|
288
|
+
def test_the_pinned_model_is_never_the_one_evicted(self):
|
|
289
|
+
for name in ("general", "ppe", "small_object"):
|
|
290
|
+
self.write_artifact(name)
|
|
291
|
+
reg = self.registry([GENERAL, PPE, SMALL_OBJECT], pinned="general", budget_mb=1000)
|
|
292
|
+
reg.refresh()
|
|
293
|
+
self.tick()
|
|
294
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
295
|
+
self.tick()
|
|
296
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["rodent"]))
|
|
297
|
+
self.assertIn("general", reg.snapshot()["resident_models"])
|
|
298
|
+
|
|
299
|
+
def test_an_evicted_model_reloads_on_the_next_request_for_its_classes(self):
|
|
300
|
+
for name in ("general", "ppe", "small_object"):
|
|
301
|
+
self.write_artifact(name)
|
|
302
|
+
reg = self.registry([GENERAL, PPE, SMALL_OBJECT], pinned="general", budget_mb=1000)
|
|
303
|
+
reg.refresh()
|
|
304
|
+
self.tick()
|
|
305
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
306
|
+
self.tick()
|
|
307
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["rodent"]))
|
|
308
|
+
self.assertNotIn("ppe", reg.snapshot()["resident_models"])
|
|
309
|
+
|
|
310
|
+
self.tick()
|
|
311
|
+
batch = reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
312
|
+
# It came back, it was PROVED again, and it answered.
|
|
313
|
+
self.assertEqual(self.built.count("ppe"), 2)
|
|
314
|
+
self.assertEqual(self.probed.count("ppe"), 2)
|
|
315
|
+
self.assertEqual(batch.models, ["ppe"])
|
|
316
|
+
self.assertEqual([d.label for d in batch.detections], ["ppe-hit"])
|
|
317
|
+
|
|
318
|
+
def test_an_evicted_class_is_still_requestable(self):
|
|
319
|
+
"""Presence, not residency, decides the offer.
|
|
320
|
+
|
|
321
|
+
Gating on residency would make the SAME profile 400 or 200 depending on
|
|
322
|
+
what ran a minute ago.
|
|
323
|
+
"""
|
|
324
|
+
for name in ("general", "ppe", "small_object"):
|
|
325
|
+
self.write_artifact(name)
|
|
326
|
+
reg = self.registry([GENERAL, PPE, SMALL_OBJECT], pinned="general", budget_mb=1000)
|
|
327
|
+
reg.refresh()
|
|
328
|
+
self.tick()
|
|
329
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
330
|
+
self.tick()
|
|
331
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["rodent"]))
|
|
332
|
+
self.assertNotIn("ppe", reg.snapshot()["resident_models"])
|
|
333
|
+
self.assertIn("glove", reg.class_index().class_names)
|
|
334
|
+
|
|
335
|
+
def test_a_set_that_cannot_fit_is_refused_with_what_did_not_fit(self):
|
|
336
|
+
"""§2: "report what it could not fit rather than silently dropping it."
|
|
337
|
+
|
|
338
|
+
The alternative — answering with the models that DID fit — is a thinner
|
|
339
|
+
frame indistinguishable downstream from a quieter venue.
|
|
340
|
+
"""
|
|
341
|
+
for name in ("general", "ppe", "small_object"):
|
|
342
|
+
self.write_artifact(name)
|
|
343
|
+
reg = self.registry([GENERAL, PPE, SMALL_OBJECT], pinned="general", budget_mb=1000)
|
|
344
|
+
reg.refresh()
|
|
345
|
+
with self.assertRaises(DetectorNotReady) as caught:
|
|
346
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove", "rodent"]))
|
|
347
|
+
message = str(caught.exception)
|
|
348
|
+
self.assertIn("ppe", message)
|
|
349
|
+
self.assertIn("small_object", message)
|
|
350
|
+
self.assertIn("budget", message)
|
|
351
|
+
# NOTHING RAN, and neither specialist was even BUILT — refusing after
|
|
352
|
+
# allocating half the set would leave the box over budget for nothing.
|
|
353
|
+
self.assertEqual(self.built, ["general"])
|
|
354
|
+
self.assertNotIn("ppe", self.sessions)
|
|
355
|
+
|
|
356
|
+
def test_a_ceiling_that_cannot_hold_the_pinned_model_is_refused_at_construction(self):
|
|
357
|
+
"""Permanently unavailable is a different problem from constrained, and
|
|
358
|
+
it must be visible at start rather than on the first frame."""
|
|
359
|
+
with self.assertRaisesRegex(ValueError, "cannot hold the pinned model"):
|
|
360
|
+
self.registry([GENERAL, PPE], pinned="general", budget_mb=100)
|
|
361
|
+
|
|
362
|
+
def test_eviction_is_reported_as_abnormal_not_as_routine(self):
|
|
363
|
+
"""A box evicting at all is OVERSUBSCRIBED against what it was allocated,
|
|
364
|
+
and the authority that set the ceiling needs to see that. A service
|
|
365
|
+
quietly thrashing under one looks exactly like a service working fine —
|
|
366
|
+
which is the failure shape this whole tree keeps catching."""
|
|
367
|
+
for name in ("general", "ppe", "small_object"):
|
|
368
|
+
self.write_artifact(name)
|
|
369
|
+
reg = self.registry([GENERAL, PPE, SMALL_OBJECT], pinned="general", budget_mb=1000)
|
|
370
|
+
reg.refresh()
|
|
371
|
+
self.assertEqual(reg.snapshot()["evictions"], 0)
|
|
372
|
+
|
|
373
|
+
self.tick()
|
|
374
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
375
|
+
self.tick()
|
|
376
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["rodent"]))
|
|
377
|
+
|
|
378
|
+
self.assertEqual(reg.snapshot()["evictions"], 1)
|
|
379
|
+
self.assertTrue(
|
|
380
|
+
any("WARNING" in line and "oversubscribed" in line for line in self.logs),
|
|
381
|
+
f"eviction was not logged as abnormal: {self.logs}",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class TestNoCeilingWasInjected(RegistryCase):
|
|
386
|
+
"""`None` means NOBODY TOLD US — it is not "unlimited" and not a default.
|
|
387
|
+
|
|
388
|
+
The allocation authority is server-side: `selectModelForDeviceTier` sizes a
|
|
389
|
+
model from `deviceRamMb`/`deviceTier` and the model manifest gates on
|
|
390
|
+
`min_ram_mb`. A ceiling chosen inside this service would be a SECOND
|
|
391
|
+
authority that cannot see the first — the `yolov8_nano` shape, advertised by
|
|
392
|
+
one component and unloadable by another.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
def test_the_pinned_model_still_loads_and_answers(self):
|
|
396
|
+
"""Holding what the server already told us to hold is the mechanism
|
|
397
|
+
doing its job, not a portfolio decision. Without this the whole detector
|
|
398
|
+
would go dark on every box that has not been told a ceiling."""
|
|
399
|
+
self.write_artifact("general")
|
|
400
|
+
self.write_artifact("ppe")
|
|
401
|
+
reg = self.registry([GENERAL, PPE], pinned="general", budget_mb=None)
|
|
402
|
+
reg.refresh()
|
|
403
|
+
self.assertEqual(reg.snapshot()["resident_models"], ["general"])
|
|
404
|
+
batch = reg.detect(b"jpeg", 0.25, reg.resolve_classes(["person"]))
|
|
405
|
+
self.assertEqual(batch.models, ["general"])
|
|
406
|
+
|
|
407
|
+
def test_making_a_SECOND_model_resident_is_refused_and_says_why(self):
|
|
408
|
+
self.write_artifact("general")
|
|
409
|
+
self.write_artifact("ppe")
|
|
410
|
+
reg = self.registry([GENERAL, PPE], pinned="general", budget_mb=None)
|
|
411
|
+
reg.refresh()
|
|
412
|
+
with self.assertRaises(DetectorNotReady) as caught:
|
|
413
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
414
|
+
message = str(caught.exception)
|
|
415
|
+
self.assertIn("no RAM ceiling was injected", message)
|
|
416
|
+
self.assertIn("will not invent one", message)
|
|
417
|
+
self.assertIn("selectModelForDeviceTier", message)
|
|
418
|
+
# And nothing was allocated on the way to refusing.
|
|
419
|
+
self.assertEqual(self.built, ["general"])
|
|
420
|
+
|
|
421
|
+
def test_health_says_the_ceiling_is_absent_rather_than_showing_a_number(self):
|
|
422
|
+
"""A reader must be able to SEE that no authority has spoken. A number
|
|
423
|
+
here would be indistinguishable from an injected one."""
|
|
424
|
+
self.write_artifact("general")
|
|
425
|
+
reg = self.registry([GENERAL, PPE], pinned="general", budget_mb=None)
|
|
426
|
+
reg.refresh()
|
|
427
|
+
snap = reg.snapshot()
|
|
428
|
+
self.assertIsNone(snap["ram_budget_bytes"])
|
|
429
|
+
self.assertFalse(snap["ram_ceiling_injected"])
|
|
430
|
+
|
|
431
|
+
def test_an_injected_ceiling_is_reported_as_injected(self):
|
|
432
|
+
self.write_artifact("general")
|
|
433
|
+
reg = self.registry([GENERAL, PPE], pinned="general", budget_mb=1024)
|
|
434
|
+
reg.refresh()
|
|
435
|
+
snap = reg.snapshot()
|
|
436
|
+
self.assertEqual(snap["ram_budget_bytes"], 1024 * MB)
|
|
437
|
+
self.assertTrue(snap["ram_ceiling_injected"])
|
|
438
|
+
|
|
439
|
+
def test_a_non_positive_ceiling_is_refused_rather_than_treated_as_absent(self):
|
|
440
|
+
"""`0` is somebody telling us something wrong, which is a different
|
|
441
|
+
problem from nobody telling us anything."""
|
|
442
|
+
for bad in (0, -1):
|
|
443
|
+
with self.subTest(bad=bad):
|
|
444
|
+
with self.assertRaisesRegex(ValueError, "must be positive"):
|
|
445
|
+
self.registry([GENERAL], pinned="general", budget_mb=bad)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class TestResidentFootprintIsMeasured(RegistryCase):
|
|
449
|
+
"""The column the server does not have, produced per load.
|
|
450
|
+
|
|
451
|
+
`min_ram_mb` is a GATE, `model_size_mb` is a FILE SIZE, and the quantity
|
|
452
|
+
portfolio admission needs — resident footprint — is neither. This measures
|
|
453
|
+
it and REPORTS it; it must never feed admission, or the service is back to
|
|
454
|
+
being a second authority using numbers the first cannot see.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
def test_a_load_records_a_footprint_and_names_the_instrument(self):
|
|
458
|
+
# SKIP ON THE PLATFORM, NEVER ON THE RESULT. Skipping because the field
|
|
459
|
+
# is absent would make this pass for the one reason it exists to
|
|
460
|
+
# catch — a measurement that stopped happening. So the skip predicate
|
|
461
|
+
# asks the platform directly, and an absent field is then a FAILURE.
|
|
462
|
+
from trillboards_detector.registry import read_process_rss
|
|
463
|
+
|
|
464
|
+
if read_process_rss() is None: # pragma: no cover - no stdlib source
|
|
465
|
+
self.skipTest("no stdlib RSS source on this platform")
|
|
466
|
+
|
|
467
|
+
self.write_artifact("general")
|
|
468
|
+
reg = self.registry([GENERAL], pinned="general")
|
|
469
|
+
reg.refresh()
|
|
470
|
+
row = reg.snapshot()["models"]["general"]
|
|
471
|
+
self.assertIn(
|
|
472
|
+
"rss_delta_bytes",
|
|
473
|
+
row,
|
|
474
|
+
"the load recorded no footprint although this platform HAS an RSS source",
|
|
475
|
+
)
|
|
476
|
+
self.assertIsInstance(row["rss_delta_bytes"], int)
|
|
477
|
+
self.assertGreaterEqual(row["rss_delta_bytes"], 0)
|
|
478
|
+
# NAMED, because the three platforms do not answer the same question:
|
|
479
|
+
# `rusage-maxrss` is a high-water mark, so its delta is a lower bound
|
|
480
|
+
# and 0 is legitimate; the other two are current RSS.
|
|
481
|
+
self.assertIn(row["rss_source"], ("proc-statm", "psapi-workingset", "rusage-maxrss"))
|
|
482
|
+
|
|
483
|
+
def test_the_measurement_never_decides_admission(self):
|
|
484
|
+
"""A fake session that allocates nothing must still be admitted on its
|
|
485
|
+
COMMITTED cost — otherwise a box would admit a model it has no committed
|
|
486
|
+
figure for, using a number only this service can see."""
|
|
487
|
+
self.write_artifact("general")
|
|
488
|
+
self.write_artifact("ppe")
|
|
489
|
+
# 1000 MiB holds the committed 600 + 400 exactly, and the fakes allocate
|
|
490
|
+
# essentially nothing. Admission must follow the committed figures.
|
|
491
|
+
reg = self.registry([GENERAL, PPE], pinned="general", budget_mb=1000)
|
|
492
|
+
reg.refresh()
|
|
493
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
494
|
+
snap = reg.snapshot()
|
|
495
|
+
self.assertEqual(snap["ram_resident_bytes"], 1000 * MB)
|
|
496
|
+
self.assertEqual(snap["models"]["ppe"]["approx_ram_bytes"], 400 * MB)
|
|
497
|
+
|
|
498
|
+
def test_a_failed_load_leaves_no_footprint_behind(self):
|
|
499
|
+
self.write_artifact("general")
|
|
500
|
+
self.write_artifact("ppe")
|
|
501
|
+
self.probe_raises["ppe"] = RuntimeError("no forward pass")
|
|
502
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
503
|
+
reg.refresh()
|
|
504
|
+
with self.assertRaises(DetectorNotReady):
|
|
505
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
506
|
+
self.assertNotIn("rss_delta_bytes", reg.snapshot()["models"]["ppe"])
|
|
507
|
+
|
|
508
|
+
def test_the_rss_reader_answers_or_says_it_cannot(self):
|
|
509
|
+
from trillboards_detector.registry import read_process_rss
|
|
510
|
+
|
|
511
|
+
reading = read_process_rss()
|
|
512
|
+
self.assertIsNotNone(reading, "no stdlib RSS source at all on this platform")
|
|
513
|
+
value, source = reading
|
|
514
|
+
self.assertGreater(value, 0)
|
|
515
|
+
self.assertIn(source, ("proc-statm", "psapi-workingset", "rusage-maxrss"))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class TestDownIsNeverEmpty(RegistryCase):
|
|
519
|
+
"""The single failure this whole component exists to prevent."""
|
|
520
|
+
|
|
521
|
+
def test_a_model_whose_file_is_missing_refuses_rather_than_answering(self):
|
|
522
|
+
self.write_artifact("general")
|
|
523
|
+
# `ppe`'s artifact is never written — the OTA has not landed.
|
|
524
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
525
|
+
reg.refresh()
|
|
526
|
+
|
|
527
|
+
snap = reg.snapshot()
|
|
528
|
+
self.assertEqual(snap["models"]["ppe"]["state"], STATE_ABSENT)
|
|
529
|
+
# Its classes are NOT offered, so §4 rule 5 holds by construction: a
|
|
530
|
+
# committed vocabulary with no artifact advertises nothing.
|
|
531
|
+
self.assertNotIn("glove", reg.class_index().class_names)
|
|
532
|
+
|
|
533
|
+
with self.assertRaises(UnknownClasses) as caught:
|
|
534
|
+
reg.resolve_classes(["glove"])
|
|
535
|
+
# And the message separates "no build ever heard of this" from "the
|
|
536
|
+
# artifact has not arrived", which are opposite problems.
|
|
537
|
+
self.assertIn("ppe", caught.exception.detail)
|
|
538
|
+
self.assertIn("has not arrived yet", caught.exception.detail)
|
|
539
|
+
|
|
540
|
+
def test_an_artifact_that_disappears_after_a_request_refuses_mid_flight(self):
|
|
541
|
+
self.write_artifact("general")
|
|
542
|
+
self.write_artifact("ppe")
|
|
543
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
544
|
+
reg.refresh()
|
|
545
|
+
wanted = reg.resolve_classes(["glove"])
|
|
546
|
+
self.remove_artifact("ppe")
|
|
547
|
+
with self.assertRaisesRegex(DetectorNotReady, "not found"):
|
|
548
|
+
reg.detect(b"jpeg", 0.25, wanted)
|
|
549
|
+
|
|
550
|
+
def test_a_model_that_fails_to_load_is_unavailable_with_the_reason(self):
|
|
551
|
+
self.write_artifact("general")
|
|
552
|
+
self.write_artifact("ppe")
|
|
553
|
+
self.factory_raises["ppe"] = ModelContractError("model has 4 class slots")
|
|
554
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
555
|
+
reg.refresh()
|
|
556
|
+
with self.assertRaises(DetectorNotReady) as caught:
|
|
557
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
558
|
+
self.assertIn("does not match the detector contract", str(caught.exception))
|
|
559
|
+
self.assertEqual(reg.snapshot()["models"]["ppe"]["state"], STATE_UNAVAILABLE)
|
|
560
|
+
|
|
561
|
+
def test_a_session_that_constructs_but_cannot_infer_is_not_ready(self):
|
|
562
|
+
"""PROVE IT, PER MODEL. `/v1/models` answers 200 without ever building
|
|
563
|
+
an engine, which is how a UTF-8 BOM 500'd every frame on every Windows
|
|
564
|
+
connector while two health checks passed."""
|
|
565
|
+
self.write_artifact("general")
|
|
566
|
+
self.write_artifact("ppe")
|
|
567
|
+
self.probe_raises["ppe"] = RuntimeError("ORT exploded on the probe frame")
|
|
568
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
569
|
+
reg.refresh()
|
|
570
|
+
with self.assertRaises(DetectorNotReady) as caught:
|
|
571
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
572
|
+
self.assertIn("probe inference failed", str(caught.exception))
|
|
573
|
+
self.assertEqual(reg.snapshot()["models"]["ppe"]["state"], STATE_UNAVAILABLE)
|
|
574
|
+
# The SESSION was built. Only the probe refused it, which is the whole
|
|
575
|
+
# point of running one.
|
|
576
|
+
self.assertIn("ppe", self.built)
|
|
577
|
+
|
|
578
|
+
def test_the_pinned_model_is_probed_before_it_is_called_ready(self):
|
|
579
|
+
self.write_artifact("general")
|
|
580
|
+
self.probe_raises["general"] = RuntimeError("no forward pass")
|
|
581
|
+
reg = self.registry([GENERAL], pinned="general")
|
|
582
|
+
reg.refresh()
|
|
583
|
+
self.assertEqual(reg.snapshot()["pinned_state"], STATE_UNAVAILABLE)
|
|
584
|
+
|
|
585
|
+
def test_one_model_of_two_failing_costs_the_whole_request(self):
|
|
586
|
+
"""ALL OR NOTHING. Answering with the model that DID work is a thinner
|
|
587
|
+
frame nothing downstream can distinguish from a quieter venue."""
|
|
588
|
+
self.write_artifact("general")
|
|
589
|
+
self.write_artifact("ppe")
|
|
590
|
+
self.session_raises["ppe"] = RuntimeError("ORT exploded")
|
|
591
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
592
|
+
reg.refresh()
|
|
593
|
+
with self.assertRaises(RuntimeError):
|
|
594
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["person", "glove"]))
|
|
595
|
+
self.assertEqual(reg.snapshot()["models"]["ppe"]["detect_failures"], 1)
|
|
596
|
+
|
|
597
|
+
def test_a_failed_plan_does_not_leave_a_model_pinned_in_flight(self):
|
|
598
|
+
"""A leak that only appears on the failure path is the kind nothing
|
|
599
|
+
notices: the first model would stay unevictable forever, so the budget
|
|
600
|
+
would silently shrink by one model per broken artifact."""
|
|
601
|
+
self.write_artifact("general")
|
|
602
|
+
self.write_artifact("ppe")
|
|
603
|
+
self.probe_raises["ppe"] = RuntimeError("no forward pass")
|
|
604
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
605
|
+
reg.refresh()
|
|
606
|
+
with self.assertRaises(DetectorNotReady):
|
|
607
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["person", "glove"]))
|
|
608
|
+
# `general` was acquired first and must have been given back.
|
|
609
|
+
self.assertEqual(reg._states["general"].in_flight, 0)
|
|
610
|
+
|
|
611
|
+
def test_merging_no_batches_raises_rather_than_returning_empty(self):
|
|
612
|
+
with self.assertRaisesRegex(ValueError, "503"):
|
|
613
|
+
merge_batches([])
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class TestSnapshotIsCheapAndHonest(RegistryCase):
|
|
617
|
+
"""`/health` reports which models are resident and must not load one."""
|
|
618
|
+
|
|
619
|
+
def test_the_snapshot_loads_nothing_and_infers_nothing(self):
|
|
620
|
+
self.write_artifact("general")
|
|
621
|
+
self.write_artifact("ppe")
|
|
622
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
623
|
+
reg.refresh()
|
|
624
|
+
built_before = list(self.built)
|
|
625
|
+
|
|
626
|
+
for _ in range(5):
|
|
627
|
+
snap = reg.snapshot()
|
|
628
|
+
|
|
629
|
+
self.assertEqual(self.built, built_before, "a health probe built a session")
|
|
630
|
+
self.assertEqual(self.sessions["general"].calls, [], "a health probe ran inference")
|
|
631
|
+
# It still reported the truth about residency while it was at it.
|
|
632
|
+
self.assertEqual(snap["resident_models"], ["general"])
|
|
633
|
+
self.assertEqual(snap["models"]["ppe"]["state"], STATE_IDLE)
|
|
634
|
+
self.assertEqual(snap["models"]["ppe"]["resident"], False)
|
|
635
|
+
|
|
636
|
+
def test_the_snapshot_names_every_catalogue_model_and_the_budget(self):
|
|
637
|
+
self.write_artifact("general")
|
|
638
|
+
reg = self.registry([GENERAL, PPE], pinned="general", budget_mb=1024)
|
|
639
|
+
reg.refresh()
|
|
640
|
+
snap = reg.snapshot()
|
|
641
|
+
self.assertEqual(sorted(snap["models"]), ["general", "ppe"])
|
|
642
|
+
self.assertEqual(snap["ram_budget_bytes"], 1024 * MB)
|
|
643
|
+
self.assertEqual(snap["ram_resident_bytes"], 600 * MB)
|
|
644
|
+
self.assertEqual(snap["models"]["general"]["state"], STATE_READY)
|
|
645
|
+
self.assertTrue(snap["models"]["general"]["pinned"])
|
|
646
|
+
self.assertFalse(snap["models"]["ppe"]["pinned"])
|
|
647
|
+
# A model whose artifact has not landed contributes no known class.
|
|
648
|
+
self.assertEqual(snap["known_class_count"], 2)
|
|
649
|
+
|
|
650
|
+
def test_drops_are_counted_per_model_never_only_in_aggregate(self):
|
|
651
|
+
"""`telemetry-producer-substrate.md`: grade a column PER PRODUCER. One
|
|
652
|
+
specialist dropping everything it detects must not be averaged into
|
|
653
|
+
silence by the general model's zero."""
|
|
654
|
+
self.write_artifact("general")
|
|
655
|
+
self.write_artifact("ppe")
|
|
656
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
657
|
+
reg.refresh()
|
|
658
|
+
|
|
659
|
+
class Dropping(FakeSession):
|
|
660
|
+
def detect(self, jpeg, min_confidence, wanted):
|
|
661
|
+
batch = super().detect(jpeg, min_confidence, wanted)
|
|
662
|
+
batch.unnamed_class_dropped = 3
|
|
663
|
+
return batch
|
|
664
|
+
|
|
665
|
+
reg._states["ppe"].session = Dropping(PPE)
|
|
666
|
+
reg._states["ppe"].state = STATE_READY
|
|
667
|
+
reg._states["ppe"].stamp = (4, 0)
|
|
668
|
+
self.sessions["ppe"] = reg._states["ppe"].session
|
|
669
|
+
reg._states["ppe"].stamp = (self.root / "models" / "ppe" / "model.onnx").stat().st_size, (
|
|
670
|
+
self.root / "models" / "ppe" / "model.onnx"
|
|
671
|
+
).stat().st_mtime_ns
|
|
672
|
+
|
|
673
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["person", "glove"]))
|
|
674
|
+
snap = reg.snapshot()
|
|
675
|
+
self.assertEqual(snap["models"]["ppe"]["unnamed_class_dropped"], 3)
|
|
676
|
+
self.assertEqual(snap["models"]["general"]["unnamed_class_dropped"], 0)
|
|
677
|
+
self.assertEqual(snap["unnamed_class_dropped"], 3)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
class TestOtaAndPresence(RegistryCase):
|
|
681
|
+
"""The artifact arrives, changes and goes away with nobody restarting us."""
|
|
682
|
+
|
|
683
|
+
def test_a_specialist_that_arrives_later_widens_the_vocabulary(self):
|
|
684
|
+
self.write_artifact("general")
|
|
685
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
686
|
+
reg.refresh()
|
|
687
|
+
self.assertNotIn("glove", reg.class_index().class_names)
|
|
688
|
+
|
|
689
|
+
self.write_artifact("ppe")
|
|
690
|
+
reg.refresh()
|
|
691
|
+
self.assertIn("glove", reg.class_index().class_names)
|
|
692
|
+
# Arriving does not load it. Lazy means lazy.
|
|
693
|
+
self.assertEqual(self.built, ["general"])
|
|
694
|
+
|
|
695
|
+
def test_a_changed_artifact_drops_the_session_so_the_next_request_reloads(self):
|
|
696
|
+
self.write_artifact("general")
|
|
697
|
+
self.write_artifact("ppe")
|
|
698
|
+
reg = self.registry([GENERAL, PPE], pinned="general")
|
|
699
|
+
reg.refresh()
|
|
700
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
701
|
+
self.assertIn("ppe", reg.snapshot()["resident_models"])
|
|
702
|
+
|
|
703
|
+
self.write_artifact("ppe", b"a different and longer onnx")
|
|
704
|
+
reg.refresh()
|
|
705
|
+
self.assertNotIn("ppe", reg.snapshot()["resident_models"])
|
|
706
|
+
# Rebuilding a specialist from the WATCHER would be an allocation made
|
|
707
|
+
# outside a request that could have been refused for not fitting.
|
|
708
|
+
self.assertEqual(self.built.count("ppe"), 1)
|
|
709
|
+
|
|
710
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
711
|
+
self.assertEqual(self.built.count("ppe"), 2)
|
|
712
|
+
|
|
713
|
+
def test_a_changed_pinned_artifact_reloads_from_the_watcher(self):
|
|
714
|
+
self.write_artifact("general")
|
|
715
|
+
reg = self.registry([GENERAL], pinned="general")
|
|
716
|
+
reg.refresh()
|
|
717
|
+
self.write_artifact("general", b"a different and longer onnx")
|
|
718
|
+
reg.refresh()
|
|
719
|
+
self.assertEqual(self.built.count("general"), 2)
|
|
720
|
+
self.assertEqual(reg.snapshot()["pinned_state"], STATE_READY)
|
|
721
|
+
|
|
722
|
+
def test_a_failed_load_is_not_retried_every_poll_on_the_same_bytes(self):
|
|
723
|
+
"""Rebuilding a session that cannot work, per poll, forever, is not a
|
|
724
|
+
retry policy. The stamp moves when `ModelManager` rewrites the file, and
|
|
725
|
+
that is the event worth reacting to."""
|
|
726
|
+
self.write_artifact("general")
|
|
727
|
+
self.factory_raises["general"] = RuntimeError("corrupt")
|
|
728
|
+
reg = self.registry([GENERAL], pinned="general")
|
|
729
|
+
for _ in range(5):
|
|
730
|
+
reg.refresh()
|
|
731
|
+
self.assertEqual(self.built.count("general"), 1)
|
|
732
|
+
|
|
733
|
+
self.write_artifact("general", b"a repaired and longer onnx")
|
|
734
|
+
del self.factory_raises["general"]
|
|
735
|
+
reg.refresh()
|
|
736
|
+
self.assertEqual(self.built.count("general"), 2)
|
|
737
|
+
self.assertEqual(reg.snapshot()["pinned_state"], STATE_READY)
|
|
738
|
+
|
|
739
|
+
def test_a_zero_byte_artifact_is_absent_not_present(self):
|
|
740
|
+
"""`ModelManager` writes `.tmp` then renames, but a truncated file on a
|
|
741
|
+
full disk is a real state and it is not a model."""
|
|
742
|
+
self.write_artifact("general", b"")
|
|
743
|
+
reg = self.registry([GENERAL], pinned="general")
|
|
744
|
+
reg.refresh()
|
|
745
|
+
self.assertEqual(reg.snapshot()["pinned_state"], STATE_ABSENT)
|
|
746
|
+
self.assertEqual(self.built, [])
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class TestConcurrency(RegistryCase):
|
|
750
|
+
"""Two requests must not each admit a model and together exceed the budget."""
|
|
751
|
+
|
|
752
|
+
def test_a_model_with_a_request_in_flight_is_not_evicted(self):
|
|
753
|
+
for name in ("general", "ppe", "small_object"):
|
|
754
|
+
self.write_artifact(name)
|
|
755
|
+
reg = self.registry([GENERAL, PPE, SMALL_OBJECT], pinned="general", budget_mb=1000)
|
|
756
|
+
reg.refresh()
|
|
757
|
+
|
|
758
|
+
entered = threading.Event()
|
|
759
|
+
release = threading.Event()
|
|
760
|
+
|
|
761
|
+
class Blocking(FakeSession):
|
|
762
|
+
def detect(inner, jpeg, min_confidence, wanted):
|
|
763
|
+
entered.set()
|
|
764
|
+
release.wait(10)
|
|
765
|
+
return super().detect(jpeg, min_confidence, wanted)
|
|
766
|
+
|
|
767
|
+
self.tick()
|
|
768
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
769
|
+
reg._states["ppe"].session = Blocking(PPE)
|
|
770
|
+
self.sessions["ppe"] = reg._states["ppe"].session
|
|
771
|
+
|
|
772
|
+
errors: "list[BaseException]" = []
|
|
773
|
+
|
|
774
|
+
def slow():
|
|
775
|
+
try:
|
|
776
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
|
|
777
|
+
except BaseException as err: # noqa: BLE001 - reported below
|
|
778
|
+
errors.append(err)
|
|
779
|
+
|
|
780
|
+
worker = threading.Thread(target=slow, daemon=True)
|
|
781
|
+
worker.start()
|
|
782
|
+
self.assertTrue(entered.wait(10), "the blocking session never ran")
|
|
783
|
+
|
|
784
|
+
# `small_object` needs 400 MiB and only `ppe`'s 400 MiB is reclaimable,
|
|
785
|
+
# but `ppe` is in flight — so the request is REFUSED rather than
|
|
786
|
+
# evicting a session a running request still holds a reference to.
|
|
787
|
+
self.tick()
|
|
788
|
+
with self.assertRaises(DetectorNotReady):
|
|
789
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["rodent"]))
|
|
790
|
+
|
|
791
|
+
release.set()
|
|
792
|
+
worker.join(10)
|
|
793
|
+
self.assertEqual(errors, [])
|
|
794
|
+
self.assertIn("ppe", reg.snapshot()["resident_models"])
|
|
795
|
+
|
|
796
|
+
# Once it is no longer in flight, the same request succeeds.
|
|
797
|
+
self.tick()
|
|
798
|
+
reg.detect(b"jpeg", 0.25, reg.resolve_classes(["rodent"]))
|
|
799
|
+
self.assertIn("small_object", reg.snapshot()["resident_models"])
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
class TestTheResidencyPolicyRunsOnABareInterpreter(unittest.TestCase):
|
|
803
|
+
"""The strongest form of "these tests run on every CI runner": they CAN.
|
|
804
|
+
|
|
805
|
+
`registry.py` reaches a model only through an injected factory, exactly as
|
|
806
|
+
`server.py` does, so importing the whole residency policy pulls in no ONNX
|
|
807
|
+
runtime, no numpy and no Pillow. That is what lets eviction, routing and
|
|
808
|
+
every refusal be exercised on a runner where the ~128 MiB artifact is absent
|
|
809
|
+
— which is all of them, since it is OTA'd and not committed.
|
|
810
|
+
|
|
811
|
+
Verified against a positive control on 2026-08-24, both ways. The same probe
|
|
812
|
+
against `trillboards_detector.loader` — which DOES need a runtime — reports
|
|
813
|
+
`['onnxruntime', 'numpy', 'PIL', 'trillboards_detector.session',
|
|
814
|
+
'trillboards_detector.loader']` on an interpreter that has them, and on a
|
|
815
|
+
bare one it does not even import (`ModuleNotFoundError: onnxruntime`, which
|
|
816
|
+
is the non-zero exit this test's returncode assertion catches). The same
|
|
817
|
+
probe against `registry` on the interpreter WITH the runtime installed
|
|
818
|
+
reports `[]`, so the emptiness is a property of the module and not of the
|
|
819
|
+
environment. A check that has never been shown to fail is not a check.
|
|
820
|
+
"""
|
|
821
|
+
|
|
822
|
+
def test_importing_the_residency_policy_pulls_in_no_inference_runtime(self):
|
|
823
|
+
import os
|
|
824
|
+
import pathlib
|
|
825
|
+
import subprocess
|
|
826
|
+
import sys
|
|
827
|
+
|
|
828
|
+
probe = (
|
|
829
|
+
"import sys; import trillboards_detector.registry as r; "
|
|
830
|
+
"leaked = [m for m in ('onnxruntime','numpy','PIL','trillboards_detector.session',"
|
|
831
|
+
"'trillboards_detector.loader') if m in sys.modules]; "
|
|
832
|
+
"print(','.join(leaked))"
|
|
833
|
+
)
|
|
834
|
+
package_root = pathlib.Path(__file__).resolve().parents[1]
|
|
835
|
+
env = dict(os.environ)
|
|
836
|
+
env["PYTHONPATH"] = os.pathsep.join(
|
|
837
|
+
[str(package_root)] + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else []),
|
|
838
|
+
)
|
|
839
|
+
out = subprocess.run(
|
|
840
|
+
[sys.executable, "-c", probe], capture_output=True, text=True, timeout=60, env=env
|
|
841
|
+
)
|
|
842
|
+
self.assertEqual(out.returncode, 0, out.stderr)
|
|
843
|
+
self.assertEqual(
|
|
844
|
+
out.stdout.strip(),
|
|
845
|
+
"",
|
|
846
|
+
"registry.py imported an inference dependency — the residency policy must reach a "
|
|
847
|
+
"model only through the injected factory, or eviction and routing stop being "
|
|
848
|
+
"testable on a runner with no artifact",
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
class TestTheShippedCatalogue(unittest.TestCase):
|
|
853
|
+
"""The one committed vocabulary, checked against the shipped artifact."""
|
|
854
|
+
|
|
855
|
+
def test_rfdetr_medium_still_declares_the_91_wide_coco_axis(self):
|
|
856
|
+
self.assertEqual(RFDETR_MEDIUM.logit_slots, 91)
|
|
857
|
+
self.assertEqual(dict(RFDETR_MEDIUM.slot_names), dict(COCO91_CLASS_NAMES))
|
|
858
|
+
|
|
859
|
+
def test_its_committed_ram_cost_is_the_measured_632_mb(self):
|
|
860
|
+
"""`cctv-detector-tier.md` §6, measured on the shipped 135,146,350-byte
|
|
861
|
+
fp32 export. A bump is a re-measurement, not a number edit."""
|
|
862
|
+
self.assertEqual(RFDETR_MEDIUM.approx_ram_bytes, 632 * MB)
|
|
863
|
+
|
|
864
|
+
def test_a_vocabulary_naming_a_slot_outside_its_axis_is_refused(self):
|
|
865
|
+
with self.assertRaisesRegex(ValueError, "outside the declared"):
|
|
866
|
+
ModelVocabulary("bad", {5: "person"}, logit_slots=3, approx_ram_bytes=1)
|
|
867
|
+
|
|
868
|
+
def test_a_vocabulary_with_a_non_canonical_class_name_is_refused(self):
|
|
869
|
+
"""`cell_phone` would be emitted on the wire while the filter matched
|
|
870
|
+
`cell phone` — a class that silently matches nothing."""
|
|
871
|
+
with self.assertRaisesRegex(ValueError, "not canonical"):
|
|
872
|
+
ModelVocabulary("bad", {0: "cell_phone"}, logit_slots=1, approx_ram_bytes=1)
|
|
873
|
+
|
|
874
|
+
def test_two_slots_sharing_a_name_is_refused(self):
|
|
875
|
+
with self.assertRaisesRegex(ValueError, "ambiguous"):
|
|
876
|
+
ModelVocabulary("bad", {0: "person", 1: "person"}, logit_slots=2, approx_ram_bytes=1)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
if __name__ == "__main__":
|
|
880
|
+
unittest.main()
|