@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.
- package/deploy/windows/enroll-connector.ps1 +7 -0
- package/dist/DetectorFieldProducer.d.ts +482 -0
- package/dist/DetectorFieldProducer.d.ts.map +1 -0
- package/dist/DetectorFieldProducer.js +691 -0
- package/dist/DetectorFieldProducer.js.map +1 -0
- package/dist/EdgeAgent.js +1 -1
- package/dist/SensingProfileClient.d.ts +28 -4
- package/dist/SensingProfileClient.d.ts.map +1 -1
- package/dist/SensingProfileClient.js +46 -15
- package/dist/SensingProfileClient.js.map +1 -1
- package/dist/cli.d.ts +19 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +117 -15
- package/dist/cli.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/discovery.d.ts +40 -2
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +86 -0
- package/dist/discovery.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/localInferenceSchema.d.ts +12 -0
- package/dist/localInferenceSchema.d.ts.map +1 -1
- package/dist/localInferenceSchema.js.map +1 -1
- package/dist/localSensing.d.ts +12 -0
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +27 -3
- package/dist/localSensing.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|