@trillboards/edge-sdk 0.2.95 → 0.2.98

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,691 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DetectorFieldProducer = exports.DETECTOR_MODEL_JOIN = exports.CONCEPT_KEY_RE = exports.toDetectorClassName = exports.DEFAULT_PRODUCE_DEADLINE_MS = exports.DEFAULT_PRODUCER_TIMEOUT_MS = exports.DEFAULT_PRODUCER_MIN_CONFIDENCE = exports.PRODUCER_WITHHOLD_REASONS = void 0;
4
+ exports.classifyProducerField = classifyProducerField;
5
+ exports.countsForClasses = countsForClasses;
6
+ exports.toFieldValues = toFieldValues;
7
+ /**
8
+ * DetectorFieldProducer — the connector's detector→declared-field producer.
9
+ *
10
+ * ── THE GAP THIS CLOSES ────────────────────────────────────────────────────
11
+ *
12
+ * The Android agent has had one of these for a year: `QueueEstimationProcessor`
13
+ * turns EfficientDet detections into `pedestrian_count` / `queue_length` /
14
+ * `queue_detected`, keyed under the model that produced them
15
+ * (`AudienceSensingService.kt:3090` — `model_outputs.efficientdet.{…}`), and
16
+ * those are exactly the fields `efficientdet` declares in its registry
17
+ * `outputs_schema`.
18
+ *
19
+ * The connector has had none. It runs a real detector on 127.0.0.1:9380 at
20
+ * 15–90 ms a frame, and the only thing that consumes the result is
21
+ * `DetectorShadowProbe`, which collapses the whole detection set to a
22
+ * three-valued enum and discards the boxes. So "how many people are there" —
23
+ * a question the detector answers in milliseconds, with boxes — was answered by
24
+ * asking a language model in prose at ~5,645 ms, at ~9% precision on specifics
25
+ * (docs/runbooks/sensing-catch-rate.md §4a).
26
+ *
27
+ * ── ONE FIELD, ONE PRODUCER ────────────────────────────────────────────────
28
+ *
29
+ * The input is the `producer` half of `modelClassLiveness.partitionProfileFields`
30
+ * — `{field, modelId, type}` per entry, where `modelId` is the model whose
31
+ * registry row DECLARES that field as an output. Ownership is data; this file is
32
+ * only the resolution. Nothing here decides which model answers a field, and it
33
+ * must never acquire that opinion: a second place that can decide is how one
34
+ * fact ends up with two producers, which is the defect the partition exists to
35
+ * remove.
36
+ *
37
+ * The output is TOTAL: `produced.length + withheld.length === fields.length`,
38
+ * always, on every path including the ones where the detector never answered.
39
+ * Same contract, and the same reason, as the partition's own
40
+ * `producer ∪ vlm ∪ withheld === fields`: two independent filters that must
41
+ * agree is precisely how a field goes missing with nothing to point at.
42
+ *
43
+ * This is also why the input is the PARTITION and not the profile's raw
44
+ * `fields[]`. `person_count` is already in `localInferenceSchema`'s
45
+ * `PRESENCE_PRIMITIVE_ORDER`, so every profile asks the language model for it
46
+ * today; a producer handed the raw list would answer it as well and the reading
47
+ * an operator saw would depend on which component wrote last. The partition is
48
+ * what takes a produced field OUT of the prompt, and a producer that could not
49
+ * be told which fields those are could not be correct.
50
+ *
51
+ * ── A GRANTED MODEL WHOSE BYTES NEVER LANDED YIELDS A *MISSING* FIELD ──────
52
+ *
53
+ * A model can be granted by the profile and still be absent from the box: the
54
+ * grant comes from `GET /v2/earner/cameras/sensing-profile` and the artifact
55
+ * arrives separately over `/v1/federated/models`, which was 404 for sixteen days
56
+ * in August.
57
+ *
58
+ * THAT HAS TWO DOORS, and only the first one looks like a failure:
59
+ *
60
+ * the class is unknown to the box — no resident model can emit it, so the
61
+ * service answers **400 before inference** (`server.py::_detect`,
62
+ * deliberately, on ANY unknown class) and `LocalDetectorClient` raises
63
+ * `DetectorUnavailableError`.
64
+ *
65
+ * the class is owned by SOMEBODY ELSE — a different resident model declares
66
+ * it, so the call **succeeds with a 200** and a perfectly good answer from
67
+ * the wrong instrument. Overlap is the norm: `hardhat` is declared by both
68
+ * `ppe_vyra_14cls_v8m` and `ppe_construction_17cls_v8n`, `person` by those
69
+ * two plus `rfdetr_medium`. The attribution check in `produce()` is the only
70
+ * thing that closes this one — see the long note at its call site before
71
+ * deciding it is redundant with the 400.
72
+ *
73
+ * In BOTH the field is then ABSENT. Not `false`, not `0`, and — the important
74
+ * one — NOT handed to the language model instead.
75
+ * `routeEvaluatorService.js:1272` already gets this right one layer up: a
76
+ * focused route reads only its mapped field and yields `no_field` rather than
77
+ * falling back. Substituting a different producer
78
+ * mid-flight is the split-brain again, in TIME instead of space: the same
79
+ * question, answered by two different instruments on two different frames, with
80
+ * nothing on the reading saying which.
81
+ *
82
+ * ── `unavailable` NEVER COLLAPSES INTO `empty` ─────────────────────────────
83
+ *
84
+ * Structural, not a convention: a value is written in exactly one place in this
85
+ * file, from a count derived from a parsed 2xx. There is no path from a caught
86
+ * error to a `false` or a `0`. Every failure — transport, 400, 503, a 2xx whose
87
+ * envelope is not the contract, the budget running out — lands in `withheld`.
88
+ *
89
+ * `LocalDetectorClient` and `DetectorShadowProbe` both carry this distinction in
90
+ * their own headers, and it is the same one: a cascade that reads "detector
91
+ * down" as "nobody there" suppresses every alert while `postCount`,
92
+ * `sensingState:'active'` and the portal's "AI active" tile all stay green
93
+ * (docs/runbooks/cctv-connector-runtime-edge-cases.md).
94
+ *
95
+ * ── ONE CALL PER MODEL, NOT ONE CALL PER FRAME ─────────────────────────────
96
+ *
97
+ * The service refuses a request naming ANY class it cannot resolve — it does not
98
+ * answer the part it recognises — and its own comment says why: "Refusing costs
99
+ * the `person` detections too; that is the right trade." That is correct for ONE
100
+ * question. A frame's declared fields are N INDEPENDENT questions, and unioning
101
+ * them into a single call would make them share a fate they do not share: one
102
+ * specialist whose bytes never landed would take `person_count` down with it.
103
+ *
104
+ * So the classes are grouped by the `modelId` the partition already supplies,
105
+ * one call per model, each carrying the union of the classes THAT model's fields
106
+ * need. A model that cannot answer loses its own fields and no others.
107
+ *
108
+ * This is not the connector holding a class→model catalogue. It cannot and must
109
+ * not: `docs/runbooks/sensing-model-library.md` is explicit that the service
110
+ * owns class→model routing because it is the only component that knows what is
111
+ * resident, and the wire carries `classes` and no model selector. The grouping
112
+ * here is FAILURE ISOLATION over an ownership fact the server sent us.
113
+ *
114
+ * Calls run SEQUENTIALLY. The box has one inference slot; issuing them in
115
+ * parallel queues them inside the service and spends the budget on scheduling.
116
+ *
117
+ * @reuse-audit
118
+ * considered: services/regression/detectorCorpusEndpoint.js
119
+ * - reused, as the SEMANTICS this file is a second implementation of: the
120
+ * `<concept>_present` / `<concept>_count` key shape, `toDetectorClassName`
121
+ * byte-for-byte, and the "trim each class at its own threshold, throw on a
122
+ * label nobody asked for" rule, comments included. NOT imported: that is
123
+ * CommonJS inside the API image and this is the connector's TypeScript
124
+ * bundle; the API does not ship edge-sdk and edge-sdk does not ship the
125
+ * API. What keeps them from drifting is that they answer the same corpus —
126
+ * the six live `expected_output` keys (`person_count`, `person_present`,
127
+ * `forklift_present`, `hardhat_present`, `excavators_present`,
128
+ * `fire_extinguisher_present`) ARE the field names, and
129
+ * `__tests__/DetectorFieldProducer.test.ts` pins this side onto the classes
130
+ * those models declare in `coco_classes`.
131
+ * - ONE DELIBERATE DIVERGENCE, marked at `countsForClasses`: its single-class
132
+ * branch returns `detections.length` unread, and this one reads the label
133
+ * on every branch. That is not a port that drifted — it is the same
134
+ * principle under a different consumer. See the note there before
135
+ * "restoring" it.
136
+ * considered: trillboard-ctv/.../audience/QueueEstimationProcessor.kt
137
+ * - reused: its SHAPE. A processor that turns one frame's detection set into
138
+ * a map keyed by registry-canonical snake_case field names, with the
139
+ * producing model's id travelling ON the values (`model_outputs.<modelId>`)
140
+ * so a reading can be attributed. `toJson`'s note — "keys use
141
+ * registry-canonical snake_case so `chipPersistence.sanitizeFieldKey` is
142
+ * idempotent on canonical input" — is why `produced[].field` is the
143
+ * DECLARED name verbatim and is never re-spelled here.
144
+ * - deliberately NOT ported: its temporal half — `serviceRatePerMinute`,
145
+ * `estimatedWaitTimeMinutes`, `trend`, and the 30-snapshot history they
146
+ * are derived from. Nothing in any producer half declares those fields, so
147
+ * emitting them would advertise a capability no registry row owns — the
148
+ * `yolov8_nano` shape, where a model was offered to ~97 of 114 profile
149
+ * authors while every device hardcoded it unloadable. When a model DOES
150
+ * declare `queue_length`, that is a field with an owner and it arrives
151
+ * here as an ordinary producer entry.
152
+ * considered: edge-sensing/src/processors/FaceDetector.ts — `nonMaxSuppression`
153
+ * + `iou` (the kernel `LocalDetectorClient`'s header names as the one to use)
154
+ * - deliberately NOT used, and this is the one worth reading twice. The
155
+ * SERVICE already ran NMS: `deploy/detector/trillboards_detector/decode.py`
156
+ * does class-offset greedy NMS at `NMS_IOU_THRESHOLD = 0.7` on the anchor
157
+ * branch, transcribed from ultralytics, and deliberately runs NONE on the
158
+ * DETR branch because "running NMS over these rows would be a second
159
+ * suppression the model was neither trained nor scored with". A pass here
160
+ * would be exactly that second suppression, on both branches, and it would
161
+ * move the counts away from the ones the committed score cards fitted their
162
+ * thresholds against. The kernel is fine; a fourth suppression is not.
163
+ * considered: edge-core `CentroidTracker` (the shared nearest-centroid tracker)
164
+ * - deliberately NOT used: no field in any producer half needs cross-frame
165
+ * identity. `<concept>_present` and `<concept>_count` are both properties
166
+ * of ONE frame — which is also how the Android processor computes
167
+ * `estimatedQueueLength` (`current.faceCount`, the latest snapshot). Wiring
168
+ * a tracker in for fields that do not ask for one would add per-feed state
169
+ * with no consumer. When dwell or a unique count lands, that tracker is the
170
+ * one to call — it is already the consolidation of three, and a fourth is
171
+ * what its own header exists to prevent.
172
+ * considered: this package's `zoneContainment.countInZone`
173
+ * - not used HERE: it takes a frame size and a normalized rect, and with a
174
+ * null rect it IS `boxes.length`. A field focused to an operator's drawn
175
+ * region is the natural next consumer of both files at once — the count
176
+ * becomes `countInZone(boxes, frame, rect)` and nothing else changes — but
177
+ * a producer entry carries no region today and inventing one would be a
178
+ * second home for a fact `sensing_route_screens` already owns.
179
+ * considered: `DetectorShadowProbe`
180
+ * - not extended, and not called: it is a MEASUREMENT that must change
181
+ * nothing, with a backoff and a 15 s per-feed cadence bound tuned to that
182
+ * job. A producer answering an operator's declared field is a PRODUCT path
183
+ * with the opposite cadence obligation.
184
+ * considered: edge-core `ModelManager.hasModel(modelId)` — the connector's own
185
+ * "is this artifact on disk" check (`fs.existsSync` over the registered path)
186
+ * - deliberately NOT used as the arrival gate. It answers about the
187
+ * CONNECTOR's download directory; what decides whether a class can be
188
+ * answered is the SERVICE's `ClassIndex`, which `labels.py` builds only
189
+ * over models whose artifact is on the box. Those are two opinions about
190
+ * one fact and they can disagree — a file present but the wrong graph
191
+ * (`ModelContractError`), or resident in a service started before the
192
+ * download. Pre-checking here would also mean a `hasModel` that returned
193
+ * true still has to handle the 400, so the check would be advisory at best
194
+ * and a second catalogue at worst. The 400 IS the arrival check, from the
195
+ * only component that knows.
196
+ */
197
+ const LocalDetectorClient_1 = require("./LocalDetectorClient");
198
+ /** Why a declared field has no reading this frame. */
199
+ exports.PRODUCER_WITHHOLD_REASONS = {
200
+ /**
201
+ * The field name is not `<concept>_present` / `<concept>_count`, so no
202
+ * detector class can be derived from it.
203
+ *
204
+ * WITHHELD RATHER THAN DROPPED. A registry row may declare `queue_length` or
205
+ * `vehicle_trend` — `efficientdet` declares both — and a detection set answers
206
+ * neither. Guessing a class out of `trend` is how a bus gets counted as a
207
+ * person; silently dropping it is how a field goes missing with nothing to
208
+ * point at.
209
+ */
210
+ UNMAPPED: 'unmapped',
211
+ /**
212
+ * The declared type contradicts what the name asks for — a `_present` field
213
+ * declared `counter`, say.
214
+ *
215
+ * The suffix decides the COMPUTATION and the type is the published contract;
216
+ * when they disagree the row is defective and either answer is wrong. Refusing
217
+ * names the defect instead of writing a boolean into a slot rendered as a
218
+ * number.
219
+ */
220
+ TYPE_CONTRADICTS_NAME: 'type_contradicts_name',
221
+ /**
222
+ * The detector did not answer for this field's model.
223
+ *
224
+ * Transport, 400 (a class no resident model can emit — i.e. a granted model
225
+ * whose bytes never landed), 503, a 2xx whose envelope is not the contract, a
226
+ * response labelled with a class nobody asked for, or the budget running out.
227
+ * All of them mean the same thing to a consumer: there is NO READING. None of
228
+ * them may become a value.
229
+ */
230
+ DETECTOR_UNAVAILABLE: 'detector_unavailable',
231
+ /**
232
+ * The entry names no model.
233
+ *
234
+ * Its own reason rather than `unmapped`, because it is a defect in a different
235
+ * place: the NAME was readable and it is the OWNERSHIP that is missing. The
236
+ * partition cannot emit such an entry — it only puts a field in `producer[]`
237
+ * once it has found an owner — so this is a claim about a caller, and saying
238
+ * "no class follows from that name" about it would send the reader to the
239
+ * registry's `outputs_schema` when the fault is upstream of it.
240
+ *
241
+ * It is refused rather than answered anonymously. The class list would resolve
242
+ * fine and the count would be correct; what would be lost is attribution, and
243
+ * "a detection whose producer cannot be named is a detection that cannot be
244
+ * attributed when a specialist later turns out to be wrong"
245
+ * (`deploy/detector/trillboards_detector/detections.py`).
246
+ */
247
+ UNOWNED: 'unowned',
248
+ /**
249
+ * The same field name appeared twice in the producer half.
250
+ *
251
+ * The first occurrence is answered and the rest are withheld, so the total
252
+ * contract holds over the input array exactly. A repeated field is the "one
253
+ * fact, two answers" defect at its smallest scale and it is worth a name.
254
+ */
255
+ DUPLICATE: 'duplicate',
256
+ };
257
+ /**
258
+ * The device's operating point when a model measured none of its own. 0.25.
259
+ *
260
+ * `LocalDetectorClient`'s default, and a FALLBACK rather than an answer — the
261
+ * same constant and the same caveat as
262
+ * `detectorCorpusEndpoint.DEFAULT_MIN_CONFIDENCE`. A model whose score card
263
+ * SELECTED a threshold must be counted at THAT threshold or the field is
264
+ * produced by a classifier no committed measurement describes: `gh_forklift_combine`
265
+ * fits 0.7435 and `rfdetr_medium`'s person gate 0.2939, and at 0.25 the
266
+ * published precision/recall simply do not apply to what shipped.
267
+ */
268
+ exports.DEFAULT_PRODUCER_MIN_CONFIDENCE = 0.25;
269
+ /**
270
+ * Per-call transport timeout. 1000 ms, matching `DEFAULT_SHADOW_TIMEOUT_MS`.
271
+ *
272
+ * NOT `LocalDetectorClient`'s own 5 s default: this runs on the path to an
273
+ * analysis, and the detector's measured cost is ~63 ms p50 / ~79 ms p95, so
274
+ * 1000 ms is >12x headroom for the slowest plausible answer.
275
+ */
276
+ exports.DEFAULT_PRODUCER_TIMEOUT_MS = 1000;
277
+ /**
278
+ * TOTAL budget across every model call for one frame. 1500 ms.
279
+ *
280
+ * A total and not a per-call bound, because the question it answers is "how long
281
+ * may producing this frame's fields hold the tick", and that is a property of
282
+ * the frame, not of one model. At the measured p95 a four-model portfolio costs
283
+ * ~316 ms, so this is ~4.7x headroom and cannot bind on a healthy box.
284
+ *
285
+ * When it DOES bind, the models that never got their call are withheld
286
+ * `detector_unavailable` — a missing field, which is the same discipline the
287
+ * 400 case gets and for the same reason. A budget that returned a value it did
288
+ * not measure would be worse than one that stalls.
289
+ *
290
+ * It bounds the number of calls STARTED and, via the per-call timeout below,
291
+ * how long each may sit idle. It does not close the one hole
292
+ * `LocalDetectorClient`'s header names — a server dribbling bytes slowly under a
293
+ * large Content-Length keeps the socket busy so the inactivity timer never fires
294
+ * — because that hole is in the transport and belongs to every client in this
295
+ * package; two call-site races for it already exist and a third is not the fix.
296
+ */
297
+ exports.DEFAULT_PRODUCE_DEADLINE_MS = 1500;
298
+ /**
299
+ * The detector's canonical wire spelling.
300
+ *
301
+ * `normalize_class_name` in
302
+ * `deploy/detector/trillboards_detector/labels.py` is
303
+ * `name.strip().lower().replace("_", " ").replace("-", " ")`, and `labels.py`
304
+ * REFUSES to import a vocabulary whose names are not already folded — so this is
305
+ * the only spelling `/detect` resolves. A field carries its concept in the
306
+ * spelling a JSON key can hold (`fire_extinguisher`); the service is asked in
307
+ * the spelling it demands (`fire extinguisher`). One fold, one place.
308
+ *
309
+ * Byte-identical to `detectorCorpusEndpoint.toDetectorClassName`, deliberately:
310
+ * the corpus that GATES a model's rollout and the producer that READS it in the
311
+ * field must ask for the same class, or the thing measured is not the thing that
312
+ * ships.
313
+ */
314
+ const toDetectorClassName = (name) => String(name).trim().toLowerCase().replace(/[_-]+/g, ' ');
315
+ exports.toDetectorClassName = toDetectorClassName;
316
+ /** The producible key shape: `<concept>_present`, `<concept>_count`. */
317
+ exports.CONCEPT_KEY_RE = /^(.+)_(present|count)$/;
318
+ /**
319
+ * How the service joins the names of the models that answered one frame.
320
+ *
321
+ * `MODEL_JOIN` in `deploy/detector/trillboards_detector/server.py`, where
322
+ * `model` is `MODEL_JOIN.join(batch.models)` — one name on a single-model box, N
323
+ * for a request the registry split across several. Held here because the
324
+ * attribution check below has to take that string apart again, and a wrong
325
+ * separator would silently make every merged answer look unattributed.
326
+ */
327
+ exports.DETECTOR_MODEL_JOIN = '+';
328
+ /** Declared types a `<concept>_count` field may carry. */
329
+ const COUNT_TYPES = new Set(['counter', 'gauge']);
330
+ /** Declared types a `<concept>_present` field may carry. */
331
+ const PRESENT_TYPES = new Set(['boolean']);
332
+ /**
333
+ * Read a declared field name, or say why it cannot be read.
334
+ *
335
+ * The SUFFIX decides the computation — that is the vocabulary the corpus is
336
+ * written in and the only self-describing signal on the entry. The declared type
337
+ * is then checked AGAINST it rather than consulted instead of it: a null type is
338
+ * fine (plenty of registry rows carry none), and a type that contradicts the
339
+ * name is a defective row rather than an instruction.
340
+ */
341
+ function classifyProducerField(entry) {
342
+ const field = typeof entry?.field === 'string' ? entry.field.trim() : '';
343
+ const match = exports.CONCEPT_KEY_RE.exec(field);
344
+ if (!match) {
345
+ return {
346
+ reason: exports.PRODUCER_WITHHOLD_REASONS.UNMAPPED,
347
+ detail: `'${field}' is neither <concept>_present nor <concept>_count, so no detector class follows from it`,
348
+ };
349
+ }
350
+ const [, concept, suffix] = match;
351
+ const kind = suffix === 'present' ? 'present' : 'count';
352
+ const declared = typeof entry.type === 'string' ? entry.type.trim().toLowerCase() : '';
353
+ if (declared) {
354
+ const allowed = kind === 'present' ? PRESENT_TYPES : COUNT_TYPES;
355
+ if (!allowed.has(declared)) {
356
+ return {
357
+ reason: exports.PRODUCER_WITHHOLD_REASONS.TYPE_CONTRADICTS_NAME,
358
+ detail: `'${field}' is a _${suffix} field but its owning row declares type '${declared}'; `
359
+ + `expected one of [${[...allowed].join(', ')}]`,
360
+ };
361
+ }
362
+ }
363
+ return {
364
+ ...entry,
365
+ field,
366
+ concept,
367
+ className: (0, exports.toDetectorClassName)(concept),
368
+ kind,
369
+ };
370
+ }
371
+ /**
372
+ * How many instances of each asked class this answer contains.
373
+ *
374
+ * Ported decision-for-decision from `detectorCorpusEndpoint.countsForConcepts`,
375
+ * because the corpus that gates a rollout and the producer that reads the model
376
+ * in the field must count the same way.
377
+ *
378
+ * @throws when the service returned a label nobody asked for — see below.
379
+ */
380
+ function countsForClasses(classes, detections, classThresholds) {
381
+ // ONE PATH, INCLUDING FOR A SINGLE ASKED CLASS — and this is the one place
382
+ // this file deliberately DIVERGES from `countsForConcepts`, which returns
383
+ // `detections.length` unread on the single-class branch.
384
+ //
385
+ // That branch is right THERE and wrong HERE, because the two consumers make a
386
+ // miscount loud in different ways. The corpus endpoint compares its answer
387
+ // against a fixture's `expected_output`, so a service that ignored `classes`
388
+ // produces a wrong count that FAILS the corpus — counting blindly is what
389
+ // makes the defect visible. In production nothing compares anything: a
390
+ // `forklift_count` of 2 from one `forklift` and one `forklift_partial` is a
391
+ // plausible number that ships and is believed.
392
+ //
393
+ // So the PRINCIPLE ports and the code does not. "Never quietly correct a
394
+ // broken class filter" becomes "refuse the group", which is this consumer's
395
+ // form of loud. Reading the label costs nothing and the service already
396
+ // narrows the response to the asked set (`ClassIndex.assign` routes each class
397
+ // to exactly one owner and `_to_wire` filters to the wanted set), so a label
398
+ // outside that set means the filter did not happen.
399
+ const counts = new Map(classes.map((className) => [className, 0]));
400
+ const unrequested = new Set();
401
+ for (const det of detections) {
402
+ const label = (0, exports.toDetectorClassName)(det?.label);
403
+ if (!counts.has(label)) {
404
+ unrequested.add(String(det?.label));
405
+ continue;
406
+ }
407
+ // The wire carried the MINIMUM threshold across the asked classes, because
408
+ // `min_confidence` is one scalar and the service applies it to all of them.
409
+ // So a class with a higher operating point is still holding detections below
410
+ // its own, and counting them here would score it at somebody else's
411
+ // threshold. Trim per class, at its own number.
412
+ //
413
+ // A class with NO entry is counted at whatever the service already applied.
414
+ // `DetectorFieldProducer` never leaves one out — it passes an EFFECTIVE map
415
+ // with an entry per asked class, `configured ?? fallback` — because without
416
+ // that, one fitted 0.05 class in the group drags the request minimum down
417
+ // and every unmeasured class beside it silently counts detections its own
418
+ // documented 0.25 fallback would have rejected.
419
+ //
420
+ // The unrequested-label check above stays FIRST and unconditional: a
421
+ // detection the service should never have returned is a broken class filter
422
+ // and must throw, whatever its confidence. Dropping it quietly for being
423
+ // low-confidence would hide exactly the defect that check exists for.
424
+ const floor = classThresholds?.get(label);
425
+ if (Number.isFinite(floor) && Number.isFinite(det?.confidence) && det.confidence < floor)
426
+ continue;
427
+ counts.set(label, counts.get(label) + 1);
428
+ }
429
+ if (unrequested.size) {
430
+ throw new Error('detector returned detections labelled '
431
+ + `[${[...unrequested].sort().join(', ')}] after being asked for [${classes.join(', ')}] `
432
+ + '— the service did not apply the class filter, so these counts cannot be attributed');
433
+ }
434
+ return counts;
435
+ }
436
+ class DetectorFieldProducer {
437
+ constructor(options = {}, clientFactory) {
438
+ this.baseUrl = options.baseUrl;
439
+ this.timeoutMs = Math.max(1, Math.trunc(options.timeoutMs ?? exports.DEFAULT_PRODUCER_TIMEOUT_MS));
440
+ this.deadlineMs = Math.max(0, Math.trunc(options.deadlineMs ?? exports.DEFAULT_PRODUCE_DEADLINE_MS));
441
+ this.classThresholds = options.classThresholds instanceof Map ? options.classThresholds : new Map();
442
+ this.minConfidence = Number.isFinite(options.minConfidence)
443
+ ? options.minConfidence
444
+ : exports.DEFAULT_PRODUCER_MIN_CONFIDENCE;
445
+ this.now = options.now ?? Date.now;
446
+ this.clientFor =
447
+ clientFactory
448
+ ?? ((opts) => new LocalDetectorClient_1.LocalDetectorClient({
449
+ ...(this.baseUrl ? { baseUrl: this.baseUrl } : {}),
450
+ classes: opts.classes,
451
+ minConfidence: opts.minConfidence,
452
+ timeoutMs: opts.timeoutMs,
453
+ }));
454
+ }
455
+ /**
456
+ * Produce this frame's declared fields.
457
+ *
458
+ * NEVER THROWS and never returns a promise that rejects: it would sit on the
459
+ * path to the only thing that produces alerts, and a producer that can fail a
460
+ * capture tick is worse than one that reports it has no reading.
461
+ *
462
+ * @param jpeg the frame's bytes
463
+ * @param fields the `producer` half of this profile's field partition
464
+ */
465
+ async produce(jpeg, fields) {
466
+ const produced = [];
467
+ const withheld = [];
468
+ const entries = Array.isArray(fields) ? fields : [];
469
+ if (entries.length === 0)
470
+ return { produced, withheld };
471
+ // ── Read every name first, so nothing is asked for that cannot be used ──
472
+ const mapped = [];
473
+ const seen = new Set();
474
+ for (const entry of entries) {
475
+ const modelId = typeof entry?.modelId === 'string' ? entry.modelId : '';
476
+ const read = classifyProducerField(entry);
477
+ if ('reason' in read) {
478
+ withheld.push({ field: String(entry?.field ?? ''), modelId, reason: read.reason, detail: read.detail });
479
+ continue;
480
+ }
481
+ // OWNERSHIP BEFORE COLLISION. Both checks can fire on one entry, and this
482
+ // order gives the more useful diagnosis: "this entry names no model" is a
483
+ // fault in the entry itself, while "already answered" is only meaningful
484
+ // between two entries that are otherwise well formed. Reporting the
485
+ // collision first would send the reader looking for a duplicate field when
486
+ // what is actually broken is that neither copy has an owner.
487
+ if (!modelId) {
488
+ // No owner means no group to ask in, and inventing one would be this
489
+ // file deciding ownership — the one thing it must never do.
490
+ withheld.push({
491
+ field: read.field,
492
+ modelId,
493
+ reason: exports.PRODUCER_WITHHOLD_REASONS.UNOWNED,
494
+ detail: `'${read.field}' carries no modelId, so no model owns it`,
495
+ });
496
+ continue;
497
+ }
498
+ if (seen.has(read.field)) {
499
+ withheld.push({
500
+ field: read.field,
501
+ modelId,
502
+ reason: exports.PRODUCER_WITHHOLD_REASONS.DUPLICATE,
503
+ detail: `'${read.field}' was already answered in this partition`,
504
+ });
505
+ continue;
506
+ }
507
+ seen.add(read.field);
508
+ mapped.push({ ...read, modelId });
509
+ }
510
+ if (mapped.length === 0)
511
+ return { produced, withheld };
512
+ // ── Group by owning model. Insertion-ordered, so the answer is stable. ──
513
+ const groups = new Map();
514
+ for (const m of mapped) {
515
+ const bucket = groups.get(m.modelId);
516
+ if (bucket)
517
+ bucket.push(m);
518
+ else
519
+ groups.set(m.modelId, [m]);
520
+ }
521
+ const startedAtMs = this.now();
522
+ for (const [modelId, groupFields] of groups) {
523
+ const classes = [...new Set(groupFields.map((f) => f.className))];
524
+ // The budget, checked BEFORE the call rather than raced against it. A call
525
+ // never started cannot be half-answered.
526
+ const remainingMs = this.deadlineMs > 0
527
+ ? this.deadlineMs - (this.now() - startedAtMs)
528
+ : Number.POSITIVE_INFINITY;
529
+ if (remainingMs <= 0) {
530
+ this.withholdGroup(withheld, groupFields, modelId, 'deadline', `frame budget of ${this.deadlineMs}ms was spent before ${modelId} was asked`);
531
+ continue;
532
+ }
533
+ // EVERY asked class gets an operating point — its own fitted one, or the
534
+ // device fallback. Not "the fitted ones, if any": with a 0.05 fitted class
535
+ // beside an unmeasured one, the request goes out at 0.05 and the trim below
536
+ // would skip the unmeasured class entirely, counting detections between
537
+ // 0.05 and its documented 0.25 fallback. The fallback has to be a real
538
+ // number in the map or it is not applied at all.
539
+ const effectiveThresholds = new Map(classes.map((className) => [
540
+ className,
541
+ this.classThresholds.get(className) ?? this.minConfidence,
542
+ ]));
543
+ // The threshold this REQUEST is made at. `min_confidence` is one scalar for
544
+ // the whole call, so with more than one class asked it has to be the LOWEST
545
+ // of their operating points or the service would suppress a class below its
546
+ // own gate before we ever see it; `countsForClasses` then trims each class
547
+ // back to its own number. With one class asked this IS that class's
548
+ // threshold and the service applies it exactly.
549
+ const minConfidence = Math.min(...effectiveThresholds.values());
550
+ const client = this.clientFor({
551
+ classes,
552
+ minConfidence,
553
+ timeoutMs: Math.max(1, Math.min(this.timeoutMs, Math.trunc(remainingMs))),
554
+ });
555
+ let counts;
556
+ let detectorModel;
557
+ try {
558
+ const result = await client.detect(jpeg);
559
+ // THROWS on an unattributable label; caught right here and booked as
560
+ // unavailable, because "the service did not apply the class filter" is a
561
+ // reading we do not have, not a reading of zero.
562
+ counts = countsForClasses(classes, result.detections, effectiveThresholds);
563
+ detectorModel = result.model;
564
+ // The invariant, ASSERTED rather than defaulted. `classes` is built from
565
+ // these very fields and `countsForClasses` keys its answer on `classes`,
566
+ // so a lookup below cannot miss — but writing `?? 0` at the read would
567
+ // turn any future violation into a manufactured measurement: a count of
568
+ // things in a frame nobody counted, indistinguishable from a genuinely
569
+ // empty one. That is the single answer this file exists to never
570
+ // produce, so the impossible case is routed to `unavailable` like every
571
+ // other way of not having a reading.
572
+ for (const f of groupFields) {
573
+ if (!counts.has(f.className)) {
574
+ throw new Error(`detector answer carries no count for '${f.className}' after asking for [${classes.join(', ')}]`);
575
+ }
576
+ }
577
+ }
578
+ catch (err) {
579
+ this.withholdGroup(withheld, groupFields, modelId, classifyDetectorFailure(err), messageOf(err));
580
+ continue;
581
+ }
582
+ // ── ATTRIBUTION: did the model the PARTITION named actually answer? ────
583
+ //
584
+ // The service resolves a class against the models RESIDENT ON THIS BOX —
585
+ // `ClassIndex._owner` gives each class exactly one owner, chosen by
586
+ // residency and catalogue order — and that owner need not be the one the
587
+ // registry says declares this field. Overlap is the norm, not an edge
588
+ // case: `hardhat` is declared by BOTH `ppe_vyra_14cls_v8m` and
589
+ // `ppe_construction_17cls_v8n`, and `person` by those two plus
590
+ // `rfdetr_medium`.
591
+ //
592
+ // So the failure this whole file is written against has a second door.
593
+ // With Vyra's artifact never delivered and Construction resident, asking
594
+ // for `hardhat` does NOT 400 — it succeeds, answered by Construction, and
595
+ // without this check `hardhat_present` would ship stamped `modelId:
596
+ // 'ppe_vyra_14cls_v8m'`. A granted-but-not-arrived model would have
597
+ // produced a value after all, from a different instrument with different
598
+ // fitted thresholds and a different precision, wearing the absent model's
599
+ // name.
600
+ //
601
+ // A reading from the wrong instrument is NOT a reading. It is withheld,
602
+ // exactly like the 400 — same promise, same outcome, different door — and
603
+ // the cause names the substitute so a registry-owner/service-owner
604
+ // disagreement is diagnosable instead of invisible.
605
+ //
606
+ // The wire cannot be asked to prevent this: it carries `classes` and no
607
+ // model selector, deliberately, because the service is the only component
608
+ // that knows what is resident. Reading back who answered is the only
609
+ // check available, and the service guarantees it is answerable — an
610
+ // unattributed batch is a 500 on its side, never a 200.
611
+ const answeredBy = detectorModel === null
612
+ ? []
613
+ : detectorModel.split(exports.DETECTOR_MODEL_JOIN).map((m) => m.trim()).filter(Boolean);
614
+ if (!answeredBy.includes(modelId)) {
615
+ this.withholdGroup(withheld, groupFields, modelId, detectorModel === null ? 'unattributed_answer' : 'answered_by_other_model', detectorModel === null
616
+ ? 'the service named no model, so this reading cannot be attributed'
617
+ : `asked for ${modelId}'s classes and '${detectorModel}' answered — `
618
+ + `the granted model is not the one resident for these classes`);
619
+ continue;
620
+ }
621
+ // ── The ONLY place in this file that writes a value. ───────────────────
622
+ for (const f of groupFields) {
623
+ const n = counts.get(f.className);
624
+ produced.push({
625
+ field: f.field,
626
+ value: f.kind === 'present' ? n > 0 : n,
627
+ modelId,
628
+ className: f.className,
629
+ minConfidence: effectiveThresholds.get(f.className),
630
+ detectorModel,
631
+ });
632
+ }
633
+ }
634
+ return { produced, withheld };
635
+ }
636
+ /**
637
+ * Every field of one model, withheld together.
638
+ *
639
+ * A model that did not answer did not answer for ANY of its fields. Booking
640
+ * them one at a time would invite a partial group, which is the collapse this
641
+ * whole file is written against.
642
+ */
643
+ withholdGroup(withheld, groupFields, modelId, cause, detail) {
644
+ for (const f of groupFields) {
645
+ withheld.push({
646
+ field: f.field,
647
+ modelId,
648
+ reason: exports.PRODUCER_WITHHOLD_REASONS.DETECTOR_UNAVAILABLE,
649
+ detail: `${cause}: ${detail}`,
650
+ });
651
+ }
652
+ }
653
+ }
654
+ exports.DetectorFieldProducer = DetectorFieldProducer;
655
+ /** Flatten an answer to the `{field: value}` map a payload carries. */
656
+ function toFieldValues(produced) {
657
+ const out = {};
658
+ for (const p of produced)
659
+ out[p.field] = p.value;
660
+ return out;
661
+ }
662
+ /**
663
+ * A short, stable cause for the local health surface.
664
+ *
665
+ * Same buckets and the same STRUCTURAL derivation as
666
+ * `DetectorShadowProbe.classifyUnavailable`: a thrown TYPE first, never prose,
667
+ * and what is left after the three matched shapes IS the envelope class.
668
+ * `filter_not_applied` is this file's own — it is thrown here, not by the
669
+ * client, and it is the one cause that means the service answered fine and its
670
+ * answer still cannot be used.
671
+ */
672
+ function classifyDetectorFailure(err) {
673
+ if (!(err instanceof LocalDetectorClient_1.DetectorUnavailableError)) {
674
+ const message = messageOf(err);
675
+ if (/did not apply the class filter/i.test(message))
676
+ return 'filter_not_applied';
677
+ return /timed out/i.test(message) ? 'timeout' : 'unknown';
678
+ }
679
+ const message = err.message;
680
+ if (/timed out/i.test(message))
681
+ return 'timeout';
682
+ if (/HTTP \d{3}/.test(message))
683
+ return 'http_error';
684
+ if (/transport failed/i.test(message))
685
+ return 'transport_failed';
686
+ return 'bad_envelope';
687
+ }
688
+ function messageOf(err) {
689
+ return err instanceof Error ? err.message : String(err);
690
+ }
691
+ //# sourceMappingURL=DetectorFieldProducer.js.map