@camstack/addon-post-analysis 1.2.134 → 1.2.136

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Dqa0bdV6.js");
5
+ const require_dist = require("../dist-kn_4KEmG.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -27000,8 +27000,12 @@ function rankAndCap(faces, order, limit) {
27000
27000
  * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
27001
27001
  * exempt from that and from every sweep — an assignment is a curation act.
27002
27002
  * The only other bound is capacity: pruneCapOverflow holds each camera's
27003
- * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
27004
- * of JSON embedding per row, that cap is what keeps the table small."
27003
+ * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50), plus a
27004
+ * SEPARATE, larger budget of deriveSuggestedBufferCap(bufferMaxPerDevice)
27005
+ * rows (default 200) for rows carrying a suggestedIdentityId — a question
27006
+ * the operator has not answered outlives a row nobody asked about, and
27007
+ * evicting one is logged with its deviceId. At 10.8 KB of JSON embedding
27008
+ * per row, those caps are what keep the table small."
27005
27009
  */
27006
27010
  var FACES_COLLECTION = "pipeline-analytics:faces";
27007
27011
  /**
@@ -27029,6 +27033,17 @@ var FACES_COLLECTION = "pipeline-analytics:faces";
27029
27033
  * cap, so the page is always the caller's bound and never the engine's.
27030
27034
  */
27031
27035
  var FACE_SWEEP_PAGE = 1e3;
27036
+ /** How many evicted face ids one per-camera eviction line names before it
27037
+ * stops — enough to find the rows, not enough to turn a log line into a page. */
27038
+ var SUGGESTED_EVICTION_IDS_LOGGED = 10;
27039
+ /**
27040
+ * A stored suggestion is present. The relational backend surfaces an unset
27041
+ * column as SQL NULL and the fake store as `undefined`; an empty string is
27042
+ * neither an identity nor a question, so it is not a suggestion either.
27043
+ */
27044
+ function hasSuggestion(raw) {
27045
+ return typeof raw === "string" && raw.length > 0;
27046
+ }
27032
27047
  /**
27033
27048
  * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
27034
27049
  * million faces — far past anything real, and reached only by a bug.
@@ -27235,12 +27250,21 @@ var FaceStore = class {
27235
27250
  * property the recognizer actually depends on: the gallery of recent
27236
27251
  * unassigned faces stays small.
27237
27252
  *
27238
- * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
27239
- * slots. Returns all deleted ids (the caller removes their crops).
27253
+ * **A row the system asked a question about outlives one it said nothing
27254
+ * about.** A single cap evicted both alike, so on 15 of 24 live cameras
27255
+ * (measured 2026-08-25) the buffer sat at exactly the cap and an unanswered
27256
+ * suggestion was deleted within about a day. Rows carrying a
27257
+ * `suggestedIdentityId` are ranked in their OWN, larger budget
27258
+ * (`maxSuggestedPerDevice`) — never immortality, because nothing clears a
27259
+ * suggestion the operator merely declines to answer. Size and trade-off:
27260
+ * `deriveSuggestedBufferCap` in `face-settings.ts:263`.
27261
+ *
27262
+ * ENROLLED (`assigned`) rows are skipped entirely and consume NO slot in
27263
+ * either tier. Returns all deleted ids (the caller removes their crops).
27240
27264
  */
27241
27265
  async pruneCapOverflow(input) {
27242
27266
  /**
27243
- * Paged, not one statement. Only three scalars per row survive the page —
27267
+ * Paged, not one statement. Only four scalars per row survive the page —
27244
27268
  * the `embedding` string that dominates the row is dropped as soon as the
27245
27269
  * page goes out of scope, so peak heap is one page rather than the whole
27246
27270
  * face table. The prune still considers EVERY row, which is what makes it a
@@ -27262,38 +27286,79 @@ var FaceStore = class {
27262
27286
  if (rows.length === 0) break;
27263
27287
  for (const r of rows) {
27264
27288
  const data = r.data;
27265
- if (data.assigned) continue;
27266
- const deviceId = Number(data.deviceId ?? -1);
27289
+ if (data["assigned"]) continue;
27290
+ const deviceId = Number(data["deviceId"] ?? -1);
27267
27291
  const list = byDevice.get(deviceId) ?? [];
27268
27292
  list.push({
27269
27293
  id: r.id,
27270
- timestamp: Number(data.timestamp ?? 0)
27294
+ timestamp: Number(data["timestamp"] ?? 0),
27295
+ suggested: hasSuggestion(data["suggestedIdentityId"])
27271
27296
  });
27272
27297
  byDevice.set(deviceId, list);
27273
27298
  }
27274
27299
  if (rows.length < 1e3) break;
27275
27300
  }
27276
27301
  const toDelete = /* @__PURE__ */ new Set();
27277
- for (const list of byDevice.values()) {
27302
+ const suggestedVictims = /* @__PURE__ */ new Map();
27303
+ for (const [deviceId, list] of byDevice) {
27278
27304
  list.sort((a, b) => b.timestamp - a.timestamp);
27279
- list.forEach((row, idx) => {
27280
- if (idx >= input.maxPerDevice) toDelete.add(row.id);
27281
- });
27305
+ let ordinaryRank = 0;
27306
+ let suggestedRank = 0;
27307
+ for (const row of list) {
27308
+ if (row.suggested) {
27309
+ if (suggestedRank++ < input.maxSuggestedPerDevice) continue;
27310
+ toDelete.add(row.id);
27311
+ const victims = suggestedVictims.get(deviceId) ?? [];
27312
+ victims.push(row.id);
27313
+ suggestedVictims.set(deviceId, victims);
27314
+ continue;
27315
+ }
27316
+ if (ordinaryRank++ < input.maxPerDevice) continue;
27317
+ toDelete.add(row.id);
27318
+ }
27282
27319
  }
27283
- const deleted = [];
27320
+ const deleted = /* @__PURE__ */ new Set();
27284
27321
  for (const id of toDelete) try {
27285
27322
  await this.store.delete.mutate({
27286
27323
  collection: FACES_COLLECTION,
27287
27324
  key: id
27288
27325
  });
27289
- deleted.push(id);
27326
+ deleted.add(id);
27290
27327
  } catch (err) {
27291
27328
  this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
27292
27329
  faceId: id,
27293
27330
  error: String(err)
27294
27331
  } });
27295
27332
  }
27296
- return deleted;
27333
+ this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
27334
+ return [...deleted];
27335
+ }
27336
+ /**
27337
+ * Name every camera that lost an UNANSWERED suggestion, and how many.
27338
+ *
27339
+ * This exists because the loss it reports ran for weeks without a single log
27340
+ * line: the prune only ever logged an aggregate count with no `deviceId`, so
27341
+ * "why did camera 618 forget the person it asked me about" could not be asked
27342
+ * of the logs at all. One line per camera (not per row) keeps the volume sane
27343
+ * while still answering the per-camera question.
27344
+ *
27345
+ * Reports rows that were ACTUALLY deleted — a row whose delete threw is still
27346
+ * in the buffer, and is reported by the failure line above instead.
27347
+ */
27348
+ announceSuggestedEvictions(victimsByDevice, deleted, cap) {
27349
+ for (const [deviceId, victims] of victimsByDevice) {
27350
+ const gone = victims.filter((id) => deleted.has(id));
27351
+ if (gone.length === 0) continue;
27352
+ this.logger.warn("face buffer evicted UNANSWERED suggestions — the operator never saw them", {
27353
+ tags: { deviceId },
27354
+ meta: {
27355
+ deleted: gone.length,
27356
+ cap,
27357
+ faceIds: gone.slice(0, SUGGESTED_EVICTION_IDS_LOGGED),
27358
+ why: "the suggested tier is full on this camera: suggestions arrive faster than they are answered, and the OLDEST unanswered ones are dropped first."
27359
+ }
27360
+ });
27361
+ }
27297
27362
  }
27298
27363
  /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
