@camstack/addon-post-analysis 1.2.101 → 1.2.102

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");
@@ -8359,8 +8359,11 @@ var en_default = {
8359
8359
  "one": "{{count}} addon update available",
8360
8360
  "other": "{{count}} addon updates available"
8361
8361
  },
8362
+ "system.addon-update-available.title.onNode": {
8363
+ "one": "{{count}} addon update available on {{nodeId}}",
8364
+ "other": "{{count}} addon updates available on {{nodeId}}"
8365
+ },
8362
8366
  "system.addon-update-available.body": "{{list}}",
8363
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8364
8367
  "system.server-update-available.title": {
8365
8368
  "one": "Server update available on {{count}} node",
8366
8369
  "other": "Server update available on {{count}} nodes"
@@ -8375,6 +8378,7 @@ var en_default = {
8375
8378
  "system.addon-updated.body": "{{list}}",
8376
8379
  "system.server-updated.title": "Server updated",
8377
8380
  "system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
8381
+ "system.server-updated.body.toOnly": "{{packageName}} on {{nodeId}} is now {{toVersion}}",
8378
8382
  "system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
8379
8383
  "system.export-completed.title": "Export ready",
8380
8384
  "system.export-completed.body": "The footage export is ready",
@@ -8573,8 +8577,11 @@ var it_default = {
8573
8577
  "one": "{{count}} aggiornamento addon disponibile",
8574
8578
  "other": "{{count}} aggiornamenti addon disponibili"
8575
8579
  },
8580
+ "system.addon-update-available.title.onNode": {
8581
+ "one": "{{count}} aggiornamento addon disponibile su {{nodeId}}",
8582
+ "other": "{{count}} aggiornamenti addon disponibili su {{nodeId}}"
8583
+ },
8576
8584
  "system.addon-update-available.body": "{{list}}",
8577
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8578
8585
  "system.server-update-available.title": {
8579
8586
  "one": "Aggiornamento server disponibile su {{count}} nodo",
8580
8587
  "other": "Aggiornamento server disponibile su {{count}} nodi"
@@ -8589,6 +8596,7 @@ var it_default = {
8589
8596
  "system.addon-updated.body": "{{list}}",
8590
8597
  "system.server-updated.title": "Server aggiornato",
8591
8598
  "system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
8599
+ "system.server-updated.body.toOnly": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}}",
8592
8600
  "system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
8593
8601
  "system.export-completed.title": "Export pronto",
8594
8602
  "system.export-completed.body": "L'export del filmato è pronto",
@@ -10411,15 +10419,15 @@ function incomingFromUpdateAvailable(facts, data) {
10411
10419
  const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
10412
10420
  const packageNames = unique(packages.map((pkg) => pkg.packageName));
10413
10421
  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
10422
  const head = packages[0];
10417
10423
  const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
10424
+ const list = packages.map((pkg) => formatAvailableLine(pkg, oneNode !== void 0)).join("\n");
10425
+ const nodes = nodeIds.join(", ");
10418
10426
  return systemSubject({
10419
10427
  facts,
10420
10428
  kind,
10421
10429
  subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
10422
- titleKey: `system.${kind}.title`,
10430
+ titleKey: addon && oneNode !== void 0 ? `system.${kind}.title.onNode` : `system.${kind}.title`,
10423
10431
  bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
10424
10432
  textParams: {
10425
10433
  list,
@@ -10478,12 +10486,12 @@ function incomingFromAddonUpdates(facts, updates) {
10478
10486
  function incomingFromServerUpdated(facts, data) {
10479
10487
  const kind = stringField(data, "kind");
10480
10488
  const requestedBy = stringField(data, "requestedBy");
10481
- if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
10482
10489
  const packageName = stringField(data, "packageName") ?? "@camstack/server";
10483
10490
  const fromVersion = stringField(data, "fromVersion");
10484
10491
  const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
10492
+ if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:") && toVersion !== void 0)) return null;
10485
10493
  const nodeId = stringField(data, "nodeId") ?? "hub";
10486
- const bodyKey = fromVersion !== void 0 && toVersion !== void 0 ? "system.server-updated.body" : "system.server-updated.body.plain";
10494
+ const bodyKey = toVersion === void 0 ? "system.server-updated.body.plain" : fromVersion === void 0 ? "system.server-updated.body.toOnly" : "system.server-updated.body";
10487
10495
  return systemSubject({
10488
10496
  facts,
10489
10497
  kind: "server-updated",
@@ -10564,9 +10572,15 @@ function updatePackages(data) {
10564
10572
  ...nodeId !== void 0 ? { nodeId } : {}
10565
10573
  }];
10566
10574
  }
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;
10575
+ /**
10576
+ * One line of an availability list — the SAME shape the completed-update list
10577
+ * uses (`incomingFromAddonUpdates`), and deliberately word-free: the list is a
10578
+ * frozen param, so any English in it ("current") is delivered verbatim to an
10579
+ * Italian household. An arrow is not a language.
10580
+ */
10581
+ function formatAvailableLine(pkg, nodeNamedInTitle) {
10582
+ const line = `${pkg.packageName} ${pkg.currentVersion} → ${pkg.latestVersion}`;
10583
+ return pkg.nodeId !== void 0 && !nodeNamedInTitle ? `${line} (${pkg.nodeId})` : line;
10570
10584
  }
10571
10585
  function versionFromRequestedBy(requestedBy) {
10572
10586
  if (requestedBy === void 0) return void 0;
@@ -28811,19 +28825,29 @@ var BEST_FRAME_POLICY = {
28811
28825
  sizeAware: true
28812
28826
  };
28813
28827
  /**
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.
28828
+ * Best FACE (face-recognizer hold buffer) — SIZE-AWARE, no hysteresis, no rate
28829
+ * limit.
28830
+ *
28831
+ * No hysteresis / no gap, unchanged: accepting a new best only swaps an
28832
+ * already-decoded crop buffer in memory (cheap; no capture is triggered), and a
28833
+ * track may live well under the best-frame rate-limit window, so a gap would
28834
+ * silently discard the real peak for nothing.
28835
+ *
28836
+ * `sizeAware` since 2026-08-19. It used to be confidence-ONLY, and that was the
28837
+ * wrong ranking: SCRFD saturates its confidence on surveillance footage, so a
28838
+ * 12 px face and a 40 px face both answer ~0.9 and the track simply kept
28839
+ * whichever arrived first. The size that matters is the face's DETECTION-FRAME
28840
+ * short side, because that bounds the landmark precision the alignment warp
28841
+ * consumes — `face-size-score.ts` carries the measurement that set the
28842
+ * reference. Because `minGapMs` is 0 here, enabling this does NOT switch on the
28843
+ * decisive-improvement rate-limit bypass (there is no rate limit to bypass); it
28844
+ * only adds the size factor to the ranking.
28821
28845
  */
28822
28846
  var BEST_FACE_POLICY = {
28823
28847
  hysteresis: 0,
28824
28848
  minGapMs: 0,
28825
28849
  edgeAware: false,
28826
- sizeAware: false
28850
+ sizeAware: true
28827
28851
  };
28828
28852
  /**
28829
28853
  * Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
@@ -32248,7 +32272,11 @@ var AudioDetectionSettingsSchema = require_dist.object({
32248
32272
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
32249
32273
  * only ever cost the operator this many markers a day. */
32250
32274
  audioMarkerMaxPerDay: require_dist.number().int().positive().default(12),
32251
- /** Materialize one event + one synthetic track per classified episode. */
32275
+ /** Materialize one event + one synthetic track per classified episode.
32276
+ * OFF switches off the GROUPING only — classified audio still persists an
32277
+ * event (class change, then one per 5 s heartbeat) and still reaches the
32278
+ * Notification Center; it just opens no synthetic track. A knob that names
32279
+ * the grouping must never silence the path (D62). */
32252
32280
  audioEpisodeEnabled: require_dist.boolean().default(true),
32253
32281
  /** Class must be heard continuously this long before the episode opens. */
32254
32282
  audioEpisodeOpenHoldSec: require_dist.number().nonnegative().default(1.5),
@@ -34468,11 +34496,44 @@ async function resolveDefaultEventMedia(deps) {
34468
34496
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
34469
34497
  }
34470
34498
  //#endregion
34499
+ //#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
34500
+ /**
34501
+ * `null` = accept. A verdict = refuse, carrying both numbers so the caller can
34502
+ * name them in a log.
34503
+ *
34504
+ * Accepts unconditionally when the bound is 0 (disabled) or when the magnitude
34505
+ * is absent / non-finite — an older runner that does not report it must keep
34506
+ * working exactly as before, never be silently gated to nothing. Pure.
34507
+ */
34508
+ function embeddingMagnitudeVerdict(magnitude, bound) {
34509
+ if (!(bound > 0)) return null;
34510
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return null;
34511
+ if (magnitude <= bound) return null;
34512
+ return {
34513
+ magnitude,
34514
+ bound
34515
+ };
34516
+ }
34517
+ /**
34518
+ * Map a detection-frame face short side to the 0..1 `sizeScore` the
34519
+ * {@link ./best-detection-tracker.BestDetectionTracker} multiplies into its
34520
+ * effective score. Linear up to {@link FACE_SIZE_SCORE_REFERENCE_PX}, saturated
34521
+ * above it, 0 for a missing/degenerate measurement (so an unmeasurable face can
34522
+ * never outrank a measured one). Pure.
34523
+ */
34524
+ function faceSizeScore(detectionFrameShortSidePx) {
34525
+ if (!Number.isFinite(detectionFrameShortSidePx) || detectionFrameShortSidePx <= 0) return 0;
34526
+ if (detectionFrameShortSidePx >= 32) return 1;
34527
+ return detectionFrameShortSidePx / 32;
34528
+ }
34529
+ //#endregion
34471
34530
  //#region src/pipeline-analytics/face-recognizer.ts
34472
34531
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
34473
34532
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
34474
34533
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
34475
34534
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
34535
+ /** At most one 'embedding magnitude' sample line per device per this interval. */
34536
+ var FACE_MAGNITUDE_LOG_THROTTLE_MS = 6e4;
34476
34537
  /**
34477
34538
  * arcface model id stamped on a detail-plane face candidate when the gallery is
34478
34539
  * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
@@ -34487,6 +34548,8 @@ var FaceRecognizer = class {
34487
34548
  names = /* @__PURE__ */ new Map();
34488
34549
  aggregates = /* @__PURE__ */ new Map();
34489
34550
  bestFace = /* @__PURE__ */ new Map();
34551
+ /** Per-device throttle clock for the magnitude sample line. */
34552
+ lastMagnitudeLogAt = /* @__PURE__ */ new Map();
34490
34553
  /**
34491
34554
  * Peak identity-MATCH cosine per track for the currently-assigned identity
34492
34555
  * (NOT the detection confidence held in {@link bestFace}). Reset on an
@@ -34536,6 +34599,21 @@ var FaceRecognizer = class {
34536
34599
  * input source moves from the per-frame plane to this per-track call.
34537
34600
  */
34538
34601
  async ingestFaceDetail(input) {
34602
+ const verdict = embeddingMagnitudeVerdict(input.embeddingMagnitude, input.settings.maxEmbeddingMagnitude);
34603
+ if (verdict !== null) {
34604
+ this.deps.logger.info("face: dropping detail — raw embedding magnitude over bound", {
34605
+ tags: { deviceId: input.deviceId },
34606
+ meta: {
34607
+ trackId: input.trackId,
34608
+ magnitude: Number(verdict.magnitude.toFixed(3)),
34609
+ bound: verdict.bound,
34610
+ score: input.score,
34611
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: input.nativeFaceShortSidePx } : {}
34612
+ }
34613
+ });
34614
+ return;
34615
+ }
34616
+ this.logEmbeddingMagnitude(input);
34539
34617
  const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
34540
34618
  const candidate = {
34541
34619
  trackId: input.trackId,
@@ -34562,6 +34640,30 @@ var FaceRecognizer = class {
34562
34640
  ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
34563
34641
  });
34564
34642
  }
34643
+ /**
34644
+ * At most one magnitude line per device per
34645
+ * {@link FACE_MAGNITUDE_LOG_THROTTLE_MS}. This is the ONLY way the operator
34646
+ * can see where this cluster's usable and degenerate face populations sit, so
34647
+ * it must exist before `maxEmbeddingMagnitude` can honestly be turned on.
34648
+ */
34649
+ logEmbeddingMagnitude(input) {
34650
+ const magnitude = input.embeddingMagnitude;
34651
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return;
34652
+ const now = Date.now();
34653
+ if (now - (this.lastMagnitudeLogAt.get(input.deviceId) ?? 0) < FACE_MAGNITUDE_LOG_THROTTLE_MS) return;
34654
+ this.lastMagnitudeLogAt.set(input.deviceId, now);
34655
+ this.deps.logger.info("face: embedding magnitude", {
34656
+ tags: { deviceId: input.deviceId },
34657
+ meta: {
34658
+ trackId: input.trackId,
34659
+ magnitude: Number(magnitude.toFixed(3)),
34660
+ bound: input.settings.maxEmbeddingMagnitude,
34661
+ score: input.score,
34662
+ detectionFrameShortSidePx: input.faceBbox !== void 0 ? Math.round(Math.min(input.faceBbox.w, input.faceBbox.h)) : 0,
34663
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: Math.round(input.nativeFaceShortSidePx) } : {}
34664
+ }
34665
+ });
34666
+ }
34565
34667
  async processFrame(input) {
34566
34668
  const { settings } = input;
34567
34669
  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 +34725,8 @@ var FaceRecognizer = class {
34623
34725
  }
34624
34726
  const recognizedIdentityId = state.assignedIdentityId ?? void 0;
34625
34727
  const held = this.bestFace.get(c.trackId);
34626
- const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
34728
+ const detectionFrameShortSidePx = c.faceBbox !== void 0 ? Math.min(c.faceBbox.w, c.faceBbox.h) : 0;
34729
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp, void 0, void 0, void 0, faceSizeScore(detectionFrameShortSidePx));
34627
34730
  const needsCrop = held !== void 0 && held.crop === void 0;
34628
34731
  if (isNewBest || needsCrop) {
34629
34732
  const cropBbox = c.faceBbox ?? c.bbox;
@@ -34883,14 +34986,38 @@ var FaceSettingsSchema = require_dist.object({
34883
34986
  * stripped from the per-device schema).
34884
34987
  */
34885
34988
  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),
34989
+ /**
34990
+ * Cosine similarity (on L2-normalized arcface vectors) required to match.
34991
+ *
34992
+ * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
34993
+ * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
34994
+ * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
34995
+ * left almost nothing recognisable. That retune lived only as a RUNTIME
34996
+ * override until 2026-08-19; it is the schema default now, because a default
34997
+ * nobody runs is a decision nobody made.
34998
+ *
34999
+ * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
35000
+ * quality, where most of what reaches ArcFace is not a correctly aligned
35001
+ * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
35002
+ * aligned, 18% marginal, 62% misaligned or containing no detectable face).
35003
+ * On correctly aligned crops the SAME models separate at a far lower
35004
+ * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
35005
+ * 0.175, best accuracy 98.3% at threshold 0.28.
35006
+ *
35007
+ * Recalibration plan, in this order:
35008
+ * 1. land the landmark-precision gate (the alignment fix),
35009
+ * 2. re-enrol the gallery from crops produced AFTER it,
35010
+ * 3. re-measure genuine/impostor over a week of post-fix traffic,
35011
+ * 4. expect to lower this toward ~0.30, and `margin` with it.
35012
+ * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
35013
+ * scores ~0.9 against everything, so the degenerate population sits ABOVE
35014
+ * any threshold you could pick — the knob cannot reach it.
35015
+ */
35016
+ similarityThreshold: require_dist.number().min(0).max(1).default(.55),
35017
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
35018
+ * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
35019
+ * same story and the same recalibration plan as {@link similarityThreshold}. */
35020
+ margin: require_dist.number().min(0).max(1).default(.12),
34894
35021
  /** Minimum face-detection confidence for a face to be considered. */
34895
35022
  minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
34896
35023
  /**
@@ -34934,6 +35061,33 @@ var FaceSettingsSchema = require_dist.object({
34934
35061
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
34935
35062
  * such a value falls back to the default). */
34936
35063
  confirmFrames: require_dist.number().int().min(1).default(3),
35064
+ /**
35065
+ * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35066
+ * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35067
+ * the gate.
35068
+ *
35069
+ * This exists because a degenerate crop does not score LOW, it scores HIGH:
35070
+ * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35071
+ * faces exceeded cosine 0.90 and the worst 40% of the face population had
35072
+ * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35073
+ * similarity threshold can reach that — it sits above every threshold — so the
35074
+ * only defence is to refuse the embedding before it is stored or compared.
35075
+ *
35076
+ * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35077
+ * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35078
+ * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35079
+ * 5.25) regardless of size, grey level or blur, while information-free crops
35080
+ * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35081
+ * 93.2% of degenerate ones.
35082
+ *
35083
+ * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35084
+ * magnitude has never been observable here — `result-assembler` discarded it
35085
+ * one line after computing it. The field and its logging ship first; set a
35086
+ * real bound once a week of live `face: embedding magnitude` lines says where
35087
+ * this cluster's populations actually sit. Enabling a gate against a number
35088
+ * nobody has measured in production is how recognition silently stops.
35089
+ */
35090
+ maxEmbeddingMagnitude: require_dist.number().min(0).default(0),
34937
35091
  /** Max buffered faces kept per device. */
34938
35092
  bufferMaxPerDevice: require_dist.number().int().min(0).default(50)
34939
35093
  });
@@ -34952,6 +35106,7 @@ function resolveFaceSettings(raw) {
34952
35106
  recognitionMinFacePx: pick("recognitionMinFacePx"),
34953
35107
  suggestionMinCosine: pick("suggestionMinCosine"),
34954
35108
  minIdentitySamples: pick("minIdentitySamples"),
35109
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
34955
35110
  confirmFrames: pick("confirmFrames"),
34956
35111
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
34957
35112
  };
@@ -39423,6 +39578,147 @@ async function applyTrackFlags(deps, input) {
39423
39578
  };
39424
39579
  }
39425
39580
  //#endregion
39581
+ //#region src/pipeline-analytics/pipeline/plate-vote.ts
39582
+ /**
39583
+ * Per-character VOTE over a track's plate reads.
39584
+ *
39585
+ * A track produces up to `plate-ocr`'s `cadence.maxPerTrack` reads of the SAME
39586
+ * physical plate, from different frames. The recognizer used to keep the single
39587
+ * read with the highest OCR score (`holdBest`, argmax) — and OCR score does not
39588
+ * correlate with correctness. In the 2026-08 audit the top-scoring read of a
39589
+ * track was routinely the wrong one while two lower-scoring reads agreed on the
39590
+ * right glyph: score ranks how confident the recognizer was about the pixels it
39591
+ * saw, not how much of the plate those pixels were.
39592
+ *
39593
+ * So the reads vote. Each read is normalized (upper, alnum only), aligned to a
39594
+ * reference read, and contributes its OCR score to whichever glyph it saw at
39595
+ * each reference position; the winner per position is the output. Two reads
39596
+ * that agree beat one confident read that disagrees, which is the whole point.
39597
+ *
39598
+ * ALIGNMENT — reads differ in length (4–9 glyphs live: a dropped leading
39599
+ * district letter, a hallucinated trailing glyph). A positional vote on raw
39600
+ * indices would let one missing character shift every later column and destroy
39601
+ * the agreement it was supposed to measure. Each read is therefore aligned to
39602
+ * the reference by Levenshtein traceback: a match or substitution votes at the
39603
+ * reference position it aligned to, an insertion votes nowhere, and a deletion
39604
+ * simply abstains at that position.
39605
+ *
39606
+ * REFERENCE — the length carrying the most SCORE MASS wins (a hallucinated
39607
+ * 9-glyph read does not get to set the output length just by being longest);
39608
+ * ties inside that length go to the highest-scoring read. With ≤5 reads this is
39609
+ * the honest cheap heuristic: there is no third source of truth to consult.
39610
+ */
39611
+ /**
39612
+ * Align `candidate` to `reference` by Levenshtein traceback. Returns one entry
39613
+ * per REFERENCE position: the candidate glyph that aligned there, or `null`
39614
+ * when the candidate had nothing at that position (a deletion). Candidate
39615
+ * insertions are dropped — they have no reference column to vote in.
39616
+ */
39617
+ function alignToReference(reference, candidate) {
39618
+ const n = reference.length;
39619
+ const m = candidate.length;
39620
+ const dist = [];
39621
+ for (let i = 0; i <= n; i++) {
39622
+ const row = new Array(m + 1).fill(0);
39623
+ row[0] = i;
39624
+ dist.push(row);
39625
+ }
39626
+ const first = dist[0];
39627
+ if (first === void 0) return [];
39628
+ for (let j = 0; j <= m; j++) first[j] = j;
39629
+ for (let i = 1; i <= n; i++) {
39630
+ const prev = dist[i - 1];
39631
+ const cur = dist[i];
39632
+ if (prev === void 0 || cur === void 0) continue;
39633
+ for (let j = 1; j <= m; j++) {
39634
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39635
+ cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
39636
+ }
39637
+ }
39638
+ const aligned = new Array(n).fill(null);
39639
+ let i = n;
39640
+ let j = m;
39641
+ while (i > 0 && j > 0) {
39642
+ const cur = dist[i];
39643
+ const prev = dist[i - 1];
39644
+ if (cur === void 0 || prev === void 0) break;
39645
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39646
+ if (cur[j] === (prev[j - 1] ?? 0) + cost) {
39647
+ aligned[i - 1] = candidate[j - 1] ?? null;
39648
+ i--;
39649
+ j--;
39650
+ } else if (cur[j] === (prev[j] ?? 0) + 1) i--;
39651
+ else j--;
39652
+ }
39653
+ return aligned;
39654
+ }
39655
+ /** The read whose LENGTH carries the most score mass; ties → highest score. */
39656
+ function pickReference(reads) {
39657
+ const massByLength = /* @__PURE__ */ new Map();
39658
+ for (const r of reads) massByLength.set(r.text.length, (massByLength.get(r.text.length) ?? 0) + r.score);
39659
+ let bestLength = 0;
39660
+ let bestMass = -1;
39661
+ for (const [length, mass] of massByLength) if (mass > bestMass || mass === bestMass && length < bestLength) {
39662
+ bestMass = mass;
39663
+ bestLength = length;
39664
+ }
39665
+ let reference = "";
39666
+ let referenceScore = -1;
39667
+ for (const r of reads) if (r.text.length === bestLength && r.score > referenceScore) {
39668
+ referenceScore = r.score;
39669
+ reference = r.text;
39670
+ }
39671
+ return reference;
39672
+ }
39673
+ /**
39674
+ * Confidence-weighted per-character vote over a track's plate reads. Returns
39675
+ * the normalized winning text (`''` when there is nothing to vote on).
39676
+ */
39677
+ function votePlateText(reads) {
39678
+ const normalized = [];
39679
+ for (const r of reads) {
39680
+ const text = normalizePlate(r.text);
39681
+ if (text.length > 0) normalized.push({
39682
+ text,
39683
+ score: r.score
39684
+ });
39685
+ }
39686
+ if (normalized.length === 0) return "";
39687
+ const only = normalized[0];
39688
+ if (normalized.length === 1 && only !== void 0) return only.text;
39689
+ const reference = pickReference(normalized);
39690
+ if (reference.length === 0) return "";
39691
+ const columns = [];
39692
+ for (let i = 0; i < reference.length; i++) columns.push(/* @__PURE__ */ new Map());
39693
+ for (const r of normalized) {
39694
+ const aligned = r.text === reference ? [...r.text] : alignToReference(reference, r.text);
39695
+ for (let i = 0; i < reference.length; i++) {
39696
+ const glyph = aligned[i];
39697
+ if (glyph === null || glyph === void 0) continue;
39698
+ const column = columns[i];
39699
+ if (column === void 0) continue;
39700
+ column.set(glyph, (column.get(glyph) ?? 0) + r.score);
39701
+ }
39702
+ }
39703
+ let out = "";
39704
+ for (let i = 0; i < reference.length; i++) {
39705
+ const column = columns[i];
39706
+ const referenceGlyph = reference[i] ?? "";
39707
+ if (column === void 0 || column.size === 0) {
39708
+ out += referenceGlyph;
39709
+ continue;
39710
+ }
39711
+ let bestGlyph = referenceGlyph;
39712
+ let bestMass = column.get(referenceGlyph) ?? -1;
39713
+ for (const [glyph, mass] of column) if (mass > bestMass) {
39714
+ bestMass = mass;
39715
+ bestGlyph = glyph;
39716
+ }
39717
+ out += bestGlyph;
39718
+ }
39719
+ return out;
39720
+ }
39721
+ //#endregion
39426
39722
  //#region src/pipeline-analytics/pipeline/plate-matcher.ts
39427
39723
  /**
39428
39724
  * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
@@ -39471,11 +39767,11 @@ function matchPlateText(probe, gallery, opts) {
39471
39767
  //#endregion
39472
39768
  //#region src/pipeline-analytics/plate-recognizer.ts
39473
39769
  /**
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.
39770
+ * PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
39771
+ * produces and stores their per-character VOTE (`plate-vote.ts`) at close, plus
39772
+ * the max-score read's crop for the gallery tile. Unlike faces there is NO
39773
+ * gallery matching: the OCR text IS the label and already rides on the event via
39774
+ * the detection pipeline. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39479
39775
  */
39480
39776
  var PlateRecognizer = class {
39481
39777
  deps;
@@ -39538,7 +39834,7 @@ var PlateRecognizer = class {
39538
39834
  const minConfidence = input.minConfidence ?? 0;
39539
39835
  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
39836
  if (candidates.length === 0) return;
39541
- for (const c of candidates) await this.holdBest({
39837
+ for (const c of candidates) await this.observeRead({
39542
39838
  deviceId: input.deviceId,
39543
39839
  trackId: c.trackId,
39544
39840
  text: c.plateText,
@@ -39561,15 +39857,50 @@ var PlateRecognizer = class {
39561
39857
  * gallery stays empty (0 plateCrop) — the observed live gap.
39562
39858
  */
39563
39859
  async observePlateRead(input) {
39564
- if (!isPlausiblePlateRead(input.text, input.score)) return;
39565
- if (input.score < (input.minConfidence ?? 0)) return;
39566
- await this.holdBest(input);
39860
+ const minConfidence = input.minConfidence ?? 0;
39861
+ const reason = !isPlausiblePlateRead(input.text, input.score) ? "implausible" : input.score < minConfidence ? "below-min-confidence" : null;
39862
+ if (reason !== null) {
39863
+ this.deps.logger.info("plate: read discarded", {
39864
+ tags: {
39865
+ deviceId: input.deviceId,
39866
+ trackId: input.trackId
39867
+ },
39868
+ meta: {
39869
+ text: input.text,
39870
+ score: input.score,
39871
+ minConfidence,
39872
+ reason
39873
+ }
39874
+ });
39875
+ return;
39876
+ }
39877
+ await this.observeRead(input);
39567
39878
  }
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) {
39879
+ /**
39880
+ * Record one plate read for a track (shared by the per-frame + detail-plane
39881
+ * paths).
39882
+ *
39883
+ * EVERY read is kept for the close-time vote — a track's reads disagree, and
39884
+ * the disagreement is the signal. The crop + geometry still track the
39885
+ * MAX-SCORE read: the vote decides the text, but the picture the operator
39886
+ * reviews should be the sharpest frame the track produced.
39887
+ *
39888
+ * The read list is capped at {@link MAX_VOTING_READS} in ARRIVAL order, not
39889
+ * by score: evicting the lowest-scoring read would re-introduce the very
39890
+ * ranking this vote exists to stop trusting.
39891
+ */
39892
+ async observeRead(input) {
39571
39893
  const held = this.bestPlate.get(input.trackId);
39572
- if (held !== void 0 && input.score <= held.score) return;
39894
+ const vote = {
39895
+ text: input.text,
39896
+ score: input.score
39897
+ };
39898
+ if (held !== void 0 && input.score <= held.score) {
39899
+ if (held.reads.length < 5) held.reads.push(vote);
39900
+ return;
39901
+ }
39902
+ const reads = held?.reads ?? [];
39903
+ if (reads.length < 5) reads.push(vote);
39573
39904
  let crop;
39574
39905
  if (input.frameHandle !== void 0) try {
39575
39906
  crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
@@ -39585,6 +39916,7 @@ var PlateRecognizer = class {
39585
39916
  this.bestPlate.set(input.trackId, {
39586
39917
  text: input.text,
39587
39918
  score: input.score,
39919
+ reads,
39588
39920
  bbox: input.bbox,
39589
39921
  timestamp: input.timestamp,
39590
39922
  frameWidth: input.frameWidth,
@@ -39661,14 +39993,16 @@ var PlateRecognizer = class {
39661
39993
  }
39662
39994
  });
39663
39995
  }
39664
- const match = this.matchVehicle(held.text, held.score);
39996
+ const voted = votePlateText(held.reads);
39997
+ const text = voted.length > 0 && isPlausiblePlateRead(voted, held.score) ? voted : normalizePlate(held.text);
39998
+ const match = this.matchVehicle(text, held.score);
39665
39999
  try {
39666
40000
  await this.deps.plateStore.insert({
39667
40001
  id: plateId,
39668
40002
  deviceId,
39669
40003
  trackId,
39670
40004
  timestamp: held.timestamp,
39671
- text: normalizePlate(held.text),
40005
+ text,
39672
40006
  score: held.score,
39673
40007
  ...mediaKey !== void 0 ? { mediaKey } : {},
39674
40008
  corrected: false,
@@ -39689,7 +40023,10 @@ var PlateRecognizer = class {
39689
40023
  },
39690
40024
  meta: {
39691
40025
  plateId,
39692
- text: normalizePlate(held.text),
40026
+ text,
40027
+ bestRead: normalizePlate(held.text),
40028
+ reads: held.reads.map((r) => `${normalizePlate(r.text)}@${r.score.toFixed(2)}`),
40029
+ votedFromReads: held.reads.length,
39693
40030
  score: held.score,
39694
40031
  hasCrop: mediaKey !== void 0,
39695
40032
  recognizedVehicleId: match?.vehicleId ?? null
@@ -50042,7 +50379,7 @@ function buildGlobalSettingsSchema() {
50042
50379
  type: "boolean",
50043
50380
  key: "audioEpisodeEnabled",
50044
50381
  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.",
50382
+ 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
50383
  default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
50047
50384
  },
50048
50385
  {
@@ -50168,6 +50505,15 @@ function buildGlobalSettingsSchema() {
50168
50505
  step: 1,
50169
50506
  default: FACE_DEFAULTS.confirmFrames
50170
50507
  },
50508
+ {
50509
+ type: "number",
50510
+ key: "maxEmbeddingMagnitude",
50511
+ label: "Max embedding magnitude (0 = off)",
50512
+ 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.",
50513
+ min: 0,
50514
+ step: .5,
50515
+ default: FACE_DEFAULTS.maxEmbeddingMagnitude
50516
+ },
50171
50517
  {
50172
50518
  type: "number",
50173
50519
  key: "bufferMaxPerDevice",
@@ -51548,6 +51894,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
51548
51894
  var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
51549
51895
  /** Persist lastSeen on an open synthetic audio track at most this often. */
51550
51896
  var AUDIO_EPISODE_PERSIST_MS = 2e3;
51897
+ /**
51898
+ * Coalescing window for classified audio inserts when episodes are OFF.
51899
+ *
51900
+ * With `audioEpisodeEnabled: false` there is no hysteresis to collapse a sound
51901
+ * into one row, but the classifier still fires at ~30 Hz — so the pre-episode
51902
+ * rule applies again: while the SAME class persists, one row per heartbeat;
51903
+ * a class change inserts immediately. The knob turns off the grouping, never
51904
+ * the persistence (D62).
51905
+ */
51906
+ var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
51551
51907
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
51552
51908
  /**
51553
51909
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -55403,6 +55759,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55403
55759
  frameHeight: frame.frameHeight,
55404
55760
  score: detail.score,
55405
55761
  embedding: decodeEmbeddingBase64(detail.embedding),
55762
+ ...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
55406
55763
  parentBbox: { ...frame.bbox },
55407
55764
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
55408
55765
  ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
@@ -55870,7 +56227,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55870
56227
  return;
55871
56228
  }
55872
56229
  }
55873
- async openAudioEpisode(input, ref) {
56230
+ /**
56231
+ * The ONE writer for a classified audio observation: the durable row, the
56232
+ * Notification Center hook fired in the same persist moment (D-2), the
56233
+ * track-level `audioLabels` stamp, and the telemetry event.
56234
+ *
56235
+ * Shared by both routes on purpose. `audioEpisodeEnabled` selects WHEN this
56236
+ * runs (once per episode, or once per heartbeat), never WHETHER — a knob
56237
+ * that names the grouping must not be able to silence the classified path
56238
+ * (D62). This body was inlined in `openAudioEpisode` and the knob-off camera
56239
+ * therefore persisted nothing at all.
56240
+ */
56241
+ async persistClassifiedAudio(input) {
55874
56242
  const ev = {
55875
56243
  id: (0, node_crypto.randomUUID)(),
55876
56244
  deviceId: input.deviceId,
@@ -55879,18 +56247,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55879
56247
  rms: input.rms,
55880
56248
  dbfs: input.dbfs,
55881
56249
  classification: {
55882
- className: ref.className,
56250
+ className: input.className,
55883
56251
  ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
55884
56252
  score: input.score
55885
56253
  }
55886
56254
  };
55887
56255
  this.lastAudioInsertByDevice.set(input.deviceId, {
55888
- className: ref.className,
56256
+ className: input.className,
55889
56257
  atMs: input.timestamp
55890
56258
  });
55891
56259
  await this.eventStore.insertAudio(ev);
55892
56260
  this.notificationCenter?.onAudioEventPersisted(ev);
55893
- this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
56261
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
55894
56262
  this.ctx.eventBus.emit({
55895
56263
  id: `pa-${ev.id}`,
55896
56264
  timestamp: new Date(ev.timestamp),
@@ -55907,6 +56275,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55907
56275
  timestamp: ev.timestamp
55908
56276
  }
55909
56277
  });
56278
+ }
56279
+ /**
56280
+ * Classified audio with episodes OFF: persist on class change or heartbeat,
56281
+ * and open NO synthetic track. This is the whole of what the knob switches —
56282
+ * the timeline track and the episode coalescing — and it is what the form
56283
+ * promises.
56284
+ */
56285
+ async persistClassifiedAudioUngrouped(input) {
56286
+ const last = this.lastAudioInsertByDevice.get(input.deviceId);
56287
+ const sameClass = last?.className === input.className;
56288
+ const heartbeatDue = last === void 0 || input.timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
56289
+ if (sameClass && !heartbeatDue) return;
56290
+ await this.persistClassifiedAudio(input);
56291
+ }
56292
+ async openAudioEpisode(input, ref) {
56293
+ await this.persistClassifiedAudio({
56294
+ ...input,
56295
+ className: ref.className
56296
+ });
55910
56297
  if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
55911
56298
  const materializer = this.syntheticTracks;
55912
56299
  if (materializer === null) return;
@@ -56018,19 +56405,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
56018
56405
  });
56019
56406
  return;
56020
56407
  }
56021
- await this.driveAudioEpisode({
56408
+ const classified = {
56022
56409
  deviceId,
56023
56410
  timestamp,
56024
56411
  className: route.className,
56025
56412
  score: topClassification.score,
56026
- originalClass: topClassification.debug?.originalClass,
56413
+ ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
56027
56414
  rms: level?.rms ?? 0,
56028
- dbfs: level?.dbfs ?? 0,
56415
+ dbfs: level?.dbfs ?? 0
56416
+ };
56417
+ if (!settings.audioEpisodeEnabled) {
56418
+ await this.persistClassifiedAudioUngrouped(classified);
56419
+ return;
56420
+ }
56421
+ await this.driveAudioEpisode({
56422
+ ...classified,
56029
56423
  knobs: this.audioEpisodeKnobs(settings)
56030
56424
  });
56031
56425
  return;
56032
56426
  }
56033
- await this.driveAudioEpisode({
56427
+ if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
56034
56428
  deviceId,
56035
56429
  timestamp,
56036
56430
  className: null,