@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.
@@ -1,4 +1,4 @@
1
- """The class vocabulary — COMMITTED, never inferred from the artifact.
1
+ """The class vocabularies — COMMITTED, never inferred from the artifact.
2
2
 
3
3
  WHY THIS FILE IS A CONSTANT AND NOT A LOOKUP
4
4
  --------------------------------------------
@@ -10,8 +10,8 @@ alternative to committing them is guessing an index — which
10
10
  fallback to index 0 once scored BUSES as people for an entire eval run and the
11
11
  resulting table looked entirely plausible.
12
12
 
13
- This map is not hand-typed. It is what the `rfdetr` package itself computes for
14
- a pretrained COCO checkpoint, at `rfdetr/detr.py`::
13
+ The COCO map is not hand-typed. It is what the `rfdetr` package itself computes
14
+ for a pretrained COCO checkpoint, at `rfdetr/detr.py`::
15
15
 
16
16
  _class_id_to_name = {coco_id: model_class_names[i]
17
17
  for i, coco_id in enumerate(COCO_CLASSES) if i < n}
@@ -27,11 +27,37 @@ without inventing one, and inventing one is precisely the failure above. It is
27
27
  dropped and COUNTED (`unnamed_class_drops` on /health), because a drop nobody
28
28
  can see is the other failure this codebase keeps hitting — an operation that
29
29
  reports success while doing nothing.