27299
27364
  async markAssigned(faceId, identityId, sampleId) {
@@ -29021,9 +29086,18 @@ function pairRiders(detections) {
29021
29086
  }
29022
29087
  //#endregion
29023
29088
  //#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
29024
- /** How far the grown box's centroid may sit, as a multiple of the LARGER
29025
- * diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
29026
- var GROWTH_CENTROID_FACTOR = 1.35;
29089
+ /**
29090
+ * How far the grown box's centroid may sit, as a multiple of the **TRACK's own**
29091
+ * diagonal. It was a multiple of the LARGER of the two diagonals until
29092
+ * 2026-08-25, which meant the bigger the INTRUDING detection, the further it
29093
+ * could reach to steal an identity. Live track `59dfb972` lost its car that
29094
+ * way: a 386×752 box 574 px away out-reached a 364×385 track purely because it
29095
+ * was 2.07× the area. The reach is now a property of the track alone.
29096
+ *
29097
+ * 2.3 covers the live `4285605a` bird→dog jump (74.7 px over a 35.8 px crop
29098
+ * diagonal = 2.085) with the same ~10% headroom the old 1.35 gave it.
29099
+ */
29100
+ var GROWTH_CENTROID_FACTOR = 2.3;
29027
29101
  function intersectionArea(a, b) {
29028
29102
  const x1 = Math.max(a.x, b.x);
29029
29103
  const y1 = Math.max(a.y, b.y);
@@ -29032,11 +29106,30 @@ function intersectionArea(a, b) {
29032
29106
  if (x2 <= x1 || y2 <= y1) return 0;
29033
29107
  return (x2 - x1) * (y2 - y1);
29034
29108
  }
29035
- /** Same-class partial crop growing into (or collapsing from) a full-body box
29036
- * with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
29037
- * A SMALL box nested inside a LARGE one is occlusion, not growth — those
29038
- * stay two tracks (sort-tracker occlusion suite). */
29039
- function growthAdjacentMatch(track, det) {
29109
+ /**
29110
+ * Same-class partial crop growing into (or collapsing from) a full-body box
29111
+ * with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
29112
+ * A SMALL box nested inside a LARGE one is occlusion, not growth — those
29113
+ * stay two tracks (sort-tracker occlusion suite).
29114
+ *
29115
+ * **Geometry alone cannot make this gate safe, and that is measured, not
29116
+ * assumed.** The two live cases:
29117
+ *
29118
+ * | case | dist | trackDiag | dist/trackDiag | dist/maxDiag | dt | trackDiag/s |
29119
+ * | --- | ---: | ---: | ---: | ---: | ---: | ---: |
29120
+ * | `4285605a` bird→dog (KEEP) | 74.7 | 35.8 | 2.085 | 1.238 | 170 ms | 12.26 |
29121
+ * | `59dfb972` car A→car B (REJECT) | 573.8 | 529.8 | 1.083 | 0.679 | 106 ms | 10.22 |
29122
+ *
29123
+ * The theft is a *milder* jump than the case the gate exists for, in EVERY
29124
+ * scale-relative measure — and still milder once divided by elapsed time. A
29125
+ * scale-invariant geometric predicate that accepts the bird therefore MUST
29126
+ * accept the car; so must a speed cap expressed in track-diagonals per second.
29127
+ * The separator has to be something other than the shape of the two boxes, and
29128
+ * the one that holds is track MATURITY ({@link GROWTH_MAX_TRACK_AGE_MS}).
29129
+ */
29130
+ function growthAdjacentMatch(track, det, ctx) {
29131
+ if (ctx.trackAgeMs > 1500) return false;
29132
+ if (ctx.gapMs > 1e3) return false;
29040
29133
  const trackArea = track.w * track.h;
29041
29134
  const detArea = det.w * det.h;
29042
29135
  if (trackArea <= 0 || detArea <= 0) return false;
@@ -29046,8 +29139,24 @@ function growthAdjacentMatch(track, det) {
29046
29139
  if (intersectionArea(track, det) / smaller >= .5) return false;
29047
29140
  const tc = bboxCentroid(track);
29048
29141
  const dc = bboxCentroid(det);
29049
- return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.max(Math.hypot(track.w, track.h), Math.hypot(det.w, det.h));
29142
+ return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.hypot(track.w, track.h);
29050
29143
  }
29144
+ /** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
29145
+ * the counts are per camera and per detection source. */
29146
+ var LooseBindSampler = class {
29147
+ counts = /* @__PURE__ */ new Map();
29148
+ /** Increment the (stage, gate) counter and answer with the running total when
29149
+ * this decision should be reported, or `undefined` when it is sampled out. */
29150
+ next(stage, gate) {
29151
+ const key = `${stage}|${gate}`;
29152
+ const count = (this.counts.get(key) ?? 0) + 1;
29153
+ this.counts.set(key, count);
29154
+ if (count === 1 || count % 50 === 0) return count;
29155
+ }
29156
+ reset() {
29157
+ this.counts.clear();
29158
+ }
29159
+ };
29051
29160
  //#endregion
29052
29161
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
29053
29162
  var DEFAULT_TRACKER_CONFIG = {
@@ -29125,12 +29234,62 @@ var SortTracker = class SortTracker {
29125
29234
  * moment the pair stops overlapping and are pruned when either track dies.
29126
29235
  */
29127
29236
  dupStreaks = /* @__PURE__ */ new Map();
29237
+ /** Per-(stage, gate) counters behind the sampled salvage-bind diagnostics. */
29238
+ bindSampler = new LooseBindSampler();
29239
+ /** Sink for salvage-bind diagnostics. Null until an owner that knows the
29240
+ * `deviceId` wires one — see {@link setBindObserver}. */
29241
+ bindObserver = null;
29128
29242
  constructor(config = {}) {
29129
29243
  this.config = {
29130
29244
  ...DEFAULT_TRACKER_CONFIG,
29131
29245
  ...config
29132
29246
  };
29133
29247
  }
29248
+ /**
29249
+ * Wire the diagnostics sink for the salvage branches (rescue, resurrection,
29250
+ * growth spawn-dedup). The tracker has no `deviceId`; the caller that does
29251
+ * stamps it on the log line. Stage 1 is never reported — it runs per track
29252
+ * per frame at frame-rate.
29253
+ */
29254
+ setBindObserver(observer) {
29255
+ this.bindObserver = observer;
29256
+ }
29257
+ /** Emit one salvage decision, subject to {@link LOOSE_BIND_LOG_SAMPLE_EVERY}.
29258
+ * Nothing is computed when no observer is wired. */
29259
+ reportBind(stage, gate, track, det, timestamp) {
29260
+ const observer = this.bindObserver;
29261
+ if (!observer) return;
29262
+ const bindCount = this.bindSampler.next(stage, gate);
29263
+ if (bindCount === void 0) return;
29264
+ const tc = bboxCentroid(track.bbox);
29265
+ const dc = bboxCentroid(det.bbox);
29266
+ const trackArea = track.bbox.w * track.bbox.h;
29267
+ const detArea = det.bbox.w * det.bbox.h;
29268
+ const smaller = Math.min(trackArea, detArea);
29269
+ observer({
29270
+ stage,
29271
+ gate,
29272
+ trackId: track.id,
29273
+ trackClass: track.class,
29274
+ detClass: det.class,
29275
+ detScore: det.score,
29276
+ iou: iou$2(track.bbox, det.bbox),
29277
+ centroidDistPx: Math.hypot(tc.x - dc.x, tc.y - dc.y),
29278
+ trackDiagPx: Math.hypot(track.bbox.w, track.bbox.h),
29279
+ areaRatio: smaller > 0 ? Math.max(trackArea, detArea) / smaller : 0,
29280
+ dtMs: timestamp - track.lastSeen,
29281
+ trackAgeMs: timestamp - track.firstSeen,
29282
+ bindCount,
29283
+ sampledEvery: 50
29284
+ });
29285
+ }
29286
+ /** When the pair is being judged, relative to `track`. */
29287
+ static growthContext(track, timestamp) {
29288
+ return {
29289
+ trackAgeMs: timestamp - track.firstSeen,
29290
+ gapMs: timestamp - track.lastSeen
29291
+ };
29292
+ }
29134
29293
  /** Whether two macro-classes may be collapsed as the SAME subject. Same class
29135
29294
  * always; the {person,animal} pair only when `personAnimalDedup` is on. */
29136
29295
  dedupCompatible(a, b) {
@@ -29175,8 +29334,15 @@ var SortTracker = class SortTracker {
29175
29334
  * set of tracks already surviving this frame (matched, coasting, resurrected,
29176
29335
  * and earlier same-frame spawns).
29177
29336
  */
29178
- isDuplicateSpawn(det, live) {
29179
- for (const t of live) if (this.dedupCompatible(t.class, det.class) && (iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou || growthAdjacentMatch(t.bbox, det.bbox))) return true;
29337
+ isDuplicateSpawn(det, live, timestamp) {
29338
+ for (const t of live) {
29339
+ if (!this.dedupCompatible(t.class, det.class)) continue;
29340
+ if (iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
29341
+ if (growthAdjacentMatch(t.bbox, det.bbox, SortTracker.growthContext(t, timestamp))) {
29342
+ this.reportBind("spawn-dedup", "growth", t, det, timestamp);
29343
+ return true;
29344
+ }
29345
+ }
29180
29346
  if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
29181
29347
  for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
29182
29348
  }
@@ -29263,15 +29429,36 @@ var SortTracker = class SortTracker {
29263
29429
  * crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
29264
29430
  * class.
29265
29431
  */
29266
- looseMatch(track, det) {
29267
- if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return false;
29268
- if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
29432
+ looseMatch(track, det, timestamp) {
29433
+ if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
29434
+ if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
29269
29435
  const tc = bboxCentroid(track.bbox);
29270
29436
  const dc = bboxCentroid(det.bbox);
29271
29437
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
29272
29438
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
29273
- if (dist <= this.config.rescueCentroidFactor * diag) return true;
29274
- return growthAdjacentMatch(track.bbox, det.bbox);
29439
+ if (dist <= this.config.rescueCentroidFactor * diag) return "centroid";
29440
+ if (growthAdjacentMatch(track.bbox, det.bbox, SortTracker.growthContext(track, timestamp))) return "growth";
29441
+ return null;
29442
+ }
29443
+ /**
29444
+ * Whether `det` would mint a track of its own if no salvage branch claimed it:
29445
+ * it clears BOTH spawn floors (per-class score, per-class bbox-area fraction).
29446
+ *
29447
+ * A rescue that rests on PROXIMITY ALONE — the `centroid` clause, reached only
29448
+ * when the overlap is already below `rescueIouThreshold` — must lose to such a
29449
+ * detection. Live track `27855a56` bound a second car 452 px away at IoU 0.058
29450
+ * purely because 452 ≤ 0.75 × the 589×445 track diagonal; the box it stole was
29451
+ * a full-size, floor-clearing vehicle that would have spawned perfectly well
29452
+ * on its own. **A spare track costs media; a stolen track corrupts the plate,
29453
+ * the travelled distance and the last frame of the track it was taken from.**
29454
+ *
29455
+ * The `iou` clause is untouched (real overlap IS evidence), and so is `growth`
29456
+ * — that one is bounded by track maturity inside `growthAdjacentMatch`, and a
29457
+ * partial crop is by construction the same subject, so refusing it here would
29458
+ * re-fragment `4285605a`.
29459
+ */
29460
+ isPlausibleBirth(det, frameArea) {
29461
+ return this.meetsClassScoreFloor(det) && this.meetsSpawnAreaFloor(det, frameArea);
29275
29462
  }
29276
29463
  /** Fold a matched detection's class + score into a track's lifetime vote
29277
29464
  * tally. A non-positive score still counts as an infinitesimal vote so a
@@ -29385,20 +29572,25 @@ var SortTracker = class SortTracker {
29385
29572
  for (let di = 0; di < detections.length; di++) {
29386
29573
  if (used.has(di) || lowSet.has(di)) continue;
29387
29574
  const det = detections[di];
29388
- if (!this.looseMatch(track, det)) continue;
29575
+ const gate = this.looseMatch(track, det, timestamp);
29576
+ if (gate === null) continue;
29577
+ if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
29389
29578
  rescuePairs.push({
29390
29579
  track,
29391
29580
  detIdx: di,
29392
- score: iou$2(track.bbox, det.bbox)
29581
+ score: iou$2(track.bbox, det.bbox),
29582
+ gate
29393
29583
  });
29394
29584
  }
29395
29585
  }
29396
29586
  rescuePairs.sort((a, b) => b.score - a.score);
29397
29587
  for (const pair of rescuePairs) {
29398
29588
  if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
29399
- matched.set(pair.track, detections[pair.detIdx]);
29589
+ const det = detections[pair.detIdx];
29590
+ matched.set(pair.track, det);
29400
29591
  matchedTracks.add(pair.track);
29401
29592
  used.add(pair.detIdx);
29593
+ this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
29402
29594
  }
29403
29595
  if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, used, matchedTracks, matched);
29404
29596
  for (const [track, det] of matched) {
@@ -29452,17 +29644,22 @@ var SortTracker = class SortTracker {
29452
29644
  const det = detections[di];
29453
29645
  let best;
29454
29646
  let bestScore = -1;
29647
+ let bestGate = "iou";
29455
29648
  for (const lost of this.lostTracks) {
29456
29649
  if (!lost.resurrectable) continue;
29457
29650
  if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
29458
- if (!this.looseMatch(lost, det)) continue;
29651
+ const gate = this.looseMatch(lost, det, timestamp);
29652
+ if (gate === null) continue;
29653
+ if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
29459
29654
  const score = iou$2(lost.bbox, det.bbox);
29460
29655
  if (score > bestScore) {
29461
29656
  best = lost;
29462
29657
  bestScore = score;
29658
+ bestGate = gate;
29463
29659
  }
29464
29660
  }
29465
29661
  if (!best) continue;
29662
+ this.reportBind("resurrection", bestGate, best, det, timestamp);
29466
29663
  this.lostTracks = this.lostTracks.filter((t) => t !== best);
29467
29664
  best.bbox = det.bbox;
29468
29665
  best.class = det.class;
@@ -29502,7 +29699,7 @@ var SortTracker = class SortTracker {
29502
29699
  continue;
29503
29700
  }
29504
29701
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
29505
- if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
29702
+ if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving, timestamp)) {
29506
29703
  used.add(di);
29507
29704
  continue;
29508
29705
  }
@@ -29561,6 +29758,7 @@ var SortTracker = class SortTracker {
29561
29758
  this.tracks = [];
29562
29759
  this.lostTracks = [];
29563
29760
  this.dupStreaks.clear();
29761
+ this.bindSampler.reset();
29564
29762
  }
29565
29763
  };
29566
29764
  //#endregion
@@ -29977,6 +30175,16 @@ var FrameProcessor = class {
29977
30175
  setStationaryGate(gate) {
29978
30176
  this.stationaryGate = gate;
29979
30177
  }
30178
+ /**
30179
+ * Wire (or clear) the tracker's salvage-bind diagnostics sink. The tracker
30180
+ * itself knows no `deviceId`; the addon that owns this processor stamps
30181
+ * `tags: { deviceId }` on the line — see the wiring in `pipeline-analytics`.
30182
+ * Only the rescue / resurrection / growth branches report, and they report
30183
+ * sampled: stage 1 runs per track per frame at frame-rate.
30184
+ */
30185
+ setBindObserver(observer) {
30186
+ this.tracker.setBindObserver(observer);
30187
+ }
29980
30188
  /** Forget a track in the underlying tracker (used when the addon promotes it
29981
30189
  * to a stationary-object registry entry). */
29982
30190
  dropTrack(trackId) {
@@ -34936,6 +35144,70 @@ async function makeSquareThumb(bytes, size) {
34936
35144
  }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
34937
35145
  }
34938
35146
  //#endregion
35147
+ //#region src/pipeline-analytics/track-media-scope.ts
35148
+ /** The stored kind a detail owner's crop is written with. */
35149
+ var STORED_DETAIL_CROP_KIND = "crop";
35150
+ /** The kinds only a detail owner can produce. */
35151
+ var DETAIL_CROP_KINDS = ["faceCrop", "plateCrop"];
35152
+ /**
35153
+ * Every owner that holds media for `trackId`, in publication order (the track's
35154
+ * own rows first, then face, then plate).
35155
+ */
35156
+ function trackMediaOwnerScopes(trackId) {
35157
+ return [
35158
+ {
35159
+ ownerKind: "track",
35160
+ ownerId: trackId,
35161
+ publishCropAs: null
35162
+ },
35163
+ {
35164
+ ownerKind: "face",
35165
+ ownerId: `${FACE_MEDIA_OWNER_PREFIX}${trackId}`,
35166
+ publishCropAs: "faceCrop"
35167
+ },
35168
+ {
35169
+ ownerKind: "plate",
35170
+ ownerId: `${PLATE_MEDIA_OWNER_PREFIX}${trackId}`,
35171
+ publishCropAs: "plateCrop"
35172
+ }
35173
+ ];
35174
+ }
35175
+ /**
35176
+ * The scopes worth READING for a `kinds`-filtered request.
35177
+ *
35178
+ * A detail owner can only ever publish `faceCrop` / `plateCrop`, so a request
35179
+ * that names neither is answered without opening those two owners — which is
35180
+ * what keeps the common per-entry fetch (`kinds: ['keyFrameSmall']`) at exactly
35181
+ * its previous cost. The track scope is never skipped: it is the only owner
35182
+ * whose kinds are open-ended, and dropping it on a guess is how a listing
35183
+ * starts lying about what a track has.
35184
+ */
35185
+ function trackMediaScopesForKinds(trackId, kinds) {
35186
+ const scopes = trackMediaOwnerScopes(trackId);
35187
+ if (kinds === void 0 || kinds.length === 0) return scopes;
35188
+ return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
35189
+ }
35190
+ /**
35191
+ * Fold every scope's rows into ONE track media listing: detail crops
35192
+ * relabelled, everything filtered to `kinds`, ordered oldest first.
35193
+ *
35194
+ * `relabel` is injected rather than spread inline so the caller keeps its own
35195
+ * concrete row type (`MediaFile` with bytes, `MediaFileInfo` without) — there
35196
+ * is no cast and no third shape in between.
35197
+ */
35198
+ function collectTrackMedia(scoped, relabel, kinds) {
35199
+ const out = [];
35200
+ for (const { scope, rows } of scoped) for (const row of rows) {
35201
+ if (scope.publishCropAs === null) {
35202
+ out.push(row);
35203
+ continue;
35204
+ }
35205
+ if (row.kind !== STORED_DETAIL_CROP_KIND) continue;
35206
+ out.push(relabel(row, scope.publishCropAs));
35207
+ }
35208
+ return (kinds === void 0 || kinds.length === 0 ? out : out.filter((row) => kinds.includes(row.kind))).toSorted((a, b) => a.timestamp - b.timestamp);
35209
+ }
35210
+ //#endregion
34939
35211
  //#region src/pipeline-analytics/zone-name-stamp.ts
34940
35212
  /**
34941
35213
  * Copy each track with `zoneNames` stamped on.
@@ -35055,10 +35327,25 @@ var AnalyticsQueryFacade = class {
35055
35327
  const all = await (this.deps.mediaStore()?.listByOwner("event", input.eventId, input.deviceId) ?? Promise.resolve([]));
35056
35328
  return input.kind ? all.filter((m) => m.kind === input.kind) : all;
35057
35329
  }
35330
+ /**
35331
+ * A track's media WITH the bytes, restricted to `kinds` when given.
35332
+ *
35333
+ * Reads every owner a track's media lives under — its own rows plus the face
35334
+ * and plate detail crops, whose stored `kind: 'crop'` is published as
35335
+ * `faceCrop` / `plateCrop`. See `track-media-scope.ts` for why the rename
35336
+ * happens on the way out and not in the caller's question.
35337
+ */
35058
35338
  async getTrackMedia(input) {
35059
- const all = await (this.deps.mediaStore()?.listByOwner("track", input.trackId, input.deviceId) ?? Promise.resolve([]));
35060
- const kinds = input.kinds;
35061
- return kinds && kinds.length > 0 ? all.filter((m) => kinds.includes(m.kind)) : all;
35339
+ const store = this.deps.mediaStore();
35340
+ if (store === null) return [];
35341
+ const scopes = trackMediaScopesForKinds(input.trackId, input.kinds);
35342
+ return collectTrackMedia(await Promise.all(scopes.map(async (scope) => ({
35343
+ scope,
35344
+ rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
35345
+ }))), (row, kind) => ({
35346
+ ...row,
35347
+ kind
35348
+ }), input.kinds);
35062
35349
  }
35063
35350
  /**
35064
35351
  * A track's media WITHOUT the bytes.
@@ -35068,9 +35355,21 @@ var AnalyticsQueryFacade = class {
35068
35355
  * that path reads every blob off disk and base64-encodes it first, so the
35069
35356
  * response would shrink while the work did not. Measured: 7.67 MB read and
35070
35357
  * ~10 MB of string allocated, to answer a question the index already holds.
35358
+ *
35359
+ * Covers the same owners as {@link getTrackMedia} — a manifest that listed
35360
+ * fewer keys than the byte read can serve (or more) is the disagreement the
35361
+ * viewer's per-entry loader turns into a red triangle.
35071
35362
  */
35072
35363
  async listTrackMedia(input) {
35073
- return this.deps.mediaStore()?.listInfoByOwner("track", input.trackId, input.deviceId) ?? Promise.resolve([]);
35364
+ const store = this.deps.mediaStore();
35365
+ if (store === null) return [];
35366
+ return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
35367
+ scope,
35368
+ rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
35369
+ }))), (row, kind) => ({
35370
+ ...row,
35371
+ kind
35372
+ }), void 0);
35074
35373
  }
35075
35374
  async pruneEventsBefore(input) {
35076
35375
  const eventStore = this.deps.eventStore();
@@ -37610,13 +37909,20 @@ var FaceSettingsSchema = require_dist.object({
37610
37909
  * 0.175, best accuracy 98.3% at threshold 0.28.
37611
37910
  *
37612
37911
  * Recalibration plan, in this order:
37613
- * 1. land the landmark-precision gate (the alignment fix),
37912
+ * 1. DONE (2026-08-19, commit b6641329f): the landmark-precision gate —
37913
+ * `landmark-precision-gate.ts` in `addon-pipeline`, called from
37914
+ * `detection-pipeline/pipeline/executor.ts:1164` BEFORE the native-size
37915
+ * substitution, floor `minLandmarkFaceSize` (default 24 detection px).
37916
+ * Do not re-implement it; the steps below are what is still open.
37614
37917
  * 2. re-enrol the gallery from crops produced AFTER it,
37615
37918
  * 3. re-measure genuine/impostor over a week of post-fix traffic,
37616
37919
  * 4. expect to lower this toward ~0.30, and `margin` with it.
37617
- * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
37618
- * scores ~0.9 against everything, so the degenerate population sits ABOVE
37619
- * any threshold you could pick — the knob cannot reach it.
37920
+ * Lowering it before step 2 is still actively harmful: the gate stops NEW
37921
+ * degenerate crops, but every vector enrolled before it is still in the
37922
+ * gallery, and a misaligned crop scores ~0.9 against everything — so the
37923
+ * degenerate population sits ABOVE any threshold you could pick and the knob
37924
+ * cannot reach it. Steps 3 and 4 need data that only exists once step 2 has
37925
+ * run and post-fix traffic has accumulated.
37620
37926
  */
37621
37927
  similarityThreshold: require_dist.number().min(0).max(1).default(.55),
37622
37928
  /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
@@ -37752,6 +38058,18 @@ function deriveSuggestionMinCosine(similarityThreshold) {
37752
38058
  return rounded < similarityThreshold ? rounded : exact;
37753
38059
  }
37754
38060
  /**
38061
+ * The suggested tier's capacity for a given ordinary capacity.
38062
+ *
38063
+ * INVARIANT, total over every finite input: the result is a non-negative
38064
+ * integer, is never BELOW `bufferMaxPerDevice` (a suggested row is never worse
38065
+ * off than an unsuggested one), and is `0` exactly when the ordinary cap is `0`
38066
+ * (one switch turns the buffer off, not two).
38067
+ */
38068
+ function deriveSuggestedBufferCap(bufferMaxPerDevice) {
38069
+ if (!Number.isFinite(bufferMaxPerDevice) || bufferMaxPerDevice <= 0) return 0;
38070
+ return Math.round(bufferMaxPerDevice * 4);
38071
+ }
38072
+ /**
37755
38073
  * The settings key that used to hold the suggestion floor. Named once, here,
37756
38074
  * because the ONLY code allowed to mention it now is the migration that removes
37757
38075
  * it from a store.
@@ -44326,13 +44644,27 @@ var SUPPRESS_IOU = .6;
44326
44644
  */
44327
44645
  var WAKE_ASSOC_IOU = .1;
44328
44646
  /**
44329
- * Look-back window over which a track must have stayed put to be PROMOTED. A
44330
- * car that drives in then parks has a large whole-life displacement but a tiny
44331
- * last-`windowMs` displacement — so promotion is judged on the recent window,
44332
- * not the full path. Also the minimum age (the track must have EXISTED this
44333
- * long) so a freshly-spawned static blob isn't promoted instantly.
44334
- */
44335
- var PROMOTION_WINDOW_MS = 3e4;
44647
+ * Look-back window over which a track must have stayed put to stop counting as
44648
+ * PRESENT for the orchestrator's session hold.
44649
+ *
44650
+ * This used to be the promotion criterion — "a track that stayed still this
44651
+ * long IS a parked object" — and it was measuring the wrong thing: it rewarded
44652
+ * an object seen well and often, while the parked cars that actually need
44653
+ * suppressing are re-discovered as dozens of short tracks that never live this
44654
+ * long (device 617: 140 tracks for one car, median life 6.8 s). Birth moved to
44655
+ * repetition — see `stationary-evidence.ts`.
44656
+ *
44657
+ * The stillness test survives because it was doing TWO jobs and only one of
44658
+ * them was destructive. Creating an entry BLINDS the pipeline to an object, so
44659
+ * it needs repeated evidence. Deciding that a motionless subject should stop
44660
+ * holding a motion session open destroys nothing — if it is wrong the session
44661
+ * closes early and the next motion re-opens it — so one recent stillness read
44662
+ * is the right amount of proof. Without this valve, removing promotion would
44663
+ * leave every session containing a parked car pinned to `maxSessionHoldMs`
44664
+ * (120 s) forever: 617 measured 504 sessions in 24 h, which is 70% detection
44665
+ * uptime on one camera, and three cameras are already above 50%.
44666
+ */
44667
+ var SESSION_STILLNESS_WINDOW_MS = 3e4;
44336
44668
  /** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
44337
44669
  * long (object removed while unobserved, or a long occlusion) → retire it. */
44338
44670
  var ENTRY_TTL_MS = 5 * 6e4;
@@ -44353,15 +44685,20 @@ var DEFAULT_MATCH_CONFIG = {
44353
44685
  * which entries are confirmed present, and which entries WOKE (their object
44354
44686
  * moved → retire the entry and let the detection spawn a normal track).
44355
44687
  *
44356
- * 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
44357
- * decides whether it has stayed put long enough to become a stationary
44358
- * entry. Reuses the static-track-gate metrics (net displacement + path span
44359
- * normalised to the frame diagonal) over the recent look-back window.
44688
+ * 2. `trackIsStill` — given a track's recent centroid path, decides whether it
44689
+ * has stopped moving. Reuses the static-track-gate metrics (net
44690
+ * displacement + path span normalised to the frame diagonal) over the
44691
+ * recent look-back window.
44692
+ *
44693
+ * This does NOT create a stationary entry, and used to. Birth is decided by
44694
+ * repetition (`stationary-evidence.ts`); this predicate now answers the one
44695
+ * question whose wrong answer is harmless — "should this subject still hold
44696
+ * a motion session open?"
44360
44697
  */
44361
- /** Default promotion tunables — static thresholds shared with the key-event
44362
- * static gate so "stationary" means the same thing in both places. */
44363
- var DEFAULT_PROMOTION_CONFIG = {
44364
- windowMs: PROMOTION_WINDOW_MS,
44698
+ /** Default stillness tunables — static thresholds shared with the key-event
44699
+ * static gate so "still" means the same thing in both places. */
44700
+ var DEFAULT_SESSION_STILLNESS_CONFIG = {
44701
+ windowMs: SESSION_STILLNESS_WINDOW_MS,
44365
44702
  netFracMax: STATIC_DISPLACEMENT_FRAC,
44366
44703
  spanFracMax: STATIC_SPAN_FRAC,
44367
44704
  minPoints: 4
@@ -44483,26 +44820,29 @@ function partitionDetectionsAgainstRegistry(input) {
44483
44820
  }
44484
44821
  var EMPTY_ARMED = /* @__PURE__ */ new Set();
44485
44822
  /**
44486
- * A track is promoted when, over the recent `windowMs`, its centroid barely
44487
- * moved (both net displacement and path span below the static thresholds) AND
44488
- * the track has actually EXISTED for at least `windowMs` (so a car that just
44489
- * arrived isn't parked yet). Judging on the recent window — not the whole life
44490
- * — is what lets a car that drove in then parked be recognised as stationary.
44823
+ * A track is STILL when, over the recent `windowMs`, its centroid barely moved
44824
+ * (both net displacement and path span below the static thresholds) AND the
44825
+ * track has actually EXISTED for at least `windowMs` (so a car that just
44826
+ * arrived has not stopped yet). Judging on the recent window — not the whole
44827
+ * life — is what lets a car that drove in then stopped be recognised.
44828
+ *
44829
+ * The one consumer is the orchestrator's presence signal: a still subject stops
44830
+ * holding its motion session open. It creates nothing and suppresses nothing.
44491
44831
  */
44492
- function evaluateStationaryPromotion(input) {
44832
+ function trackIsStill(input) {
44493
44833
  const { positions, referenceDiagonalPx, now, config } = input;
44494
- if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
44495
- if (now - positions[0].timestamp < config.windowMs) return { promote: false };
44834
+ if (!(referenceDiagonalPx > 0) || positions.length === 0) return { still: false };
44835
+ if (now - positions[0].timestamp < config.windowMs) return { still: false };
44496
44836
  const cutoff = now - config.windowMs;
44497
44837
  const window = positions.filter((p) => p.timestamp >= cutoff);
44498
- if (window.length < config.minPoints) return { promote: false };
44838
+ if (window.length < config.minPoints) return { still: false };
44499
44839
  const metrics = computeStaticTrackMetrics(window.map((p) => ({
44500
44840
  x: p.x,
44501
44841
  y: p.y
44502
44842
  })), referenceDiagonalPx);
44503
- if (metrics === void 0) return { promote: false };
44843
+ if (metrics === void 0) return { still: false };
44504
44844
  return {
44505
- promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
44845
+ still: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
44506
44846
  netFrac: metrics.netDisplacementFrac,
44507
44847
  spanFrac: metrics.pathSpanFrac
44508
44848
  };
@@ -45009,6 +45349,602 @@ var PackageDropDetector = class {
45009
45349
  }
45010
45350
  };
45011
45351
  //#endregion
45352
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
45353
+ /**
45354
+ * stationary-evidence — how a parked object EARNS a registry entry.
45355
+ *
45356
+ * ## The criterion this replaces, and why it was measuring the wrong thing
45357
+ *
45358
+ * Until 2026-08-28 an object became stationary by TRACK CONTINUITY: one track
45359
+ * had to stay alive and barely move for `PROMOTION_WINDOW_MS` (30 s). That
45360
+ * rewards an object that is *seen well and often* — precisely the opposite of
45361
+ * what the operator needs. Measured on device 617 (the parking lot), 24 h:
45362
+ *
45363
+ * - three cars are parked in view; the registry held exactly ONE entry;
45364
+ * - the two it missed were re-discovered as **140 and 112 separate tracks**,
45365
+ * median life **6.8 s**, p90 29 s. Not one of them could ever reach 30 s.
45366
+ * - those two objects alone produced **13 494 of 18 402** vehicle
45367
+ * observations (73%) — the whole re-spawn flood the registry exists to stop.
45368
+ *
45369
+ * The track dies; the OBJECT does not. So the evidence must outlive the track.
45370
+ *
45371
+ * ## The new criterion: a RATIO, not a count
45372
+ *
45373
+ * "The same thing, in the same place, most of the times it was seen."
45374
+ *
45375
+ * N — observations that AGREED with the candidate's anchor box (IoU ≥
45376
+ * {@link StationaryEvidencePolicy.agreeIou}).
45377
+ * M — observations OFFERED by a track once it bound to this candidate,
45378
+ * agreeing or not.
45379
+ *
45380
+ * A count would rescue nothing: a badly-lit car at night is seen rarely, and
45381
+ * any floor high enough to exclude a passer-by would exclude it too. A RATIO
45382
+ * asks a question a rarely-seen object can still answer well — 8 sightings out
45383
+ * of 10 in the same spot is stationary even though 10 is a small number.
45384
+ *
45385
+ * The dilution is the whole point, and it is why M counts a bound track's
45386
+ * observations even after it walks away: a car that drives THROUGH a spot binds
45387
+ * for a few frames and then contributes dozens of disagreeing ones. Measured on
45388
+ * 617 the two populations do not overlap:
45389
+ *
45390
+ * | candidate | N / M | ratio |
45391
+ * | ---------------------------- | ----------- | ----- |
45392
+ * | parked car A (missed today) | 6405 / 6467 | 0.99 |
45393
+ * | parked car B (missed today) | 343 / 343 | 1.00 |
45394
+ * | car crossing the lot | 5 / 70 | 0.07 |
45395
+ * | car turning through the lot | 14 / 91 | 0.15 |
45396
+ * | car manoeuvring | 12 / 41 | 0.29 |
45397
+ *
45398
+ * The threshold sits in the empty band between 0.29 and 0.99.
45399
+ *
45400
+ * ## The span is what a ratio cannot say
45401
+ *
45402
+ * A ratio alone promotes a car that paused for four seconds — its handful of
45403
+ * observations all agree. So evidence must also STRETCH: the agreeing
45404
+ * observations must span {@link StationaryEvidencePolicy.minSpanMs}. Measured
45405
+ * on 617, real parked cars span 2 min to 24 h; the two momentary stops in the
45406
+ * same day spanned 4.2 s and 8.0 s. Setting the span to
45407
+ * `DEFAULT_MAX_SESSION_HOLD_MS` also buys a structural guarantee: no single
45408
+ * motion session, however long it is held open, can promote on its own.
45409
+ *
45410
+ * ## ONE ledger, two producers
45411
+ *
45412
+ * Evidence is anchored to a BOX, not to a track and not to a session, so an
45413
+ * object confirmed half by an active motion session and half by a periodic
45414
+ * burst still promotes. Occasion A (the session) is implemented and is
45415
+ * sufficient on its own — it is free, because the pipeline is already decoding
45416
+ * and already inferring. Occasion B (the burst) is a precaution for the cases
45417
+ * where A could not have fired at all (hub restarted, camera offline, motion
45418
+ * detection failed, object already parked at boot); it is NOT implemented yet,
45419
+ * and {@link StationaryEvidenceSource} is the seam it will arrive through.
45420
+ *
45421
+ * ## Identity is IoU. There is no grid.
45422
+ *
45423
+ * The predecessor mechanism (`phantom-cells.ts`) quantises a point to a 32 px
45424
+ * cell, and its write path passes a CENTROID while its read path passes a
45425
+ * top-left CORNER — at 32 px every box bigger than 32 px lands in a different
45426
+ * cell on read, and its unit test never crosses that boundary because it calls
45427
+ * both halves with the same literal coordinates. This module has no such
45428
+ * boundary to get wrong: a candidate is found by overlap against the boxes a
45429
+ * device already holds, bounded by {@link EVIDENCE_MAX_CANDIDATES_PER_DEVICE}.
45430
+ * Device 617 — the busiest lot in the fleet — holds 8.
45431
+ */
45432
+ /**
45433
+ * `vehicle` and its kin. Every number below is read off device 617 over 24 h
45434
+ * (18 402 vehicle observations, 290 tracks) with the three parked cars the
45435
+ * operator can see out of the window as the bench.
45436
+ *
45437
+ * - `agreeIou` **0.5** — between the registry's own `WAKE_ASSOC_IOU` (0.1) and
45438
+ * `SUPPRESS_IOU` (0.6). At 0.7 the two missed cars lose 11% of their evidence
45439
+ * to night-time box wobble for no reduction in promotions; at 0.3 the day's
45440
+ * promotions double (10 vs 5) without catching anything new.
45441
+ * - `minAgreementRatio` **0.6** — the measured gap is [0.29, 0.90]; 0.6 is the
45442
+ * middle of it. Raising it to 0.95 starts dropping real parked cars.
45443
+ * - `minAgreed` **8** — a floor, not a discriminator: sweeping it 3→60 changes
45444
+ * nothing on 617 (the smallest true positive promotes with N=15). Set well
45445
+ * below that so a rarely-seen object is never excluded for being rare, which
45446
+ * is the entire point of using a ratio.
45447
+ * - `minSpanMs` **120 000** — the real discriminator. True positives span
45448
+ * 2 min–24 h; the day's two momentary stops spanned 4.2 s and 8.0 s.
45449
+ */
45450
+ var VEHICLE_EVIDENCE_POLICY = {
45451
+ agreeIou: .5,
45452
+ minAgreed: 8,
45453
+ minAgreementRatio: .6,
45454
+ minSpanMs: 12e4,
45455
+ promotesByDefault: true
45456
+ };
45457
+ /**
45458
+ * `person`, and it ships OFF.
45459
+ *
45460
+ * This is not caution, it is a measurement. Replaying 24 h of person
45461
+ * observations from devices 617, 1443, 590 and 592 through this exact ledger:
45462
+ *
45463
+ * | ratio | minAgreed | minSpanMs | promotions / 24 h across 4 cameras |
45464
+ * | ----- | --------- | --------- | ---------------------------------- |
45465
+ * | 0.85 | 20 | 600 s | 22 |
45466
+ * | 0.95 | 40 | 1800 s | 10 |
45467
+ * | 0.99 | 80 | 1800 s | **6** |
45468
+ *
45469
+ * There is no setting that reaches zero. Device 590 holds a person-shaped thing
45470
+ * that agrees with itself 1292 times out of 1654 (0.78) across 26 tracks over
45471
+ * ten hours, and device 592 produced eleven candidates at ratio 1.00 between
45472
+ * 02:00 and 05:00 — a statue, a poster, an IR artifact; the ledger cannot tell
45473
+ * those from a person standing still, because they look identical to it.
45474
+ *
45475
+ * Promoting a person is a BLINDING decision (D49 read in the direction that
45476
+ * destroys work: for a car the destructive read is the RETIREMENT — the flood
45477
+ * comes back; for a person it is the BIRTH — we stop seeing them). So the
45478
+ * class is available, tuned as strictly as the data allows, and switched off
45479
+ * until an operator turns it on for a camera where they know what is standing
45480
+ * there.
45481
+ */
45482
+ var PERSON_EVIDENCE_POLICY = {
45483
+ agreeIou: .5,
45484
+ minAgreed: 40,
45485
+ minAgreementRatio: .95,
45486
+ minSpanMs: 18e5,
45487
+ promotesByDefault: false
45488
+ };
45489
+ /** Classes with no policy of their own fall back to the vehicle numbers —
45490
+ * `animal`, `package` and friends are all "an object that ended up somewhere",
45491
+ * and none of them makes the camera blind to a person. */
45492
+ var DEFAULT_EVIDENCE_POLICY = VEHICLE_EVIDENCE_POLICY;
45493
+ var POLICY_BY_CLASS = new Map([["vehicle", VEHICLE_EVIDENCE_POLICY], ["person", PERSON_EVIDENCE_POLICY]]);
45494
+ function evidencePolicyFor(className) {
45495
+ return POLICY_BY_CLASS.get(className) ?? DEFAULT_EVIDENCE_POLICY;
45496
+ }
45497
+ function stationaryEvidenceKey(deviceId, className, anchorId) {
45498
+ return `${deviceId}:${className}:${anchorId}`;
45499
+ }
45500
+ /**
45501
+ * Does this candidate's evidence justify a registry entry?
45502
+ *
45503
+ * Pure, and ordered cheapest-question-first so the reason a candidate was
45504
+ * refused is the FIRST thing it failed, not the last.
45505
+ */
45506
+ function evaluateStationaryEvidence(row, policy, classEnabled) {
45507
+ const ratio = row.seen > 0 ? row.agreed / row.seen : 0;
45508
+ const spanMs = row.lastAgreedAt - row.firstAgreedAt;
45509
+ const base = {
45510
+ agreed: row.agreed,
45511
+ seen: row.seen,
45512
+ ratio,
45513
+ spanMs
45514
+ };
45515
+ if (!classEnabled) return {
45516
+ kind: "accumulating",
45517
+ shortfall: "class-not-promoted",
45518
+ ...base
45519
+ };
45520
+ if (row.agreed < policy.minAgreed) return {
45521
+ kind: "accumulating",
45522
+ shortfall: "too-few-agreements",
45523
+ ...base
45524
+ };
45525
+ if (ratio < policy.minAgreementRatio) return {
45526
+ kind: "accumulating",
45527
+ shortfall: "inconsistent-position",
45528
+ ...base
45529
+ };
45530
+ if (spanMs < policy.minSpanMs) return {
45531
+ kind: "accumulating",
45532
+ shortfall: "span-too-short",
45533
+ ...base
45534
+ };
45535
+ return {
45536
+ kind: "promote",
45537
+ ...base
45538
+ };
45539
+ }
45540
+ /**
45541
+ * The candidate this observation belongs to: the same-class row whose anchor it
45542
+ * overlaps most, provided the overlap reaches `agreeIou`. Overlap is the ONLY
45543
+ * notion of sameness here, and it is the same {@link iou} the suppression gate
45544
+ * uses — a second notion of "same place" is a second source of truth.
45545
+ */
45546
+ function matchEvidenceCandidate(rows, className, bbox, agreeIou) {
45547
+ let best;
45548
+ let bestOverlap = 0;
45549
+ for (const row of rows) {
45550
+ if (row.className !== className) continue;
45551
+ const overlap = iou(row.anchor, bbox);
45552
+ if (overlap > bestOverlap) {
45553
+ bestOverlap = overlap;
45554
+ best = row;
45555
+ }
45556
+ }
45557
+ if (best === void 0 || bestOverlap < agreeIou) return void 0;
45558
+ return {
45559
+ row: best,
45560
+ overlap: bestOverlap
45561
+ };
45562
+ }
45563
+ /**
45564
+ * Fold one observation into a candidate.
45565
+ *
45566
+ * `agrees` is decided by the CALLER, because M is what makes the ratio mean
45567
+ * anything: once a track has bound to a candidate, every later observation of
45568
+ * that track counts toward M whether it agrees or not. That is what dilutes a
45569
+ * car that merely drove through the spot, and it is why this function cannot
45570
+ * re-derive agreement from the box alone.
45571
+ */
45572
+ function foldObservation(row, observation, agrees, overlap) {
45573
+ const seen = row.seen + 1;
45574
+ if (!agrees) return {
45575
+ ...row,
45576
+ seen,
45577
+ updatedAt: observation.timestamp
45578
+ };
45579
+ const improves = overlap >= iou(row.anchor, row.bestBbox);
45580
+ return {
45581
+ ...row,
45582
+ seen,
45583
+ agreed: row.agreed + 1,
45584
+ firstAgreedAt: Math.min(row.firstAgreedAt, observation.timestamp),
45585
+ lastAgreedAt: Math.max(row.lastAgreedAt, observation.timestamp),
45586
+ ...improves ? {
45587
+ bestBbox: { ...observation.bbox },
45588
+ ...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
45589
+ ...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
45590
+ ...observation.label !== void 0 ? { label: observation.label } : {}
45591
+ } : {},
45592
+ sessionAgreed: row.sessionAgreed + (observation.source === "session" ? 1 : 0),
45593
+ burstAgreed: row.burstAgreed + (observation.source === "burst" ? 1 : 0),
45594
+ updatedAt: observation.timestamp
45595
+ };
45596
+ }
45597
+ /** A brand-new candidate anchored on the observation that had nowhere to go. */
45598
+ function newEvidenceRow(observation, anchorId) {
45599
+ return {
45600
+ key: stationaryEvidenceKey(observation.deviceId, observation.className, anchorId),
45601
+ deviceId: observation.deviceId,
45602
+ className: observation.className,
45603
+ anchor: { ...observation.bbox },
45604
+ agreed: 1,
45605
+ seen: 1,
45606
+ firstAgreedAt: observation.timestamp,
45607
+ lastAgreedAt: observation.timestamp,
45608
+ bestBbox: { ...observation.bbox },
45609
+ ...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
45610
+ ...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
45611
+ ...observation.label !== void 0 ? { label: observation.label } : {},
45612
+ sessionAgreed: observation.source === "session" ? 1 : 0,
45613
+ burstAgreed: observation.source === "burst" ? 1 : 0,
45614
+ updatedAt: observation.timestamp
45615
+ };
45616
+ }
45617
+ //#endregion
45618
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence-ledger.ts
45619
+ /**
45620
+ * @durable class=ledger owner=pipeline-analytics
45621
+ * write="a candidate's BIRTH and its PROMOTION are written immediately; the per-frame agreement tallies are STAGED in RAM and flushed on the 5-minute sweep (a parked car is observed 5-30 times a second and one commit per observation would offer ~10^6 writes a day to maintain a handful of rows)"
45622
+ * retention="a candidate untouched for 6h is deleted, and a device is capped at 512 candidates (least-recently-touched evicted). A promoted candidate is deleted the moment its entry is born. Never orphaned by track retention — evidence describes an OBJECT that outlives every track that ever saw it, which is the entire reason this collection exists."
45623
+ */
45624
+ var STATIONARY_EVIDENCE_COLLECTION = "pipeline-analytics:stationary-evidence";
45625
+ var STATIONARY_EVIDENCE_COLUMNS = [
45626
+ {
45627
+ name: "id",
45628
+ type: "TEXT",
45629
+ primaryKey: true,
45630
+ notNull: true
45631
+ },
45632
+ {
45633
+ name: "deviceId",
45634
+ type: "INTEGER",
45635
+ notNull: true
45636
+ },
45637
+ {
45638
+ name: "className",
45639
+ type: "TEXT",
45640
+ notNull: true
45641
+ },
45642
+ {
45643
+ name: "anchor",
45644
+ type: "JSON"
45645
+ },
45646
+ {
45647
+ name: "agreed",
45648
+ type: "INTEGER"
45649
+ },
45650
+ {
45651
+ name: "seen",
45652
+ type: "INTEGER"
45653
+ },
45654
+ {
45655
+ name: "firstAgreedAt",
45656
+ type: "INTEGER"
45657
+ },
45658
+ {
45659
+ name: "lastAgreedAt",
45660
+ type: "INTEGER"
45661
+ },
45662
+ {
45663
+ name: "bestBbox",
45664
+ type: "JSON"
45665
+ },
45666
+ {
45667
+ name: "sourceTrackId",
45668
+ type: "TEXT"
45669
+ },
45670
+ {
45671
+ name: "keyFrameMediaKey",
45672
+ type: "TEXT"
45673
+ },
45674
+ {
45675
+ name: "label",
45676
+ type: "TEXT"
45677
+ },
45678
+ {
45679
+ name: "sessionAgreed",
45680
+ type: "INTEGER"
45681
+ },
45682
+ {
45683
+ name: "burstAgreed",
45684
+ type: "INTEGER"
45685
+ },
45686
+ {
45687
+ name: "updatedAt",
45688
+ type: "INTEGER"
45689
+ }
45690
+ ];
45691
+ var STATIONARY_EVIDENCE_INDEXES = [{
45692
+ name: "idx_stationary_evidence_device",
45693
+ columns: ["deviceId"]
45694
+ }];
45695
+ function parseBbox(value) {
45696
+ if (typeof value !== "object" || value === null) return null;
45697
+ const { x, y, w, h } = { ...value };
45698
+ if (typeof x !== "number" || typeof y !== "number") return null;
45699
+ if (typeof w !== "number" || typeof h !== "number") return null;
45700
+ return {
45701
+ x,
45702
+ y,
45703
+ w,
45704
+ h
45705
+ };
45706
+ }
45707
+ function numberOr(value, fallback) {
45708
+ return typeof value === "number" ? value : fallback;
45709
+ }
45710
+ function stringOrUndefined(value) {
45711
+ return typeof value === "string" ? value : void 0;
45712
+ }
45713
+ var StationaryEvidenceLedger = class {
45714
+ ledger;
45715
+ logger;
45716
+ /** deviceId → candidate keys, so a device's candidates are found without
45717
+ * scanning the fleet. */
45718
+ byDevice = /* @__PURE__ */ new Map();
45719
+ /** `deviceId:trackId` → candidate key. RAM-only, see the file header. */
45720
+ binding = /* @__PURE__ */ new Map();
45721
+ /** Keys whose staged tallies have not been persisted yet. */
45722
+ dirty = /* @__PURE__ */ new Set();
45723
+ anchorSeq = 0;
45724
+ constructor(deps) {
45725
+ this.logger = deps.logger;
45726
+ const spec = {
45727
+ collection: STATIONARY_EVIDENCE_COLLECTION,
45728
+ columns: [...STATIONARY_EVIDENCE_COLUMNS],
45729
+ indexes: [...STATIONARY_EVIDENCE_INDEXES],
45730
+ writeMode: "write-behind",
45731
+ keyOf: (row) => row.key,
45732
+ toValue: (row) => ({
45733
+ deviceId: row.deviceId,
45734
+ className: row.className,
45735
+ anchor: { ...row.anchor },
45736
+ agreed: row.agreed,
45737
+ seen: row.seen,
45738
+ firstAgreedAt: row.firstAgreedAt,
45739
+ lastAgreedAt: row.lastAgreedAt,
45740
+ bestBbox: { ...row.bestBbox },
45741
+ ...row.sourceTrackId !== void 0 ? { sourceTrackId: row.sourceTrackId } : {},
45742
+ ...row.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: row.keyFrameMediaKey } : {},
45743
+ ...row.label !== void 0 ? { label: row.label } : {},
45744
+ sessionAgreed: row.sessionAgreed,
45745
+ burstAgreed: row.burstAgreed,
45746
+ updatedAt: row.updatedAt
45747
+ }),
45748
+ fromRecord: (key, data) => {
45749
+ const deviceId = data.deviceId;
45750
+ const className = data.className;
45751
+ if (typeof deviceId !== "number" || typeof className !== "string") return null;
45752
+ const anchor = parseBbox(data.anchor);
45753
+ const bestBbox = parseBbox(data.bestBbox) ?? anchor;
45754
+ if (anchor === null || bestBbox === null) return null;
45755
+ const sourceTrackId = stringOrUndefined(data.sourceTrackId);
45756
+ const keyFrameMediaKey = stringOrUndefined(data.keyFrameMediaKey);
45757
+ const label = stringOrUndefined(data.label);
45758
+ const updatedAt = numberOr(data.updatedAt, 0);
45759
+ return {
45760
+ key,
45761
+ deviceId,
45762
+ className,
45763
+ anchor,
45764
+ agreed: numberOr(data.agreed, 0),
45765
+ seen: numberOr(data.seen, 0),
45766
+ firstAgreedAt: numberOr(data.firstAgreedAt, updatedAt),
45767
+ lastAgreedAt: numberOr(data.lastAgreedAt, updatedAt),
45768
+ bestBbox,
45769
+ ...sourceTrackId !== void 0 ? { sourceTrackId } : {},
45770
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
45771
+ ...label !== void 0 ? { label } : {},
45772
+ sessionAgreed: numberOr(data.sessionAgreed, 0),
45773
+ burstAgreed: numberOr(data.burstAgreed, 0),
45774
+ updatedAt
45775
+ };
45776
+ },
45777
+ deviceIdOf: (row) => row.deviceId
45778
+ };
45779
+ this.ledger = new DurableLedger({
45780
+ spec,
45781
+ store: deps.store,
45782
+ logger: deps.logger
45783
+ });
45784
+ }
45785
+ declare() {
45786
+ return this.ledger.declare();
45787
+ }
45788
+ /** Boot reseed. Returns how many candidates came back — "loaded 0" after a
45789
+ * container recreate is the one line that explains a silent regression. */
45790
+ async load() {
45791
+ const rows = await this.ledger.load();
45792
+ for (const row of rows) this.index(row);
45793
+ return rows.length;
45794
+ }
45795
+ get size() {
45796
+ return this.ledger.snapshot().length;
45797
+ }
45798
+ candidatesFor(deviceId) {
45799
+ const keys = this.byDevice.get(deviceId);
45800
+ if (!keys) return [];
45801
+ const rows = [];
45802
+ for (const key of keys) {
45803
+ const row = this.ledger.get(key);
45804
+ if (row) rows.push(row);
45805
+ }
45806
+ return rows;
45807
+ }
45808
+ /**
45809
+ * Fold ONE observation in and say whether it just earned an entry.
45810
+ *
45811
+ * Synchronous by construction: the verdict is read from the RAM mirror, so a
45812
+ * slow or failing store can never change what this frame decides. The
45813
+ * durable half is fired behind the verdict.
45814
+ */
45815
+ observe(observation, classEnabled) {
45816
+ const policy = evidencePolicyFor(observation.className);
45817
+ this.expire(observation.deviceId, observation.timestamp);
45818
+ const bindKey = observation.trackId === void 0 ? void 0 : `${observation.deviceId}:${observation.trackId}`;
45819
+ const candidates = this.candidatesFor(observation.deviceId);
45820
+ let row;
45821
+ const boundKey = bindKey === void 0 ? void 0 : this.binding.get(bindKey);
45822
+ if (boundKey !== void 0) row = this.ledger.get(boundKey);
45823
+ let overlap = row === void 0 ? 0 : iou(row.anchor, observation.bbox);
45824
+ let created = false;
45825
+ if (row === void 0) {
45826
+ const match = matchEvidenceCandidate(candidates, observation.className, observation.bbox, policy.agreeIou);
45827
+ if (match) {
45828
+ row = match.row;
45829
+ overlap = match.overlap;
45830
+ } else {
45831
+ this.anchorSeq += 1;
45832
+ row = newEvidenceRow(observation, `a${this.anchorSeq}`);
45833
+ overlap = 1;
45834
+ created = true;
45835
+ this.index(row);
45836
+ this.write(row);
45837
+ if (bindKey !== void 0) this.binding.set(bindKey, row.key);
45838
+ this.capDevice(observation.deviceId);
45839
+ return {
45840
+ row,
45841
+ verdict: evaluateStationaryEvidence(row, policy, classEnabled),
45842
+ promoted: false
45843
+ };
45844
+ }
45845
+ if (bindKey !== void 0) this.binding.set(bindKey, row.key);
45846
+ }
45847
+ const agrees = overlap >= policy.agreeIou;
45848
+ const next = foldObservation(row, observation, agrees, overlap);
45849
+ this.ledger.stage(next);
45850
+ this.dirty.add(next.key);
45851
+ if (created) this.capDevice(observation.deviceId);
45852
+ const verdict = evaluateStationaryEvidence(next, policy, classEnabled);
45853
+ if (verdict.kind !== "promote") return {
45854
+ row: next,
45855
+ verdict,
45856
+ promoted: false
45857
+ };
45858
+ this.forgetKey(next.key);
45859
+ return {
45860
+ row: next,
45861
+ verdict,
45862
+ promoted: true
45863
+ };
45864
+ }
45865
+ /** Persist the staged tallies. Called from the registry's 5-minute sweep. */
45866
+ async flush() {
45867
+ const keys = [...this.dirty];
45868
+ this.dirty.clear();
45869
+ let written = 0;
45870
+ for (const key of keys) {
45871
+ const row = this.ledger.get(key);
45872
+ if (!row) continue;
45873
+ await this.write(row);
45874
+ written += 1;
45875
+ }
45876
+ return written;
45877
+ }
45878
+ /** Drop a device's evidence wholesale (camera removed / analytics reset). */
45879
+ async clearDevice(deviceId) {
45880
+ for (const row of this.candidatesFor(deviceId)) await this.forgetKeyDurable(row.key);
45881
+ this.byDevice.delete(deviceId);
45882
+ for (const [bindKey] of this.binding) if (bindKey.startsWith(`${deviceId}:`)) this.binding.delete(bindKey);
45883
+ }
45884
+ /** Forget the bindings of tracks the tracker no longer carries, so the map
45885
+ * cannot grow for the process's lifetime on a busy camera. */
45886
+ retainTracks(deviceId, liveTrackIds) {
45887
+ const prefix = `${deviceId}:`;
45888
+ for (const bindKey of [...this.binding.keys()]) {
45889
+ if (!bindKey.startsWith(prefix)) continue;
45890
+ if (!liveTrackIds.has(bindKey.slice(prefix.length))) this.binding.delete(bindKey);
45891
+ }
45892
+ }
45893
+ forgetKey(key) {
45894
+ this.forgetKeyDurable(key);
45895
+ }
45896
+ async forgetKeyDurable(key) {
45897
+ const row = this.ledger.get(key);
45898
+ this.dirty.delete(key);
45899
+ if (row) this.byDevice.get(row.deviceId)?.delete(key);
45900
+ for (const [bindKey, boundTo] of this.binding) if (boundTo === key) this.binding.delete(bindKey);
45901
+ await this.ledger.forget(key);
45902
+ }
45903
+ async write(row) {
45904
+ this.dirty.delete(row.key);
45905
+ await this.ledger.put(row);
45906
+ }
45907
+ index(row) {
45908
+ let keys = this.byDevice.get(row.deviceId);
45909
+ if (!keys) {
45910
+ keys = /* @__PURE__ */ new Set();
45911
+ this.byDevice.set(row.deviceId, keys);
45912
+ }
45913
+ keys.add(row.key);
45914
+ }
45915
+ /** Drop candidates untouched for {@link EVIDENCE_WINDOW_MS}. Evidence
45916
+ * describes an object that can drive away; a stale candidate is not a
45917
+ * weaker one, it is one about a thing that is gone. */
45918
+ expire(deviceId, now) {
45919
+ for (const row of this.candidatesFor(deviceId)) {
45920
+ if (now - row.updatedAt <= 216e5) continue;
45921
+ this.logger.debug("stationary evidence expired — candidate untouched past the window", {
45922
+ tags: { deviceId },
45923
+ meta: {
45924
+ className: row.className,
45925
+ agreed: row.agreed,
45926
+ seen: row.seen,
45927
+ ageMs: now - row.updatedAt
45928
+ }
45929
+ });
45930
+ this.forgetKey(row.key);
45931
+ }
45932
+ }
45933
+ capDevice(deviceId) {
45934
+ const rows = this.candidatesFor(deviceId);
45935
+ if (rows.length <= 512) return;
45936
+ const excess = [...rows].sort((a, b) => a.updatedAt - b.updatedAt).slice(0, rows.length - 512);
45937
+ this.logger.info("stationary evidence capped — least-recently-touched candidates evicted", {
45938
+ tags: { deviceId },
45939
+ meta: {
45940
+ evicted: excess.length,
45941
+ cap: 512
45942
+ }
45943
+ });
45944
+ for (const row of excess) this.forgetKey(row.key);
45945
+ }
45946
+ };
45947
+ //#endregion
45012
45948
  //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
45013
45949
  /**
45014
45950
  * @durable class=ledger owner=pipeline-analytics
@@ -55654,8 +56590,8 @@ function resolveTrackingSettings(raw) {
55654
56590
  * with no drift. Guarded by a unit test asserting the equality.
55655
56591
  */
55656
56592
  var StationarySettingsSchema = require_dist.object({
55657
- /** Master switch. Off ⇒ no promotion + no suppression for this camera (every
55658
- * parked object keeps spawning normal tracks). */
56593
+ /** Master switch. Off ⇒ this camera gathers no evidence and suppresses
56594
+ * nothing (every parked object keeps spawning normal tracks). */
55659
56595
  enabled: require_dist.boolean().default(true),
55660
56596
  /** IoU at/above which a detection is the same parked object, unmoved →
55661
56597
  * suppress its spawn. `SUPPRESS_IOU`. */
@@ -55664,9 +56600,22 @@ var StationarySettingsSchema = require_dist.object({
55664
56600
  * wake it) — the overlap gate that stops a different vehicle from waking a
55665
56601
  * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
55666
56602
  wakeAssocIou: require_dist.number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
55667
- /** Recent-window stillness a track must hold to be PROMOTED to a parked
55668
- * entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
55669
- promotionWindowMs: require_dist.number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
56603
+ /** Recent-window stillness after which a track stops holding its motion
56604
+ * session open (also the minimum track age). Creates nothing — birth is
56605
+ * decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
56606
+ sessionStillnessWindowMs: require_dist.number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
56607
+ /**
56608
+ * Let a motionless PERSON become a stationary entry on this camera.
56609
+ *
56610
+ * Off, and it is a measurement rather than caution. Replaying 24 h of person
56611
+ * observations from devices 617/1443/590/592 through the evidence ledger
56612
+ * produces 6-22 entries a day depending on how strictly it is tuned, and none
56613
+ * of them can be tuned to zero: device 592 alone yielded eleven candidates at
56614
+ * a perfect agreement ratio between 02:00 and 05:00. A stationary entry
56615
+ * SUPPRESSES its object, so a wrong one makes the camera blind to a person —
56616
+ * turn this on only for a camera where you know what is standing there.
56617
+ */
56618
+ promotePersons: require_dist.boolean().default(false),
55670
56619
  /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
55671
56620
  * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
55672
56621
  entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
@@ -55685,7 +56634,8 @@ function resolveStationarySettings(raw) {
55685
56634
  enabled: pick("enabled"),
55686
56635
  suppressIou: pick("suppressIou"),
55687
56636
  wakeAssocIou: pick("wakeAssocIou"),
55688
- promotionWindowMs: pick("promotionWindowMs"),
56637
+ sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
56638
+ promotePersons: pick("promotePersons"),
55689
56639
  entryTtlMs: pick("entryTtlMs")
55690
56640
  };
55691
56641
  }
@@ -56210,17 +57160,24 @@ function buildDetectionSettingsSections() {
56210
57160
  },
56211
57161
  {
56212
57162
  type: "slider",
56213
- key: "promotionWindowMs",
56214
- label: "Promotion stillness window",
56215
- description: "How long (ms) an object must stay put — and the track must have existed — before it is promoted to a parked entry. Longer = slower to stop a re-spawn flood.",
57163
+ key: "sessionStillnessWindowMs",
57164
+ label: "Session stillness window",
57165
+ description: "How long (ms) a tracked object must stay put before it stops holding a motion session open. It creates nothing: a parked object becomes an entry by being seen repeatedly in the same place, not by one track surviving this long.",
56216
57166
  min: 5e3,
56217
57167
  max: 12e4,
56218
57168
  step: 1e3,
56219
- default: STATIONARY_DEFAULTS.promotionWindowMs,
57169
+ default: STATIONARY_DEFAULTS.sessionStillnessWindowMs,
56220
57170
  showValue: true,
56221
57171
  unit: "s",
56222
57172
  displayScale: 1e3
56223
57173
  },
57174
+ {
57175
+ type: "boolean",
57176
+ key: "promotePersons",
57177
+ label: "Also park motionless people",
57178
+ description: "Off by default. A stationary entry SUPPRESSES its object, so a wrong one makes this camera blind to a person standing there. Replaying a day of real person detections produced entries on every camera tried, at every threshold tried — turn this on only where you know what is standing still.",
57179
+ default: STATIONARY_DEFAULTS.promotePersons
57180
+ },
56224
57181
  {
56225
57182
  type: "slider",
56226
57183
  key: "entryTtlMs",
@@ -58713,6 +59670,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58713
59670
  * confirmation gate's bar there; one moving track clears it. Null until
58714
59671
  * onInitialize. */
58715
59672
  phantomCells = null;
59673
+ /** Repeated-sighting evidence — the ONLY thing that creates a stationary
59674
+ * entry. Two producers may feed it; only the motion session does today. */
59675
+ stationaryEvidence = null;
58716
59676
  mediaStore = null;
58717
59677
  /** The enrolled gallery's rows. Retained so the photo-enrollment bridge can
58718
59678
  * reach them without going back through the cap surface. Null until S7. */
@@ -59976,6 +60936,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59976
60936
  }
59977
60937
  });
59978
60938
  await this.stationaryRegistry.load();
60939
+ this.stationaryEvidence = new StationaryEvidenceLedger({
60940
+ store: api.settingsStore,
60941
+ logger: logger.child("StationaryEvidence")
60942
+ });
60943
+ await this.stationaryEvidence.declare();
60944
+ const evidenceLoaded = await this.stationaryEvidence.load();
60945
+ logger.info("stationary evidence loaded", { meta: { candidates: evidenceLoaded } });
59979
60946
  this.phantomCells = new PhantomCellRegistry({
59980
60947
  store: api.settingsStore,
59981
60948
  logger: logger.child("PhantomCells")
@@ -61364,6 +62331,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61364
62331
  this.retentionSweepTimer = setInterval(() => {
61365
62332
  this.sweepRetention();
61366
62333
  this.stationaryRegistry?.sweep(Date.now());
62334
+ this.stationaryEvidence?.flush();
61367
62335
  this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
61368
62336
  }, RETENTION_SWEEP_INTERVAL_MS);
61369
62337
  this.ltsFlushTimer = setInterval(() => {
@@ -62198,38 +63166,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62198
63166
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
62199
63167
  const packageSettings = this.packageDropSettingsFromCache(deviceId);
62200
63168
  const packageClasses = new Set(packageSettings.packageDropClassFilter);
62201
- if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
62202
- const dims = this.lastFrameDimsByDevice.get(deviceId);
62203
- if (dims && dims.w > 0 && dims.h > 0) {
62204
- const refDiag = Math.hypot(dims.w, dims.h);
62205
- const promotionConfig = {
62206
- ...DEFAULT_PROMOTION_CONFIG,
62207
- windowMs: stationarySettings.promotionWindowMs
63169
+ if (source === "pipeline" && this.stationaryRegistry && this.stationaryEvidence && stationarySettings.enabled) {
63170
+ const evidence = this.stationaryEvidence;
63171
+ for (const t of result.tracked) {
63172
+ if (packageClasses.has(t.className)) continue;
63173
+ const classEnabled = t.className === "person" ? stationarySettings.promotePersons : evidencePolicyFor(t.className).promotesByDefault;
63174
+ const active = this.trackStore?.peekActive(t.trackId) ?? null;
63175
+ const observedLabel = active === null ? void 0 : displayLabel(active);
63176
+ const observedKeyFrame = this.residents.keyFrameKey(t.trackId);
63177
+ const observation = {
63178
+ deviceId,
63179
+ className: t.className,
63180
+ bbox: { ...t.bbox },
63181
+ timestamp: result.timestamp,
63182
+ source: "session",
63183
+ trackId: t.trackId,
63184
+ ...observedKeyFrame !== void 0 ? { keyFrameMediaKey: observedKeyFrame } : {},
63185
+ ...observedLabel !== void 0 ? { label: observedLabel } : {}
62208
63186
  };
62209
- for (const t of result.tracked) {
62210
- if (packageClasses.has(t.className)) continue;
62211
- const active = this.trackStore.peekActive(t.trackId);
62212
- if (!active) continue;
62213
- const { promote } = evaluateStationaryPromotion({
62214
- positions: active.positions,
62215
- referenceDiagonalPx: refDiag,
62216
- now: result.timestamp,
62217
- config: promotionConfig
62218
- });
62219
- if (!promote) continue;
62220
- this.promoteToStationary({
62221
- deviceId,
62222
- key,
62223
- processor,
62224
- track: t,
62225
- firstSeen: active.firstSeen,
62226
- label: displayLabel(active),
62227
- frameWidth: result.frameWidth,
62228
- frameHeight: result.frameHeight,
62229
- timestamp: result.timestamp
62230
- });
62231
- }
63187
+ const outcome = evidence.observe(observation, classEnabled);
63188
+ if (outcome === void 0 || !outcome.promoted) continue;
63189
+ this.birthStationaryFromEvidence({
63190
+ deviceId,
63191
+ key,
63192
+ processor,
63193
+ track: t,
63194
+ row: outcome.row,
63195
+ frameWidth: result.frameWidth,
63196
+ frameHeight: result.frameHeight,
63197
+ timestamp: result.timestamp,
63198
+ firstSeen: active === null ? outcome.row.firstAgreedAt : active.firstSeen
63199
+ });
62232
63200
  }
63201
+ evidence.retainTracks(deviceId, currentTrackIds);
62233
63202
  }
62234
63203
  if (source === "pipeline") this.runPackageStillness({
62235
63204
  deviceId,
@@ -62522,7 +63491,22 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62522
63491
  }
62523
63492
  overlayDetections = frame.detections;
62524
63493
  }
62525
- const hasMovingTrack = result.tracked.some((t) => t.state === "moving" || t.state === "entered" || t.state === "left");
63494
+ const stillnessConfig = {
63495
+ ...DEFAULT_SESSION_STILLNESS_CONFIG,
63496
+ windowMs: this.stationarySettingsFromCache(deviceId).sessionStillnessWindowMs
63497
+ };
63498
+ const stillnessDims = this.lastFrameDimsByDevice.get(deviceId);
63499
+ const stillnessDiag = stillnessDims && stillnessDims.w > 0 && stillnessDims.h > 0 ? Math.hypot(stillnessDims.w, stillnessDims.h) : 0;
63500
+ const hasLiveTrack = result.tracked.some((t) => {
63501
+ const active = this.trackStore?.peekActive(t.trackId);
63502
+ if (!active) return true;
63503
+ return !trackIsStill({
63504
+ positions: active.positions,
63505
+ referenceDiagonalPx: stillnessDiag,
63506
+ now: result.timestamp,
63507
+ config: stillnessConfig
63508
+ }).still;
63509
+ });
62526
63510
  this.ctx.eventBus.emit({
62527
63511
  id: `pa-${(0, node_crypto.randomUUID)()}`,
62528
63512
  timestamp: new Date(result.timestamp),
@@ -62538,7 +63522,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62538
63522
  frameWidth: result.frameWidth,
62539
63523
  frameHeight: result.frameHeight,
62540
63524
  detections: overlayDetections,
62541
- hasMovingTrack
63525
+ hasLiveTrack
62542
63526
  }
62543
63527
  });
62544
63528
  }
@@ -62805,9 +63789,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62805
63789
  /**
62806
63790
  * Count SHORT + MOTIONLESS track closes per frame cell.
62807
63791
  *
62808
- * The stationary registry cannot see this class of phantom. Promotion needs a
62809
- * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
62810
- * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
63792
+ * The stationary registry answers a different question (is this object parked,
63793
+ * and should it be suppressed?). This one raises a spawn bar. The dead
63794
+ * ornamental grass on device 615 produced tracks of 3.8 s,
62811
63795
  * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
62812
63796
  * fifteen of them in a day, all at the same spot. Each one is individually
62813
63797
  * innocent; the RECURRENCE is the signal, and nothing survives a track death
@@ -64344,29 +65328,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64344
65328
  * track-OWNED entity (both rows carry a `trackId`) and it aged crops out from
64345
65329
  * under tracks still well inside their own window. Only the capacity bound
64346
65330
  * remains (D61).
65331
+ *
65332
+ * The cap is the OPERATOR'S, not the schema default. `bufferMaxPerDevice` is
65333
+ * offered on the settings form and this sweep read `FACE_DEFAULTS` instead,
65334
+ * so an override was a knob that wrote a store nobody read — the D62 shape.
65335
+ * The suggested tier is DERIVED from whatever value is in force
65336
+ * (`deriveSuggestedBufferCap`), so the two can never disagree.
64347
65337
  */
64348
65338
  async holdBufferCaps() {
64349
65339
  const mediaStore = this.mediaStore;
64350
65340
  if (!mediaStore) return;
65341
+ const bufferMaxPerDevice = await this.resolveBufferCap();
65342
+ const maxSuggestedPerDevice = deriveSuggestedBufferCap(bufferMaxPerDevice);
64351
65343
  if (this.faceStore) try {
64352
- const deletedFaceIds = await this.faceStore.pruneCapOverflow({ maxPerDevice: FACE_DEFAULTS.bufferMaxPerDevice });
65344
+ const deletedFaceIds = await this.faceStore.pruneCapOverflow({
65345
+ maxPerDevice: bufferMaxPerDevice,
65346
+ maxSuggestedPerDevice
65347
+ });
64353
65348
  if (deletedFaceIds.length > 0) {
64354
65349
  await mediaStore.deleteForOwner("face", deletedFaceIds);
64355
65350
  this.ctx.logger.info("face buffer capacity prune", { meta: {
64356
65351
  deleted: deletedFaceIds.length,
64357
- cap: FACE_DEFAULTS.bufferMaxPerDevice
65352
+ cap: bufferMaxPerDevice,
65353
+ suggestedCap: maxSuggestedPerDevice
64358
65354
  } });
64359
65355
  }
64360
65356
  } catch (err) {
64361
65357
  this.ctx.logger.debug("face buffer cap prune failed", { meta: { error: String(err) } });
64362
65358
  }
64363
65359
  if (this.plateStore) try {
64364
- const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice: FACE_DEFAULTS.bufferMaxPerDevice });
65360
+ const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice: bufferMaxPerDevice });
64365
65361
  if (deletedPlateIds.length > 0) {
64366
65362
  await mediaStore.deleteForOwner("plate", deletedPlateIds);
64367
65363
  this.ctx.logger.info("plate buffer capacity prune", { meta: {
64368
65364
  deleted: deletedPlateIds.length,
64369
- cap: FACE_DEFAULTS.bufferMaxPerDevice
65365
+ cap: bufferMaxPerDevice
64370
65366
  } });
64371
65367
  }
64372
65368
  } catch (err) {
@@ -64374,6 +65370,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64374
65370
  }
64375
65371
  }
64376
65372
  /**
65373
+ * The buffer capacity actually in force: the GLOBAL setting, falling back to
65374
+ * the schema default when the store cannot be read.
65375
+ *
65376
+ * Global-only on purpose. The sweep is one pass over the whole collection and
65377
+ * a per-device cascade would mean one settings read per camera on every tick,
65378
+ * for a knob whose whole job is bounding a table. A per-camera capacity is a
65379
+ * decision nobody has asked for; the fallback here is the same number the
65380
+ * sweep used unconditionally before, so a settings outage cannot make the
65381
+ * bound stricter than it has ever been.
65382
+ */
65383
+ async resolveBufferCap() {
65384
+ try {
65385
+ return resolveFaceSettings(await this.resolveGlobalStore()).bufferMaxPerDevice;
65386
+ } catch (err) {
65387
+ this.ctx.logger.debug("face buffer cap: global settings unreadable, using the default", { meta: {
65388
+ error: String(err),
65389
+ cap: FACE_DEFAULTS.bufferMaxPerDevice
65390
+ } });
65391
+ return FACE_DEFAULTS.bufferMaxPerDevice;
65392
+ }
65393
+ }
65394
+ /**
64377
65395
  * Ingest a native (onboard) camera detection push and run it through
64378
65396
  * the SAME analysis layers as the ML pipeline, tagged `source:
64379
65397
  * 'onboard'`. Flag-only pushes (no bbox) can't be zoned/tracked and are
@@ -64660,29 +65678,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64660
65678
  });
64661
65679
  }
64662
65680
  }
64663
- promoteToStationary(input) {
64664
- const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
64665
- const keyFrameMediaKey = this.residents.keyFrameKey(track.trackId);
64666
- const plausible = isPlausibleThumbnailBox(track.bbox, frameWidth, frameHeight);
64667
- if (!plausible) this.ctx.logger.info("stationary promotion rejected (near-full-frame box)", {
65681
+ /**
65682
+ * Turn a candidate whose evidence is sufficient into a registry entry.
65683
+ *
65684
+ * The BOX, label and key-frame come from the evidence row — the best agreeing
65685
+ * sighting across every track that ever contributed — not from whichever
65686
+ * track happened to be alive on the tipping frame. That is the point of the
65687
+ * ledger: the object outlives its tracks, so the entry must be built from
65688
+ * what was learned about the object, not from the last one to see it.
65689
+ */
65690
+ birthStationaryFromEvidence(input) {
65691
+ const { deviceId, key, processor, track, row, firstSeen, frameWidth, frameHeight, timestamp } = input;
65692
+ const label = row.label;
65693
+ const keyFrameMediaKey = row.keyFrameMediaKey ?? this.residents.keyFrameKey(track.trackId);
65694
+ const plausible = isPlausibleThumbnailBox(row.bestBbox, frameWidth, frameHeight);
65695
+ if (!plausible) this.ctx.logger.info("stationary birth rejected (near-full-frame box)", {
64668
65696
  tags: { deviceId },
64669
65697
  meta: {
64670
65698
  trackId: track.trackId,
64671
- className: track.className,
64672
- bbox: track.bbox
65699
+ className: row.className,
65700
+ bbox: row.bestBbox,
65701
+ agreed: row.agreed,
65702
+ seen: row.seen
64673
65703
  }
64674
65704
  });
64675
65705
  const entry = {
64676
65706
  id: (0, node_crypto.randomUUID)(),
64677
65707
  deviceId,
64678
- className: track.className,
64679
- bbox: { ...track.bbox },
65708
+ className: row.className,
65709
+ bbox: { ...row.bestBbox },
64680
65710
  frameWidth,
64681
65711
  frameHeight,
64682
65712
  firstSeenAt: firstSeen,
64683
65713
  becameStationaryAt: timestamp,
64684
65714
  lastConfirmedAt: timestamp,
64685
- sourceTrackId: track.trackId,
65715
+ sourceTrackId: row.sourceTrackId ?? track.trackId,
64686
65716
  ...label !== void 0 ? { label } : {},
64687
65717
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
64688
65718
  };
@@ -64720,12 +65750,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64720
65750
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
64721
65751
  this.overlayState.onTrackEnded(deviceId, track.trackId);
64722
65752
  this.lastActiveTrackIds.get(key)?.delete(track.trackId);
64723
- this.ctx.logger.info("track promoted to stationary", {
65753
+ this.ctx.logger.info("stationary entry born from repeated evidence", {
64724
65754
  tags: { deviceId },
64725
65755
  meta: {
64726
65756
  trackId: track.trackId,
64727
- className: track.className,
65757
+ className: row.className,
64728
65758
  entryId: entry.id,
65759
+ agreed: row.agreed,
65760
+ seen: row.seen,
65761
+ agreementRatio: row.seen > 0 ? Number((row.agreed / row.seen).toFixed(3)) : 0,
65762
+ spanMs: row.lastAgreedAt - row.firstAgreedAt,
65763
+ sessionAgreed: row.sessionAgreed,
65764
+ burstAgreed: row.burstAgreed,
65765
+ plausible,
64729
65766
  ...label ? { label } : {}
64730
65767
  }
64731
65768
  });
@@ -64782,6 +65819,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64782
65819
  cooldownSec,
64783
65820
  minTrackAgeMs: trk.minTrackAgeMs
64784
65821
  }, source);
65822
+ p.setBindObserver((record) => {
65823
+ this.ctx.logger.debug("tracker: salvage bind", {
65824
+ tags: { deviceId },
65825
+ meta: {
65826
+ source,
65827
+ ...record
65828
+ }
65829
+ });
65830
+ });
64785
65831
  p.setTrackLabelState((trackId) => {
64786
65832
  const active = this.trackStore?.getActiveByTrack(trackId);
64787
65833
  return active !== null && active !== void 0 ? {
@@ -65158,6 +66204,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65158
66204
  this.residents.closeDevice(input.deviceId);
65159
66205
  this.stationaryRegistry?.clearDevice(input.deviceId);
65160
66206
  this.phantomCells?.clearDevice(input.deviceId);
66207
+ this.stationaryEvidence?.clearDevice(input.deviceId);
65161
66208
  this.packageBackground?.clearDevice(input.deviceId);
65162
66209
  this.overlayState.clearDevice(input.deviceId);
65163
66210
  this.overlaySynthesisWarnAt.delete(input.deviceId);
@@ -66608,6 +67655,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66608
67655
  async wipeAllAnalytics(input) {
66609
67656
  await this.stationaryRegistry?.clearDevice(input.deviceId);
66610
67657
  await this.phantomCells?.clearDevice(input.deviceId);
67658
+ await this.stationaryEvidence?.clearDevice(input.deviceId);
66611
67659
  await this.packageBackground?.clearDevice(input.deviceId);
66612
67660
  return this.pruneTracksBefore({
66613
67661
  deviceId: input.deviceId,