@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.
@@ -0,0 +1,836 @@
1
+ """Many models on one box: who is resident, who gets evicted, and who answers.
2
+
3
+ WHY A REGISTRY AND NOT A SECOND `DetectorLoader`
4
+ ------------------------------------------------
5
+ `docs/runbooks/sensing-model-library.md` §3 decides the shape and this module
6
+ implements it. Three of its sentences are the whole specification:
7
+
8
+ * "The loader holds a name -> session map with a class index built at load."
9
+ * "Models load LAZILY on first request for their classes, with LRU eviction
10
+ under a stated RAM budget."
11
+ * "A request spanning two models runs both and merges."
12
+
13
+ §2 is why it cannot be eager. The binding field box is a Ryzen 5 7640HS with a
14
+ measured 9.8 GB, carrying 6 of the 9 spec-reporting screens; Gemma's working set
15
+ alone is 3.31 GB and RF-DETR Medium peaks at 632 MB. "The sum has to fit, not
16
+ each member" — and a selector that approves each model independently approves a
17
+ set that OOM-kills the box, where the failure then looks like a camera problem.
18
+
19
+ STDLIB ONLY, LIKE `server.py`, AND FOR THE SAME REASON. The ONNX session arrives
20
+ through an injected factory, so every rule below — routing, admission,
21
+ eviction, the refusals — is exercisable on a bare interpreter with no
22
+ onnxruntime, no numpy, no Pillow and no 128 MiB artifact. That is every CI
23
+ runner. A residency policy that could only be tested on a box with the model
24
+ present is a residency policy nothing tests, which is the same argument
25
+ `test_server.py` already makes for the wire.
26
+
27
+ WHAT IS PINNED AND WHY IT IS NOT "EAGER LOADING BY ANOTHER NAME"
28
+ ---------------------------------------------------------------
29
+ The DEFAULT model — the general cascade gate — is loaded by the watcher and is
30
+ never evicted. Specialists are lazy. That is not a softening of §3; it is what
31
+ makes §3 coexist with the health contract that shipped first:
32
+
33
+ * `/health` promises 200 only when "a load completed AND a probe inference
34
+ succeeded". Under pure lazy loading nothing is resident at startup, so
35
+ `/health` would answer 503 until the first request — and `LocalDetectorClient
36
+ .isHealthy()` returning false is precisely how the connector decides the
37
+ detector is down and sends every frame to the VLM. The cascade would then
38
+ never make its first call, and the 84.7%-of-calls saving would silently
39
+ never happen on any box.
40
+ * §3's arithmetic is about the SET: "loading all of them eagerly does not fit
41
+ the 9.8 GB box". One 632 MB model does, and no budget that can hold anything
42
+ at all cannot hold it.
43
+ * Evicting the general detector to make room for a specialist and reloading it
44
+ on the next frame is pure thrash — it is on the hot path for essentially
45
+ every profile.
46
+
47
+ CONCURRENCY. One coarse admission lock covers plan -> evict -> load, so two
48
+ requests can never each admit a model and together exceed the budget. Inference
49
+ itself runs OUTSIDE it, holding only strong references to the sessions, and a
50
+ model with a request in flight is not evictable — otherwise the accounting would
51
+ be describing a set that is not the resident set. `/health` takes a different,
52
+ short-lived lock and therefore never queues behind a load, which is the property
53
+ that keeps it cheap.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import os
59
+ import sys
60
+ import threading
61
+ import time
62
+ from pathlib import Path
63
+
64
+ from .detections import DetectionBatch, merge_batches
65
+ from .errors import DetectorNotReady, ModelContractError, UnknownClasses
66
+ from .labels import ClassIndex, ModelVocabulary, normalize_class_name
67
+
68
+ #: Per-model residency states. `absent` and `idle` are new and they are
69
+ #: different facts: `absent` means the artifact is not on disk (it is OTA'd, so
70
+ #: on a fresh install that is the normal starting state), `idle` means the file
71
+ #: is there and no session is built — never loaded, or evicted to make room.
72
+ #: Collapsing them would make "the OTA has not landed" indistinguishable from
73
+ #: "the LRU reclaimed it", which are opposite problems.
74
+ STATE_ABSENT = "absent"
75
+ STATE_IDLE = "idle"
76
+ STATE_LOADING = "loading"
77
+ STATE_READY = "ready"
78
+ STATE_UNAVAILABLE = "unavailable"
79
+
80
+ #: THIS SERVICE OWNS NO RAM POLICY, AND MUST NOT.
81
+ #:
82
+ #: There is already an allocation authority and it is server-side:
83
+ #: `sensingProfileService.selectModelForDeviceTier` picks a model from
84
+ #: `deviceRamMb` + `deviceTier`, and `registerInternalControlSurface` gates each
85
+ #: model on `ramMb >= model.min_ram_mb`. A ceiling chosen HERE would be a second
86
+ #: authority that cannot see the first — the server deciding what a device gets
87
+ #: while the service independently decides what it keeps. That is the
88
+ #: `yolov8_nano` shape exactly: advertised by one component, unloadable by
89
+ #: another, for six months, with every surface green.
90
+ #:
91
+ #: So the ceiling is INJECTED and has NO DEFAULT. `None` does not mean
92
+ #: "unlimited" and does not mean a number this module picked; it means NOBODY
93
+ #: HAS TOLD US ONE, and the registry refuses to make any portfolio decision in
94
+ #: that state (see `_admit`).
95
+ #:
96
+ #: WHY THE SERVER CANNOT SIMPLY SUM A COLUMN IT ALREADY HAS: `min_ram_mb` is a
97
+ #: GATE, not a footprint. `gemma_4_e2b` carries `min_ram_mb` 8000,
98
+ #: `model_size_mb` 2580, and a measured working set of 3.31 GB — three different
99
+ #: quantities, and the one portfolio admission needs (resident footprint) is in
100
+ #: none of them. Summing `min_ram_mb` over-rejects; summing `model_size_mb`
101
+ #: under-rejects. `ModelVocabulary.approx_ram_bytes` is that missing quantity as
102
+ #: a COMMITTED figure, and `rss_delta_bytes` on /health is this service
103
+ #: MEASURING it per load so the authority above can eventually stop guessing.
104
+
105
+
106
+ class ModelEntry:
107
+ """One catalogue row: a committed vocabulary bound to a path on this box."""
108
+
109
+ __slots__ = ("vocabulary", "path", "pinned")
110
+
111
+ def __init__(self, vocabulary: ModelVocabulary, path: Path, pinned: bool = False):
112
+ self.vocabulary = vocabulary
113
+ self.path = Path(path)
114
+ self.pinned = pinned
115
+
116
+ @property
117
+ def name(self) -> str:
118
+ return self.vocabulary.model_name
119
+
120
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
121
+ return f"ModelEntry({self.name!r}, {str(self.path)!r}, pinned={self.pinned})"
122
+
123
+
124
+ class _ModelState:
125
+ """Everything mutable about one catalogue row. Guarded by the registry lock."""
126
+
127
+ def __init__(self, entry: ModelEntry):
128
+ self.entry = entry
129
+ # LOADING, NOT ABSENT, BEFORE THE FIRST `stat`.
130
+ #
131
+ # `absent` is a claim — "the artifact is not on this box" — and at
132
+ # construction nobody has looked. The service starts before the watcher
133
+ # thread does, so a `/health` probe can arrive in that window, and the
134
+ # single-model loader it replaced correctly said `loading` there. Saying
135
+ # `absent` would report a fact we had not established, and the three
136
+ # installers' post-install probes read exactly this word to decide
137
+ # whether to wait or to tell the operator the OTA has not landed.
138
+ self.state = STATE_LOADING
139
+ self.reason: "str | None" = None
140
+ self.session = None
141
+ self.stamp: "tuple[int, int] | None" = None
142
+ self.present = False
143
+ self.warmup_ms: "float | None" = None
144
+ self.loaded_at: "float | None" = None
145
+ self.last_used_at: "float | None" = None
146
+ self.detect_count = 0
147
+ self.detect_failures = 0
148
+ self.degenerate_dropped = 0
149
+ self.unnamed_class_dropped = 0
150
+ self.load_failures = 0
151
+ self.evictions = 0
152
+ self.in_flight = 0
153
+ #: MEASURED resident cost of this load, and the instrument that read it.
154
+ #: REPORTED, NEVER USED FOR ADMISSION — a service that admitted on its
155
+ #: own measurements would be back to being a second allocation
156
+ #: authority. This is the footprint column the server does not have
157
+ #: (`min_ram_mb` is a gate, `model_size_mb` is a file size), produced so
158
+ #: the authority above can eventually stop estimating.
159
+ self.rss_delta_bytes: "int | None" = None
160
+ self.rss_source: "str | None" = None
161
+
162
+ @property
163
+ def resident(self) -> bool:
164
+ return self.session is not None
165
+
166
+
167
+ class ModelRegistry:
168
+ """The name -> session map, its class index, and the residency policy."""
169
+
170
+ def __init__(
171
+ self,
172
+ entries: "list[ModelEntry]",
173
+ session_factory,
174
+ probe,
175
+ ram_budget_bytes: "int | None",
176
+ log=print,
177
+ clock=time.monotonic,
178
+ ):
179
+ if not entries:
180
+ raise ValueError("a registry with no models can answer nothing")
181
+ names = [e.name for e in entries]
182
+ if len(set(names)) != len(names):
183
+ raise ValueError(f"duplicate model names in the catalogue: {names}")
184
+ pinned = [e for e in entries if e.pinned]
185
+ if len(pinned) != 1:
186
+ raise ValueError(
187
+ "exactly one catalogue entry must be pinned — it is what `/health` reports and "
188
+ f"what the cascade gates on; got {[e.name for e in pinned]}"
189
+ )
190
+ if ram_budget_bytes is not None:
191
+ if ram_budget_bytes <= 0:
192
+ raise ValueError(f"an injected RAM ceiling must be positive, got {ram_budget_bytes}")
193
+ smallest_possible = pinned[0].vocabulary.approx_ram_bytes
194
+ if ram_budget_bytes < smallest_possible:
195
+ raise ValueError(
196
+ f"the injected RAM ceiling {ram_budget_bytes} B cannot hold the pinned model "
197
+ f"{pinned[0].name} ({smallest_possible} B); the service would be "
198
+ "permanently unavailable rather than merely constrained"
199
+ )
200
+
201
+ self._entries = list(entries)
202
+ self._session_factory = session_factory
203
+ self._probe = probe
204
+ #: `None` means NO CEILING WAS INJECTED — not "unlimited".
205
+ self._budget = None if ram_budget_bytes is None else int(ram_budget_bytes)
206
+ self._log = log
207
+ self._clock = clock
208
+
209
+ #: SHORT-LIVED. Guards the state dict and the counters, and nothing
210
+ #: slow ever happens under it — that is what keeps `/health` cheap.
211
+ self._lock = threading.Lock()
212
+ #: COARSE. Held across plan -> evict -> load so two concurrent requests
213
+ #: cannot both admit and together exceed the budget. Never held during
214
+ #: inference.
215
+ self._admission = threading.RLock()
216
+
217
+ self._evictions = 0
218
+ self._states = {e.name: _ModelState(e) for e in entries}
219
+ self._pinned_name = pinned[0].name
220
+ self._index: "ClassIndex | None" = None
221
+
222
+ # ── the catalogue's public face ──────────────────────────────────────────
223
+
224
+ @property
225
+ def pinned_name(self) -> str:
226
+ return self._pinned_name
227
+
228
+ @property
229
+ def ram_budget_bytes(self) -> "int | None":
230
+ return self._budget
231
+
232
+ def class_index(self) -> ClassIndex:
233
+ """The union vocabulary of the models whose ARTIFACT IS ON THIS BOX.
234
+
235
+ Presence, not residency. A specialist that has been evicted is still
236
+ answerable — the next request for its classes reloads it — so gating the
237
+ offer on residency would make the requestable vocabulary depend on cache
238
+ state, and a caller would get a 400 or a 200 for the same profile
239
+ depending on what ran a minute ago. Presence is the honest line: the
240
+ artifact is here, so the question can be answered.
241
+
242
+ Gating on presence rather than on the committed list is also what keeps
243
+ §4 rule 5 true by construction — a vocabulary added to
244
+ `BUILTIN_VOCABULARIES` before its ONNX ships advertises nothing at all,
245
+ which is the `yolov8_nano` failure made unreachable rather than merely
246
+ forbidden.
247
+
248
+ Cached, and rebuilt only when presence changes: it is read on every
249
+ request.
250
+ """
251
+ with self._lock:
252
+ if self._index is None:
253
+ self._index = ClassIndex(
254
+ [s.entry.vocabulary for s in self._states.values() if s.present]
255
+ )
256
+ return self._index
257
+
258
+ def resolve_classes(self, requested: "list[str]") -> "set[str]":
259
+ """Canonicalise a caller's class list, or raise `UnknownClasses`.
260
+
261
+ ANY unknown is a refusal, not just all of them — the partial case is the
262
+ dangerous one and it becomes the COMMON one the moment specialists
263
+ exist. A PPE profile asks for `glove` and `hairnet` alongside `person`,
264
+ and on a box holding only the general detector the operator would
265
+ otherwise get person counts and silence about gloves.
266
+
267
+ The message distinguishes the two reasons a class can be unanswerable,
268
+ because they are opposite problems with opposite fixes: a class no build
269
+ has ever heard of is a profile bug, while a class a committed vocabulary
270
+ names but whose artifact has not landed is an OTA that has not arrived
271
+ yet and will fix itself.
272
+ """
273
+ index = self.class_index()
274
+ canonical, unknown = index.resolve(requested)
275
+ if unknown:
276
+ raise UnknownClasses(unknown, index.class_names, self._unknown_detail(unknown, index))
277
+ if not canonical:
278
+ raise UnknownClasses([], index.class_names, "`classes` resolved to nothing requestable")
279
+ return canonical
280
+
281
+ def _unknown_detail(self, unknown: "list[str]", index: ClassIndex) -> str:
282
+ pending: "dict[str, str]" = {}
283
+ with self._lock:
284
+ for state in self._states.values():
285
+ if state.present:
286
+ continue
287
+ for raw in unknown:
288
+ if normalize_class_name(raw) in state.entry.vocabulary.class_names:
289
+ pending[raw] = state.entry.name
290
+ detail = f"requested classes not in this box's vocabulary: {unknown}. "
291
+ if pending:
292
+ detail += (
293
+ "Some are named by a model whose artifact has not arrived yet "
294
+ f"({sorted(set(pending.items()))}); it is OTA'd and this service picks it up "
295
+ "within one poll interval of it landing. "
296
+ )
297
+ detail += f"Known classes: {sorted(index.class_names)}"
298
+ return detail
299
+
300
+ # ── the watcher's entry point ────────────────────────────────────────────
301
+
302
+ def refresh(self) -> None:
303
+ """Stat every artifact; load the pinned model; react to an OTA.
304
+
305
+ NO INFERENCE HAPPENS HERE EXCEPT THE PROBE, and no specialist is loaded:
306
+ a `stat` per catalogue row is the whole cost on the common path.
307
+ """
308
+ for entry in self._entries:
309
+ self._refresh_one(entry)
310
+
311
+ def _refresh_one(self, entry: ModelEntry) -> None:
312
+ state = self._states[entry.name]
313
+ stamp = _file_stamp(entry.path)
314
+
315
+ if stamp is None:
316
+ with self._lock:
317
+ was_present, was_resident = state.present, state.resident
318
+ state.present = False
319
+ state.session = None
320
+ state.stamp = None
321
+ state.warmup_ms = None
322
+ state.loaded_at = None
323
+ state.state = STATE_ABSENT
324
+ state.reason = (
325
+ f"model artifact not found at {entry.path}; it arrives over the air and this "
326
+ "service will pick it up within one poll interval of it landing"
327
+ )
328
+ if was_present or was_resident:
329
+ self._index = None
330
+ if was_present or was_resident:
331
+ self._log(f"[detector] {entry.name}: artifact went away at {entry.path}")
332
+ return
333
+
334
+ with self._lock:
335
+ first_sighting = not state.present
336
+ state.present = True
337
+ if first_sighting:
338
+ self._index = None
339
+ unchanged = state.resident and state.stamp == stamp
340
+ failed_on_this_stamp = state.state == STATE_UNAVAILABLE and state.stamp == stamp
341
+ if not state.resident and state.state in (STATE_ABSENT, STATE_LOADING):
342
+ state.state = STATE_IDLE
343
+ state.reason = None
344
+
345
+ if unchanged:
346
+ return
347
+
348
+ if failed_on_this_stamp:
349
+ # The exact bytes that failed to load are still on disk. Retrying
350
+ # every poll would rebuild a session that cannot work, per poll,
351
+ # forever. The stamp moves when `ModelManager` rewrites the file,
352
+ # and that is the event worth reacting to.
353
+ return
354
+
355
+ with self._admission:
356
+ with self._lock:
357
+ was_resident = state.resident
358
+ if was_resident:
359
+ state.session = None
360
+ state.state = STATE_IDLE
361
+ state.warmup_ms = None
362
+ state.loaded_at = None
363
+ if was_resident:
364
+ self._log(f"[detector] {entry.name}: artifact changed on disk — dropping the session")
365
+
366
+ if entry.pinned:
367
+ # ONLY THE PINNED MODEL LOADS HERE, and the constructor
368
+ # guarantees it fits on its own. Rebuilding a specialist from
369
+ # the watcher would be an allocation nobody asked for, made
370
+ # outside a request that could have been refused for not
371
+ # fitting — the "approves each model independently" mistake
372
+ # §2 names. A dropped specialist is `idle`: present,
373
+ # answerable, one lazy load away on the next request for its
374
+ # classes.
375
+ self._ensure_loaded(state, stamp)
376
+ else:
377
+ with self._lock:
378
+ state.stamp = stamp
379
+
380
+ # ── the request path ─────────────────────────────────────────────────────
381
+
382
+ def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None") -> DetectionBatch:
383
+ """Run every model this request needs, in catalogue order, and merge.
384
+
385
+ ALL OR NOTHING. If any model in the plan cannot be made resident — the
386
+ artifact vanished, the load failed, the probe failed, or the set does not
387
+ fit the budget — the whole request is a `DetectorNotReady`, which the
388
+ server turns into a 503 and `LocalDetectorClient` into a
389
+ `DetectorUnavailableError`. Answering with the models that DID load
390
+ would be a thinner frame that nothing downstream can distinguish from a
391
+ quieter venue, which is the single failure this component exists to
392
+ prevent.
393
+ """
394
+ plan = self.class_index().assign(wanted)
395
+ if not plan:
396
+ raise DetectorNotReady(
397
+ "no model on this box can answer that request; "
398
+ + self._availability_summary()
399
+ )
400
+
401
+ acquired = self._acquire([vocabulary.model_name for vocabulary, _ in plan])
402
+ try:
403
+ batches = []
404
+ for (vocabulary, classes), session in zip(plan, acquired):
405
+ state = self._states[vocabulary.model_name]
406
+ try:
407
+ batch = session.detect(jpeg, min_confidence, classes)
408
+ except Exception:
409
+ with self._lock:
410
+ state.detect_failures += 1
411
+ raise
412
+ with self._lock:
413
+ state.detect_count += 1
414
+ # PER MODEL, not per service. A whole-service drop counter
415
+ # is the aggregation mistake `telemetry-producer-substrate.md`
416
+ # records: `vision_call_log.latency_ms` was NULL on 100% of
417
+ # local-vlm rows while the cloud model was 100% populated,
418
+ # and a whole-table fill check called that a healthy 92%.
419
+ # One specialist dropping every detection it makes must not
420
+ # be averaged into silence by the general detector's zero.
421
+ state.degenerate_dropped += batch.degenerate_dropped
422
+ state.unnamed_class_dropped += batch.unnamed_class_dropped
423
+ batches.append(batch)
424
+ finally:
425
+ self._release([vocabulary.model_name for vocabulary, _ in plan])
426
+
427
+ merged = merge_batches(batches)
428
+ if merged.degenerate_dropped or merged.unnamed_class_dropped:
429
+ self._log(
430
+ f"[detector] dropped {merged.degenerate_dropped} zero-extent box(es) and "
431
+ f"{merged.unnamed_class_dropped} unnamed-class detection(s) from one frame"
432
+ )
433
+ return merged
434
+
435
+ def _acquire(self, needed: "list[str]") -> list:
436
+ """Make every named model resident under the budget, then pin it in flight.
437
+
438
+ The admission decision, the eviction and the loads all happen under one
439
+ lock. Splitting them is how two requests each pass a fit check and then
440
+ both allocate.
441
+ """
442
+ with self._admission:
443
+ self._admit(needed)
444
+ sessions = []
445
+ taken: "list[str]" = []
446
+ try:
447
+ for name in needed:
448
+ state = self._states[name]
449
+ stamp = _file_stamp(state.entry.path)
450
+ if stamp is None:
451
+ raise DetectorNotReady(
452
+ f"{name}: model artifact not found at {state.entry.path}"
453
+ )
454
+ if not state.resident or state.stamp != stamp:
455
+ self._ensure_loaded(state, stamp)
456
+ with self._lock:
457
+ if state.session is None:
458
+ raise DetectorNotReady(f"{name}: {state.reason or 'model is not loaded'}")
459
+ state.in_flight += 1
460
+ state.last_used_at = self._clock()
461
+ taken.append(name)
462
+ sessions.append(state.session)
463
+ except BaseException:
464
+ # RELEASE WHAT WE ALREADY TOOK. A two-model plan whose second
465
+ # model fails to load would otherwise leave the first pinned
466
+ # in-flight for the life of the process — permanently
467
+ # unevictable, so the budget silently shrinks by one model every
468
+ # time a specialist's artifact is broken. A leak that only
469
+ # appears on the failure path is the kind nothing notices.
470
+ self._release(taken)
471
+ raise
472
+ return sessions
473
+
474
+ def _release(self, names: "list[str]") -> None:
475
+ with self._lock:
476
+ for name in names:
477
+ state = self._states[name]
478
+ if state.in_flight > 0:
479
+ state.in_flight -= 1
480
+ state.last_used_at = self._clock()
481
+
482
+ def _admit(self, needed: "list[str]") -> None:
483
+ """Evict LRU until `needed` fits alongside what must stay, or refuse.
484
+
485
+ "Must stay" is the pinned model plus anything with a request in flight.
486
+ Evicting either would make the accounting describe a set that is not the
487
+ resident set — and in the in-flight case would not even free the memory,
488
+ since the running request still holds a reference.
489
+ """
490
+ needed_set = set(needed)
491
+ with self._lock:
492
+ resident = {n for n, s in self._states.items() if s.resident}
493
+ in_flight = {n for n, s in self._states.items() if s.in_flight > 0}
494
+ cost = {n: s.entry.vocabulary.approx_ram_bytes for n, s in self._states.items()}
495
+
496
+ if self._budget is None:
497
+ # NO CEILING WAS INJECTED, so there is no portfolio decision this
498
+ # service is entitled to make. The pinned model is exempt because it
499
+ # is not a portfolio: the server ALREADY chose it — that is what
500
+ # `selectModelForDeviceTier` does — and holding what we were told to
501
+ # hold is the mechanism doing its job. Making a SECOND model resident
502
+ # is an allocation, and an allocation needs an authority.
503
+ #
504
+ # Refusing here rather than guessing is the whole correction: a
505
+ # number picked in this file would be a second authority that cannot
506
+ # see `min_ram_mb`, and the two would disagree silently.
507
+ unentitled = sorted(needed_set - {self._pinned_name})
508
+ if unentitled:
509
+ raise DetectorNotReady(
510
+ f"cannot admit {unentitled} — no RAM ceiling was injected, so this service "
511
+ "has no basis for a portfolio decision and will not invent one. The ceiling "
512
+ "comes from the allocation authority that already sizes models for this box "
513
+ "(sensingProfileService.selectModelForDeviceTier / the model-manifest "
514
+ "min_ram_mb gate); pass it as --ram-budget-mb or "
515
+ "TRILLBOARDS_DETECTOR_RAM_BUDGET_MB"
516
+ )
517
+ return
518
+
519
+ keep = needed_set | {self._pinned_name} | (resident & in_flight)
520
+ required = sum(cost[n] for n in keep)
521
+ if required > self._budget:
522
+ raise DetectorNotReady(
523
+ f"this request needs {sorted(needed_set)} resident at once, which with "
524
+ f"{sorted(keep - needed_set)} comes to {required} B against a "
525
+ f"{self._budget} B portfolio budget. Nothing was dropped to make it fit and "
526
+ "no partial answer was produced; raise --ram-budget-mb or narrow the profile"
527
+ )
528
+
529
+ evictable = sorted(
530
+ (n for n in resident if n not in keep),
531
+ key=lambda n: (self._states[n].last_used_at or 0.0),
532
+ )
533
+ projected = sum(cost[n] for n in resident | needed_set)
534
+ for name in evictable:
535
+ if projected <= self._budget:
536
+ break
537
+ self._evict(name)
538
+ projected -= cost[name]
539
+
540
+ if projected > self._budget: # pragma: no cover - `keep` bounds this
541
+ raise DetectorNotReady(
542
+ f"could not fit {sorted(needed_set)} in {self._budget} B even after evicting "
543
+ f"{evictable}; this is an accounting bug, not a capacity one"
544
+ )
545
+
546
+ def _evict(self, name: str) -> None:
547
+ """Drop the session. Refcounting does the freeing.
548
+
549
+ Nothing else holds a reference: `_acquire` takes one only for the life
550
+ of a request and in-flight models are never chosen here, so the ORT
551
+ session is released the moment this assignment lands. The model stays
552
+ `idle` — present on disk, answerable, one load away.
553
+ """
554
+ with self._lock:
555
+ state = self._states[name]
556
+ if state.session is None:
557
+ return
558
+ state.session = None
559
+ state.state = STATE_IDLE
560
+ state.reason = None
561
+ state.warmup_ms = None
562
+ state.loaded_at = None
563
+ state.evictions += 1
564
+ self._evictions += 1
565
+ # LOUD, AND COUNTED WHERE `/health` PUBLISHES IT. Eviction is a
566
+ # last-resort safety valve, not the allocator: a box evicting at all is
567
+ # oversubscribed against the ceiling it was given, and a service quietly
568
+ # thrashing under one looks exactly like a service working fine. The
569
+ # authority above needs to see this to correct the ceiling, so it is a
570
+ # WARNING and a counter, never a debug line.
571
+ self._log(
572
+ f"[detector] WARNING evicted {name} to stay inside the injected {self._budget} B "
573
+ f"ceiling — this box is oversubscribed against what it was allocated "
574
+ f"(evictions={self._evictions})"
575
+ )
576
+
577
+ def _ensure_loaded(self, state: _ModelState, stamp: "tuple[int, int]") -> None:
578
+ """Build the session and PROVE IT before calling it ready.
579
+
580
+ A session that constructs is not a session that infers. `/v1/models`
581
+ answers 200 without ever building an engine, which is how a UTF-8 BOM in
582
+ litert-lm's config.json 500'd every frame on every Windows connector
583
+ while the installer's health check AND its CPU-fallback re-check both
584
+ passed. So every model — the pinned one on startup and every specialist
585
+ on its first request — runs a real forward pass on a synthetic frame
586
+ before it is allowed to answer anything.
587
+ """
588
+ entry = state.entry
589
+ with self._lock:
590
+ state.state = STATE_LOADING
591
+ state.reason = None
592
+
593
+ before = read_process_rss()
594
+ try:
595
+ session = self._session_factory(entry)
596
+ except ModelContractError as err:
597
+ self._fail(state, stamp, f"artifact loaded but does not match the detector contract: {err}")
598
+ return
599
+ except Exception as err:
600
+ self._fail(state, stamp, f"could not load {entry.path}: {err}")
601
+ return
602
+
603
+ try:
604
+ started = time.perf_counter()
605
+ self._probe(session)
606
+ warmup_ms = (time.perf_counter() - started) * 1000.0
607
+ except Exception as err:
608
+ self._fail(state, stamp, f"artifact loaded but a probe inference failed: {err}")
609
+ return
610
+
611
+ # MEASURED AFTER THE PROBE, because a session that has not run a forward
612
+ # pass has not allocated its working buffers — the number an admission
613
+ # decision would need is the one AFTER inference, not after construction.
614
+ after = read_process_rss()
615
+ rss_delta = None
616
+ rss_source = None
617
+ if before is not None and after is not None and before[1] == after[1]:
618
+ rss_delta = max(0, after[0] - before[0])
619
+ rss_source = after[1]
620
+
621
+ with self._lock:
622
+ state.session = session
623
+ state.stamp = stamp
624
+ state.state = STATE_READY
625
+ state.reason = None
626
+ state.warmup_ms = warmup_ms
627
+ state.loaded_at = time.time()
628
+ state.last_used_at = self._clock()
629
+ state.rss_delta_bytes = rss_delta
630
+ state.rss_source = rss_source
631
+ self._index = None
632
+ measured = f", rss delta {rss_delta} B via {rss_source}" if rss_delta is not None else ""
633
+ self._log(
634
+ f"[detector] ready — {entry.name} at {entry.path} "
635
+ f"(probe {warmup_ms:.0f} ms, {len(entry.vocabulary.slot_names)} named classes{measured})"
636
+ )
637
+
638
+ def _fail(self, state: _ModelState, stamp: "tuple[int, int]", reason: str) -> None:
639
+ with self._lock:
640
+ state.session = None
641
+ state.stamp = stamp
642
+ state.state = STATE_UNAVAILABLE
643
+ state.reason = reason
644
+ state.warmup_ms = None
645
+ state.loaded_at = None
646
+ state.rss_delta_bytes = None
647
+ state.rss_source = None
648
+ state.load_failures += 1
649
+ self._index = None
650
+ self._log(f"[detector] {state.entry.name}: {reason}")
651
+
652
+ # ── what `/health` reads ─────────────────────────────────────────────────
653
+
654
+ def _availability_summary(self) -> str:
655
+ with self._lock:
656
+ return ", ".join(
657
+ f"{n}={s.state}" for n, s in sorted(self._states.items())
658
+ )
659
+
660
+ def snapshot(self) -> dict:
661
+ """Everything `/health` needs, from state that already exists.
662
+
663
+ CANNOT LOAD A MODEL AND CANNOT INFER. It reads the outcome of loads that
664
+ already happened, under the short lock only, so a probe issued while a
665
+ specialist is loading answers immediately rather than queueing behind
666
+ it. Reporting a load that has not happened is the exact dishonesty
667
+ `GET /v1/models` shipped.
668
+ """
669
+ with self._lock:
670
+ pinned = self._states[self._pinned_name]
671
+ resident_bytes = sum(
672
+ s.entry.vocabulary.approx_ram_bytes for s in self._states.values() if s.resident
673
+ )
674
+ models = {}
675
+ for name, s in self._states.items():
676
+ row = {
677
+ "state": s.state,
678
+ "path": str(s.entry.path),
679
+ "present": s.present,
680
+ "resident": s.resident,
681
+ "pinned": s.entry.pinned,
682
+ "named_classes": len(s.entry.vocabulary.slot_names),
683
+ "approx_ram_bytes": s.entry.vocabulary.approx_ram_bytes,
684
+ "detect_count": s.detect_count,
685
+ "detect_failures": s.detect_failures,
686
+ "degenerate_boxes_dropped": s.degenerate_dropped,
687
+ "unnamed_class_dropped": s.unnamed_class_dropped,
688
+ "load_failures": s.load_failures,
689
+ "evictions": s.evictions,
690
+ }
691
+ if s.reason:
692
+ row["reason"] = s.reason
693
+ if s.warmup_ms is not None:
694
+ row["warmup_inference_ms"] = round(s.warmup_ms, 1)
695
+ if s.loaded_at is not None:
696
+ row["loaded_at"] = s.loaded_at
697
+ if s.rss_delta_bytes is not None:
698
+ # NAMED WITH ITS INSTRUMENT. `rusage-maxrss` is a high-water
699
+ # mark, so its delta is a LOWER BOUND and 0 is a legitimate
700
+ # reading; `proc-statm` and `psapi-workingset` are current
701
+ # RSS. A reader that cannot tell them apart will average the
702
+ # two into a number that describes neither.
703
+ row["rss_delta_bytes"] = s.rss_delta_bytes
704
+ row["rss_source"] = s.rss_source
705
+ if s.session is not None and getattr(s.session, "spec", None) is not None:
706
+ spec = s.session.spec
707
+ row["input"] = f"{spec.width}x{spec.height}"
708
+ row["classes"] = spec.num_classes
709
+ row["providers"] = list(spec.providers)
710
+ models[name] = row
711
+
712
+ # Counted from `present` directly rather than through
713
+ # `class_index()`, which takes this same non-reentrant lock. It is a
714
+ # set union over at most a handful of committed frozensets — no
715
+ # session is touched and nothing is built that a request would then
716
+ # have to wait for.
717
+ known = len(
718
+ frozenset().union(
719
+ *[s.entry.vocabulary.class_names for s in self._states.values() if s.present]
720
+ ) if any(s.present for s in self._states.values()) else frozenset()
721
+ )
722
+
723
+ return {
724
+ "pinned_model": self._pinned_name,
725
+ "pinned": models[self._pinned_name],
726
+ "pinned_state": pinned.state,
727
+ "models": models,
728
+ "resident_models": sorted(n for n, s in self._states.items() if s.resident),
729
+ # THE CEILING AS INJECTED — `null` when nobody told us one,
730
+ # which is a state a reader must be able to see rather than a
731
+ # number this service picked.
732
+ "ram_budget_bytes": self._budget,
733
+ "ram_ceiling_injected": self._budget is not None,
734
+ "ram_resident_bytes": resident_bytes,
735
+ #: Non-zero means this box is OVERSUBSCRIBED against the ceiling
736
+ #: it was given. Published because a service quietly thrashing
737
+ #: under one is indistinguishable from a service working fine.
738
+ "evictions": self._evictions,
739
+ "known_class_count": known,
740
+ "detect_count": sum(s.detect_count for s in self._states.values()),
741
+ "detect_failures": sum(s.detect_failures for s in self._states.values()),
742
+ "degenerate_boxes_dropped": sum(s.degenerate_dropped for s in self._states.values()),
743
+ "unnamed_class_dropped": sum(s.unnamed_class_dropped for s in self._states.values()),
744
+ }
745
+
746
+
747
+ _PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
748
+
749
+
750
+ def read_process_rss() -> "tuple[int, str] | None":
751
+ """This process's resident set, in bytes, and HOW IT WAS READ.
752
+
753
+ The source is returned with the number because the three platforms do not
754
+ answer the same question and a caller that cannot tell them apart will
755
+ average a high-water mark into a current reading. `telemetry-producer-substrate.md`
756
+ is a whole runbook about that: `vision_call_log.latency_ms` was NULL on 100%
757
+ of `local-vlm` rows while the cloud model was 100% populated, and a
758
+ whole-table fill check reported a healthy 92% — grade a column PER PRODUCER,
759
+ never in aggregate.
760
+
761
+ proc-statm Linux. CURRENT RSS, exact, one small file read.
762
+ psapi-workingset Windows. CURRENT working set, exact, one syscall.
763
+ rusage-maxrss macOS and the fallback. A HIGH-WATER MARK for the whole
764
+ process, so a delta across a load is a LOWER BOUND and
765
+ legitimately reads 0 once the peak is behind us.
766
+
767
+ Stdlib only, and cheap enough to sit on a load path that already spends
768
+ hundreds of milliseconds building an ORT session.
769
+ """
770
+ try:
771
+ with open("/proc/self/statm", "rb") as handle:
772
+ fields = handle.read().split()
773
+ if len(fields) >= 2:
774
+ return int(fields[1]) * _PAGE_SIZE, "proc-statm"
775
+ except (OSError, ValueError):
776
+ pass
777
+
778
+ if sys.platform == "win32": # pragma: no cover - exercised on Windows only
779
+ try:
780
+ import ctypes
781
+ from ctypes import wintypes
782
+
783
+ class _Counters(ctypes.Structure):
784
+ _fields_ = [
785
+ ("cb", wintypes.DWORD),
786
+ ("PageFaultCount", wintypes.DWORD),
787
+ ("PeakWorkingSetSize", ctypes.c_size_t),
788
+ ("WorkingSetSize", ctypes.c_size_t),
789
+ ("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
790
+ ("QuotaPagedPoolUsage", ctypes.c_size_t),
791
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
792
+ ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
793
+ ("PagefileUsage", ctypes.c_size_t),
794
+ ("PeakPagefileUsage", ctypes.c_size_t),
795
+ ]
796
+
797
+ counters = _Counters()
798
+ counters.cb = ctypes.sizeof(_Counters)
799
+ handle = ctypes.windll.kernel32.GetCurrentProcess()
800
+ if ctypes.windll.psapi.GetProcessMemoryInfo(
801
+ handle, ctypes.byref(counters), counters.cb
802
+ ):
803
+ return int(counters.WorkingSetSize), "psapi-workingset"
804
+ except Exception:
805
+ pass
806
+
807
+ try:
808
+ import resource
809
+
810
+ raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
811
+ # macOS reports bytes; Linux and most others report kilobytes.
812
+ scale = 1 if sys.platform == "darwin" else 1024
813
+ return int(raw) * scale, "rusage-maxrss"
814
+ except Exception: # pragma: no cover - no stdlib path left
815
+ return None
816
+
817
+
818
+ def _file_stamp(path: Path) -> "tuple[int, int] | None":
819
+ try:
820
+ st = path.stat()
821
+ except OSError:
822
+ return None
823
+ if st.st_size == 0:
824
+ return None
825
+ return (st.st_size, st.st_mtime_ns)
826
+
827
+
828
+ __all__ = [
829
+ "ModelEntry",
830
+ "ModelRegistry",
831
+ "STATE_ABSENT",
832
+ "STATE_IDLE",
833
+ "STATE_LOADING",
834
+ "STATE_READY",
835
+ "STATE_UNAVAILABLE",
836
+ ]