@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.
@@ -1,4 +1,4 @@
1
- import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-ecY_-nDx.mjs";
1
+ import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-CZm8slPB.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -8295,6 +8295,10 @@ var en_default = {
8295
8295
  "one": "plate",
8296
8296
  "other": "plates"
8297
8297
  },
8298
+ "class.audio": {
8299
+ "one": "sound",
8300
+ "other": "sounds"
8301
+ },
8298
8302
  "class.other": "{{name}}",
8299
8303
  "group.others.person": {
8300
8304
  "one": "{{count}} other person",
@@ -8352,8 +8356,11 @@ var en_default = {
8352
8356
  "one": "{{count}} addon update available",
8353
8357
  "other": "{{count}} addon updates available"
8354
8358
  },
8359
+ "system.addon-update-available.title.onNode": {
8360
+ "one": "{{count}} addon update available on {{nodeId}}",
8361
+ "other": "{{count}} addon updates available on {{nodeId}}"
8362
+ },
8355
8363
  "system.addon-update-available.body": "{{list}}",
8356
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8357
8364
  "system.server-update-available.title": {
8358
8365
  "one": "Server update available on {{count}} node",
8359
8366
  "other": "Server update available on {{count}} nodes"
@@ -8368,6 +8375,7 @@ var en_default = {
8368
8375
  "system.addon-updated.body": "{{list}}",
8369
8376
  "system.server-updated.title": "Server updated",
8370
8377
  "system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
8378
+ "system.server-updated.body.toOnly": "{{packageName}} on {{nodeId}} is now {{toVersion}}",
8371
8379
  "system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
8372
8380
  "system.export-completed.title": "Export ready",
8373
8381
  "system.export-completed.body": "The footage export is ready",
@@ -8509,6 +8517,10 @@ var it_default = {
8509
8517
  "one": "targa",
8510
8518
  "other": "targhe"
8511
8519
  },
8520
+ "class.audio": {
8521
+ "one": "suono",
8522
+ "other": "suoni"
8523
+ },
8512
8524
  "class.other": "{{name}}",
8513
8525
  "group.others.person": {
8514
8526
  "one": "{{count}} altra persona",
@@ -8566,8 +8578,11 @@ var it_default = {
8566
8578
  "one": "{{count}} aggiornamento addon disponibile",
8567
8579
  "other": "{{count}} aggiornamenti addon disponibili"
8568
8580
  },
8581
+ "system.addon-update-available.title.onNode": {
8582
+ "one": "{{count}} aggiornamento addon disponibile su {{nodeId}}",
8583
+ "other": "{{count}} aggiornamenti addon disponibili su {{nodeId}}"
8584
+ },
8569
8585
  "system.addon-update-available.body": "{{list}}",
8570
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8571
8586
  "system.server-update-available.title": {
8572
8587
  "one": "Aggiornamento server disponibile su {{count}} nodo",
8573
8588
  "other": "Aggiornamento server disponibile su {{count}} nodi"
@@ -8582,6 +8597,7 @@ var it_default = {
8582
8597
  "system.addon-updated.body": "{{list}}",
8583
8598
  "system.server-updated.title": "Server aggiornato",
8584
8599
  "system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
8600
+ "system.server-updated.body.toOnly": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}}",
8585
8601
  "system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
8586
8602
  "system.export-completed.title": "Export pronto",
8587
8603
  "system.export-completed.body": "L'export del filmato è pronto",
@@ -10404,15 +10420,15 @@ function incomingFromUpdateAvailable(facts, data) {
10404
10420
  const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
10405
10421
  const packageNames = unique(packages.map((pkg) => pkg.packageName));
10406
10422
  const signature = packages.map((pkg) => `${pkg.nodeId ?? ""}:${pkg.packageName}@${pkg.latestVersion}`).toSorted().join(",");
10407
- const list = packages.map(formatAvailableLine).join("\n");
10408
- const nodes = nodeIds.join(", ");
10409
10423
  const head = packages[0];
10410
10424
  const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
10425
+ const list = packages.map((pkg) => formatAvailableLine(pkg, oneNode !== void 0)).join("\n");
10426
+ const nodes = nodeIds.join(", ");
10411
10427
  return systemSubject({
10412
10428
  facts,
10413
10429
  kind,
10414
10430
  subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
10415
- titleKey: `system.${kind}.title`,
10431
+ titleKey: addon && oneNode !== void 0 ? `system.${kind}.title.onNode` : `system.${kind}.title`,
10416
10432
  bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
10417
10433
  textParams: {
10418
10434
  list,
@@ -10471,12 +10487,12 @@ function incomingFromAddonUpdates(facts, updates) {
10471
10487
  function incomingFromServerUpdated(facts, data) {
10472
10488
  const kind = stringField(data, "kind");
10473
10489
  const requestedBy = stringField(data, "requestedBy");
10474
- if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
10475
10490
  const packageName = stringField(data, "packageName") ?? "@camstack/server";
10476
10491
  const fromVersion = stringField(data, "fromVersion");
10477
10492
  const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
10493
+ if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:") && toVersion !== void 0)) return null;
10478
10494
  const nodeId = stringField(data, "nodeId") ?? "hub";
10479
- const bodyKey = fromVersion !== void 0 && toVersion !== void 0 ? "system.server-updated.body" : "system.server-updated.body.plain";
10495
+ const bodyKey = toVersion === void 0 ? "system.server-updated.body.plain" : fromVersion === void 0 ? "system.server-updated.body.toOnly" : "system.server-updated.body";
10480
10496
  return systemSubject({
10481
10497
  facts,
10482
10498
  kind: "server-updated",
@@ -10557,9 +10573,15 @@ function updatePackages(data) {
10557
10573
  ...nodeId !== void 0 ? { nodeId } : {}
10558
10574
  }];
10559
10575
  }
10560
- function formatAvailableLine(pkg) {
10561
- const line = `${pkg.packageName} ${pkg.latestVersion} (current ${pkg.currentVersion})`;
10562
- return pkg.nodeId !== void 0 ? `${line} on ${pkg.nodeId}` : line;
10576
+ /**
10577
+ * One line of an availability list — the SAME shape the completed-update list
10578
+ * uses (`incomingFromAddonUpdates`), and deliberately word-free: the list is a
10579
+ * frozen param, so any English in it ("current") is delivered verbatim to an
10580
+ * Italian household. An arrow is not a language.
10581
+ */
10582
+ function formatAvailableLine(pkg, nodeNamedInTitle) {
10583
+ const line = `${pkg.packageName} ${pkg.currentVersion} → ${pkg.latestVersion}`;
10584
+ return pkg.nodeId !== void 0 && !nodeNamedInTitle ? `${line} (${pkg.nodeId})` : line;
10563
10585
  }
10564
10586
  function versionFromRequestedBy(requestedBy) {
10565
10587
  if (requestedBy === void 0) return void 0;
@@ -25162,7 +25184,16 @@ function clamp01$1(x) {
25162
25184
  if (x > 1) return 1;
25163
25185
  return x;
25164
25186
  }
25165
- /** Rank a class name into [0,1] by how event-worthy it is. Case-insensitive. */
25187
+ /**
25188
+ * Rank a class name into [0,1] by how event-worthy it is. Case-insensitive.
25189
+ *
25190
+ * `audio` falls through to {@link CLASS_RANK_DEFAULT} on purpose — a sound is
25191
+ * not a subject in frame. Before D189 an audio episode carried the HEARD class
25192
+ * here, so `dog`/`cat`/`animal` landed in {@link ANIMAL_CLASSES} and a bark
25193
+ * ranked as if an animal had walked past, while `speech` (500 of the 576 audio
25194
+ * rows measured live) already ranked default. That boost was an accident of the
25195
+ * class collision, not a decision. Pinned by `key-event-scoring.spec.ts`.
25196
+ */
25166
25197
  function classRank(className) {
25167
25198
  const c = className.toLowerCase();
25168
25199
  if (PERSON_CLASSES.has(c)) return 1;
@@ -28789,19 +28820,29 @@ var BEST_FRAME_POLICY = {
28789
28820
  sizeAware: true
28790
28821
  };
28791
28822
  /**
28792
- * Best FACE (face-recognizer hold buffer) — confidence-only, no hysteresis, no
28793
- * rate limit. WHY: the face path must keep the TRUE highest-confidence face —
28794
- * accepting a new best only swaps an already-decoded crop buffer in memory
28795
- * (cheap; no capture is triggered), and a track may live well under the
28796
- * best-frame rate-limit window, so a gap/hysteresis would silently discard the
28797
- * real peak for nothing. This was the face path's de-facto behavior all along
28798
- * (it constructed the tracker with defaults); S9 declares it.
28823
+ * Best FACE (face-recognizer hold buffer) — SIZE-AWARE, no hysteresis, no rate
28824
+ * limit.
28825
+ *
28826
+ * No hysteresis / no gap, unchanged: accepting a new best only swaps an
28827
+ * already-decoded crop buffer in memory (cheap; no capture is triggered), and a
28828
+ * track may live well under the best-frame rate-limit window, so a gap would
28829
+ * silently discard the real peak for nothing.
28830
+ *
28831
+ * `sizeAware` since 2026-08-19. It used to be confidence-ONLY, and that was the
28832
+ * wrong ranking: SCRFD saturates its confidence on surveillance footage, so a
28833
+ * 12 px face and a 40 px face both answer ~0.9 and the track simply kept
28834
+ * whichever arrived first. The size that matters is the face's DETECTION-FRAME
28835
+ * short side, because that bounds the landmark precision the alignment warp
28836
+ * consumes — `face-size-score.ts` carries the measurement that set the
28837
+ * reference. Because `minGapMs` is 0 here, enabling this does NOT switch on the
28838
+ * decisive-improvement rate-limit bypass (there is no rate limit to bypass); it
28839
+ * only adds the size factor to the ranking.
28799
28840
  */
28800
28841
  var BEST_FACE_POLICY = {
28801
28842
  hysteresis: 0,
28802
28843
  minGapMs: 0,
28803
28844
  edgeAware: false,
28804
- sizeAware: false
28845
+ sizeAware: true
28805
28846
  };
28806
28847
  /**
28807
28848
  * Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
@@ -32188,7 +32229,11 @@ var AudioDetectionSettingsSchema = object({
32188
32229
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
32189
32230
  * only ever cost the operator this many markers a day. */
32190
32231
  audioMarkerMaxPerDay: number().int().positive().default(12),
32191
- /** Materialize one event + one synthetic track per classified episode. */
32232
+ /** Materialize one event + one synthetic track per classified episode.
32233
+ * OFF switches off the GROUPING only — classified audio still persists an
32234
+ * event (class change, then one per 5 s heartbeat) and still reaches the
32235
+ * Notification Center; it just opens no synthetic track. A knob that names
32236
+ * the grouping must never silence the path (D62). */
32192
32237
  audioEpisodeEnabled: boolean().default(true),
32193
32238
  /** Class must be heard continuously this long before the episode opens. */
32194
32239
  audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
@@ -34408,11 +34453,44 @@ async function resolveDefaultEventMedia(deps) {
34408
34453
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
34409
34454
  }
34410
34455
  //#endregion
34456
+ //#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
34457
+ /**
34458
+ * `null` = accept. A verdict = refuse, carrying both numbers so the caller can
34459
+ * name them in a log.
34460
+ *
34461
+ * Accepts unconditionally when the bound is 0 (disabled) or when the magnitude
34462
+ * is absent / non-finite — an older runner that does not report it must keep
34463
+ * working exactly as before, never be silently gated to nothing. Pure.
34464
+ */
34465
+ function embeddingMagnitudeVerdict(magnitude, bound) {
34466
+ if (!(bound > 0)) return null;
34467
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return null;
34468
+ if (magnitude <= bound) return null;
34469
+ return {
34470
+ magnitude,
34471
+ bound
34472
+ };
34473
+ }
34474
+ /**
34475
+ * Map a detection-frame face short side to the 0..1 `sizeScore` the
34476
+ * {@link ./best-detection-tracker.BestDetectionTracker} multiplies into its
34477
+ * effective score. Linear up to {@link FACE_SIZE_SCORE_REFERENCE_PX}, saturated
34478
+ * above it, 0 for a missing/degenerate measurement (so an unmeasurable face can
34479
+ * never outrank a measured one). Pure.
34480
+ */
34481
+ function faceSizeScore(detectionFrameShortSidePx) {
34482
+ if (!Number.isFinite(detectionFrameShortSidePx) || detectionFrameShortSidePx <= 0) return 0;
34483
+ if (detectionFrameShortSidePx >= 32) return 1;
34484
+ return detectionFrameShortSidePx / 32;
34485
+ }
34486
+ //#endregion
34411
34487
  //#region src/pipeline-analytics/face-recognizer.ts
34412
34488
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
34413
34489
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
34414
34490
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
34415
34491
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
34492
+ /** At most one 'embedding magnitude' sample line per device per this interval. */
34493
+ var FACE_MAGNITUDE_LOG_THROTTLE_MS = 6e4;
34416
34494
  /**
34417
34495
  * arcface model id stamped on a detail-plane face candidate when the gallery is
34418
34496
  * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
@@ -34427,6 +34505,8 @@ var FaceRecognizer = class {
34427
34505
  names = /* @__PURE__ */ new Map();
34428
34506
  aggregates = /* @__PURE__ */ new Map();
34429
34507
  bestFace = /* @__PURE__ */ new Map();
34508
+ /** Per-device throttle clock for the magnitude sample line. */
34509
+ lastMagnitudeLogAt = /* @__PURE__ */ new Map();
34430
34510
  /**
34431
34511
  * Peak identity-MATCH cosine per track for the currently-assigned identity
34432
34512
  * (NOT the detection confidence held in {@link bestFace}). Reset on an
@@ -34476,6 +34556,21 @@ var FaceRecognizer = class {
34476
34556
  * input source moves from the per-frame plane to this per-track call.
34477
34557
  */
34478
34558
  async ingestFaceDetail(input) {
34559
+ const verdict = embeddingMagnitudeVerdict(input.embeddingMagnitude, input.settings.maxEmbeddingMagnitude);
34560
+ if (verdict !== null) {
34561
+ this.deps.logger.info("face: dropping detail — raw embedding magnitude over bound", {
34562
+ tags: { deviceId: input.deviceId },
34563
+ meta: {
34564
+ trackId: input.trackId,
34565
+ magnitude: Number(verdict.magnitude.toFixed(3)),
34566
+ bound: verdict.bound,
34567
+ score: input.score,
34568
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: input.nativeFaceShortSidePx } : {}
34569
+ }
34570
+ });
34571
+ return;
34572
+ }
34573
+ this.logEmbeddingMagnitude(input);
34479
34574
  const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
34480
34575
  const candidate = {
34481
34576
  trackId: input.trackId,
@@ -34502,6 +34597,30 @@ var FaceRecognizer = class {
34502
34597
  ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
34503
34598
  });
34504
34599
  }
34600
+ /**
34601
+ * At most one magnitude line per device per
34602
+ * {@link FACE_MAGNITUDE_LOG_THROTTLE_MS}. This is the ONLY way the operator
34603
+ * can see where this cluster's usable and degenerate face populations sit, so
34604
+ * it must exist before `maxEmbeddingMagnitude` can honestly be turned on.
34605
+ */
34606
+ logEmbeddingMagnitude(input) {
34607
+ const magnitude = input.embeddingMagnitude;
34608
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return;
34609
+ const now = Date.now();
34610
+ if (now - (this.lastMagnitudeLogAt.get(input.deviceId) ?? 0) < FACE_MAGNITUDE_LOG_THROTTLE_MS) return;
34611
+ this.lastMagnitudeLogAt.set(input.deviceId, now);
34612
+ this.deps.logger.info("face: embedding magnitude", {
34613
+ tags: { deviceId: input.deviceId },
34614
+ meta: {
34615
+ trackId: input.trackId,
34616
+ magnitude: Number(magnitude.toFixed(3)),
34617
+ bound: input.settings.maxEmbeddingMagnitude,
34618
+ score: input.score,
34619
+ detectionFrameShortSidePx: input.faceBbox !== void 0 ? Math.round(Math.min(input.faceBbox.w, input.faceBbox.h)) : 0,
34620
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: Math.round(input.nativeFaceShortSidePx) } : {}
34621
+ }
34622
+ });
34623
+ }
34505
34624
  async processFrame(input) {
34506
34625
  const { settings } = input;
34507
34626
  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);
@@ -34563,7 +34682,8 @@ var FaceRecognizer = class {
34563
34682
  }
34564
34683
  const recognizedIdentityId = state.assignedIdentityId ?? void 0;
34565
34684
  const held = this.bestFace.get(c.trackId);
34566
- const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
34685
+ const detectionFrameShortSidePx = c.faceBbox !== void 0 ? Math.min(c.faceBbox.w, c.faceBbox.h) : 0;
34686
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp, void 0, void 0, void 0, faceSizeScore(detectionFrameShortSidePx));
34567
34687
  const needsCrop = held !== void 0 && held.crop === void 0;
34568
34688
  if (isNewBest || needsCrop) {
34569
34689
  const cropBbox = c.faceBbox ?? c.bbox;
@@ -34823,14 +34943,38 @@ var FaceSettingsSchema = object({
34823
34943
  * stripped from the per-device schema).
34824
34944
  */
34825
34945
  enabled: boolean().default(true),
34826
- /** Cosine similarity (on L2-normalized arcface vectors) required to match.
34827
- * Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
34828
- * loose against thin/low-quality galleries and produced ~25-40% visually
34829
- * wrong auto-assignments in the 12h audit. */
34830
- similarityThreshold: number().min(0).max(1).default(.62),
34831
- /** Reject ambiguous matches: require best secondBest margin. Raised
34832
- * 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
34833
- margin: number().min(0).max(1).default(.15),
34946
+ /**
34947
+ * Cosine similarity (on L2-normalized arcface vectors) required to match.
34948
+ *
34949
+ * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
34950
+ * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
34951
+ * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
34952
+ * left almost nothing recognisable. That retune lived only as a RUNTIME
34953
+ * override until 2026-08-19; it is the schema default now, because a default
34954
+ * nobody runs is a decision nobody made.
34955
+ *
34956
+ * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
34957
+ * quality, where most of what reaches ArcFace is not a correctly aligned
34958
+ * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
34959
+ * aligned, 18% marginal, 62% misaligned or containing no detectable face).
34960
+ * On correctly aligned crops the SAME models separate at a far lower
34961
+ * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
34962
+ * 0.175, best accuracy 98.3% at threshold 0.28.
34963
+ *
34964
+ * Recalibration plan, in this order:
34965
+ * 1. land the landmark-precision gate (the alignment fix),
34966
+ * 2. re-enrol the gallery from crops produced AFTER it,
34967
+ * 3. re-measure genuine/impostor over a week of post-fix traffic,
34968
+ * 4. expect to lower this toward ~0.30, and `margin` with it.
34969
+ * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
34970
+ * scores ~0.9 against everything, so the degenerate population sits ABOVE
34971
+ * any threshold you could pick — the knob cannot reach it.
34972
+ */
34973
+ similarityThreshold: number().min(0).max(1).default(.55),
34974
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
34975
+ * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
34976
+ * same story and the same recalibration plan as {@link similarityThreshold}. */
34977
+ margin: number().min(0).max(1).default(.12),
34834
34978
  /** Minimum face-detection confidence for a face to be considered. */
34835
34979
  minFaceConfidence: number().min(0).max(1).default(.5),
34836
34980
  /**
@@ -34874,6 +35018,33 @@ var FaceSettingsSchema = object({
34874
35018
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
34875
35019
  * such a value falls back to the default). */
34876
35020
  confirmFrames: number().int().min(1).default(3),
35021
+ /**
35022
+ * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35023
+ * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35024
+ * the gate.
35025
+ *
35026
+ * This exists because a degenerate crop does not score LOW, it scores HIGH:
35027
+ * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35028
+ * faces exceeded cosine 0.90 and the worst 40% of the face population had
35029
+ * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35030
+ * similarity threshold can reach that — it sits above every threshold — so the
35031
+ * only defence is to refuse the embedding before it is stored or compared.
35032
+ *
35033
+ * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35034
+ * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35035
+ * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35036
+ * 5.25) regardless of size, grey level or blur, while information-free crops
35037
+ * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35038
+ * 93.2% of degenerate ones.
35039
+ *
35040
+ * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35041
+ * magnitude has never been observable here — `result-assembler` discarded it
35042
+ * one line after computing it. The field and its logging ship first; set a
35043
+ * real bound once a week of live `face: embedding magnitude` lines says where
35044
+ * this cluster's populations actually sit. Enabling a gate against a number
35045
+ * nobody has measured in production is how recognition silently stops.
35046
+ */
35047
+ maxEmbeddingMagnitude: number().min(0).default(0),
34877
35048
  /** Max buffered faces kept per device. */
34878
35049
  bufferMaxPerDevice: number().int().min(0).default(50)
34879
35050
  });
@@ -34892,6 +35063,7 @@ function resolveFaceSettings(raw) {
34892
35063
  recognitionMinFacePx: pick("recognitionMinFacePx"),
34893
35064
  suggestionMinCosine: pick("suggestionMinCosine"),
34894
35065
  minIdentitySamples: pick("minIdentitySamples"),
35066
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
34895
35067
  confirmFrames: pick("confirmFrames"),
34896
35068
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
34897
35069
  };
@@ -39363,6 +39535,147 @@ async function applyTrackFlags(deps, input) {
39363
39535
  };
39364
39536
  }
39365
39537
  //#endregion
39538
+ //#region src/pipeline-analytics/pipeline/plate-vote.ts
39539
+ /**
39540
+ * Per-character VOTE over a track's plate reads.
39541
+ *
39542
+ * A track produces up to `plate-ocr`'s `cadence.maxPerTrack` reads of the SAME
39543
+ * physical plate, from different frames. The recognizer used to keep the single
39544
+ * read with the highest OCR score (`holdBest`, argmax) — and OCR score does not
39545
+ * correlate with correctness. In the 2026-08 audit the top-scoring read of a
39546
+ * track was routinely the wrong one while two lower-scoring reads agreed on the
39547
+ * right glyph: score ranks how confident the recognizer was about the pixels it
39548
+ * saw, not how much of the plate those pixels were.
39549
+ *
39550
+ * So the reads vote. Each read is normalized (upper, alnum only), aligned to a
39551
+ * reference read, and contributes its OCR score to whichever glyph it saw at
39552
+ * each reference position; the winner per position is the output. Two reads
39553
+ * that agree beat one confident read that disagrees, which is the whole point.
39554
+ *
39555
+ * ALIGNMENT — reads differ in length (4–9 glyphs live: a dropped leading
39556
+ * district letter, a hallucinated trailing glyph). A positional vote on raw
39557
+ * indices would let one missing character shift every later column and destroy
39558
+ * the agreement it was supposed to measure. Each read is therefore aligned to
39559
+ * the reference by Levenshtein traceback: a match or substitution votes at the
39560
+ * reference position it aligned to, an insertion votes nowhere, and a deletion
39561
+ * simply abstains at that position.
39562
+ *
39563
+ * REFERENCE — the length carrying the most SCORE MASS wins (a hallucinated
39564
+ * 9-glyph read does not get to set the output length just by being longest);
39565
+ * ties inside that length go to the highest-scoring read. With ≤5 reads this is
39566
+ * the honest cheap heuristic: there is no third source of truth to consult.
39567
+ */
39568
+ /**
39569
+ * Align `candidate` to `reference` by Levenshtein traceback. Returns one entry
39570
+ * per REFERENCE position: the candidate glyph that aligned there, or `null`
39571
+ * when the candidate had nothing at that position (a deletion). Candidate
39572
+ * insertions are dropped — they have no reference column to vote in.
39573
+ */
39574
+ function alignToReference(reference, candidate) {
39575
+ const n = reference.length;
39576
+ const m = candidate.length;
39577
+ const dist = [];
39578
+ for (let i = 0; i <= n; i++) {
39579
+ const row = new Array(m + 1).fill(0);
39580
+ row[0] = i;
39581
+ dist.push(row);
39582
+ }
39583
+ const first = dist[0];
39584
+ if (first === void 0) return [];
39585
+ for (let j = 0; j <= m; j++) first[j] = j;
39586
+ for (let i = 1; i <= n; i++) {
39587
+ const prev = dist[i - 1];
39588
+ const cur = dist[i];
39589
+ if (prev === void 0 || cur === void 0) continue;
39590
+ for (let j = 1; j <= m; j++) {
39591
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39592
+ cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
39593
+ }
39594
+ }
39595
+ const aligned = new Array(n).fill(null);
39596
+ let i = n;
39597
+ let j = m;
39598
+ while (i > 0 && j > 0) {
39599
+ const cur = dist[i];
39600
+ const prev = dist[i - 1];
39601
+ if (cur === void 0 || prev === void 0) break;
39602
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39603
+ if (cur[j] === (prev[j - 1] ?? 0) + cost) {
39604
+ aligned[i - 1] = candidate[j - 1] ?? null;
39605
+ i--;
39606
+ j--;
39607
+ } else if (cur[j] === (prev[j] ?? 0) + 1) i--;
39608
+ else j--;
39609
+ }
39610
+ return aligned;
39611
+ }
39612
+ /** The read whose LENGTH carries the most score mass; ties → highest score. */
39613
+ function pickReference(reads) {
39614
+ const massByLength = /* @__PURE__ */ new Map();
39615
+ for (const r of reads) massByLength.set(r.text.length, (massByLength.get(r.text.length) ?? 0) + r.score);
39616
+ let bestLength = 0;
39617
+ let bestMass = -1;
39618
+ for (const [length, mass] of massByLength) if (mass > bestMass || mass === bestMass && length < bestLength) {
39619
+ bestMass = mass;
39620
+ bestLength = length;
39621
+ }
39622
+ let reference = "";
39623
+ let referenceScore = -1;
39624
+ for (const r of reads) if (r.text.length === bestLength && r.score > referenceScore) {
39625
+ referenceScore = r.score;
39626
+ reference = r.text;
39627
+ }
39628
+ return reference;
39629
+ }
39630
+ /**
39631
+ * Confidence-weighted per-character vote over a track's plate reads. Returns
39632
+ * the normalized winning text (`''` when there is nothing to vote on).
39633
+ */
39634
+ function votePlateText(reads) {
39635
+ const normalized = [];
39636
+ for (const r of reads) {
39637
+ const text = normalizePlate(r.text);
39638
+ if (text.length > 0) normalized.push({
39639
+ text,
39640
+ score: r.score
39641
+ });
39642
+ }
39643
+ if (normalized.length === 0) return "";
39644
+ const only = normalized[0];
39645
+ if (normalized.length === 1 && only !== void 0) return only.text;
39646
+ const reference = pickReference(normalized);
39647
+ if (reference.length === 0) return "";
39648
+ const columns = [];
39649
+ for (let i = 0; i < reference.length; i++) columns.push(/* @__PURE__ */ new Map());
39650
+ for (const r of normalized) {
39651
+ const aligned = r.text === reference ? [...r.text] : alignToReference(reference, r.text);
39652
+ for (let i = 0; i < reference.length; i++) {
39653
+ const glyph = aligned[i];
39654
+ if (glyph === null || glyph === void 0) continue;
39655
+ const column = columns[i];
39656
+ if (column === void 0) continue;
39657
+ column.set(glyph, (column.get(glyph) ?? 0) + r.score);
39658
+ }
39659
+ }
39660
+ let out = "";
39661
+ for (let i = 0; i < reference.length; i++) {
39662
+ const column = columns[i];
39663
+ const referenceGlyph = reference[i] ?? "";
39664
+ if (column === void 0 || column.size === 0) {
39665
+ out += referenceGlyph;
39666
+ continue;
39667
+ }
39668
+ let bestGlyph = referenceGlyph;
39669
+ let bestMass = column.get(referenceGlyph) ?? -1;
39670
+ for (const [glyph, mass] of column) if (mass > bestMass) {
39671
+ bestMass = mass;
39672
+ bestGlyph = glyph;
39673
+ }
39674
+ out += bestGlyph;
39675
+ }
39676
+ return out;
39677
+ }
39678
+ //#endregion
39366
39679
  //#region src/pipeline-analytics/pipeline/plate-matcher.ts
39367
39680
  /**
39368
39681
  * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
@@ -39411,11 +39724,11 @@ function matchPlateText(probe, gallery, opts) {
39411
39724
  //#endregion
39412
39725
  //#region src/pipeline-analytics/plate-recognizer.ts
39413
39726
  /**
39414
- * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
39415
- * for the plate gallery. Unlike faces there is NO gallery matching: the OCR text
39416
- * IS the label and already rides on the event via the detection pipeline. This
39417
- * only collects per-track reads (+ a tight plate crop) so the gallery can group,
39418
- * review and search them. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39727
+ * PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
39728
+ * produces and stores their per-character VOTE (`plate-vote.ts`) at close, plus
39729
+ * the max-score read's crop for the gallery tile. Unlike faces there is NO
39730
+ * gallery matching: the OCR text IS the label and already rides on the event via
39731
+ * the detection pipeline. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39419
39732
  */
39420
39733
  var PlateRecognizer = class {
39421
39734
  deps;
@@ -39478,7 +39791,7 @@ var PlateRecognizer = class {
39478
39791
  const minConfidence = input.minConfidence ?? 0;
39479
39792
  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);
39480
39793
  if (candidates.length === 0) return;
39481
- for (const c of candidates) await this.holdBest({
39794
+ for (const c of candidates) await this.observeRead({
39482
39795
  deviceId: input.deviceId,
39483
39796
  trackId: c.trackId,
39484
39797
  text: c.plateText,
@@ -39501,15 +39814,50 @@ var PlateRecognizer = class {
39501
39814
  * gallery stays empty (0 plateCrop) — the observed live gap.
39502
39815
  */
39503
39816
  async observePlateRead(input) {
39504
- if (!isPlausiblePlateRead(input.text, input.score)) return;
39505
- if (input.score < (input.minConfidence ?? 0)) return;
39506
- await this.holdBest(input);
39817
+ const minConfidence = input.minConfidence ?? 0;
39818
+ const reason = !isPlausiblePlateRead(input.text, input.score) ? "implausible" : input.score < minConfidence ? "below-min-confidence" : null;
39819
+ if (reason !== null) {
39820
+ this.deps.logger.info("plate: read discarded", {
39821
+ tags: {
39822
+ deviceId: input.deviceId,
39823
+ trackId: input.trackId
39824
+ },
39825
+ meta: {
39826
+ text: input.text,
39827
+ score: input.score,
39828
+ minConfidence,
39829
+ reason
39830
+ }
39831
+ });
39832
+ return;
39833
+ }
39834
+ await this.observeRead(input);
39507
39835
  }
39508
- /** Hold the highest-scoring plate read per track, capturing a tight crop the
39509
- * first time a new best is seen (shared by the per-frame + detail-plane paths). */
39510
- async holdBest(input) {
39836
+ /**
39837
+ * Record one plate read for a track (shared by the per-frame + detail-plane
39838
+ * paths).
39839
+ *
39840
+ * EVERY read is kept for the close-time vote — a track's reads disagree, and
39841
+ * the disagreement is the signal. The crop + geometry still track the
39842
+ * MAX-SCORE read: the vote decides the text, but the picture the operator
39843
+ * reviews should be the sharpest frame the track produced.
39844
+ *
39845
+ * The read list is capped at {@link MAX_VOTING_READS} in ARRIVAL order, not
39846
+ * by score: evicting the lowest-scoring read would re-introduce the very
39847
+ * ranking this vote exists to stop trusting.
39848
+ */
39849
+ async observeRead(input) {
39511
39850
  const held = this.bestPlate.get(input.trackId);
39512
- if (held !== void 0 && input.score <= held.score) return;
39851
+ const vote = {
39852
+ text: input.text,
39853
+ score: input.score
39854
+ };
39855
+ if (held !== void 0 && input.score <= held.score) {
39856
+ if (held.reads.length < 5) held.reads.push(vote);
39857
+ return;
39858
+ }
39859
+ const reads = held?.reads ?? [];
39860
+ if (reads.length < 5) reads.push(vote);
39513
39861
  let crop;
39514
39862
  if (input.frameHandle !== void 0) try {
39515
39863
  crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
@@ -39525,6 +39873,7 @@ var PlateRecognizer = class {
39525
39873
  this.bestPlate.set(input.trackId, {
39526
39874
  text: input.text,
39527
39875
  score: input.score,
39876
+ reads,
39528
39877
  bbox: input.bbox,
39529
39878
  timestamp: input.timestamp,
39530
39879
  frameWidth: input.frameWidth,
@@ -39601,14 +39950,16 @@ var PlateRecognizer = class {
39601
39950
  }
39602
39951
  });
39603
39952
  }
39604
- const match = this.matchVehicle(held.text, held.score);
39953
+ const voted = votePlateText(held.reads);
39954
+ const text = voted.length > 0 && isPlausiblePlateRead(voted, held.score) ? voted : normalizePlate(held.text);
39955
+ const match = this.matchVehicle(text, held.score);
39605
39956
  try {
39606
39957
  await this.deps.plateStore.insert({
39607
39958
  id: plateId,
39608
39959
  deviceId,
39609
39960
  trackId,
39610
39961
  timestamp: held.timestamp,
39611
- text: normalizePlate(held.text),
39962
+ text,
39612
39963
  score: held.score,
39613
39964
  ...mediaKey !== void 0 ? { mediaKey } : {},
39614
39965
  corrected: false,
@@ -39629,7 +39980,10 @@ var PlateRecognizer = class {
39629
39980
  },
39630
39981
  meta: {
39631
39982
  plateId,
39632
- text: normalizePlate(held.text),
39983
+ text,
39984
+ bestRead: normalizePlate(held.text),
39985
+ reads: held.reads.map((r) => `${normalizePlate(r.text)}@${r.score.toFixed(2)}`),
39986
+ votedFromReads: held.reads.length,
39633
39987
  score: held.score,
39634
39988
  hasCrop: mediaKey !== void 0,
39635
39989
  recognizedVehicleId: match?.vehicleId ?? null
@@ -40147,6 +40501,24 @@ function buildRecordedStillPorts(input) {
40147
40501
  var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40148
40502
  var MIGRATION_MARKER_KEY = "label-tier-4g";
40149
40503
  /**
40504
+ * The owner declares its marker collection — like every other store in this
40505
+ * addon. KV shape (a single `data` column: `get`/`set` route the value as a
40506
+ * JSON blob; the engine adds the implicit `id` primary key). This declaration
40507
+ * never existed, so the engine refused every marker write, the catch below
40508
+ * warned, and every boot re-scanned the full analytics store — ~36k rows per
40509
+ * boot from the day the migration shipped.
40510
+ */
40511
+ async function declareMigrationMarkers(store) {
40512
+ await store.declareCollection.mutate({
40513
+ collection: MIGRATION_MARKER_COLLECTION,
40514
+ columns: [{
40515
+ name: "data",
40516
+ type: "TEXT",
40517
+ notNull: true
40518
+ }]
40519
+ });
40520
+ }
40521
+ /**
40150
40522
  * The decision for ONE row, exposed so the rule is testable without a store.
40151
40523
  *
40152
40524
  * `null` = leave the row alone.
@@ -43327,6 +43699,47 @@ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
43327
43699
  return mark.priorRollbacks < 3;
43328
43700
  }
43329
43701
  //#endregion
43702
+ //#region src/pipeline-analytics/store/audio-track-class.ts
43703
+ /** The taxonomy macro every audio-sourced track carries as its class. */
43704
+ var AUDIO_TRACK_CLASS = "audio";
43705
+ /**
43706
+ * True when this row predates the contract: audio-sourced, but carrying a
43707
+ * naked audio macro as its class.
43708
+ *
43709
+ * Deliberately NOT "className is in AUDIO_MACRO_LABELS" — an id added to the
43710
+ * vocabulary after a row was written would then read as modern forever. The
43711
+ * question is the simple one: is this an audio row that does not say `audio`?
43712
+ */
43713
+ function isLegacyAudioTrackClass(track) {
43714
+ return track.source === "audio" && track.className !== "audio";
43715
+ }
43716
+ /**
43717
+ * Bring a hydrated row up to the contract. Returns the SAME reference when
43718
+ * there is nothing to do, so the hot path (every non-audio track, and every
43719
+ * audio track written after this shipped) allocates nothing.
43720
+ *
43721
+ * The naked class is preserved as an `audioLabels` entry when — and only when
43722
+ * — the row carries none. On the 576 legacy rows measured on the live hub that
43723
+ * branch is unreachable (all of them have labels); it exists so the rewrite
43724
+ * can never be the step that DESTROYS the only copy of what was heard.
43725
+ */
43726
+ function normalizeAudioTrackClass(track) {
43727
+ if (!isLegacyAudioTrackClass(track)) return track;
43728
+ const heard = {
43729
+ label: track.className,
43730
+ peakScore: 0,
43731
+ count: 1,
43732
+ firstAt: track.firstSeen,
43733
+ lastAt: track.lastSeen
43734
+ };
43735
+ const audioLabels = track.audioLabels !== void 0 && track.audioLabels.length > 0 ? track.audioLabels : [heard];
43736
+ return {
43737
+ ...track,
43738
+ className: AUDIO_TRACK_CLASS,
43739
+ audioLabels
43740
+ };
43741
+ }
43742
+ //#endregion
43330
43743
  //#region src/pipeline-analytics/store/recent-cursor.ts
43331
43744
  function encodeRecentCursor(cursor) {
43332
43745
  return Buffer.from(JSON.stringify({
@@ -44974,7 +45387,7 @@ var TrackStore = class {
44974
45387
  maxX: envMaxX,
44975
45388
  maxY: envMaxY
44976
45389
  } : null;
44977
- return {
45390
+ return normalizeAudioTrackClass({
44978
45391
  trackId: id,
44979
45392
  deviceId: Number(data["deviceId"]),
44980
45393
  className: String(data["className"]),
@@ -45005,7 +45418,7 @@ var TrackStore = class {
45005
45418
  ...closedReason !== void 0 ? { closedReason } : {},
45006
45419
  observations,
45007
45420
  ...envelope !== null ? { envelope } : {}
45008
- };
45421
+ });
45009
45422
  }
45010
45423
  };
45011
45424
  //#endregion
@@ -47338,7 +47751,7 @@ var AudioMarkerProjector = class {
47338
47751
  cameraId: deviceId,
47339
47752
  sourceDeviceId: deviceId,
47340
47753
  source: "audio",
47341
- kind: "audio",
47754
+ kind: AUDIO_TRACK_CLASS,
47342
47755
  timestamp: timestampMs,
47343
47756
  ...audioLabels !== void 0 ? { audioLabels } : {}
47344
47757
  });
@@ -48546,6 +48959,23 @@ async function projectSensorMarkers(deps, data, timestamp) {
48546
48959
  * {@link isSpatialTrack}.
48547
48960
  */
48548
48961
  var DEFAULT_DEBOUNCE_MS = 1e4;
48962
+ /**
48963
+ * What the debounce treats as "the same event happening again".
48964
+ *
48965
+ * `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
48966
+ * audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
48967
+ * keying on `kind` alone would collapse this to one bucket per camera. The
48968
+ * episode hysteresis cools down PER CLASS, so a bark three seconds after speech
48969
+ * is a legitimate second episode — and a per-camera bucket would drop it.
48970
+ *
48971
+ * Only the FIRST label is used: a materialize carries the one class the episode
48972
+ * opened on. Joining all of them would make `[speech]` and `[speech, dog]`
48973
+ * different buckets, which is the opposite of what a debounce is for.
48974
+ */
48975
+ function debounceKey(input) {
48976
+ const heard = input.audioLabels?.[0]?.label;
48977
+ return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
48978
+ }
48549
48979
  /** A hanging snapshot cap must not stall the sensor state-change path. */
48550
48980
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
48551
48981
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
@@ -48588,7 +49018,7 @@ var SyntheticTrackMaterializer = class {
48588
49018
  snapshotTimeoutMs;
48589
49019
  makeId;
48590
49020
  now;
48591
- /** Last snapshot time per `${cameraId}:${kind}`. */
49021
+ /** Last snapshot time per {@link debounceKey}. */
48592
49022
  lastAt = /* @__PURE__ */ new Map();
48593
49023
  constructor(deps) {
48594
49024
  this.deps = deps;
@@ -48627,7 +49057,7 @@ var SyntheticTrackMaterializer = class {
48627
49057
  * still lands a track (with no media) — it never blocks the durable record.
48628
49058
  */
48629
49059
  async materialize(input) {
48630
- const key = `${input.cameraId}:${input.kind}`;
49060
+ const key = debounceKey(input);
48631
49061
  const last = this.lastAt.get(key);
48632
49062
  if (last !== void 0 && this.now() - last < this.debounceMs) return null;
48633
49063
  this.lastAt.set(key, this.now());
@@ -49982,7 +50412,7 @@ function buildGlobalSettingsSchema() {
49982
50412
  type: "boolean",
49983
50413
  key: "audioEpisodeEnabled",
49984
50414
  label: "Create audio episodes",
49985
- description: "When off, classified audio still notifies from other paths but does not open a synthetic track or coalesce classification events.",
50415
+ 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).",
49986
50416
  default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
49987
50417
  },
49988
50418
  {
@@ -50108,6 +50538,15 @@ function buildGlobalSettingsSchema() {
50108
50538
  step: 1,
50109
50539
  default: FACE_DEFAULTS.confirmFrames
50110
50540
  },
50541
+ {
50542
+ type: "number",
50543
+ key: "maxEmbeddingMagnitude",
50544
+ label: "Max embedding magnitude (0 = off)",
50545
+ 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.",
50546
+ min: 0,
50547
+ step: .5,
50548
+ default: FACE_DEFAULTS.maxEmbeddingMagnitude
50549
+ },
50111
50550
  {
50112
50551
  type: "number",
50113
50552
  key: "bufferMaxPerDevice",
@@ -50185,6 +50624,125 @@ function evaluatePeriodicSnapshot(input) {
50185
50624
  movedFraction
50186
50625
  };
50187
50626
  }
50627
+ /** Where the completion marker lives — the collection `runLabelTierMigration`
50628
+ * already established for exactly this. */
50629
+ var AUDIO_CLASS_MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
50630
+ var AUDIO_CLASS_MIGRATION_MARKER_KEY = "audio-track-class-d189";
50631
+ /**
50632
+ * The decision for ONE stored row, exposed so the rule is testable without a
50633
+ * store. `null` = leave the row alone.
50634
+ *
50635
+ * Reads the RAW row rather than a hydrated `Track` on purpose: this is the
50636
+ * column the pass rewrites, and hydrating through `rowToTrack` would apply the
50637
+ * read-side normalization and hide the very rows it is looking for.
50638
+ */
50639
+ function decideAudioClassRowMigration(data) {
50640
+ if (data["source"] !== "audio") return null;
50641
+ const className = data["className"];
50642
+ if (typeof className !== "string" || className.length === 0) return null;
50643
+ if (className === "audio") return null;
50644
+ const stored = data["audioLabels"];
50645
+ if (Array.isArray(stored) && stored.length > 0) return { className: AUDIO_TRACK_CLASS };
50646
+ const firstSeen = typeof data["firstSeen"] === "number" ? data["firstSeen"] : 0;
50647
+ return {
50648
+ className: AUDIO_TRACK_CLASS,
50649
+ audioLabels: [{
50650
+ label: className,
50651
+ peakScore: 0,
50652
+ count: 1,
50653
+ firstAt: firstSeen,
50654
+ lastAt: typeof data["lastSeen"] === "number" ? data["lastSeen"] : firstSeen
50655
+ }]
50656
+ };
50657
+ }
50658
+ /**
50659
+ * Run the pass over the tracks collection, once.
50660
+ *
50661
+ * The marker short-circuits a completed migration; it is written only when the
50662
+ * pass did NOT truncate, so an interrupted run resumes on the next boot instead
50663
+ * of declaring itself done.
50664
+ */
50665
+ async function runAudioTrackClassMigration(deps, collection) {
50666
+ try {
50667
+ const marker = await deps.store.get.query({
50668
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50669
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY
50670
+ });
50671
+ if (marker !== null && marker !== void 0) return null;
50672
+ } catch {}
50673
+ let scanned = 0;
50674
+ let rewritten = 0;
50675
+ let labelsRecovered = 0;
50676
+ let skipped = 0;
50677
+ let offset = 0;
50678
+ let truncated = false;
50679
+ for (;;) {
50680
+ if (scanned >= 2e5) {
50681
+ truncated = true;
50682
+ break;
50683
+ }
50684
+ const rows = await deps.store.query.query({
50685
+ collection,
50686
+ filter: {
50687
+ limit: 500,
50688
+ offset
50689
+ }
50690
+ });
50691
+ if (rows.length === 0) break;
50692
+ for (const row of rows) {
50693
+ scanned++;
50694
+ const patch = decideAudioClassRowMigration(row.data);
50695
+ if (patch === null) {
50696
+ skipped++;
50697
+ continue;
50698
+ }
50699
+ const deviceId = typeof row.data["deviceId"] === "number" ? row.data["deviceId"] : void 0;
50700
+ try {
50701
+ await deps.store.update.mutate({
50702
+ collection,
50703
+ id: row.id,
50704
+ data: patch
50705
+ });
50706
+ rewritten++;
50707
+ if (patch["audioLabels"] !== void 0) labelsRecovered++;
50708
+ } catch (err) {
50709
+ deps.logger.warn("audio track class migration row failed", {
50710
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
50711
+ meta: {
50712
+ collection,
50713
+ id: row.id,
50714
+ error: err instanceof Error ? err.message : String(err)
50715
+ }
50716
+ });
50717
+ skipped++;
50718
+ }
50719
+ }
50720
+ if (rows.length < 500) break;
50721
+ offset += rows.length;
50722
+ }
50723
+ if (!truncated) try {
50724
+ await deps.store.set.mutate({
50725
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50726
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY,
50727
+ value: {
50728
+ completedAt: deps.now(),
50729
+ rewritten
50730
+ }
50731
+ });
50732
+ } catch (err) {
50733
+ deps.logger.warn("audio track class migration marker failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
50734
+ }
50735
+ const stats = {
50736
+ collection,
50737
+ scanned,
50738
+ rewritten,
50739
+ labelsRecovered,
50740
+ skipped,
50741
+ truncated
50742
+ };
50743
+ deps.logger.info("audio track class migration", { meta: { ...stats } });
50744
+ return stats;
50745
+ }
50188
50746
  //#endregion
50189
50747
  //#region src/pipeline-analytics/store/object-embedding-store.ts
50190
50748
  /**
@@ -51488,6 +52046,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
51488
52046
  var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
51489
52047
  /** Persist lastSeen on an open synthetic audio track at most this often. */
51490
52048
  var AUDIO_EPISODE_PERSIST_MS = 2e3;
52049
+ /**
52050
+ * Coalescing window for classified audio inserts when episodes are OFF.
52051
+ *
52052
+ * With `audioEpisodeEnabled: false` there is no hysteresis to collapse a sound
52053
+ * into one row, but the classifier still fires at ~30 Hz — so the pre-episode
52054
+ * rule applies again: while the SAME class persists, one row per heartbeat;
52055
+ * a class change inserts immediately. The knob turns off the grouping, never
52056
+ * the persistence (D62).
52057
+ */
52058
+ var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
51491
52059
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
51492
52060
  /**
51493
52061
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -52593,7 +53161,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52593
53161
  AnalyticsLts.declare(api.settingsStore),
52594
53162
  SceneStore.declare(api.settingsStore),
52595
53163
  NotificationCenter.declare(api.settingsStore),
52596
- ViewerSettingsSnapshotStore.declare(api.settingsStore)
53164
+ ViewerSettingsSnapshotStore.declare(api.settingsStore),
53165
+ declareMigrationMarkers(api.settingsStore)
52597
53166
  ]);
52598
53167
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
52599
53168
  try {
@@ -52611,6 +53180,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52611
53180
  }, [TRACKS_COLLECTION, OBJECT_EVENTS_COLLECTION]).catch((err) => {
52612
53181
  this.ctx.logger.warn("label tier migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
52613
53182
  });
53183
+ runAudioTrackClassMigration({
53184
+ store: api.settingsStore,
53185
+ logger: this.ctx.logger,
53186
+ now: () => Date.now()
53187
+ }, TRACKS_COLLECTION).catch((err) => {
53188
+ this.ctx.logger.warn("audio track class migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53189
+ });
52614
53190
  }
52615
53191
  /** Event-media storage. By default it uses `ctx.kernel.storage`, rooted at
52616
53192
  * the node data dir (appData) → heavy thumbnail/track-frame churn hammers
@@ -55343,6 +55919,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55343
55919
  frameHeight: frame.frameHeight,
55344
55920
  score: detail.score,
55345
55921
  embedding: decodeEmbeddingBase64(detail.embedding),
55922
+ ...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
55346
55923
  parentBbox: { ...frame.bbox },
55347
55924
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
55348
55925
  ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
@@ -55810,7 +56387,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55810
56387
  return;
55811
56388
  }
55812
56389
  }
55813
- async openAudioEpisode(input, ref) {
56390
+ /**
56391
+ * The ONE writer for a classified audio observation: the durable row, the
56392
+ * Notification Center hook fired in the same persist moment (D-2), the
56393
+ * track-level `audioLabels` stamp, and the telemetry event.
56394
+ *
56395
+ * Shared by both routes on purpose. `audioEpisodeEnabled` selects WHEN this
56396
+ * runs (once per episode, or once per heartbeat), never WHETHER — a knob
56397
+ * that names the grouping must not be able to silence the classified path
56398
+ * (D62). This body was inlined in `openAudioEpisode` and the knob-off camera
56399
+ * therefore persisted nothing at all.
56400
+ */
56401
+ async persistClassifiedAudio(input) {
55814
56402
  const ev = {
55815
56403
  id: randomUUID(),
55816
56404
  deviceId: input.deviceId,
@@ -55819,18 +56407,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55819
56407
  rms: input.rms,
55820
56408
  dbfs: input.dbfs,
55821
56409
  classification: {
55822
- className: ref.className,
56410
+ className: input.className,
55823
56411
  ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
55824
56412
  score: input.score
55825
56413
  }
55826
56414
  };
55827
56415
  this.lastAudioInsertByDevice.set(input.deviceId, {
55828
- className: ref.className,
56416
+ className: input.className,
55829
56417
  atMs: input.timestamp
55830
56418
  });
55831
56419
  await this.eventStore.insertAudio(ev);
55832
56420
  this.notificationCenter?.onAudioEventPersisted(ev);
55833
- this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
56421
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
55834
56422
  this.ctx.eventBus.emit({
55835
56423
  id: `pa-${ev.id}`,
55836
56424
  timestamp: new Date(ev.timestamp),
@@ -55847,6 +56435,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55847
56435
  timestamp: ev.timestamp
55848
56436
  }
55849
56437
  });
56438
+ }
56439
+ /**
56440
+ * Classified audio with episodes OFF: persist on class change or heartbeat,
56441
+ * and open NO synthetic track. This is the whole of what the knob switches —
56442
+ * the timeline track and the episode coalescing — and it is what the form
56443
+ * promises.
56444
+ */
56445
+ async persistClassifiedAudioUngrouped(input) {
56446
+ const last = this.lastAudioInsertByDevice.get(input.deviceId);
56447
+ const sameClass = last?.className === input.className;
56448
+ const heartbeatDue = last === void 0 || input.timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
56449
+ if (sameClass && !heartbeatDue) return;
56450
+ await this.persistClassifiedAudio(input);
56451
+ }
56452
+ async openAudioEpisode(input, ref) {
56453
+ await this.persistClassifiedAudio({
56454
+ ...input,
56455
+ className: ref.className
56456
+ });
55850
56457
  if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
55851
56458
  const materializer = this.syntheticTracks;
55852
56459
  if (materializer === null) return;
@@ -55855,7 +56462,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55855
56462
  cameraId: input.deviceId,
55856
56463
  sourceDeviceId: input.deviceId,
55857
56464
  source: "audio",
55858
- kind: ref.className,
56465
+ kind: AUDIO_TRACK_CLASS,
55859
56466
  timestamp: input.timestamp,
55860
56467
  audioLabels: [{
55861
56468
  label: ref.className,
@@ -55958,19 +56565,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55958
56565
  });
55959
56566
  return;
55960
56567
  }
55961
- await this.driveAudioEpisode({
56568
+ const classified = {
55962
56569
  deviceId,
55963
56570
  timestamp,
55964
56571
  className: route.className,
55965
56572
  score: topClassification.score,
55966
- originalClass: topClassification.debug?.originalClass,
56573
+ ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
55967
56574
  rms: level?.rms ?? 0,
55968
- dbfs: level?.dbfs ?? 0,
56575
+ dbfs: level?.dbfs ?? 0
56576
+ };
56577
+ if (!settings.audioEpisodeEnabled) {
56578
+ await this.persistClassifiedAudioUngrouped(classified);
56579
+ return;
56580
+ }
56581
+ await this.driveAudioEpisode({
56582
+ ...classified,
55969
56583
  knobs: this.audioEpisodeKnobs(settings)
55970
56584
  });
55971
56585
  return;
55972
56586
  }
55973
- await this.driveAudioEpisode({
56587
+ if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
55974
56588
  deviceId,
55975
56589
  timestamp,
55976
56590
  className: null,