30
+
31
+ WHY THERE IS A `ModelVocabulary` AND NOT JUST A MAP
32
+ --------------------------------------------------
33
+ `docs/runbooks/sensing-model-library.md` §3: "`KNOWN_CLASS_NAMES` becomes the
34
+ union of the loaded models' vocabularies rather than a fixed COCO list." A
35
+ frozen COCO set is the reason the service could not express a specialist AT ALL
36
+ — a PPE model's `glove` came back `unknown` and the request 400'd, so publishing
37
+ one would have changed nothing here. The union has to be computed from whatever
38
+ this box actually holds, which means one vocabulary object per model and an
39
+ index over the set of them (`ClassIndex`).
40
+
41
+ ADDING A SPECIALIST IS AN EDIT TO `BUILTIN_VOCABULARIES`, and §4 rule 1 —
42
+ "read the class list, never the title" — is why. A "fine-tuned fisheye" model
43
+ scored worse than raw COCO because its classes were `{bus,bike,car,pedestrian,
44
+ truck}`: a traffic model. The class list IS the model's identity, so it is
45
+ committed alongside the name rather than trusted from a filename, a registry row
46
+ or the artifact.
47
+
48
+ A COMMITTED VOCABULARY ADVERTISES NOTHING ON ITS OWN. `ClassIndex` is built by
49
+ `registry.py` over the models whose ARTIFACT IS ON DISK, so a vocabulary added
50
+ here before its ONNX has been OTA'd contributes no requestable class. That is
51
+ §4 rule 5 ("the artifact, its checksum and its score land together, or it does
52
+ not ship") enforced by construction rather than by remembering.
30
53
  """
31
54
 
32
55
  from __future__ import annotations
33
56
 
57
+ import re
58
+ from dataclasses import dataclass
34
59
  from types import MappingProxyType
60
+ from typing import Mapping, Sequence
35
61
 
36
62
  # Slot index (the position on the model's 91-wide logit axis) -> class name.
37
63
  # Slot index IS the COCO category id here; the gaps are real and deliberate.
@@ -57,7 +83,7 @@ COCO91_CLASS_NAMES: "MappingProxyType[int, str]" = MappingProxyType({
57
83
  })
58
84
 
59
85
  # How wide the logit axis must be for the map above to mean anything. Checked
60
- # against the loaded graph at startup — a mismatch takes the service UNHEALTHY
86
+ # against the loaded graph at startup — a mismatch takes THAT MODEL unavailable
61
87
  # rather than relabelling every box by a constant offset. That is the whole
62
88
  # point: an off-by-one on this axis is invisible downstream, it just renames
63
89
  # every object in the venue, and the resulting numbers stay plausible.
@@ -69,9 +95,6 @@ PERSON_SLOT = 1
69
95
  assert COCO91_CLASS_NAMES[PERSON_SLOT] == "person"
70
96
  assert max(COCO91_CLASS_NAMES) < EXPECTED_LOGIT_SLOTS
71
97
 
72
- #: Canonical names, for validating a caller's `classes` filter.
73
- KNOWN_CLASS_NAMES = frozenset(COCO91_CLASS_NAMES.values())
74
-
75
98
 
76
99
  def normalize_class_name(name: str) -> str:
77
100
  """Fold a requested class name to its canonical form.
@@ -84,24 +107,216 @@ def normalize_class_name(name: str) -> str:
84
107
  return name.strip().lower().replace("_", " ").replace("-", " ")
85
108
 
86
109
 
87
- _CANONICAL_BY_NORMALIZED = {normalize_class_name(v): v for v in COCO91_CLASS_NAMES.values()}
110
+ #: A model name is a DIRECTORY name (`models/<name>/model.onnx`) on three
111
+ #: platforms, so it is held to the intersection of what all three accept.
112
+ _MODEL_NAME_RE = re.compile(r"[a-z0-9][a-z0-9._-]*")
88
113
 
89
114
 
90
- def resolve_requested_classes(requested: list[str]) -> tuple[set[str], list[str]]:
91
- """Map caller-supplied class names onto canonical ones.
115
+ @dataclass(frozen=True)
116
+ class ModelVocabulary:
117
+ """What ONE model can be asked about, and what one resident copy costs.
92
118
 
93
- Returns ``(canonical_names, unknown_inputs)``. The caller decides what an
94
- unknown means; this function never silently discards one, because a profile
95
- naming a class the model cannot emit is exactly the `yolov8_nano` shape —
96
- offered to profile authors everywhere, loadable nowhere, and ~0% for six
97
- months while every surface looked fine.
119
+ `approx_ram_bytes` is committed for the same reason the class list is: the
120
+ portfolio budget has to be decided BEFORE the allocation happens, and there
121
+ is no way to measure a session's footprint without first creating it. An
122
+ admission test that loads the model to find out whether the model fits is
123
+ not an admission test. So the figure is measured once, written down here
124
+ with its provenance, and re-measured when the artifact changes — never
125
+ inferred at runtime.
126
+
127
+ It is deliberately allowed to be wrong by a margin: `cctv-detector-tier.md`
128
+ §6 notes the detector is ~9% of the binding box's RAM, "so the conclusion
129
+ survives being wrong about the 632 MB by several times over".
130
+
131
+ THIS FIGURE IS AN INPUT TO A DECISION MADE ABOVE, NOT THE DECISION. The
132
+ ceiling it is checked against is injected (see `registry.py`) because the
133
+ allocation authority is server-side. What this column supplies is the
134
+ quantity that authority does not have: `min_ram_mb` is a GATE and
135
+ `model_size_mb` is a FILE SIZE, and neither is resident footprint —
136
+ `gemma_4_e2b` carries 8000 and 2580 against a measured working set of
137
+ 3.31 GB. `rss_delta_bytes` on /health is the service measuring the same
138
+ quantity per load, so the committed figure can be checked against reality.
98
139
  """
99
- canonical: set[str] = set()
100
- unknown: list[str] = []
101
- for raw in requested:
102
- hit = _CANONICAL_BY_NORMALIZED.get(normalize_class_name(raw))
103
- if hit is None:
104
- unknown.append(raw)
105
- else:
106
- canonical.add(hit)
107
- return canonical, unknown
140
+
141
+ model_name: str
142
+ #: Logit-axis slot -> class name. The slot index is the model's own, so a
143
+ #: sparse map (COCO's id gaps) and a dense one are both fine.
144
+ slot_names: "Mapping[int, str]"
145
+ #: How wide the class axis must be for `slot_names` to describe it.
146
+ logit_slots: int
147
+ #: Peak RSS of ONE resident session, in bytes.
148
+ approx_ram_bytes: int
149
+
150
+ def __post_init__(self) -> None:
151
+ # The model name IS A DIRECTORY NAME — `resolve_model_path` puts the
152
+ # artifact at `models/<model-name>/model.onnx` on every platform — so it
153
+ # is restricted to what is safe in a path on all three, and NOT folded
154
+ # by `normalize_class_name` (which turns `_` into a space; the shipped
155
+ # `rfdetr_medium` is a valid name and an invalid class).
156
+ if not self.model_name or not _MODEL_NAME_RE.fullmatch(self.model_name):
157
+ raise ValueError(
158
+ f"model name {self.model_name!r} must match {_MODEL_NAME_RE.pattern} — it is a "
159
+ "directory name on macOS, Linux and Windows"
160
+ )
161
+ if self.logit_slots <= 0:
162
+ raise ValueError(f"{self.model_name}: logit_slots must be positive, got {self.logit_slots}")
163
+ if self.approx_ram_bytes <= 0:
164
+ raise ValueError(f"{self.model_name}: approx_ram_bytes must be positive")
165
+ if not self.slot_names:
166
+ raise ValueError(f"{self.model_name}: a model with no named class can answer nothing")
167
+ for slot, name in self.slot_names.items():
168
+ if not isinstance(slot, int) or slot < 0 or slot >= self.logit_slots:
169
+ raise ValueError(
170
+ f"{self.model_name}: slot {slot!r} is outside the declared {self.logit_slots}-wide axis"
171
+ )
172
+ # ALREADY-CANONICAL NAMES ONLY. `_to_wire` puts the DECLARED name on
173
+ # the wire and `postprocess` filters on the CANONICAL one, so a
174
+ # vocabulary declaring `cell_phone` would emit a label the caller's
175
+ # own request had just been folded away from — a class silently
176
+ # matching nothing, on a box where everything looks loaded. Checked
177
+ # at import, so a sloppy specialist fails at start rather than in a
178
+ # venue.
179
+ if not name or normalize_class_name(name) != name:
180
+ raise ValueError(
181
+ f"{self.model_name}: class name {name!r} is not canonical "
182
+ f"(expected {normalize_class_name(name)!r})"
183
+ )
184
+ if len(set(self.slot_names.values())) != len(self.slot_names):
185
+ raise ValueError(f"{self.model_name}: two slots share one class name, so a filter would be ambiguous")
186
+
187
+ @property
188
+ def class_names(self) -> "frozenset[str]":
189
+ return frozenset(self.slot_names.values())
190
+
191
+
192
+ #: The artifact this service was built around and the ONLY one measured.
193
+ #:
194
+ #: 632 MB peak RSS — `docs/runbooks/cctv-detector-tier.md` §6, measured on
195
+ #: macOS/arm64 against the shipped 135,146,350-byte fp32 export. Treated as MiB
196
+ #: here, which over-reserves by ~4.9% against the runbook's decimal MB: the safe
197
+ #: direction, since under-reserving is what the OOM killer resolves.
198
+ RFDETR_MEDIUM = ModelVocabulary(
199
+ model_name="rfdetr_medium",
200
+ slot_names=COCO91_CLASS_NAMES,
201
+ logit_slots=EXPECTED_LOGIT_SLOTS,
202
+ approx_ram_bytes=632 * 1024 * 1024,
203
+ )
204
+
205
+ #: Every model this build knows how to label, IN PRIORITY ORDER.
206
+ #:
207
+ #: Order is load-bearing and it is the whole disambiguation rule: a class named
208
+ #: by more than one vocabulary routes to the FIRST entry here that names it, so
209
+ #: adding a specialist can never move an existing class off the model it was
210
+ #: scored on. `person` stays on `rfdetr_medium` even after a PPE model that also
211
+ #: emits `person` arrives, and that is correct rather than merely deterministic
212
+ #: — the 98.8/98.8 pooled figure in `cctv-detector-tier.md` is a claim about THIS
213
+ #: model's person, not about the concept.
214
+ #:
215
+ #: ONE ENTRY TODAY. That is not the multi-model machinery being unused; it is
216
+ #: §4 rule 5 holding: a vocabulary here without a published artifact advertises
217
+ #: nothing (see `ClassIndex`, built over models whose file is present), and
218
+ #: publishing is a separate task. What changed is that the service can now
219
+ #: EXPRESS a second entry, which before this it structurally could not.
220
+ BUILTIN_VOCABULARIES: "tuple[ModelVocabulary, ...]" = (RFDETR_MEDIUM,)
221
+
222
+
223
+ class ClassIndex:
224
+ """The union vocabulary of a SET of models, and who owns each class.
225
+
226
+ Built by `registry.py` over the models whose artifact is actually on the
227
+ box, so it answers two questions the old fixed `KNOWN_CLASS_NAMES` could
228
+ not: what may a caller ask for right now, and which model has to run to
229
+ answer it.
230
+ """
231
+
232
+ def __init__(self, vocabularies: "Sequence[ModelVocabulary]"):
233
+ self._vocabularies = tuple(vocabularies)
234
+ owner: "dict[str, str]" = {}
235
+ for vocabulary in self._vocabularies:
236
+ for name in sorted(vocabulary.class_names):
237
+ # First-in-catalogue-order wins. `setdefault`, not assignment:
238
+ # a later specialist must not capture a class an earlier model
239
+ # already owns.
240
+ owner.setdefault(name, vocabulary.model_name)
241
+ if len({v.model_name for v in self._vocabularies}) != len(self._vocabularies):
242
+ raise ValueError("two vocabularies share one model name")
243
+ self._owner = owner
244
+ self._class_names = frozenset(owner)
245
+
246
+ @property
247
+ def class_names(self) -> "frozenset[str]":
248
+ """The union. This is what a caller may ask for, and nothing else."""
249
+ return self._class_names
250
+
251
+ @property
252
+ def model_names(self) -> "tuple[str, ...]":
253
+ return tuple(v.model_name for v in self._vocabularies)
254
+
255
+ @property
256
+ def vocabularies(self) -> "tuple[ModelVocabulary, ...]":
257
+ return self._vocabularies
258
+
259
+ def owner_of(self, canonical_class: str) -> "str | None":
260
+ return self._owner.get(canonical_class)
261
+
262
+ def resolve(self, requested: "list[str]") -> "tuple[set[str], list[str]]":
263
+ """Map caller-supplied class names onto canonical ones.
264
+
265
+ Returns ``(canonical_names, unknown_inputs)``. Never silently discards
266
+ an unknown, because a profile naming a class no model on this box can
267
+ emit is exactly the `yolov8_nano` shape — offered to profile authors
268
+ everywhere, loadable nowhere, and ~0% for six months while every
269
+ surface looked fine. The caller turns a non-empty `unknown` into a 400.
270
+ """
271
+ canonical: "set[str]" = set()
272
+ unknown: "list[str]" = []
273
+ for raw in requested:
274
+ folded = normalize_class_name(raw)
275
+ if folded in self._class_names:
276
+ canonical.add(folded)
277
+ else:
278
+ unknown.append(raw)
279
+ return canonical, unknown
280
+
281
+ def assign(self, wanted: "set[str] | None") -> "list[tuple[ModelVocabulary, set[str] | None]]":
282
+ """Split a request across the models that have to run, in catalogue order.
283
+
284
+ `wanted=None` — the wire's "every class" — expands to the whole union
285
+ FIRST and is then routed like any other request. Running each model over
286
+ its own full vocabulary instead would double-report every class two
287
+ models share: one `person` box from the general detector and another
288
+ from a specialist, for the same person, with nothing downstream able to
289
+ tell that apart from two people. Expanding and routing means an
290
+ overlapping class is answered exactly once, by its owner.
291
+
292
+ A model whose assignment is its ENTIRE vocabulary gets `None`, which is
293
+ the same object the single-model service passed before this existed —
294
+ so the one-model box takes byte-identical code through `_to_wire`.
295
+ """
296
+ target = self._class_names if wanted is None else wanted
297
+ by_model: "dict[str, set[str]]" = {}
298
+ for name in target:
299
+ owner = self._owner.get(name)
300
+ if owner is None:
301
+ continue
302
+ by_model.setdefault(owner, set()).add(name)
303
+
304
+ plan: "list[tuple[ModelVocabulary, set[str] | None]]" = []
305
+ for vocabulary in self._vocabularies:
306
+ classes = by_model.get(vocabulary.model_name)
307
+ if not classes:
308
+ continue
309
+ plan.append((vocabulary, None if classes == set(vocabulary.class_names) else classes))
310
+ return plan
311
+
312
+
313
+ __all__ = [
314
+ "BUILTIN_VOCABULARIES",
315
+ "COCO91_CLASS_NAMES",
316
+ "EXPECTED_LOGIT_SLOTS",
317
+ "PERSON_SLOT",
318
+ "RFDETR_MEDIUM",
319
+ "ClassIndex",
320
+ "ModelVocabulary",
321
+ "normalize_class_name",
322
+ ]