@camstack/addon-post-analysis 1.2.101 → 1.2.103

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-BQsMSX2A.js");
5
+ const require_dist = require("../dist-BwxXdV9d.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");
@@ -8302,6 +8302,10 @@ var en_default = {
8302
8302
  "one": "plate",
8303
8303
  "other": "plates"
8304
8304
  },
8305
+ "class.audio": {
8306
+ "one": "sound",
8307
+ "other": "sounds"
8308
+ },
8305
8309
  "class.other": "{{name}}",
8306
8310
  "group.others.person": {
8307
8311
  "one": "{{count}} other person",
@@ -8359,8 +8363,11 @@ var en_default = {
8359
8363
  "one": "{{count}} addon update available",
8360
8364
  "other": "{{count}} addon updates available"
8361
8365
  },
8366
+ "system.addon-update-available.title.onNode": {
8367
+ "one": "{{count}} addon update available on {{nodeId}}",
8368
+ "other": "{{count}} addon updates available on {{nodeId}}"
8369
+ },
8362
8370
  "system.addon-update-available.body": "{{list}}",
8363
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8364
8371
  "system.server-update-available.title": {
8365
8372
  "one": "Server update available on {{count}} node",
8366
8373
  "other": "Server update available on {{count}} nodes"
@@ -8375,6 +8382,7 @@ var en_default = {
8375
8382
  "system.addon-updated.body": "{{list}}",
8376
8383
  "system.server-updated.title": "Server updated",
8377
8384
  "system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
8385
+ "system.server-updated.body.toOnly": "{{packageName}} on {{nodeId}} is now {{toVersion}}",
8378
8386
  "system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
8379
8387
  "system.export-completed.title": "Export ready",
8380
8388
  "system.export-completed.body": "The footage export is ready",
@@ -8516,6 +8524,10 @@ var it_default = {
8516
8524
  "one": "targa",
8517
8525
  "other": "targhe"
8518
8526
  },
8527
+ "class.audio": {
8528
+ "one": "suono",
8529
+ "other": "suoni"
8530
+ },
8519
8531
  "class.other": "{{name}}",
8520
8532
  "group.others.person": {
8521
8533
  "one": "{{count}} altra persona",
@@ -8573,8 +8585,11 @@ var it_default = {
8573
8585
  "one": "{{count}} aggiornamento addon disponibile",
8574
8586
  "other": "{{count}} aggiornamenti addon disponibili"
8575
8587
  },
8588
+ "system.addon-update-available.title.onNode": {
8589
+ "one": "{{count}} aggiornamento addon disponibile su {{nodeId}}",
8590
+ "other": "{{count}} aggiornamenti addon disponibili su {{nodeId}}"
8591
+ },
8576
8592
  "system.addon-update-available.body": "{{list}}",
8577
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8578
8593
  "system.server-update-available.title": {
8579
8594
  "one": "Aggiornamento server disponibile su {{count}} nodo",
8580
8595
  "other": "Aggiornamento server disponibile su {{count}} nodi"
@@ -8589,6 +8604,7 @@ var it_default = {
8589
8604
  "system.addon-updated.body": "{{list}}",
8590
8605
  "system.server-updated.title": "Server aggiornato",
8591
8606
  "system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
8607
+ "system.server-updated.body.toOnly": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}}",
8592
8608
  "system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
8593
8609
  "system.export-completed.title": "Export pronto",
8594
8610
  "system.export-completed.body": "L'export del filmato è pronto",
@@ -10411,15 +10427,15 @@ function incomingFromUpdateAvailable(facts, data) {
10411
10427
  const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
10412
10428
  const packageNames = unique(packages.map((pkg) => pkg.packageName));
10413
10429
  const signature = packages.map((pkg) => `${pkg.nodeId ?? ""}:${pkg.packageName}@${pkg.latestVersion}`).toSorted().join(",");
10414
- const list = packages.map(formatAvailableLine).join("\n");
10415
- const nodes = nodeIds.join(", ");
10416
10430
  const head = packages[0];
10417
10431
  const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
10432
+ const list = packages.map((pkg) => formatAvailableLine(pkg, oneNode !== void 0)).join("\n");
10433
+ const nodes = nodeIds.join(", ");
10418
10434
  return systemSubject({
10419
10435
  facts,
10420
10436
  kind,
10421
10437
  subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
10422
- titleKey: `system.${kind}.title`,
10438
+ titleKey: addon && oneNode !== void 0 ? `system.${kind}.title.onNode` : `system.${kind}.title`,
10423
10439
  bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
10424
10440
  textParams: {
10425
10441
  list,
@@ -10478,12 +10494,12 @@ function incomingFromAddonUpdates(facts, updates) {
10478
10494
  function incomingFromServerUpdated(facts, data) {
10479
10495
  const kind = stringField(data, "kind");
10480
10496
  const requestedBy = stringField(data, "requestedBy");
10481
- if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
10482
10497
  const packageName = stringField(data, "packageName") ?? "@camstack/server";
10483
10498
  const fromVersion = stringField(data, "fromVersion");
10484
10499
  const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
10500
+ if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:") && toVersion !== void 0)) return null;
10485
10501
  const nodeId = stringField(data, "nodeId") ?? "hub";
10486
- const bodyKey = fromVersion !== void 0 && toVersion !== void 0 ? "system.server-updated.body" : "system.server-updated.body.plain";
10502
+ const bodyKey = toVersion === void 0 ? "system.server-updated.body.plain" : fromVersion === void 0 ? "system.server-updated.body.toOnly" : "system.server-updated.body";
10487
10503
  return systemSubject({
10488
10504
  facts,
10489
10505
  kind: "server-updated",
@@ -10564,9 +10580,15 @@ function updatePackages(data) {
10564
10580
  ...nodeId !== void 0 ? { nodeId } : {}
10565
10581
  }];
10566
10582
  }
10567
- function formatAvailableLine(pkg) {
10568
- const line = `${pkg.packageName} ${pkg.latestVersion} (current ${pkg.currentVersion})`;
10569
- return pkg.nodeId !== void 0 ? `${line} on ${pkg.nodeId}` : line;
10583
+ /**
10584
+ * One line of an availability list — the SAME shape the completed-update list
10585
+ * uses (`incomingFromAddonUpdates`), and deliberately word-free: the list is a
10586
+ * frozen param, so any English in it ("current") is delivered verbatim to an
10587
+ * Italian household. An arrow is not a language.
10588
+ */
10589
+ function formatAvailableLine(pkg, nodeNamedInTitle) {
10590
+ const line = `${pkg.packageName} ${pkg.currentVersion} → ${pkg.latestVersion}`;
10591
+ return pkg.nodeId !== void 0 && !nodeNamedInTitle ? `${line} (${pkg.nodeId})` : line;
10570
10592
  }
10571
10593
  function versionFromRequestedBy(requestedBy) {
10572
10594
  if (requestedBy === void 0) return void 0;
@@ -25184,7 +25206,16 @@ function clamp01$1(x) {
25184
25206
  if (x > 1) return 1;
25185
25207
  return x;
25186
25208
  }
25187
- /** Rank a class name into [0,1] by how event-worthy it is. Case-insensitive. */
25209
+ /**
25210
+ * Rank a class name into [0,1] by how event-worthy it is. Case-insensitive.
25211
+ *
25212
+ * `audio` falls through to {@link CLASS_RANK_DEFAULT} on purpose — a sound is
25213
+ * not a subject in frame. Before D189 an audio episode carried the HEARD class
25214
+ * here, so `dog`/`cat`/`animal` landed in {@link ANIMAL_CLASSES} and a bark
25215
+ * ranked as if an animal had walked past, while `speech` (500 of the 576 audio
25216
+ * rows measured live) already ranked default. That boost was an accident of the
25217
+ * class collision, not a decision. Pinned by `key-event-scoring.spec.ts`.
25218
+ */
25188
25219
  function classRank(className) {
25189
25220
  const c = className.toLowerCase();
25190
25221
  if (PERSON_CLASSES.has(c)) return 1;
@@ -28811,19 +28842,29 @@ var BEST_FRAME_POLICY = {
28811
28842
  sizeAware: true
28812
28843
  };
28813
28844
  /**
28814
- * Best FACE (face-recognizer hold buffer) — confidence-only, no hysteresis, no
28815
- * rate limit. WHY: the face path must keep the TRUE highest-confidence face —
28816
- * accepting a new best only swaps an already-decoded crop buffer in memory
28817
- * (cheap; no capture is triggered), and a track may live well under the
28818
- * best-frame rate-limit window, so a gap/hysteresis would silently discard the
28819
- * real peak for nothing. This was the face path's de-facto behavior all along
28820
- * (it constructed the tracker with defaults); S9 declares it.
28845
+ * Best FACE (face-recognizer hold buffer) — SIZE-AWARE, no hysteresis, no rate
28846
+ * limit.
28847
+ *
28848
+ * No hysteresis / no gap, unchanged: accepting a new best only swaps an
28849
+ * already-decoded crop buffer in memory (cheap; no capture is triggered), and a
28850
+ * track may live well under the best-frame rate-limit window, so a gap would
28851
+ * silently discard the real peak for nothing.
28852
+ *
28853
+ * `sizeAware` since 2026-08-19. It used to be confidence-ONLY, and that was the
28854
+ * wrong ranking: SCRFD saturates its confidence on surveillance footage, so a
28855
+ * 12 px face and a 40 px face both answer ~0.9 and the track simply kept
28856
+ * whichever arrived first. The size that matters is the face's DETECTION-FRAME
28857
+ * short side, because that bounds the landmark precision the alignment warp
28858
+ * consumes — `face-size-score.ts` carries the measurement that set the
28859
+ * reference. Because `minGapMs` is 0 here, enabling this does NOT switch on the
28860
+ * decisive-improvement rate-limit bypass (there is no rate limit to bypass); it
28861
+ * only adds the size factor to the ranking.
28821
28862
  */
28822
28863
  var BEST_FACE_POLICY = {
28823
28864
  hysteresis: 0,
28824
28865
  minGapMs: 0,
28825
28866
  edgeAware: false,
28826
- sizeAware: false
28867
+ sizeAware: true
28827
28868
  };
28828
28869
  /**
28829
28870
  * Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
@@ -32248,7 +32289,11 @@ var AudioDetectionSettingsSchema = require_dist.object({
32248
32289
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
32249
32290
  * only ever cost the operator this many markers a day. */
32250
32291
  audioMarkerMaxPerDay: require_dist.number().int().positive().default(12),
32251
- /** Materialize one event + one synthetic track per classified episode. */
32292
+ /** Materialize one event + one synthetic track per classified episode.
32293
+ * OFF switches off the GROUPING only — classified audio still persists an
32294
+ * event (class change, then one per 5 s heartbeat) and still reaches the
32295
+ * Notification Center; it just opens no synthetic track. A knob that names
32296
+ * the grouping must never silence the path (D62). */
32252
32297
  audioEpisodeEnabled: require_dist.boolean().default(true),
32253
32298
  /** Class must be heard continuously this long before the episode opens. */
32254
32299
  audioEpisodeOpenHoldSec: require_dist.number().nonnegative().default(1.5),
@@ -34468,11 +34513,44 @@ async function resolveDefaultEventMedia(deps) {
34468
34513
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
34469
34514
  }
34470
34515
  //#endregion
34516
+ //#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
34517
+ /**
34518
+ * `null` = accept. A verdict = refuse, carrying both numbers so the caller can
34519
+ * name them in a log.
34520
+ *
34521
+ * Accepts unconditionally when the bound is 0 (disabled) or when the magnitude
34522
+ * is absent / non-finite — an older runner that does not report it must keep
34523
+ * working exactly as before, never be silently gated to nothing. Pure.
34524
+ */
34525
+ function embeddingMagnitudeVerdict(magnitude, bound) {
34526
+ if (!(bound > 0)) return null;
34527
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return null;
34528
+ if (magnitude <= bound) return null;
34529
+ return {
34530
+ magnitude,
34531
+ bound
34532
+ };
34533
+ }
34534
+ /**
34535
+ * Map a detection-frame face short side to the 0..1 `sizeScore` the
34536
+ * {@link ./best-detection-tracker.BestDetectionTracker} multiplies into its
34537
+ * effective score. Linear up to {@link FACE_SIZE_SCORE_REFERENCE_PX}, saturated
34538
+ * above it, 0 for a missing/degenerate measurement (so an unmeasurable face can
34539
+ * never outrank a measured one). Pure.
34540
+ */
34541
+ function faceSizeScore(detectionFrameShortSidePx) {
34542
+ if (!Number.isFinite(detectionFrameShortSidePx) || detectionFrameShortSidePx <= 0) return 0;
34543
+ if (detectionFrameShortSidePx >= 32) return 1;
34544
+ return detectionFrameShortSidePx / 32;
34545
+ }
34546
+ //#endregion
34471
34547
  //#region src/pipeline-analytics/face-recognizer.ts
34472
34548
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
34473
34549
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
34474
34550
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
34475
34551
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
34552
+ /** At most one 'embedding magnitude' sample line per device per this interval. */
34553
+ var FACE_MAGNITUDE_LOG_THROTTLE_MS = 6e4;
34476
34554
  /**
34477
34555
  * arcface model id stamped on a detail-plane face candidate when the gallery is
34478
34556
  * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
@@ -34487,6 +34565,8 @@ var FaceRecognizer = class {
34487
34565
  names = /* @__PURE__ */ new Map();
34488
34566
  aggregates = /* @__PURE__ */ new Map();
34489
34567
  bestFace = /* @__PURE__ */ new Map();
34568
+ /** Per-device throttle clock for the magnitude sample line. */
34569
+ lastMagnitudeLogAt = /* @__PURE__ */ new Map();
34490
34570
  /**
34491
34571
  * Peak identity-MATCH cosine per track for the currently-assigned identity
34492
34572
  * (NOT the detection confidence held in {@link bestFace}). Reset on an
@@ -34536,6 +34616,21 @@ var FaceRecognizer = class {
34536
34616
  * input source moves from the per-frame plane to this per-track call.
34537
34617
  */
34538
34618
  async ingestFaceDetail(input) {
34619
+ const verdict = embeddingMagnitudeVerdict(input.embeddingMagnitude, input.settings.maxEmbeddingMagnitude);
34620
+ if (verdict !== null) {
34621
+ this.deps.logger.info("face: dropping detail — raw embedding magnitude over bound", {
34622
+ tags: { deviceId: input.deviceId },
34623
+ meta: {
34624
+ trackId: input.trackId,
34625
+ magnitude: Number(verdict.magnitude.toFixed(3)),
34626
+ bound: verdict.bound,
34627
+ score: input.score,
34628
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: input.nativeFaceShortSidePx } : {}
34629
+ }
34630
+ });
34631
+ return;
34632
+ }
34633
+ this.logEmbeddingMagnitude(input);
34539
34634
  const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
34540
34635
  const candidate = {
34541
34636
  trackId: input.trackId,
@@ -34562,6 +34657,30 @@ var FaceRecognizer = class {
34562
34657
  ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
34563
34658
  });
34564
34659
  }
34660
+ /**
34661
+ * At most one magnitude line per device per
34662
+ * {@link FACE_MAGNITUDE_LOG_THROTTLE_MS}. This is the ONLY way the operator
34663
+ * can see where this cluster's usable and degenerate face populations sit, so
34664
+ * it must exist before `maxEmbeddingMagnitude` can honestly be turned on.
34665
+ */
34666
+ logEmbeddingMagnitude(input) {
34667
+ const magnitude = input.embeddingMagnitude;
34668
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return;
34669
+ const now = Date.now();
34670
+ if (now - (this.lastMagnitudeLogAt.get(input.deviceId) ?? 0) < FACE_MAGNITUDE_LOG_THROTTLE_MS) return;
34671
+ this.lastMagnitudeLogAt.set(input.deviceId, now);
34672
+ this.deps.logger.info("face: embedding magnitude", {
34673
+ tags: { deviceId: input.deviceId },
34674
+ meta: {
34675
+ trackId: input.trackId,
34676
+ magnitude: Number(magnitude.toFixed(3)),
34677
+ bound: input.settings.maxEmbeddingMagnitude,
34678
+ score: input.score,
34679
+ detectionFrameShortSidePx: input.faceBbox !== void 0 ? Math.round(Math.min(input.faceBbox.w, input.faceBbox.h)) : 0,
34680
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: Math.round(input.nativeFaceShortSidePx) } : {}
34681
+ }
34682
+ });
34683
+ }
34565
34684
  async processFrame(input) {
34566
34685
  const { settings } = input;
34567
34686
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.minFacePx);
@@ -34623,7 +34742,8 @@ var FaceRecognizer = class {
34623
34742
  }
34624
34743
  const recognizedIdentityId = state.assignedIdentityId ?? void 0;
34625
34744
  const held = this.bestFace.get(c.trackId);
34626
- const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
34745
+ const detectionFrameShortSidePx = c.faceBbox !== void 0 ? Math.min(c.faceBbox.w, c.faceBbox.h) : 0;
34746
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp, void 0, void 0, void 0, faceSizeScore(detectionFrameShortSidePx));
34627
34747
  const needsCrop = held !== void 0 && held.crop === void 0;
34628
34748
  if (isNewBest || needsCrop) {
34629
34749
  const cropBbox = c.faceBbox ?? c.bbox;
@@ -34883,14 +35003,38 @@ var FaceSettingsSchema = require_dist.object({
34883
35003
  * stripped from the per-device schema).
34884
35004
  */
34885
35005
  enabled: require_dist.boolean().default(true),
34886
- /** Cosine similarity (on L2-normalized arcface vectors) required to match.
34887
- * Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
34888
- * loose against thin/low-quality galleries and produced ~25-40% visually
34889
- * wrong auto-assignments in the 12h audit. */
34890
- similarityThreshold: require_dist.number().min(0).max(1).default(.62),
34891
- /** Reject ambiguous matches: require best secondBest margin. Raised
34892
- * 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
34893
- margin: require_dist.number().min(0).max(1).default(.15),
35006
+ /**
35007
+ * Cosine similarity (on L2-normalized arcface vectors) required to match.
35008
+ *
35009
+ * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
35010
+ * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
35011
+ * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
35012
+ * left almost nothing recognisable. That retune lived only as a RUNTIME
35013
+ * override until 2026-08-19; it is the schema default now, because a default
35014
+ * nobody runs is a decision nobody made.
35015
+ *
35016
+ * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
35017
+ * quality, where most of what reaches ArcFace is not a correctly aligned
35018
+ * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
35019
+ * aligned, 18% marginal, 62% misaligned or containing no detectable face).
35020
+ * On correctly aligned crops the SAME models separate at a far lower
35021
+ * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
35022
+ * 0.175, best accuracy 98.3% at threshold 0.28.
35023
+ *
35024
+ * Recalibration plan, in this order:
35025
+ * 1. land the landmark-precision gate (the alignment fix),
35026
+ * 2. re-enrol the gallery from crops produced AFTER it,
35027
+ * 3. re-measure genuine/impostor over a week of post-fix traffic,
35028
+ * 4. expect to lower this toward ~0.30, and `margin` with it.
35029
+ * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
35030
+ * scores ~0.9 against everything, so the degenerate population sits ABOVE
35031
+ * any threshold you could pick — the knob cannot reach it.
35032
+ */
35033
+ similarityThreshold: require_dist.number().min(0).max(1).default(.55),
35034
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
35035
+ * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
35036
+ * same story and the same recalibration plan as {@link similarityThreshold}. */
35037
+ margin: require_dist.number().min(0).max(1).default(.12),
34894
35038
  /** Minimum face-detection confidence for a face to be considered. */
34895
35039
  minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
34896
35040
  /**
@@ -34934,6 +35078,33 @@ var FaceSettingsSchema = require_dist.object({
34934
35078
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
34935
35079
  * such a value falls back to the default). */
34936
35080
  confirmFrames: require_dist.number().int().min(1).default(3),
35081
+ /**
35082
+ * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35083
+ * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35084
+ * the gate.
35085
+ *
35086
+ * This exists because a degenerate crop does not score LOW, it scores HIGH:
35087
+ * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35088
+ * faces exceeded cosine 0.90 and the worst 40% of the face population had
35089
+ * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35090
+ * similarity threshold can reach that — it sits above every threshold — so the
35091
+ * only defence is to refuse the embedding before it is stored or compared.
35092
+ *
35093
+ * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35094
+ * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35095
+ * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35096
+ * 5.25) regardless of size, grey level or blur, while information-free crops
35097
+ * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35098
+ * 93.2% of degenerate ones.
35099
+ *
35100
+ * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35101
+ * magnitude has never been observable here — `result-assembler` discarded it
35102
+ * one line after computing it. The field and its logging ship first; set a
35103
+ * real bound once a week of live `face: embedding magnitude` lines says where
35104
+ * this cluster's populations actually sit. Enabling a gate against a number
35105
+ * nobody has measured in production is how recognition silently stops.
35106
+ */
35107
+ maxEmbeddingMagnitude: require_dist.number().min(0).default(0),
34937
35108
  /** Max buffered faces kept per device. */
34938
35109
  bufferMaxPerDevice: require_dist.number().int().min(0).default(50)
34939
35110
  });
@@ -34952,6 +35123,7 @@ function resolveFaceSettings(raw) {
34952
35123
  recognitionMinFacePx: pick("recognitionMinFacePx"),
34953
35124
  suggestionMinCosine: pick("suggestionMinCosine"),
34954
35125
  minIdentitySamples: pick("minIdentitySamples"),
35126
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
34955
35127
  confirmFrames: pick("confirmFrames"),
34956
35128
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
34957
35129
  };
@@ -39423,6 +39595,147 @@ async function applyTrackFlags(deps, input) {
39423
39595
  };
39424
39596
  }
39425
39597
  //#endregion
39598
+ //#region src/pipeline-analytics/pipeline/plate-vote.ts
39599
+ /**
39600
+ * Per-character VOTE over a track's plate reads.
39601
+ *
39602
+ * A track produces up to `plate-ocr`'s `cadence.maxPerTrack` reads of the SAME
39603
+ * physical plate, from different frames. The recognizer used to keep the single
39604
+ * read with the highest OCR score (`holdBest`, argmax) — and OCR score does not
39605
+ * correlate with correctness. In the 2026-08 audit the top-scoring read of a
39606
+ * track was routinely the wrong one while two lower-scoring reads agreed on the
39607
+ * right glyph: score ranks how confident the recognizer was about the pixels it
39608
+ * saw, not how much of the plate those pixels were.
39609
+ *
39610
+ * So the reads vote. Each read is normalized (upper, alnum only), aligned to a
39611
+ * reference read, and contributes its OCR score to whichever glyph it saw at
39612
+ * each reference position; the winner per position is the output. Two reads
39613
+ * that agree beat one confident read that disagrees, which is the whole point.
39614
+ *
39615
+ * ALIGNMENT — reads differ in length (4–9 glyphs live: a dropped leading
39616
+ * district letter, a hallucinated trailing glyph). A positional vote on raw
39617
+ * indices would let one missing character shift every later column and destroy
39618
+ * the agreement it was supposed to measure. Each read is therefore aligned to
39619
+ * the reference by Levenshtein traceback: a match or substitution votes at the
39620
+ * reference position it aligned to, an insertion votes nowhere, and a deletion
39621
+ * simply abstains at that position.
39622
+ *
39623
+ * REFERENCE — the length carrying the most SCORE MASS wins (a hallucinated
39624
+ * 9-glyph read does not get to set the output length just by being longest);
39625
+ * ties inside that length go to the highest-scoring read. With ≤5 reads this is
39626
+ * the honest cheap heuristic: there is no third source of truth to consult.
39627
+ */
39628
+ /**
39629
+ * Align `candidate` to `reference` by Levenshtein traceback. Returns one entry
39630
+ * per REFERENCE position: the candidate glyph that aligned there, or `null`
39631
+ * when the candidate had nothing at that position (a deletion). Candidate
39632
+ * insertions are dropped — they have no reference column to vote in.
39633
+ */
39634
+ function alignToReference(reference, candidate) {
39635
+ const n = reference.length;
39636
+ const m = candidate.length;
39637
+ const dist = [];
39638
+ for (let i = 0; i <= n; i++) {
39639
+ const row = new Array(m + 1).fill(0);
39640
+ row[0] = i;
39641
+ dist.push(row);
39642
+ }
39643
+ const first = dist[0];
39644
+ if (first === void 0) return [];
39645
+ for (let j = 0; j <= m; j++) first[j] = j;
39646
+ for (let i = 1; i <= n; i++) {
39647
+ const prev = dist[i - 1];
39648
+ const cur = dist[i];
39649
+ if (prev === void 0 || cur === void 0) continue;
39650
+ for (let j = 1; j <= m; j++) {
39651
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39652
+ cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
39653
+ }
39654
+ }
39655
+ const aligned = new Array(n).fill(null);
39656
+ let i = n;
39657
+ let j = m;
39658
+ while (i > 0 && j > 0) {
39659
+ const cur = dist[i];
39660
+ const prev = dist[i - 1];
39661
+ if (cur === void 0 || prev === void 0) break;
39662
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39663
+ if (cur[j] === (prev[j - 1] ?? 0) + cost) {
39664
+ aligned[i - 1] = candidate[j - 1] ?? null;
39665
+ i--;
39666
+ j--;
39667
+ } else if (cur[j] === (prev[j] ?? 0) + 1) i--;
39668
+ else j--;
39669
+ }
39670
+ return aligned;
39671
+ }
39672
+ /** The read whose LENGTH carries the most score mass; ties → highest score. */
39673
+ function pickReference(reads) {
39674
+ const massByLength = /* @__PURE__ */ new Map();
39675
+ for (const r of reads) massByLength.set(r.text.length, (massByLength.get(r.text.length) ?? 0) + r.score);
39676
+ let bestLength = 0;
39677
+ let bestMass = -1;
39678
+ for (const [length, mass] of massByLength) if (mass > bestMass || mass === bestMass && length < bestLength) {
39679
+ bestMass = mass;
39680
+ bestLength = length;
39681
+ }
39682
+ let reference = "";
39683
+ let referenceScore = -1;
39684
+ for (const r of reads) if (r.text.length === bestLength && r.score > referenceScore) {
39685
+ referenceScore = r.score;
39686
+ reference = r.text;
39687
+ }
39688
+ return reference;
39689
+ }
39690
+ /**
39691
+ * Confidence-weighted per-character vote over a track's plate reads. Returns
39692
+ * the normalized winning text (`''` when there is nothing to vote on).
39693
+ */
39694
+ function votePlateText(reads) {
39695
+ const normalized = [];
39696
+ for (const r of reads) {
39697
+ const text = normalizePlate(r.text);
39698
+ if (text.length > 0) normalized.push({
39699
+ text,
39700
+ score: r.score
39701
+ });
39702
+ }
39703
+ if (normalized.length === 0) return "";
39704
+ const only = normalized[0];
39705
+ if (normalized.length === 1 && only !== void 0) return only.text;
39706
+ const reference = pickReference(normalized);
39707
+ if (reference.length === 0) return "";
39708
+ const columns = [];
39709
+ for (let i = 0; i < reference.length; i++) columns.push(/* @__PURE__ */ new Map());
39710
+ for (const r of normalized) {
39711
+ const aligned = r.text === reference ? [...r.text] : alignToReference(reference, r.text);
39712
+ for (let i = 0; i < reference.length; i++) {
39713
+ const glyph = aligned[i];
39714
+ if (glyph === null || glyph === void 0) continue;
39715
+ const column = columns[i];
39716
+ if (column === void 0) continue;
39717
+ column.set(glyph, (column.get(glyph) ?? 0) + r.score);
39718
+ }
39719
+ }
39720
+ let out = "";
39721
+ for (let i = 0; i < reference.length; i++) {
39722
+ const column = columns[i];
39723
+ const referenceGlyph = reference[i] ?? "";
39724
+ if (column === void 0 || column.size === 0) {
39725
+ out += referenceGlyph;
39726
+ continue;
39727
+ }
39728
+ let bestGlyph = referenceGlyph;
39729
+ let bestMass = column.get(referenceGlyph) ?? -1;
39730
+ for (const [glyph, mass] of column) if (mass > bestMass) {
39731
+ bestMass = mass;
39732
+ bestGlyph = glyph;
39733
+ }
39734
+ out += bestGlyph;
39735
+ }
39736
+ return out;
39737
+ }
39738
+ //#endregion
39426
39739
  //#region src/pipeline-analytics/pipeline/plate-matcher.ts
39427
39740
  /**
39428
39741
  * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
@@ -39471,11 +39784,11 @@ function matchPlateText(probe, gallery, opts) {
39471
39784
  //#endregion
39472
39785
  //#region src/pipeline-analytics/plate-recognizer.ts
39473
39786
  /**
39474
- * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
39475
- * for the plate gallery. Unlike faces there is NO gallery matching: the OCR text
39476
- * IS the label and already rides on the event via the detection pipeline. This
39477
- * only collects per-track reads (+ a tight plate crop) so the gallery can group,
39478
- * review and search them. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39787
+ * PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
39788
+ * produces and stores their per-character VOTE (`plate-vote.ts`) at close, plus
39789
+ * the max-score read's crop for the gallery tile. Unlike faces there is NO
39790
+ * gallery matching: the OCR text IS the label and already rides on the event via
39791
+ * the detection pipeline. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39479
39792
  */
39480
39793
  var PlateRecognizer = class {
39481
39794
  deps;
@@ -39538,7 +39851,7 @@ var PlateRecognizer = class {
39538
39851
  const minConfidence = input.minConfidence ?? 0;
39539
39852
  const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
39540
39853
  if (candidates.length === 0) return;
39541
- for (const c of candidates) await this.holdBest({
39854
+ for (const c of candidates) await this.observeRead({
39542
39855
  deviceId: input.deviceId,
39543
39856
  trackId: c.trackId,
39544
39857
  text: c.plateText,
@@ -39561,15 +39874,50 @@ var PlateRecognizer = class {
39561
39874
  * gallery stays empty (0 plateCrop) — the observed live gap.
39562
39875
  */
39563
39876
  async observePlateRead(input) {
39564
- if (!isPlausiblePlateRead(input.text, input.score)) return;
39565
- if (input.score < (input.minConfidence ?? 0)) return;
39566
- await this.holdBest(input);
39877
+ const minConfidence = input.minConfidence ?? 0;
39878
+ const reason = !isPlausiblePlateRead(input.text, input.score) ? "implausible" : input.score < minConfidence ? "below-min-confidence" : null;
39879
+ if (reason !== null) {
39880
+ this.deps.logger.info("plate: read discarded", {
39881
+ tags: {
39882
+ deviceId: input.deviceId,
39883
+ trackId: input.trackId
39884
+ },
39885
+ meta: {
39886
+ text: input.text,
39887
+ score: input.score,
39888
+ minConfidence,
39889
+ reason
39890
+ }
39891
+ });
39892
+ return;
39893
+ }
39894
+ await this.observeRead(input);
39567
39895
  }
39568
- /** Hold the highest-scoring plate read per track, capturing a tight crop the
39569
- * first time a new best is seen (shared by the per-frame + detail-plane paths). */
39570
- async holdBest(input) {
39896
+ /**
39897
+ * Record one plate read for a track (shared by the per-frame + detail-plane
39898
+ * paths).
39899
+ *
39900
+ * EVERY read is kept for the close-time vote — a track's reads disagree, and
39901
+ * the disagreement is the signal. The crop + geometry still track the
39902
+ * MAX-SCORE read: the vote decides the text, but the picture the operator
39903
+ * reviews should be the sharpest frame the track produced.
39904
+ *
39905
+ * The read list is capped at {@link MAX_VOTING_READS} in ARRIVAL order, not
39906
+ * by score: evicting the lowest-scoring read would re-introduce the very
39907
+ * ranking this vote exists to stop trusting.
39908
+ */
39909
+ async observeRead(input) {
39571
39910
  const held = this.bestPlate.get(input.trackId);
39572
- if (held !== void 0 && input.score <= held.score) return;
39911
+ const vote = {
39912
+ text: input.text,
39913
+ score: input.score
39914
+ };
39915
+ if (held !== void 0 && input.score <= held.score) {
39916
+ if (held.reads.length < 5) held.reads.push(vote);
39917
+ return;
39918
+ }
39919
+ const reads = held?.reads ?? [];
39920
+ if (reads.length < 5) reads.push(vote);
39573
39921
  let crop;
39574
39922
  if (input.frameHandle !== void 0) try {
39575
39923
  crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
@@ -39585,6 +39933,7 @@ var PlateRecognizer = class {
39585
39933
  this.bestPlate.set(input.trackId, {
39586
39934
  text: input.text,
39587
39935
  score: input.score,
39936
+ reads,
39588
39937
  bbox: input.bbox,
39589
39938
  timestamp: input.timestamp,
39590
39939
  frameWidth: input.frameWidth,
@@ -39661,14 +40010,16 @@ var PlateRecognizer = class {
39661
40010
  }
39662
40011
  });
39663
40012
  }
39664
- const match = this.matchVehicle(held.text, held.score);
40013
+ const voted = votePlateText(held.reads);
40014
+ const text = voted.length > 0 && isPlausiblePlateRead(voted, held.score) ? voted : normalizePlate(held.text);
40015
+ const match = this.matchVehicle(text, held.score);
39665
40016
  try {
39666
40017
  await this.deps.plateStore.insert({
39667
40018
  id: plateId,
39668
40019
  deviceId,
39669
40020
  trackId,
39670
40021
  timestamp: held.timestamp,
39671
- text: normalizePlate(held.text),
40022
+ text,
39672
40023
  score: held.score,
39673
40024
  ...mediaKey !== void 0 ? { mediaKey } : {},
39674
40025
  corrected: false,
@@ -39689,7 +40040,10 @@ var PlateRecognizer = class {
39689
40040
  },
39690
40041
  meta: {
39691
40042
  plateId,
39692
- text: normalizePlate(held.text),
40043
+ text,
40044
+ bestRead: normalizePlate(held.text),
40045
+ reads: held.reads.map((r) => `${normalizePlate(r.text)}@${r.score.toFixed(2)}`),
40046
+ votedFromReads: held.reads.length,
39693
40047
  score: held.score,
39694
40048
  hasCrop: mediaKey !== void 0,
39695
40049
  recognizedVehicleId: match?.vehicleId ?? null
@@ -40207,6 +40561,24 @@ function buildRecordedStillPorts(input) {
40207
40561
  var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40208
40562
  var MIGRATION_MARKER_KEY = "label-tier-4g";
40209
40563
  /**
40564
+ * The owner declares its marker collection — like every other store in this
40565
+ * addon. KV shape (a single `data` column: `get`/`set` route the value as a
40566
+ * JSON blob; the engine adds the implicit `id` primary key). This declaration
40567
+ * never existed, so the engine refused every marker write, the catch below
40568
+ * warned, and every boot re-scanned the full analytics store — ~36k rows per
40569
+ * boot from the day the migration shipped.
40570
+ */
40571
+ async function declareMigrationMarkers(store) {
40572
+ await store.declareCollection.mutate({
40573
+ collection: MIGRATION_MARKER_COLLECTION,
40574
+ columns: [{
40575
+ name: "data",
40576
+ type: "TEXT",
40577
+ notNull: true
40578
+ }]
40579
+ });
40580
+ }
40581
+ /**
40210
40582
  * The decision for ONE row, exposed so the rule is testable without a store.
40211
40583
  *
40212
40584
  * `null` = leave the row alone.
@@ -43387,6 +43759,47 @@ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
43387
43759
  return mark.priorRollbacks < 3;
43388
43760
  }
43389
43761
  //#endregion
43762
+ //#region src/pipeline-analytics/store/audio-track-class.ts
43763
+ /** The taxonomy macro every audio-sourced track carries as its class. */
43764
+ var AUDIO_TRACK_CLASS = "audio";
43765
+ /**
43766
+ * True when this row predates the contract: audio-sourced, but carrying a
43767
+ * naked audio macro as its class.
43768
+ *
43769
+ * Deliberately NOT "className is in AUDIO_MACRO_LABELS" — an id added to the
43770
+ * vocabulary after a row was written would then read as modern forever. The
43771
+ * question is the simple one: is this an audio row that does not say `audio`?
43772
+ */
43773
+ function isLegacyAudioTrackClass(track) {
43774
+ return track.source === "audio" && track.className !== "audio";
43775
+ }
43776
+ /**
43777
+ * Bring a hydrated row up to the contract. Returns the SAME reference when
43778
+ * there is nothing to do, so the hot path (every non-audio track, and every
43779
+ * audio track written after this shipped) allocates nothing.
43780
+ *
43781
+ * The naked class is preserved as an `audioLabels` entry when — and only when
43782
+ * — the row carries none. On the 576 legacy rows measured on the live hub that
43783
+ * branch is unreachable (all of them have labels); it exists so the rewrite
43784
+ * can never be the step that DESTROYS the only copy of what was heard.
43785
+ */
43786
+ function normalizeAudioTrackClass(track) {
43787
+ if (!isLegacyAudioTrackClass(track)) return track;
43788
+ const heard = {
43789
+ label: track.className,
43790
+ peakScore: 0,
43791
+ count: 1,
43792
+ firstAt: track.firstSeen,
43793
+ lastAt: track.lastSeen
43794
+ };
43795
+ const audioLabels = track.audioLabels !== void 0 && track.audioLabels.length > 0 ? track.audioLabels : [heard];
43796
+ return {
43797
+ ...track,
43798
+ className: AUDIO_TRACK_CLASS,
43799
+ audioLabels
43800
+ };
43801
+ }
43802
+ //#endregion
43390
43803
  //#region src/pipeline-analytics/store/recent-cursor.ts
43391
43804
  function encodeRecentCursor(cursor) {
43392
43805
  return Buffer.from(JSON.stringify({
@@ -45034,7 +45447,7 @@ var TrackStore = class {
45034
45447
  maxX: envMaxX,
45035
45448
  maxY: envMaxY
45036
45449
  } : null;
45037
- return {
45450
+ return normalizeAudioTrackClass({
45038
45451
  trackId: id,
45039
45452
  deviceId: Number(data["deviceId"]),
45040
45453
  className: String(data["className"]),
@@ -45065,7 +45478,7 @@ var TrackStore = class {
45065
45478
  ...closedReason !== void 0 ? { closedReason } : {},
45066
45479
  observations,
45067
45480
  ...envelope !== null ? { envelope } : {}
45068
- };
45481
+ });
45069
45482
  }
45070
45483
  };
45071
45484
  //#endregion
@@ -47398,7 +47811,7 @@ var AudioMarkerProjector = class {
47398
47811
  cameraId: deviceId,
47399
47812
  sourceDeviceId: deviceId,
47400
47813
  source: "audio",
47401
- kind: "audio",
47814
+ kind: AUDIO_TRACK_CLASS,
47402
47815
  timestamp: timestampMs,
47403
47816
  ...audioLabels !== void 0 ? { audioLabels } : {}
47404
47817
  });
@@ -48606,6 +49019,23 @@ async function projectSensorMarkers(deps, data, timestamp) {
48606
49019
  * {@link isSpatialTrack}.
48607
49020
  */
48608
49021
  var DEFAULT_DEBOUNCE_MS = 1e4;
49022
+ /**
49023
+ * What the debounce treats as "the same event happening again".
49024
+ *
49025
+ * `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
49026
+ * audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
49027
+ * keying on `kind` alone would collapse this to one bucket per camera. The
49028
+ * episode hysteresis cools down PER CLASS, so a bark three seconds after speech
49029
+ * is a legitimate second episode — and a per-camera bucket would drop it.
49030
+ *
49031
+ * Only the FIRST label is used: a materialize carries the one class the episode
49032
+ * opened on. Joining all of them would make `[speech]` and `[speech, dog]`
49033
+ * different buckets, which is the opposite of what a debounce is for.
49034
+ */
49035
+ function debounceKey(input) {
49036
+ const heard = input.audioLabels?.[0]?.label;
49037
+ return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
49038
+ }
48609
49039
  /** A hanging snapshot cap must not stall the sensor state-change path. */
48610
49040
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
48611
49041
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
@@ -48648,7 +49078,7 @@ var SyntheticTrackMaterializer = class {
48648
49078
  snapshotTimeoutMs;
48649
49079
  makeId;
48650
49080
  now;
48651
- /** Last snapshot time per `${cameraId}:${kind}`. */
49081
+ /** Last snapshot time per {@link debounceKey}. */
48652
49082
  lastAt = /* @__PURE__ */ new Map();
48653
49083
  constructor(deps) {
48654
49084
  this.deps = deps;
@@ -48687,7 +49117,7 @@ var SyntheticTrackMaterializer = class {
48687
49117
  * still lands a track (with no media) — it never blocks the durable record.
48688
49118
  */
48689
49119
  async materialize(input) {
48690
- const key = `${input.cameraId}:${input.kind}`;
49120
+ const key = debounceKey(input);
48691
49121
  const last = this.lastAt.get(key);
48692
49122
  if (last !== void 0 && this.now() - last < this.debounceMs) return null;
48693
49123
  this.lastAt.set(key, this.now());
@@ -50042,7 +50472,7 @@ function buildGlobalSettingsSchema() {
50042
50472
  type: "boolean",
50043
50473
  key: "audioEpisodeEnabled",
50044
50474
  label: "Create audio episodes",
50045
- description: "When off, classified audio still notifies from other paths but does not open a synthetic track or coalesce classification events.",
50475
+ description: "Off switches off the GROUPING only: classified audio still persists an event and still notifies, but no timeline track is opened and a long sound is no longer collapsed into one episode (one event per class change, then one every 5 s while it lasts).",
50046
50476
  default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
50047
50477
  },
50048
50478
  {
@@ -50168,6 +50598,15 @@ function buildGlobalSettingsSchema() {
50168
50598
  step: 1,
50169
50599
  default: FACE_DEFAULTS.confirmFrames
50170
50600
  },
50601
+ {
50602
+ type: "number",
50603
+ key: "maxEmbeddingMagnitude",
50604
+ label: "Max embedding magnitude (0 = off)",
50605
+ description: "Refuse a face whose RAW ArcFace feature magnitude exceeds this, before it is buffered, matched or enrolled. A degenerate crop does not score LOW against the gallery — it scores HIGH against everything — so no similarity threshold can catch it; the raw magnitude can. On this model a usable face sits around 4.4 whatever its size, grey level or blur, while an information-free crop jumps to 10–16. Leave at 0 until the \"face: embedding magnitude\" log lines show where YOUR cameras sit; a bound guessed from someone else's data silently stops recognising everybody.",
50606
+ min: 0,
50607
+ step: .5,
50608
+ default: FACE_DEFAULTS.maxEmbeddingMagnitude
50609
+ },
50171
50610
  {
50172
50611
  type: "number",
50173
50612
  key: "bufferMaxPerDevice",
@@ -50245,6 +50684,125 @@ function evaluatePeriodicSnapshot(input) {
50245
50684
  movedFraction
50246
50685
  };
50247
50686
  }
50687
+ /** Where the completion marker lives — the collection `runLabelTierMigration`
50688
+ * already established for exactly this. */
50689
+ var AUDIO_CLASS_MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
50690
+ var AUDIO_CLASS_MIGRATION_MARKER_KEY = "audio-track-class-d189";
50691
+ /**
50692
+ * The decision for ONE stored row, exposed so the rule is testable without a
50693
+ * store. `null` = leave the row alone.
50694
+ *
50695
+ * Reads the RAW row rather than a hydrated `Track` on purpose: this is the
50696
+ * column the pass rewrites, and hydrating through `rowToTrack` would apply the
50697
+ * read-side normalization and hide the very rows it is looking for.
50698
+ */
50699
+ function decideAudioClassRowMigration(data) {
50700
+ if (data["source"] !== "audio") return null;
50701
+ const className = data["className"];
50702
+ if (typeof className !== "string" || className.length === 0) return null;
50703
+ if (className === "audio") return null;
50704
+ const stored = data["audioLabels"];
50705
+ if (Array.isArray(stored) && stored.length > 0) return { className: AUDIO_TRACK_CLASS };
50706
+ const firstSeen = typeof data["firstSeen"] === "number" ? data["firstSeen"] : 0;
50707
+ return {
50708
+ className: AUDIO_TRACK_CLASS,
50709
+ audioLabels: [{
50710
+ label: className,
50711
+ peakScore: 0,
50712
+ count: 1,
50713
+ firstAt: firstSeen,
50714
+ lastAt: typeof data["lastSeen"] === "number" ? data["lastSeen"] : firstSeen
50715
+ }]
50716
+ };
50717
+ }
50718
+ /**
50719
+ * Run the pass over the tracks collection, once.
50720
+ *
50721
+ * The marker short-circuits a completed migration; it is written only when the
50722
+ * pass did NOT truncate, so an interrupted run resumes on the next boot instead
50723
+ * of declaring itself done.
50724
+ */
50725
+ async function runAudioTrackClassMigration(deps, collection) {
50726
+ try {
50727
+ const marker = await deps.store.get.query({
50728
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50729
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY
50730
+ });
50731
+ if (marker !== null && marker !== void 0) return null;
50732
+ } catch {}
50733
+ let scanned = 0;
50734
+ let rewritten = 0;
50735
+ let labelsRecovered = 0;
50736
+ let skipped = 0;
50737
+ let offset = 0;
50738
+ let truncated = false;
50739
+ for (;;) {
50740
+ if (scanned >= 2e5) {
50741
+ truncated = true;
50742
+ break;
50743
+ }
50744
+ const rows = await deps.store.query.query({
50745
+ collection,
50746
+ filter: {
50747
+ limit: 500,
50748
+ offset
50749
+ }
50750
+ });
50751
+ if (rows.length === 0) break;
50752
+ for (const row of rows) {
50753
+ scanned++;
50754
+ const patch = decideAudioClassRowMigration(row.data);
50755
+ if (patch === null) {
50756
+ skipped++;
50757
+ continue;
50758
+ }
50759
+ const deviceId = typeof row.data["deviceId"] === "number" ? row.data["deviceId"] : void 0;
50760
+ try {
50761
+ await deps.store.update.mutate({
50762
+ collection,
50763
+ id: row.id,
50764
+ data: patch
50765
+ });
50766
+ rewritten++;
50767
+ if (patch["audioLabels"] !== void 0) labelsRecovered++;
50768
+ } catch (err) {
50769
+ deps.logger.warn("audio track class migration row failed", {
50770
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
50771
+ meta: {
50772
+ collection,
50773
+ id: row.id,
50774
+ error: err instanceof Error ? err.message : String(err)
50775
+ }
50776
+ });
50777
+ skipped++;
50778
+ }
50779
+ }
50780
+ if (rows.length < 500) break;
50781
+ offset += rows.length;
50782
+ }
50783
+ if (!truncated) try {
50784
+ await deps.store.set.mutate({
50785
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50786
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY,
50787
+ value: {
50788
+ completedAt: deps.now(),
50789
+ rewritten
50790
+ }
50791
+ });
50792
+ } catch (err) {
50793
+ deps.logger.warn("audio track class migration marker failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
50794
+ }
50795
+ const stats = {
50796
+ collection,
50797
+ scanned,
50798
+ rewritten,
50799
+ labelsRecovered,
50800
+ skipped,
50801
+ truncated
50802
+ };
50803
+ deps.logger.info("audio track class migration", { meta: { ...stats } });
50804
+ return stats;
50805
+ }
50248
50806
  //#endregion
50249
50807
  //#region src/pipeline-analytics/store/object-embedding-store.ts
50250
50808
  /**
@@ -51548,6 +52106,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
51548
52106
  var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
51549
52107
  /** Persist lastSeen on an open synthetic audio track at most this often. */
51550
52108
  var AUDIO_EPISODE_PERSIST_MS = 2e3;
52109
+ /**
52110
+ * Coalescing window for classified audio inserts when episodes are OFF.
52111
+ *
52112
+ * With `audioEpisodeEnabled: false` there is no hysteresis to collapse a sound
52113
+ * into one row, but the classifier still fires at ~30 Hz — so the pre-episode
52114
+ * rule applies again: while the SAME class persists, one row per heartbeat;
52115
+ * a class change inserts immediately. The knob turns off the grouping, never
52116
+ * the persistence (D62).
52117
+ */
52118
+ var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
51551
52119
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
51552
52120
  /**
51553
52121
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -52653,7 +53221,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52653
53221
  AnalyticsLts.declare(api.settingsStore),
52654
53222
  SceneStore.declare(api.settingsStore),
52655
53223
  NotificationCenter.declare(api.settingsStore),
52656
- ViewerSettingsSnapshotStore.declare(api.settingsStore)
53224
+ ViewerSettingsSnapshotStore.declare(api.settingsStore),
53225
+ declareMigrationMarkers(api.settingsStore)
52657
53226
  ]);
52658
53227
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
52659
53228
  try {
@@ -52671,6 +53240,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52671
53240
  }, [TRACKS_COLLECTION, OBJECT_EVENTS_COLLECTION]).catch((err) => {
52672
53241
  this.ctx.logger.warn("label tier migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
52673
53242
  });
53243
+ runAudioTrackClassMigration({
53244
+ store: api.settingsStore,
53245
+ logger: this.ctx.logger,
53246
+ now: () => Date.now()
53247
+ }, TRACKS_COLLECTION).catch((err) => {
53248
+ this.ctx.logger.warn("audio track class migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53249
+ });
52674
53250
  }
52675
53251
  /** Event-media storage. By default it uses `ctx.kernel.storage`, rooted at
52676
53252
  * the node data dir (appData) → heavy thumbnail/track-frame churn hammers
@@ -55403,6 +55979,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55403
55979
  frameHeight: frame.frameHeight,
55404
55980
  score: detail.score,
55405
55981
  embedding: decodeEmbeddingBase64(detail.embedding),
55982
+ ...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
55406
55983
  parentBbox: { ...frame.bbox },
55407
55984
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
55408
55985
  ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
@@ -55870,7 +56447,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55870
56447
  return;
55871
56448
  }
55872
56449
  }
55873
- async openAudioEpisode(input, ref) {
56450
+ /**
56451
+ * The ONE writer for a classified audio observation: the durable row, the
56452
+ * Notification Center hook fired in the same persist moment (D-2), the
56453
+ * track-level `audioLabels` stamp, and the telemetry event.
56454
+ *
56455
+ * Shared by both routes on purpose. `audioEpisodeEnabled` selects WHEN this
56456
+ * runs (once per episode, or once per heartbeat), never WHETHER — a knob
56457
+ * that names the grouping must not be able to silence the classified path
56458
+ * (D62). This body was inlined in `openAudioEpisode` and the knob-off camera
56459
+ * therefore persisted nothing at all.
56460
+ */
56461
+ async persistClassifiedAudio(input) {
55874
56462
  const ev = {
55875
56463
  id: (0, node_crypto.randomUUID)(),
55876
56464
  deviceId: input.deviceId,
@@ -55879,18 +56467,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55879
56467
  rms: input.rms,
55880
56468
  dbfs: input.dbfs,
55881
56469
  classification: {
55882
- className: ref.className,
56470
+ className: input.className,
55883
56471
  ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
55884
56472
  score: input.score
55885
56473
  }
55886
56474
  };
55887
56475
  this.lastAudioInsertByDevice.set(input.deviceId, {
55888
- className: ref.className,
56476
+ className: input.className,
55889
56477
  atMs: input.timestamp
55890
56478
  });
55891
56479
  await this.eventStore.insertAudio(ev);
55892
56480
  this.notificationCenter?.onAudioEventPersisted(ev);
55893
- this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
56481
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
55894
56482
  this.ctx.eventBus.emit({
55895
56483
  id: `pa-${ev.id}`,
55896
56484
  timestamp: new Date(ev.timestamp),
@@ -55907,6 +56495,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55907
56495
  timestamp: ev.timestamp
55908
56496
  }
55909
56497
  });
56498
+ }
56499
+ /**
56500
+ * Classified audio with episodes OFF: persist on class change or heartbeat,
56501
+ * and open NO synthetic track. This is the whole of what the knob switches —
56502
+ * the timeline track and the episode coalescing — and it is what the form
56503
+ * promises.
56504
+ */
56505
+ async persistClassifiedAudioUngrouped(input) {
56506
+ const last = this.lastAudioInsertByDevice.get(input.deviceId);
56507
+ const sameClass = last?.className === input.className;
56508
+ const heartbeatDue = last === void 0 || input.timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
56509
+ if (sameClass && !heartbeatDue) return;
56510
+ await this.persistClassifiedAudio(input);
56511
+ }
56512
+ async openAudioEpisode(input, ref) {
56513
+ await this.persistClassifiedAudio({
56514
+ ...input,
56515
+ className: ref.className
56516
+ });
55910
56517
  if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
55911
56518
  const materializer = this.syntheticTracks;
55912
56519
  if (materializer === null) return;
@@ -55915,7 +56522,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55915
56522
  cameraId: input.deviceId,
55916
56523
  sourceDeviceId: input.deviceId,
55917
56524
  source: "audio",
55918
- kind: ref.className,
56525
+ kind: AUDIO_TRACK_CLASS,
55919
56526
  timestamp: input.timestamp,
55920
56527
  audioLabels: [{
55921
56528
  label: ref.className,
@@ -56018,19 +56625,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
56018
56625
  });
56019
56626
  return;
56020
56627
  }
56021
- await this.driveAudioEpisode({
56628
+ const classified = {
56022
56629
  deviceId,
56023
56630
  timestamp,
56024
56631
  className: route.className,
56025
56632
  score: topClassification.score,
56026
- originalClass: topClassification.debug?.originalClass,
56633
+ ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
56027
56634
  rms: level?.rms ?? 0,
56028
- dbfs: level?.dbfs ?? 0,
56635
+ dbfs: level?.dbfs ?? 0
56636
+ };
56637
+ if (!settings.audioEpisodeEnabled) {
56638
+ await this.persistClassifiedAudioUngrouped(classified);
56639
+ return;
56640
+ }
56641
+ await this.driveAudioEpisode({
56642
+ ...classified,
56029
56643
  knobs: this.audioEpisodeKnobs(settings)
56030
56644
  });
56031
56645
  return;
56032
56646
  }
56033
- await this.driveAudioEpisode({
56647
+ if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
56034
56648
  deviceId,
56035
56649
  timestamp,
56036
56650
  className: null,