@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,4 +1,4 @@
1
- """Model lifecycle: load it, prove it, watch for the OTA, and say which.
1
+ """Model lifecycle: build the catalogue, watch the disk, and say which is up.
2
2
 
3
3
  WHY LOADING IS A BACKGROUND STATE MACHINE AND NOT A STARTUP STEP
4
4
  ---------------------------------------------------------------
@@ -25,21 +25,38 @@ Three constraints, none optional:
25
25
  synthetic frame before declaring ready, and `/health` reports that outcome
26
26
  rather than re-deriving anything — which is also how `/health` stays cheap
27
27
  and inference-free.
28
+
29
+ WHAT THIS FILE IS AFTER THE MODEL LIBRARY
30
+ -----------------------------------------
31
+ It used to own the session, singular — its own docstring said so, and
32
+ `docs/runbooks/sensing-model-library.md` §3 names that as the thing standing
33
+ between here and a library of specialists. The residency policy (name -> session
34
+ map, class index, lazy load, LRU eviction under a RAM budget, merging two
35
+ models' answers) now lives in `registry.py`, which imports nothing heavier than
36
+ the standard library and is therefore testable on every CI runner. What is left
37
+ here is the half that genuinely needs onnxruntime and Pillow: building a real
38
+ session, running a real probe frame through it, and the poll loop that watches
39
+ the disk.
28
40
  """
29
41
 
30
42
  from __future__ import annotations
31
43
 
32
44
  import io
33
45
  import threading
34
- import time
35
46
  from pathlib import Path
36
47
 
37
- from .errors import DetectorNotReady
38
- from .session import DetectionBatch, ModelContractError, OnnxDetector
39
-
40
- STATE_LOADING = "loading"
41
- STATE_READY = "ready"
42
- STATE_UNAVAILABLE = "unavailable"
48
+ from .detections import DetectionBatch
49
+ from .errors import DetectorNotReady, UnknownClasses
50
+ from .labels import BUILTIN_VOCABULARIES, ClassIndex, ModelVocabulary
51
+ from .modelpath import resolve_model_path
52
+ from .registry import (
53
+ STATE_LOADING,
54
+ STATE_READY,
55
+ STATE_UNAVAILABLE,
56
+ ModelEntry,
57
+ ModelRegistry,
58
+ )
59
+ from .session import OnnxDetector
43
60
 
44
61
  DEFAULT_POLL_INTERVAL_S = 30.0
45
62
 
@@ -58,8 +75,49 @@ def _warmup_jpeg() -> bytes:
58
75
  return buf.getvalue()
59
76
 
60
77
 
78
+ def build_catalogue(
79
+ default_model_name: str,
80
+ default_model_path: "Path | None" = None,
81
+ vocabularies: "tuple[ModelVocabulary, ...]" = BUILTIN_VOCABULARIES,
82
+ env=None,
83
+ ) -> "list[ModelEntry]":
84
+ """Every model this build can label, bound to where it would live on THIS box.
85
+
86
+ One entry per committed vocabulary, each at `resolve_model_path(<name>)` —
87
+ the per-model-name directory layout that `modelpath.py` was already written
88
+ for ("the multi-model shape was anticipated at the path layer"). Nothing
89
+ here checks whether the file exists: presence is the watcher's business and
90
+ it changes under us, so deciding it once at startup would freeze a fact that
91
+ an OTA is about to move.
92
+
93
+ `default_model_name` names the PINNED entry — the general cascade gate, the
94
+ one `/health` reports and the one that is never evicted. It must be a
95
+ committed vocabulary: a name with no class list is a model nothing can
96
+ label, which is `yolov8_nano` exactly.
97
+ """
98
+ by_name = {v.model_name: v for v in vocabularies}
99
+ if default_model_name not in by_name:
100
+ raise ValueError(
101
+ f"no committed vocabulary for {default_model_name!r}; known: {sorted(by_name)}. "
102
+ "A model with no class list is a model nothing can label — see labels.py."
103
+ )
104
+ entries = []
105
+ for vocabulary in vocabularies:
106
+ pinned = vocabulary.model_name == default_model_name
107
+ if pinned and default_model_path is not None:
108
+ path = default_model_path
109
+ else:
110
+ path = resolve_model_path(vocabulary.model_name, env)
111
+ entries.append(ModelEntry(vocabulary, path, pinned=pinned))
112
+ # Pinned first, so catalogue order — which is what breaks a class-ownership
113
+ # tie — puts the general detector ahead of every specialist. `person` must
114
+ # keep coming from the model the 98.8/98.8 was measured on.
115
+ entries.sort(key=lambda e: (not e.pinned,))
116
+ return entries
117
+
118
+
61
119
  class DetectorLoader:
62
- """Owns the session and the one word that describes it."""
120
+ """Owns the model CATALOGUE, the poll loop, and the words describing both."""
63
121
 
64
122
  def __init__(
65
123
  self,
@@ -67,30 +125,45 @@ class DetectorLoader:
67
125
  model_name: str,
68
126
  intra_op_threads: "int | None" = None,
69
127
  poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
128
+ ram_budget_bytes: "int | None" = None,
129
+ vocabularies: "tuple[ModelVocabulary, ...]" = BUILTIN_VOCABULARIES,
70
130
  log=print,
71
131
  ):
132
+ # `ram_budget_bytes=None` is NOT a default policy — it is the absence of
133
+ # one, and the registry refuses every portfolio decision in that state
134
+ # rather than inventing a number. See `registry.py`'s header: the
135
+ # allocation authority is server-side and a ceiling chosen here would be
136
+ # a second one that cannot see it.
72
137
  self.model_path = model_path
73
138
  self.model_name = model_name
74
139
  self._intra_op_threads = intra_op_threads
75
140
  self._poll_interval_s = poll_interval_s
76
141
  self._log = log
77
142
 
78
- self._lock = threading.Lock()
79
- self._detector: "OnnxDetector | None" = None
80
- self._state = STATE_LOADING
81
- self._reason: "str | None" = None
82
- self._stamp: "tuple[int, int] | None" = None
83
- self._warmup_ms: "float | None" = None
84
- self._loaded_at: "float | None" = None
85
-
86
- self._degenerate_dropped = 0
87
- self._unnamed_class_dropped = 0
88
- self._detect_count = 0
89
- self._detect_failures = 0
143
+ self.registry = ModelRegistry(
144
+ entries=build_catalogue(model_name, model_path, vocabularies),
145
+ session_factory=self._build_session,
146
+ probe=self._probe,
147
+ ram_budget_bytes=ram_budget_bytes,
148
+ log=log,
149
+ )
90
150
 
91
151
  self._stop = threading.Event()
92
152
  self._thread = threading.Thread(target=self._run, name="detector-loader", daemon=True)
93
153
 
154
+ # ── the two halves that need a real runtime ──────────────────────────────
155
+
156
+ def _build_session(self, entry: ModelEntry) -> OnnxDetector:
157
+ return OnnxDetector(entry.path, entry.vocabulary, self._intra_op_threads)
158
+
159
+ def _probe(self, session: OnnxDetector) -> None:
160
+ """PROVE IT. A session that constructs is not a session that infers.
161
+
162
+ `0.99` keeps the probe's own post-processing trivial on a grey frame;
163
+ what is being established is that a forward pass completes at all.
164
+ """
165
+ session.detect(_warmup_jpeg(), 0.99, None)
166
+
94
167
  def start(self) -> None:
95
168
  self._thread.start()
96
169
 
@@ -102,156 +175,121 @@ class DetectorLoader:
102
175
 
103
176
  # ── what the server reads ────────────────────────────────────────────────
104
177
 
178
+ @staticmethod
179
+ def _top_level_state(pinned_state: str) -> str:
180
+ if pinned_state == STATE_READY:
181
+ return STATE_READY
182
+ if pinned_state == STATE_LOADING:
183
+ return STATE_LOADING
184
+ return STATE_UNAVAILABLE
185
+
105
186
  @property
106
187
  def state(self) -> str:
107
- with self._lock:
108
- return self._state
188
+ """The PINNED model's state, in the three words that shipped first.
189
+
190
+ `/health` has always answered 200 iff a load completed and a probe
191
+ inference succeeded, and `LocalDetectorClient.isHealthy()` turns
192
+ anything else into "detector down, send the frame to the VLM". So the
193
+ top-level word keeps describing the general cascade gate: a box whose
194
+ gate is missing cannot do the job it exists for, whatever specialists
195
+ happen to be resident. Per-model states — including `idle` and `absent`,
196
+ which have no top-level equivalent — are in `models` on the same
197
+ payload.
198
+ """
199
+ return self._top_level_state(self.registry.snapshot()["pinned_state"])
109
200
 
110
201
  @property
111
202
  def reason(self) -> "str | None":
112
- with self._lock:
113
- return self._reason
203
+ return self.registry.snapshot()["pinned"].get("reason")
204
+
205
+ def class_index(self) -> ClassIndex:
206
+ """The union vocabulary of every model whose artifact is on this box."""
207
+ return self.registry.class_index()
208
+
209
+ def resolve_classes(self, requested: "list[str]") -> "set[str]":
210
+ """Canonicalise, or raise `UnknownClasses` — which the server makes a 400."""
211
+ return self.registry.resolve_classes(requested)
114
212
 
115
213
  def info(self) -> dict:
116
- with self._lock:
117
- d = self._detector
118
- payload = {
119
- "state": self._state,
120
- "model": self.model_name,
121
- "model_path": str(self.model_path),
122
- "detect_count": self._detect_count,
123
- "detect_failures": self._detect_failures,
124
- # Published because a drop nobody can see is the failure this
125
- # whole stack keeps repeating. Both are normally 0.
126
- "degenerate_boxes_dropped": self._degenerate_dropped,
127
- "unnamed_class_dropped": self._unnamed_class_dropped,
128
- }
129
- if self._reason:
130
- payload["reason"] = self._reason
131
- if self._warmup_ms is not None:
132
- payload["warmup_inference_ms"] = round(self._warmup_ms, 1)
133
- if self._loaded_at is not None:
134
- payload["loaded_at"] = self._loaded_at
135
- if d is not None:
136
- payload["input"] = f"{d.spec.width}x{d.spec.height}"
137
- payload["classes"] = d.spec.num_classes
138
- payload["providers"] = d.spec.providers
139
- return payload
214
+ """The `/health` payload. Reads state that already exists; loads nothing.
215
+
216
+ THE TOP-LEVEL KEYS STILL DESCRIBE THE PINNED MODEL, unchanged, because
217
+ every existing reader — the three installers' post-install probes, the
218
+ integration test, an operator with `curl` — was written against them.
219
+ `models`, `resident_models` and the two RAM figures are additive.
220
+
221
+ ONE SNAPSHOT, not one per field. Reading `self.state` here would take a
222
+ SECOND snapshot, and the watcher thread can move between them — so
223
+ `/health` could answer `state: "ready"` next to a `models` map showing
224
+ the pinned model still loading. Neither half would be wrong and the
225
+ pair would describe no moment, which is precisely the shape
226
+ `postgres-tls-uri-override.md` records for two scans a minute apart. A
227
+ payload whose entire job is honesty about a load that happened must be
228
+ one read.
229
+ """
230
+ snap = self.registry.snapshot()
231
+ pinned = snap["pinned"]
232
+ payload = {
233
+ "state": self._top_level_state(snap["pinned_state"]),
234
+ "model": snap["pinned_model"],
235
+ "model_path": pinned["path"],
236
+ "detect_count": snap["detect_count"],
237
+ "detect_failures": snap["detect_failures"],
238
+ # Published because a drop nobody can see is the failure this
239
+ # whole stack keeps repeating. Both are normally 0. The per-model
240
+ # split is in `models`; these are the box-wide totals the existing
241
+ # readers already parse.
242
+ "degenerate_boxes_dropped": snap["degenerate_boxes_dropped"],
243
+ "unnamed_class_dropped": snap["unnamed_class_dropped"],
244
+ # WHICH MODELS ARE RESIDENT — `sensing-model-library.md` §3. Derived
245
+ # from the same state the loads wrote, so this stays a report and
246
+ # never becomes a probe.
247
+ "models": snap["models"],
248
+ "resident_models": snap["resident_models"],
249
+ # THE CEILING AS INJECTED, and whether one was injected at ALL —
250
+ # `ram_budget_bytes: null` alone is ambiguous with a reader that
251
+ # drops nulls, and the difference between "nobody has set a ceiling"
252
+ # and "the ceiling is X" is the difference between a policy hole and
253
+ # a policy. The service picks no number in either case.
254
+ "ram_budget_bytes": snap["ram_budget_bytes"],
255
+ "ram_ceiling_injected": snap["ram_ceiling_injected"],
256
+ "ram_resident_bytes": snap["ram_resident_bytes"],
257
+ # Non-zero means OVERSUBSCRIBED against what this box was allocated.
258
+ # Eviction is a safety valve, not the allocator, and a service
259
+ # quietly thrashing under a ceiling looks exactly like one working
260
+ # fine — so the count has to leave the process.
261
+ "evictions": snap["evictions"],
262
+ "known_class_count": snap["known_class_count"],
263
+ }
264
+ reason = pinned.get("reason")
265
+ if reason:
266
+ payload["reason"] = reason
267
+ for key in ("warmup_inference_ms", "loaded_at", "input", "classes", "providers"):
268
+ if key in pinned:
269
+ payload[key] = pinned[key]
270
+ return payload
140
271
 
141
272
  def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None") -> DetectionBatch:
142
- with self._lock:
143
- detector, reason = self._detector, self._reason
144
- if detector is None:
145
- raise DetectorNotReady(reason or "model is not loaded")
146
- try:
147
- batch = detector.detect(jpeg, min_confidence, wanted)
148
- except Exception:
149
- with self._lock:
150
- self._detect_failures += 1
151
- raise
152
- with self._lock:
153
- self._detect_count += 1
154
- self._degenerate_dropped += batch.degenerate_dropped
155
- self._unnamed_class_dropped += batch.unnamed_class_dropped
156
- if batch.degenerate_dropped or batch.unnamed_class_dropped:
157
- self._log(
158
- f"[detector] dropped {batch.degenerate_dropped} zero-extent box(es) and "
159
- f"{batch.unnamed_class_dropped} unnamed-class detection(s) from one frame"
160
- )
161
- return batch
273
+ return self.registry.detect(jpeg, min_confidence, wanted)
162
274
 
163
275
  # ── the loop ─────────────────────────────────────────────────────────────
164
276
 
165
277
  def _run(self) -> None:
166
278
  while not self._stop.is_set():
167
279
  try:
168
- self._tick()
280
+ self.registry.refresh()
169
281
  except Exception as err: # never let the watcher die
170
- self._set(STATE_UNAVAILABLE, f"loader crashed: {err}")
282
+ self._log(f"[detector] loader crashed: {type(err).__name__}: {err}")
171
283
  self._stop.wait(self._poll_interval_s)
172
284
 
173
- def _tick(self) -> None:
174
- stamp = self._file_stamp()
175
- if stamp is None:
176
- if self._state != STATE_UNAVAILABLE or self._detector is not None:
177
- self._log(f"[detector] model artifact not present at {self.model_path}")
178
- self._drop(
179
- f"model artifact not found at {self.model_path}; it arrives over the air "
180
- "and this service will pick it up within "
181
- f"{int(self._poll_interval_s)}s of it landing"
182
- )
183
- return
184
-
185
- with self._lock:
186
- unchanged = self._detector is not None and self._stamp == stamp
187
- if unchanged:
188
- return
189
-
190
- if self._detector is not None:
191
- self._log(f"[detector] artifact changed on disk — reloading {self.model_path}")
192
- self._load(stamp)
193
-
194
- def _load(self, stamp: "tuple[int, int]") -> None:
195
- self._set(STATE_LOADING, None)
196
- try:
197
- detector = OnnxDetector(self.model_path, self.model_name, self._intra_op_threads)
198
- except ModelContractError as err:
199
- self._drop(f"artifact loaded but does not match the detector contract: {err}")
200
- return
201
- except Exception as err:
202
- self._drop(f"could not load {self.model_path}: {err}")
203
- return
204
-
205
- # PROVE IT. A session that constructs is not a session that infers.
206
- try:
207
- started = time.perf_counter()
208
- detector.detect(_warmup_jpeg(), 0.99, None)
209
- warmup_ms = (time.perf_counter() - started) * 1000.0
210
- except Exception as err:
211
- self._drop(f"artifact loaded but a probe inference failed: {err}")
212
- return
213
-
214
- with self._lock:
215
- self._detector = detector
216
- self._stamp = stamp
217
- self._state = STATE_READY
218
- self._reason = None
219
- self._warmup_ms = warmup_ms
220
- self._loaded_at = time.time()
221
- self._log(
222
- f"[detector] ready — {self.model_name} at {self.model_path} "
223
- f"({detector.spec.width}x{detector.spec.height}, {detector.spec.num_classes} classes, "
224
- f"probe {warmup_ms:.0f} ms, providers {detector.spec.providers})"
225
- )
226
-
227
- def _file_stamp(self) -> "tuple[int, int] | None":
228
- try:
229
- st = self.model_path.stat()
230
- except OSError:
231
- return None
232
- if st.st_size == 0:
233
- return None
234
- return (st.st_size, st.st_mtime_ns)
235
-
236
- def _drop(self, reason: str) -> None:
237
- with self._lock:
238
- self._detector = None
239
- self._stamp = None
240
- self._warmup_ms = None
241
- self._loaded_at = None
242
- self._state = STATE_UNAVAILABLE
243
- self._reason = reason
244
-
245
- def _set(self, state: str, reason: "str | None") -> None:
246
- with self._lock:
247
- self._state = state
248
- self._reason = reason
249
-
250
285
 
251
286
  __all__ = [
287
+ "DEFAULT_POLL_INTERVAL_S",
252
288
  "DetectorLoader",
253
289
  "DetectorNotReady",
254
290
  "STATE_LOADING",
255
291
  "STATE_READY",
256
292
  "STATE_UNAVAILABLE",
293
+ "UnknownClasses",
294
+ "build_catalogue",
257
295
  ]
@@ -72,3 +72,35 @@ def resolve_model_path(
72
72
  if override:
73
73
  return Path(override)
74
74
  return resolve_data_dir(e) / "models" / model_name / MODEL_FILE_NAME
75
+
76
+
77
+ def resolve_ram_budget_mb(
78
+ env: "os._Environ[str] | dict[str, str] | None" = None,
79
+ ) -> "int | None":
80
+ """`TRILLBOARDS_DETECTOR_RAM_BUDGET_MB`, or None to take the default.
81
+
82
+ An env override as well as a CLI flag because the three supervisor
83
+ definitions are the awkward place to add an argument — a launchd plist, a
84
+ systemd unit and a Windows Scheduled Task each hold their own hard-coded
85
+ `ProgramArguments`, and all three are rewritten by installers. An
86
+ environment variable reaches the process through all three without touching
87
+ any of them.
88
+
89
+ A malformed value RAISES rather than falling back to the default. Silently
90
+ ignoring `RAM_BUDGET_MB=1.5gb` would leave an operator convinced they had
91
+ raised a limit that never moved — an operation reporting success while doing
92
+ nothing, which is the failure shape this whole tree keeps catching.
93
+ """
94
+ e = os.environ if env is None else env
95
+ raw = (e.get("TRILLBOARDS_DETECTOR_RAM_BUDGET_MB") or "").strip()
96
+ if not raw:
97
+ return None
98
+ try:
99
+ value = int(raw)
100
+ except ValueError:
101
+ raise ValueError(
102
+ f"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB must be an integer number of MiB, got {raw!r}"
103
+ ) from None
104
+ if value <= 0:
105
+ raise ValueError(f"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB must be positive, got {value}")
106
+ return value