@camstack/addon-post-analysis 1.2.218 → 1.2.219

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-CbQHenLE.js");
5
+ const require_dist = require("../dist-Cnlk6Tyz.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");
@@ -8341,8 +8341,55 @@ function incomingFromSystemEvent(event, lookup) {
8341
8341
  default: return null;
8342
8342
  }
8343
8343
  }
8344
+ /** The shells whose update has its own sentence. Anything else gets the plain
8345
+ * one: a body that names a shell nobody observed is worse than a vague body. */
8346
+ var WRAPPER_BODY_KINDS = new Set([
8347
+ "docker",
8348
+ "electron",
8349
+ "native"
8350
+ ]);
8351
+ /**
8352
+ * The SHELL announcement (D437) — the container / app bundle a node runs
8353
+ * inside is behind the release contract.
8354
+ *
8355
+ * It is deliberately NOT folded into `server-update-available`: the versions
8356
+ * here are the shell's (a seed closure, an app bundle), not the running code's,
8357
+ * and on 2026-08-02 the running code was perfectly current on a hub whose image
8358
+ * was 13 days old from a repository nothing rebuilt. It also mints no button —
8359
+ * no process can replace the shell it is running inside — so the BODY is the
8360
+ * whole delivery, and it is chosen by `wrapperKind` so a docker operator is
8361
+ * handed the exact command instead of a button that cannot exist.
8362
+ */
8363
+ function incomingFromWrapperUpdate(facts, data) {
8364
+ const packages = updatePackages(data);
8365
+ if (packages.length === 0) return null;
8366
+ const head = packages[0];
8367
+ const nodeId = head.nodeId ?? stringField(data, "nodeId");
8368
+ const wrapperKind = stringField(data, "wrapperKind");
8369
+ const bodySuffix = wrapperKind !== void 0 && WRAPPER_BODY_KINDS.has(wrapperKind) ? wrapperKind : null;
8370
+ return systemSubject({
8371
+ facts,
8372
+ kind: "wrapper-update-available",
8373
+ subject: `wrapper:${nodeId ?? "hub"}`,
8374
+ titleKey: "system.wrapper-update-available.title",
8375
+ bodyKey: bodySuffix === null ? "system.wrapper-update-available.body" : `system.wrapper-update-available.body.${bodySuffix}`,
8376
+ textParams: {
8377
+ currentVersion: head.currentVersion,
8378
+ latestVersion: head.latestVersion,
8379
+ ...nodeId !== void 0 ? { nodeId } : {}
8380
+ },
8381
+ textCount: 1,
8382
+ packageName: head.packageName,
8383
+ packageNames: [head.packageName],
8384
+ ...nodeId !== void 0 ? {
8385
+ nodeId,
8386
+ nodeIds: [nodeId]
8387
+ } : {}
8388
+ });
8389
+ }
8344
8390
  function incomingFromUpdateAvailable(facts, data) {
8345
8391
  const target = stringField(data, "target");
8392
+ if (target === "wrapper") return incomingFromWrapperUpdate(facts, data);
8346
8393
  if (target !== "addon" && target !== "server") return null;
8347
8394
  const packages = updatePackages(data);
8348
8395
  if (packages.length === 0) return null;
@@ -9452,7 +9499,7 @@ var CAMERA_DEVICE_TYPE = "camera";
9452
9499
  /** Kinds whose action is "go and apply an ADDON update". */
9453
9500
  var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
9454
9501
  /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
9455
- var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
9502
+ var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
9456
9503
  /**
9457
9504
  * Kinds whose action is "go and look at this device's stream".
9458
9505
  *
@@ -10017,6 +10064,11 @@ var en_default = {
10017
10064
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
10018
10065
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
10019
10066
  "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on {{nodes}}",
10067
+ "system.wrapper-update-available.title": "The container or app on {{nodeId}} is out of date",
10068
+ "system.wrapper-update-available.body": "The shell this node runs inside carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Updating the server keeps the code current but never replaces the shell.",
10069
+ "system.wrapper-update-available.body.docker": "This node's image carries seed {{currentVersion}}; the release contract delivers {{latestVersion}}. A container cannot replace its own image — recreate it from the contract image: docker compose pull && docker compose up -d",
10070
+ "system.wrapper-update-available.body.electron": "The desktop app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. The app downloads the new build on its own and installs it the next time it quits.",
10071
+ "system.wrapper-update-available.body.native": "The installed app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Reinstall it from the current release to converge.",
10020
10072
  "system.addon-updated.title": {
10021
10073
  "one": "{{count}} addon updated",
10022
10074
  "other": "{{count}} addons updated"
@@ -10272,6 +10324,11 @@ var it_default = {
10272
10324
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
10273
10325
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
10274
10326
  "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) su {{nodes}}",
10327
+ "system.wrapper-update-available.title": "Il contenitore o l'app di {{nodeId}} è indietro",
10328
+ "system.wrapper-update-available.body": "Il guscio in cui gira questo nodo porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Aggiornare il server tiene il codice aggiornato ma non sostituisce mai il guscio.",
10329
+ "system.wrapper-update-available.body.docker": "L'immagine di questo nodo porta il seed {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Un container non può sostituire la propria immagine — ricrealo dall'immagine del contratto: docker compose pull && docker compose up -d",
10330
+ "system.wrapper-update-available.body.electron": "L'app desktop su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. L'app scarica da sola la nuova build e la installa alla prossima chiusura.",
10331
+ "system.wrapper-update-available.body.native": "L'app installata su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Reinstallala dal rilascio corrente per allinearla.",
10275
10332
  "system.addon-updated.title": {
10276
10333
  "one": "{{count}} addon aggiornato",
10277
10334
  "other": "{{count}} addon aggiornati"
@@ -25824,6 +25881,7 @@ function systemEventSubject(kind, scope) {
25824
25881
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
25825
25882
  if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
25826
25883
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
25884
+ if (kind === "wrapper-update-available") return `wrapper:${scope.nodeId ?? "hub"}`;
25827
25885
  if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
25828
25886
  if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
25829
25887
  if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
@@ -33426,7 +33484,12 @@ var FaceGalleryProvider = class {
33426
33484
  identityId: input.identityId
33427
33485
  });
33428
33486
  assigned += 1;
33429
- } catch {
33487
+ } catch (err) {
33488
+ this.logger.error("assignFaces refused a face", { meta: {
33489
+ faceId,
33490
+ identityId: input.identityId,
33491
+ error: err instanceof Error ? `${err.name}: ${err.message}` : String(err)
33492
+ } });
33430
33493
  failed.push(faceId);
33431
33494
  }
33432
33495
  return {
@@ -34810,6 +34873,21 @@ var SpawnBesideCoastingSampler = class {
34810
34873
  this.counts.clear();
34811
34874
  }
34812
34875
  };
34876
+ /** Per-cause counter + sampling decision. One instance per tracker, so the
34877
+ * counts are per camera and per detection source. */
34878
+ var TrackLossSampler = class {
34879
+ counts = /* @__PURE__ */ new Map();
34880
+ /** The running count when this loss should be emitted, else `undefined`. */
34881
+ next(cause) {
34882
+ const count = (this.counts.get(cause) ?? 0) + 1;
34883
+ this.counts.set(cause, count);
34884
+ if (count <= 10) return count;
34885
+ return count % 50 === 0 ? count : void 0;
34886
+ }
34887
+ reset() {
34888
+ this.counts.clear();
34889
+ }
34890
+ };
34813
34891
  /** Per-reason counter + sampling decision. One instance per tracker, so the
34814
34892
  * counts are per camera and per detection source. */
34815
34893
  var SpawnRefusalSampler = class {
@@ -34918,6 +34996,16 @@ var SortTracker = class SortTracker {
34918
34996
  refusalSink = null;
34919
34997
  /** Per-reason counters behind the sampled spawn-beside-coasting diagnostics. */
34920
34998
  spawnBesideSampler = new SpawnBesideCoastingSampler();
34999
+ /**
35000
+ * Deliveries to {@link update} since this tracker was constructed. One per
35001
+ * frame the pipeline actually hands over — which is exactly what "the camera
35002
+ * was looking" means, and is NOT derivable from wall clock: a quiet camera
35003
+ * and a busy one span the same seconds (D438).
35004
+ */
35005
+ frameSeq = 0;
35006
+ /** Sink for track deaths — see {@link setTrackLossObserver}. */
35007
+ trackLossObserver = null;
35008
+ trackLossSampler = new TrackLossSampler();
34921
35009
  /** Sink for fragmentation diagnostics — see {@link setSpawnBesideCoastingObserver}. */
34922
35010
  spawnBesideObserver = null;
34923
35011
  constructor(config = {}) {
@@ -34950,6 +35038,13 @@ var SortTracker = class SortTracker {
34950
35038
  * Same contract as the other two sinks — the tracker has no `deviceId`, the
34951
35039
  * caller stamps it, and nothing is computed when no observer is wired.
34952
35040
  */
35041
+ /**
35042
+ * Wire the sink for {@link TrackLossRecord}. Nothing is computed when no
35043
+ * observer is attached.
35044
+ */
35045
+ setTrackLossObserver(observer) {
35046
+ this.trackLossObserver = observer;
35047
+ }
34953
35048
  setSpawnBesideCoastingObserver(observer) {
34954
35049
  this.spawnBesideObserver = observer;
34955
35050
  }
@@ -34964,13 +35059,13 @@ var SortTracker = class SortTracker {
34964
35059
  reportSpawnBesideCoasting(spawnedId, det, coasting, detections, frameArea, timestamp) {
34965
35060
  const observer = this.spawnBesideObserver;
34966
35061
  if (!observer) return;
34967
- const group = this.assocGroup(det.class);
35062
+ const family = SortTracker.spawnBesideFamily(det.class);
34968
35063
  const dc = bboxCentroid(det.bbox);
34969
35064
  let best;
34970
35065
  let bestDist = Number.POSITIVE_INFINITY;
34971
35066
  let bestReach = 0;
34972
35067
  for (const track of coasting) {
34973
- if (this.assocGroup(track.class) !== group) continue;
35068
+ if (SortTracker.spawnBesideFamily(track.class) !== family) continue;
34974
35069
  const tc = bboxCentroid(track.bbox);
34975
35070
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
34976
35071
  const reach = this.reacquireReach(track, timestamp, true);
@@ -34985,6 +35080,7 @@ var SortTracker = class SortTracker {
34985
35080
  const declinedBy = this.explainDecline(best, det, detections, frameArea, timestamp, bestDist);
34986
35081
  const spawnCount = this.spawnBesideSampler.next(declinedBy);
34987
35082
  if (spawnCount === void 0) return;
35083
+ const crowd = declinedBy === "crowd-guard" ? this.crowdAround(best, det, detections) : void 0;
34988
35084
  observer({
34989
35085
  spawnedTrackId: spawnedId,
34990
35086
  spawnedClass: det.class,
@@ -35001,6 +35097,8 @@ var SortTracker = class SortTracker {
35001
35097
  gapMs: timestamp - best.lastSeen,
35002
35098
  trackAgeMs: timestamp - best.firstSeen,
35003
35099
  declinedBy,
35100
+ ...crowd === void 0 ? {} : { competitorCount: crowd.count },
35101
+ ...crowd?.nearestPx === void 0 ? {} : { nearestCompetitorPx: crowd.nearestPx },
35004
35102
  spawnCount,
35005
35103
  sampledEvery: 10
35006
35104
  });
@@ -35015,8 +35113,7 @@ var SortTracker = class SortTracker {
35015
35113
  * `already-claimed` means.
35016
35114
  */
35017
35115
  explainDecline(track, det, detections, frameArea, timestamp, centroidDist) {
35018
- if (!this.sameGroupAsMeasured(track.class, det)) return "native-measured-class";
35019
- if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return "assoc-group";
35116
+ if (track.class !== det.class) return det.nativeRecovery !== void 0 ? "native-measured-class" : "cross-class";
35020
35117
  if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "already-claimed";
35021
35118
  if (centroidDist <= this.reacquireReach(track, timestamp, true)) {
35022
35119
  if (!this.isPlausibleBirth(det, frameArea)) return "already-claimed";
@@ -35075,14 +35172,44 @@ var SortTracker = class SortTracker {
35075
35172
  * come back — a graveyarded track may still resurrect, and a line about work
35076
35173
  * that was not lost is worse than no line.
35077
35174
  */
35175
+ /**
35176
+ * Report one track that ended with nothing taking over (D438).
35177
+ *
35178
+ * Deliberately separate from {@link reportTrackLoss}, which speaks only for
35179
+ * spawns that never reached `minHits`. The population under investigation —
35180
+ * animal tracks dying mid-frame after six frames — had all EMITTED, so that
35181
+ * path never saw a single one of them.
35182
+ *
35183
+ * `timestamp` is the frame doing the sweeping, not the track's own clock: the
35184
+ * question is how much passed while nobody was looking.
35185
+ */
35186
+ reportTrackDeath(track, cause, timestamp) {
35187
+ const observer = this.trackLossObserver;
35188
+ if (!observer) return;
35189
+ const lossCount = this.trackLossSampler.next(cause);
35190
+ if (lossCount === void 0) return;
35191
+ observer({
35192
+ trackId: track.id,
35193
+ trackClass: track.class,
35194
+ cause,
35195
+ framesSinceLastSeen: this.frameSeq - track.lastSeenFrameSeq,
35196
+ msSinceLastSeen: timestamp - track.lastSeen,
35197
+ lifeMs: track.lastSeen - track.firstSeen,
35198
+ positions: track.hits,
35199
+ emitted: track.emitted,
35200
+ lossCount,
35201
+ sampledEvery: 50
35202
+ });
35203
+ }
35078
35204
  reportTrackLoss(track) {
35079
35205
  if (track.emitted || track.lossReported) return;
35080
35206
  track.lossReported = true;
35081
35207
  this.reportRefusal("unconfirmed-spawn-lost", {
35082
- class: track.class,
35208
+ class: track.frameClass,
35083
35209
  originalClass: track.originalClass,
35084
35210
  score: track.score,
35085
- bbox: track.bbox
35211
+ bbox: track.bbox,
35212
+ ...track.lastNativeRecovery !== void 0 ? { nativeRecovery: track.lastNativeRecovery } : {}
35086
35213
  }, {
35087
35214
  threshold: this.config.minHits,
35088
35215
  measured: track.hits,
@@ -35137,43 +35264,73 @@ var SortTracker = class SortTracker {
35137
35264
  static pairKey(a, b) {
35138
35265
  return a < b ? `${a}|${b}` : `${b}|${a}`;
35139
35266
  }
35140
- /** Association-gating group of a class. When `classGroupAssoc` is on the
35141
- * {person,animal} pair collapses to one group so a per-frame class flip
35142
- * (crouch person animal) re-matches the existing track instead of spawning a
35143
- * concurrent id. Every other class is its own group. */
35144
- assocGroup(cls) {
35145
- if (this.config.classGroupAssoc && (cls === PERSON_CLASS || cls === ANIMAL_CLASS)) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
35267
+ /** The two macro-classes a coarse pass confuses on a non-upright person. */
35268
+ static isPersonAnimalPair(a, b) {
35269
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
35270
+ }
35271
+ /**
35272
+ * The family a spawn-beside-coasting line is reported AGAINST {person,
35273
+ * animal} as one, every other class its own. Reporting only, never a binding
35274
+ * rule: a cross-class pair is exactly what that line exists to name (the
35275
+ * D378 veto, the D439 salvage rule), so a pair the tracker refused must still
35276
+ * be reportable. Independent of `classGroupAssoc` for the same reason.
35277
+ */
35278
+ static spawnBesideFamily(cls) {
35279
+ if (cls === PERSON_CLASS || cls === ANIMAL_CLASS) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
35146
35280
  return cls;
35147
35281
  }
35148
- /** Whether a detection may associate to a track under the current class gate:
35149
- * exact class when `classGating` is off (no gate at all) matching the legacy
35150
- * contract — otherwise same association group (group == exact class unless
35151
- * `classGroupAssoc` widened it to {person,animal}). */
35282
+ /**
35283
+ * A CROSS-CLASS BIND IS A FLIP CLAIM, AND A FLIP IS WEAK (D439).
35284
+ *
35285
+ * `classGroupAssoc` exists for one failure of the COARSE full-frame pass: a
35286
+ * bending person re-labelled `animal` for a frame, over the person's own
35287
+ * box, at the 0.4-0.6 the coarse pass gives a shape it half recognises. That
35288
+ * claim has three parts, and this predicate is all three:
35289
+ *
35290
+ * - the box was measured by the coarse pass — never `nativeRecovery`. The
35291
+ * runner's second pass measures the OPPOSITE thing: a 640-800 px native
35292
+ * square around a motion region the full-frame boxes do NOT account for,
35293
+ * which is by construction not the subject the full pass already has.
35294
+ * 2026-09-07 07:51:33, an `animal` at `nativeRecoveryScore=0.833`
35295
+ * absorbed into a person: the D378 half of this rule;
35296
+ * - it is a {person, animal} pair;
35297
+ * - it is WEAK — below {@link TrackerConfig.animalOverPersonMaxScore}, the
35298
+ * same bar the spawn gate uses for the same claim. A `person` at 0.86 or
35299
+ * an `animal` at 0.92 is not a half-recognised shape, it is a subject,
35300
+ * and binding it to the other class's track hands one subject's id to
35301
+ * another (camera 615, 17:11:01: person 0.8616 → animal track
35302
+ * `a28c1557`, which was then born as a person).
35303
+ *
35304
+ * Where the caller may use it is the fourth part, and it is the caller's:
35305
+ * only the predicted-box passes ({@link greedyAssociate}), where "same place"
35306
+ * is measured against a prediction on the strict `iouThreshold`. The salvage
35307
+ * passes ({@link looseMatch}) never cross classes — their whole premise is
35308
+ * weak overlap, and a weak-overlap cross-class bind is a dog beside its
35309
+ * owner, every time.
35310
+ */
35311
+ crossClassFlip(trackClass, det) {
35312
+ if (!this.config.classGroupAssoc) return false;
35313
+ if (det.nativeRecovery !== void 0) return false;
35314
+ if (!SortTracker.isPersonAnimalPair(trackClass, det.class)) return false;
35315
+ return det.score < this.config.animalOverPersonMaxScore;
35316
+ }
35317
+ /** Whether a detection may associate to a track on a PREDICTED-BOX pass:
35318
+ * its own class, or — with `classGroupAssoc` — a weak coarse flip of the
35319
+ * {person, animal} pair ({@link crossClassFlip}). `classGating` off means no
35320
+ * gate at all, matching the legacy contract, except that a native-measured
35321
+ * box still binds only its own class (D378). */
35152
35322
  associable(trackClass, det) {
35153
- if (!this.sameGroupAsMeasured(trackClass, det)) return false;
35323
+ if (trackClass === det.class) return true;
35324
+ if (det.nativeRecovery !== void 0) return false;
35154
35325
  if (!this.config.classGating) return true;
35155
- return this.assocGroup(trackClass) === this.assocGroup(det.class);
35326
+ return this.crossClassFlip(trackClass, det);
35156
35327
  }
35157
35328
  /**
35158
- * A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378).
35159
- *
35160
- * `classGroupAssoc` collapses {person, animal} into one association group for
35161
- * one failure of the COARSE full-frame pass: a bending person re-labelled
35162
- * `animal` for a frame, over the person's own box. Letting that box re-match
35163
- * the person track is strictly better than minting a concurrent id.
35164
- *
35165
- * The runner's second pass makes the opposite measurement — the same detector
35166
- * on a 640-800 px native square around a motion region the full-frame boxes
35167
- * do NOT account for. Such a box is, by construction, not the subject the
35168
- * full pass already has; absorbing it into a person track means the animal is
35169
- * never seen at all, which on 2026-09-07 07:51:33 is exactly what happened to
35170
- * an `animal` at `nativeRecoveryScore=0.833`.
35171
- *
35172
- * So the widening does not apply to a detection carrying `nativeRecovery`:
35173
- * it associates to its OWN class or it spawns. Every other guard on that
35174
- * spawn still runs — the per-class score floor, the area floor, the
35175
- * `dedupSpawnIou` overlap, and `resolveDuplicates` afterwards, where a person
35176
- * still beats a cross-class animal.
35329
+ * A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378) — the
35330
+ * occlusion gate's copy of the first clause of {@link crossClassFlip}. A
35331
+ * cross-class occluder is not a same-subject claim for a box the native
35332
+ * pass measured: a dog at its owner's knee is 100% contained in the person's
35333
+ * box and is not the person.
35177
35334
  */
35178
35335
  sameGroupAsMeasured(trackClass, det) {
35179
35336
  if (det.nativeRecovery === void 0) return true;
@@ -35346,9 +35503,16 @@ var SortTracker = class SortTracker {
35346
35503
  };
35347
35504
  }
35348
35505
  /**
35349
- * How many same-association-group subjects OTHER than `corpse` share the
35350
- * scene with it: every track LIVE on this frame, plus any other resurrectable
35351
- * corpse whose lifetime OVERLAPPED the candidate's.
35506
+ * How many SAME-CLASS subjects OTHER than `corpse` share the scene with it:
35507
+ * every track LIVE on this frame, plus any other resurrectable corpse whose
35508
+ * lifetime OVERLAPPED the candidate's.
35509
+ *
35510
+ * Exact class, not the {person, animal} family (D439): a competitor is a
35511
+ * subject the id could actually be handed to, and since a salvage pass binds
35512
+ * its own class only, a person cannot take a dog's id there. Counting the
35513
+ * owner as the dog's competitor split one dog's id 12 times on camera 615 in
35514
+ * 6.6 h — every bind the reach clause accepted, the guard then refused
35515
+ * because the person was on screen.
35352
35516
  *
35353
35517
  * The overlap clause on the graveyard half is what keeps this guard off the
35354
35518
  * case the resurrection pass exists for. A single flickering subject leaves a
@@ -35362,30 +35526,30 @@ var SortTracker = class SortTracker {
35362
35526
  * steals an id is very often one whose OWN track was born after the corpse
35363
35527
  * died (camera 618 — the toddler's track post-dates the older boy's).
35364
35528
  */
35365
- sameGroupPeerCount(corpse, live) {
35366
- const group = this.assocGroup(corpse.class);
35529
+ sameClassPeerCount(corpse, live) {
35367
35530
  let peers = 0;
35368
- for (const t of live) if (t !== corpse && this.assocGroup(t.class) === group) peers++;
35531
+ for (const t of live) if (t !== corpse && t.class === corpse.class) peers++;
35369
35532
  for (const other of this.lostTracks) {
35370
35533
  if (other === corpse) continue;
35371
35534
  if (!other.resurrectable) continue;
35372
- if (this.assocGroup(other.class) !== group) continue;
35535
+ if (other.class !== corpse.class) continue;
35373
35536
  if (other.firstSeen <= corpse.lastSeen && corpse.firstSeen <= other.lastSeen) peers++;
35374
35537
  }
35375
35538
  return peers;
35376
35539
  }
35377
35540
  /**
35378
- * Loose same-group gate used by the rescue and resurrection passes: accept
35379
- * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
35380
- * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
35381
- * gated (a rescue must never cross association groups) independent of
35382
- * `classGating`, so a coasting person track can be rescued by its own
35383
- * crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
35384
- * class.
35541
+ * Loose gate used by the rescue and resurrection passes: accept when the
35542
+ * detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or its
35543
+ * centroid is within reach. EXACT-class, always, independent of
35544
+ * `classGating` and of `classGroupAssoc` (D439): a salvage pass binds on
35545
+ * weak overlap by definition, and a weak-overlap bind across {person,
35546
+ * animal} is not a crouch flip a flip sits on the subject's own box — it
35547
+ * is a dog beside its owner, or the owner beside the dog. Until 2026-09-10
35548
+ * this gate crossed the group, and it is where a `person` at 0.86 took over
35549
+ * an animal's id (camera 615, 17:11:01).
35385
35550
  */
35386
35551
  looseMatch(track, det, timestamp, timeScaled = true) {
35387
- if (!this.sameGroupAsMeasured(track.class, det)) return null;
35388
- if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
35552
+ if (track.class !== det.class) return null;
35389
35553
  if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
35390
35554
  const tc = bboxCentroid(track.bbox);
35391
35555
  const dc = bboxCentroid(det.bbox);
@@ -35431,12 +35595,41 @@ var SortTracker = class SortTracker {
35431
35595
  * bind time and at report time — the spawn-beside-coasting line has to be able
35432
35596
  * to say which gate declined without re-running the greedy competition.
35433
35597
  */
35598
+ /**
35599
+ * WHO the crowd was, for the record only — this changes no decision.
35600
+ *
35601
+ * {@link isUnambiguousReach} refuses on PRESENCE, with no distance in the
35602
+ * question, so a person at the far corner of a room splits a track as surely
35603
+ * as one standing beside it. Counting them, and measuring the nearest, is
35604
+ * what makes narrowing that rule a decision rather than a guess (D436).
35605
+ */
35606
+ crowdAround(track, det, detections) {
35607
+ const cx = det.bbox.x + det.bbox.w / 2;
35608
+ const cy = det.bbox.y + det.bbox.h / 2;
35609
+ const counted = [];
35610
+ let nearest;
35611
+ const consider = (bbox) => {
35612
+ counted.push(bbox);
35613
+ const d = Math.hypot(bbox.x + bbox.w / 2 - cx, bbox.y + bbox.h / 2 - cy);
35614
+ if (nearest === void 0 || d < nearest) nearest = d;
35615
+ };
35616
+ for (const t of this.tracks) if (t !== track && t.class === track.class) consider(t.bbox);
35617
+ for (const other of detections) {
35618
+ if (other === det) continue;
35619
+ if (other.class !== track.class) continue;
35620
+ if (counted.some((b) => iou$3(b, other.bbox) > 0)) continue;
35621
+ consider(other.bbox);
35622
+ }
35623
+ return {
35624
+ count: counted.length,
35625
+ nearestPx: nearest
35626
+ };
35627
+ }
35434
35628
  isUnambiguousReach(track, det, detections) {
35435
- if (this.sameGroupPeerCount(track, this.tracks) > 0) return false;
35436
- const group = this.assocGroup(track.class);
35629
+ if (this.sameClassPeerCount(track, this.tracks) > 0) return false;
35437
35630
  for (const other of detections) {
35438
35631
  if (other === det) continue;
35439
- if (this.assocGroup(other.class) === group) return false;
35632
+ if (other.class === track.class) return false;
35440
35633
  }
35441
35634
  return true;
35442
35635
  }
@@ -35473,8 +35666,8 @@ var SortTracker = class SortTracker {
35473
35666
  * weight; otherwise the latest-frame class (ambiguous vote or voting off).
35474
35667
  */
35475
35668
  resolveReportedClass(t) {
35476
- if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
35477
- let bestClass = t.class;
35669
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.frameClass;
35670
+ let bestClass = t.frameClass;
35478
35671
  let bestVote = -1;
35479
35672
  let total = 0;
35480
35673
  for (const [cls, v] of t.classVotes) {
@@ -35484,8 +35677,8 @@ var SortTracker = class SortTracker {
35484
35677
  bestClass = cls;
35485
35678
  }
35486
35679
  }
35487
- if (total <= 0) return t.class;
35488
- return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
35680
+ if (total <= 0) return t.frameClass;
35681
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.frameClass;
35489
35682
  }
35490
35683
  /** Whether a detection clears its per-class spawn score floor. Only gates NEW
35491
35684
  * spawns — an established track still matches below its floor. */
@@ -35501,16 +35694,67 @@ var SortTracker = class SortTracker {
35501
35694
  return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
35502
35695
  }
35503
35696
  /**
35697
+ * The live tracks split into the two tiers that claim a box in a fixed
35698
+ * order (D442, see {@link update}): those already confirmed for emission,
35699
+ * and the provisional ones still under `minHits`. Decided on the state at
35700
+ * the start of the frame, as ByteTrack does. With `minHits: 1` the second
35701
+ * tier is always empty and every pass over it is a no-op.
35702
+ */
35703
+ associationTiers() {
35704
+ const established = [];
35705
+ const provisional = [];
35706
+ for (const track of this.tracks) if (this.isConfirmedForEmit(track)) established.push(track);
35707
+ else provisional.push(track);
35708
+ return [established, provisional];
35709
+ }
35710
+ /**
35711
+ * The rescue pass. HIGH-score detections the greedy pass left unmatched get
35712
+ * a second, looser chance against the still-unmatched (coasting) tracks of
35713
+ * `tracks` — measured on the track's LAST-KNOWN bbox, never the drifted
35714
+ * prediction. Recovers half-box re-acquisition and small-drift flicker
35715
+ * without spawning a duplicate. Best (highest last-known IoU) pairs first;
35716
+ * LOW detections are reserved for the ByteTrack low stage.
35717
+ */
35718
+ rescueAssociate(tracks, gates, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly) {
35719
+ const rescuePairs = [];
35720
+ for (const track of tracks) {
35721
+ if (matchedTracks.has(track)) continue;
35722
+ for (let di = 0; di < detections.length; di++) {
35723
+ if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35724
+ const det = detections[di];
35725
+ const gate = this.looseMatch(track, det, timestamp);
35726
+ if (gate === null || !gates.has(gate)) continue;
35727
+ if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
35728
+ rescuePairs.push({
35729
+ track,
35730
+ detIdx: di,
35731
+ score: iou$3(track.bbox, det.bbox),
35732
+ gate
35733
+ });
35734
+ }
35735
+ }
35736
+ rescuePairs.sort((a, b) => b.score - a.score);
35737
+ for (const pair of rescuePairs) {
35738
+ if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
35739
+ const det = detections[pair.detIdx];
35740
+ matched.set(pair.track, det);
35741
+ matchedTracks.add(pair.track);
35742
+ used.add(pair.detIdx);
35743
+ this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35744
+ }
35745
+ }
35746
+ /**
35504
35747
  * One greedy IoU association pass over a candidate detection subset against the
35505
- * currently-UNMATCHED tracks, on each track's PREDICTED box. Highest-IoU pairs
35748
+ * still-UNMATCHED tracks of `tracks` (one association tier see
35749
+ * {@link associationTiers}), on each track's PREDICTED box. Highest-IoU pairs
35506
35750
  * bind first; a track/detection binds at most once. Shared by the high-score
35507
35751
  * first stage and the low-score second stage (different candidate sets + IoU
35508
35752
  * gates) so the two-tier ByteTrack association stays a single implementation.
35509
35753
  * Mutates `used` / `matchedTracks` / `matched` in place.
35510
35754
  */
35511
- greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35755
+ greedyAssociate(tracks, detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35512
35756
  const pairs = [];
35513
- for (const track of this.tracks) {
35757
+ for (const track of tracks) {
35514
35758
  if (matchedTracks.has(track)) continue;
35515
35759
  const pbox = this.predicted(track, timestamp);
35516
35760
  const confirmed = this.isConfirmedForEmit(track);
@@ -35536,6 +35780,7 @@ var SortTracker = class SortTracker {
35536
35780
  }
35537
35781
  }
35538
35782
  update(detections, timestamp, frameContext) {
35783
+ this.frameSeq++;
35539
35784
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
35540
35785
  const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
35541
35786
  const resurrectedScene = /* @__PURE__ */ new Map();
@@ -35546,6 +35791,7 @@ var SortTracker = class SortTracker {
35546
35791
  track.lostAt = timestamp;
35547
35792
  track.resurrectable = false;
35548
35793
  this.reportTrackLoss(track);
35794
+ this.reportTrackDeath(track, "lifetime-cap", timestamp);
35549
35795
  this.lostTracks.push(track);
35550
35796
  } else alive.push(track);
35551
35797
  this.tracks = alive;
@@ -35558,13 +35804,17 @@ var SortTracker = class SortTracker {
35558
35804
  track.lostAt = track.lastSeen;
35559
35805
  track.resurrectable = false;
35560
35806
  this.reportTrackLoss(track);
35807
+ this.reportTrackDeath(track, "stale-beyond-budgets", timestamp);
35561
35808
  this.lostTracks.push(track);
35562
35809
  } else alive.push(track);
35563
35810
  this.tracks = alive;
35564
35811
  }
35565
35812
  const stillResurrectable = [];
35566
35813
  for (const t of this.lostTracks) if (timestamp - t.lostAt <= this.config.resurrectionWindowMs) stillResurrectable.push(t);
35567
- else this.reportTrackLoss(t);
35814
+ else {
35815
+ this.reportTrackLoss(t);
35816
+ this.reportTrackDeath(t, "graveyard-expired", timestamp);
35817
+ }
35568
35818
  this.lostTracks = stillResurrectable;
35569
35819
  const used = /* @__PURE__ */ new Set();
35570
35820
  const matchedTracks = /* @__PURE__ */ new Set();
@@ -35574,36 +35824,21 @@ var SortTracker = class SortTracker {
35574
35824
  for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
35575
35825
  else highIdxs.push(di);
35576
35826
  const lowSet = new Set(lowIdxs);
35577
- this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35578
- const rescuePairs = [];
35579
- for (const track of this.tracks) {
35580
- if (matchedTracks.has(track)) continue;
35581
- for (let di = 0; di < detections.length; di++) {
35582
- if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35583
- const det = detections[di];
35584
- const gate = this.looseMatch(track, det, timestamp);
35585
- if (gate === null) continue;
35586
- if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
35587
- rescuePairs.push({
35588
- track,
35589
- detIdx: di,
35590
- score: iou$3(track.bbox, det.bbox),
35591
- gate
35592
- });
35593
- }
35594
- }
35595
- rescuePairs.sort((a, b) => b.score - a.score);
35596
- for (const pair of rescuePairs) {
35597
- if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
35598
- const det = detections[pair.detIdx];
35599
- matched.set(pair.track, det);
35600
- matchedTracks.add(pair.track);
35601
- used.add(pair.detIdx);
35602
- this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35603
- }
35604
- if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35827
+ const [established, provisional] = this.associationTiers();
35828
+ const OVERLAP = new Set(["iou"]);
35829
+ const PROXIMITY = new Set(["centroid", "growth"]);
35830
+ this.greedyAssociate(established, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35831
+ this.rescueAssociate(established, OVERLAP, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35832
+ this.greedyAssociate(provisional, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35833
+ this.rescueAssociate(established, PROXIMITY, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35834
+ this.rescueAssociate(provisional, new Set([...OVERLAP, ...PROXIMITY]), detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35835
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) for (const tier of [established, provisional]) this.greedyAssociate(tier, detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35605
35836
  for (const [track, det] of matched) {
35606
- if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, { against: track });
35837
+ if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, {
35838
+ against: track,
35839
+ measured: iou$3(this.predicted(track, timestamp), det.bbox),
35840
+ threshold: this.config.animalOverPersonMaxScore
35841
+ });
35607
35842
  const prevCenter = bboxCentroid({
35608
35843
  x: track.bbox.x,
35609
35844
  y: track.bbox.y,
@@ -35619,12 +35854,15 @@ var SortTracker = class SortTracker {
35619
35854
  const frames = track.age + 1;
35620
35855
  const dtMs = timestamp - track.lastSeen;
35621
35856
  track.bbox = det.bbox;
35622
- track.class = det.class;
35857
+ if (!this.crossClassFlip(track.class, det)) track.class = det.class;
35858
+ track.frameClass = det.class;
35623
35859
  track.originalClass = det.originalClass;
35624
35860
  track.score = det.score;
35861
+ track.lastNativeRecovery = det.nativeRecovery;
35625
35862
  track.age = 0;
35626
35863
  track.hits++;
35627
35864
  track.lastSeen = timestamp;
35865
+ track.lastSeenFrameSeq = this.frameSeq;
35628
35866
  if (dtMs > 0) {
35629
35867
  track.velocity = {
35630
35868
  dx: (newCenter.x - prevCenter.x) / dtMs,
@@ -35673,7 +35911,7 @@ var SortTracker = class SortTracker {
35673
35911
  if (gate === null) continue;
35674
35912
  if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
35675
35913
  const score = iou$3(lost.bbox, det.bbox);
35676
- const peers = this.sameGroupPeerCount(lost, surviving);
35914
+ const peers = this.sameClassPeerCount(lost, surviving);
35677
35915
  if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
35678
35916
  if (score > refusedScore) {
35679
35917
  refused = lost;
@@ -35698,12 +35936,15 @@ var SortTracker = class SortTracker {
35698
35936
  this.lostTracks = this.lostTracks.filter((t) => t !== best);
35699
35937
  best.bbox = det.bbox;
35700
35938
  best.class = det.class;
35939
+ best.frameClass = det.class;
35701
35940
  best.originalClass = det.originalClass;
35702
35941
  best.score = det.score;
35942
+ best.lastNativeRecovery = det.nativeRecovery;
35703
35943
  best.age = 0;
35704
35944
  best.hits++;
35705
35945
  best.lost = false;
35706
35946
  best.lastSeen = timestamp;
35947
+ best.lastSeenFrameSeq = this.frameSeq;
35707
35948
  best.velocity = {
35708
35949
  dx: 0,
35709
35950
  dy: 0
@@ -35760,6 +36001,7 @@ var SortTracker = class SortTracker {
35760
36001
  id: spawnedId,
35761
36002
  bbox: det.bbox,
35762
36003
  class: det.class,
36004
+ frameClass: det.class,
35763
36005
  originalClass: det.originalClass,
35764
36006
  score: det.score,
35765
36007
  age: 0,
@@ -35767,6 +36009,7 @@ var SortTracker = class SortTracker {
35767
36009
  path: [det.bbox],
35768
36010
  firstSeen: timestamp,
35769
36011
  lastSeen: timestamp,
36012
+ lastSeenFrameSeq: this.frameSeq,
35770
36013
  velocity: {
35771
36014
  dx: 0,
35772
36015
  dy: 0
@@ -35777,7 +36020,8 @@ var SortTracker = class SortTracker {
35777
36020
  resurrectable: true,
35778
36021
  classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]]),
35779
36022
  emitted: false,
35780
- lossReported: false
36023
+ lossReported: false,
36024
+ lastNativeRecovery: det.nativeRecovery
35781
36025
  });
35782
36026
  }
35783
36027
  this.tracks = surviving;
@@ -36307,6 +36551,10 @@ var FrameProcessor = class {
36307
36551
  /** Wire (or clear) the tracker's fragmentation sink — a new id minted beside a
36308
36552
  * live coasting track that could have claimed it (D399). Same `deviceId`
36309
36553
  * contract as the two sinks above. */
36554
+ /** Wire the sink for track deaths (D438) — pure passthrough to the tracker. */
36555
+ setTrackLossObserver(observer) {
36556
+ this.tracker.setTrackLossObserver(observer);
36557
+ }
36310
36558
  setSpawnBesideCoastingObserver(observer) {
36311
36559
  this.tracker.setSpawnBesideCoastingObserver(observer);
36312
36560
  }
@@ -66696,10 +66944,12 @@ var TrackingSettingsSchema = require_dist.object({
66696
66944
  minSpawnAreaFracAnimal: require_dist.number().min(0).max(1).default(0),
66697
66945
  /** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
66698
66946
  minSpawnAreaFracVehicle: require_dist.number().min(0).max(1).default(0),
66699
- /** Gate association on a class GROUP ({person,animal}) instead of the exact
66700
- * per-frame class, so a person that flips to `animal` for a frame re-matches
66701
- * its existing track (no concurrent animal id). Reported label stays via the
66702
- * class vote. Off = exact per-frame class gating. */
66947
+ /** Let a person that flips to `animal` for a frame re-match its existing
66948
+ * track (no concurrent animal id). A flip is the same subject in the same
66949
+ * place, weakly read (D439): the bind is allowed only on the predicted-box
66950
+ * passes, only for a coarse box below `animalOverPersonMaxScore`, never on
66951
+ * the rescue / resurrection passes. Reported label stays via the class
66952
+ * vote. Off = exact per-frame class gating. */
66703
66953
  classGroupAssoc: require_dist.boolean().default(true)
66704
66954
  });
66705
66955
  var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
@@ -67289,7 +67539,7 @@ function buildDetectionSettingsSections() {
67289
67539
  type: "boolean",
67290
67540
  key: "classGroupAssoc",
67291
67541
  label: "Person/animal group matching",
67292
- description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
67542
+ description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. A flip is the same subject in the same place, weakly read: the cross-class match happens only on the strict predicted-box pass and only for a full-frame box below the \"animal over person max score\" bar — a confident animal beside a person, or a person beside an animal, is its own subject and keeps its own track. The reported class is still decided by the per-track class vote.",
67293
67543
  default: TRACKING_DEFAULTS.classGroupAssoc
67294
67544
  },
67295
67545
  {
@@ -77760,6 +78010,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77760
78010
  }
77761
78011
  });
77762
78012
  });
78013
+ p.setTrackLossObserver((record) => {
78014
+ this.ctx.logger.info("tracker: a track ended and nothing took over", {
78015
+ tags: { deviceId },
78016
+ meta: {
78017
+ source,
78018
+ ...record
78019
+ }
78020
+ });
78021
+ });
77763
78022
  p.setSpawnBesideCoastingObserver((record) => {
77764
78023
  this.ctx.logger.info("tracker: new id minted beside a live coasting track", {
77765
78024
  tags: { deviceId },