@camstack/addon-post-analysis 1.2.100 → 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");
@@ -8304,23 +8304,23 @@ var en_default = {
8304
8304
  },
8305
8305
  "class.other": "{{name}}",
8306
8306
  "group.others.person": {
8307
- "one": "1 other person",
8307
+ "one": "{{count}} other person",
8308
8308
  "other": "{{count}} other people"
8309
8309
  },
8310
8310
  "group.others.vehicle": {
8311
- "one": "1 other vehicle",
8311
+ "one": "{{count}} other vehicle",
8312
8312
  "other": "{{count}} other vehicles"
8313
8313
  },
8314
8314
  "group.others.animal": {
8315
- "one": "1 other animal",
8315
+ "one": "{{count}} other animal",
8316
8316
  "other": "{{count}} other animals"
8317
8317
  },
8318
8318
  "group.others.package": {
8319
- "one": "1 other package",
8319
+ "one": "{{count}} other package",
8320
8320
  "other": "{{count}} other packages"
8321
8321
  },
8322
8322
  "group.others.other": {
8323
- "one": "1 other {{classNoun}}",
8323
+ "one": "{{count}} other {{classNoun}}",
8324
8324
  "other": "{{count}} other {{classNoun}}"
8325
8325
  },
8326
8326
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
@@ -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",
@@ -8518,23 +8522,23 @@ var it_default = {
8518
8522
  },
8519
8523
  "class.other": "{{name}}",
8520
8524
  "group.others.person": {
8521
- "one": "1 altra persona",
8525
+ "one": "{{count}} altra persona",
8522
8526
  "other": "{{count}} altre persone"
8523
8527
  },
8524
8528
  "group.others.vehicle": {
8525
- "one": "1 altro veicolo",
8529
+ "one": "{{count}} altro veicolo",
8526
8530
  "other": "{{count}} altri veicoli"
8527
8531
  },
8528
8532
  "group.others.animal": {
8529
- "one": "1 altro animale",
8533
+ "one": "{{count}} altro animale",
8530
8534
  "other": "{{count}} altri animali"
8531
8535
  },
8532
8536
  "group.others.package": {
8533
- "one": "1 altro pacco",
8537
+ "one": "{{count}} altro pacco",
8534
8538
  "other": "{{count}} altri pacchi"
8535
8539
  },
8536
8540
  "group.others.other": {
8537
- "one": "1 altro {{classNoun}}",
8541
+ "one": "{{count}} altro {{classNoun}}",
8538
8542
  "other": "{{count}} altri {{classNoun}}"
8539
8543
  },
8540
8544
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
@@ -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
@@ -31058,8 +31082,14 @@ function reportUnvouchedClips(deps, deviceId, clips) {
31058
31082
  * @durable class=config owner=pipeline-analytics
31059
31083
  * write="a signed-in viewer user saves a named snapshot of their local stores"
31060
31084
  * retention="none — a row goes only when its owner deletes it"
31085
+ *
31086
+ * Renamed from the bare `viewer-settings-snapshots` on 2026-08-19: an addon
31087
+ * collection carries its owner's prefix, and the bare name sat in the same flat
31088
+ * namespace as the kernel's `users` / `system-settings`. Rows already written
31089
+ * under the old name are adopted at boot by
31090
+ * `viewer-settings-legacy-adoption.ts`.
31061
31091
  */
31062
- var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
31092
+ var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "pipeline-analytics:viewer-settings-snapshots";
31063
31093
  var COLUMNS = [
31064
31094
  {
31065
31095
  name: "id",
@@ -31159,7 +31189,7 @@ var ViewerSettingsSnapshotStore = class {
31159
31189
  collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31160
31190
  columns: [...COLUMNS],
31161
31191
  indexes: [{
31162
- name: "idx_viewer_settings_owner",
31192
+ name: "idx_pa_viewer_settings_owner",
31163
31193
  columns: ["ownerUserId"]
31164
31194
  }]
31165
31195
  });
@@ -31258,6 +31288,183 @@ function makeViewerSettingsActionHandlers(store) {
31258
31288
  }
31259
31289
  };
31260
31290
  }
31291
+ //#endregion
31292
+ //#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
31293
+ /**
31294
+ * @durable class=config owner=pipeline-analytics
31295
+ * write="NOTHING writes it any more — it is the pre-2026-08-19 bare name of
31296
+ * `pipeline-analytics:viewer-settings-snapshots`, declared here only so its
31297
+ * surviving rows can be READ once and moved"
31298
+ * retention="drained, not pruned: a row goes as soon as the successor is
31299
+ * confirmed to hold the same id. The table is empty and inert once every
31300
+ * hub has booted this addon; it is then dropped through the kernel's
31301
+ * `RETIRED_COLLECTIONS` and this module deleted."
31302
+ *
31303
+ * Bare, and therefore a namespacing violation — hence the single
31304
+ * `ALLOWED_BARE` entry in `scripts/check-collection-namespacing.ts`, which
31305
+ * names this file and the condition for removing both.
31306
+ */
31307
+ var LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
31308
+ /**
31309
+ * The ORIGINAL column list, byte-for-byte.
31310
+ *
31311
+ * Do not "tidy" this to reference the successor's constant: they are allowed to
31312
+ * diverge, and the day they do, a shared constant would silently rebuild the
31313
+ * legacy table and destroy the rows.
31314
+ */
31315
+ var LEGACY_COLUMNS = [
31316
+ {
31317
+ name: "id",
31318
+ type: "TEXT",
31319
+ primaryKey: true,
31320
+ notNull: true
31321
+ },
31322
+ {
31323
+ name: "name",
31324
+ type: "TEXT",
31325
+ notNull: true
31326
+ },
31327
+ {
31328
+ name: "ownerUserId",
31329
+ type: "TEXT",
31330
+ notNull: true
31331
+ },
31332
+ {
31333
+ name: "ownerUsername",
31334
+ type: "TEXT",
31335
+ notNull: true
31336
+ },
31337
+ {
31338
+ name: "createdAt",
31339
+ type: "INTEGER",
31340
+ notNull: true
31341
+ },
31342
+ {
31343
+ name: "updatedAt",
31344
+ type: "INTEGER",
31345
+ notNull: true
31346
+ },
31347
+ {
31348
+ name: "blob",
31349
+ type: "JSON",
31350
+ notNull: true
31351
+ }
31352
+ ];
31353
+ /** Bounded read: this table held 1 row on the reference hub. */
31354
+ var LEGACY_READ_LIMIT = 500;
31355
+ function errText(err) {
31356
+ return err instanceof Error ? err.message : String(err);
31357
+ }
31358
+ function asRows(raw) {
31359
+ if (!Array.isArray(raw)) return [];
31360
+ const out = [];
31361
+ for (const item of raw) {
31362
+ if (typeof item !== "object" || item === null) continue;
31363
+ const id = Reflect.get(item, "id");
31364
+ const data = Reflect.get(item, "data");
31365
+ if (typeof id !== "string" || typeof data !== "object" || data === null) continue;
31366
+ out.push({
31367
+ id,
31368
+ data
31369
+ });
31370
+ }
31371
+ return out;
31372
+ }
31373
+ /**
31374
+ * Move every legacy row into the successor and clear the legacy table.
31375
+ *
31376
+ * Returns the number of legacy rows RETIRED — the completion metric, not the
31377
+ * number written. A row copied but not yet verified counts as zero and is
31378
+ * retried on the next boot, which is the safe direction.
31379
+ */
31380
+ async function adoptLegacyViewerSettingsSnapshots(deps) {
31381
+ await deps.store.declareCollection.mutate({
31382
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31383
+ columns: LEGACY_COLUMNS
31384
+ });
31385
+ let legacy;
31386
+ try {
31387
+ legacy = asRows(await deps.store.query.query({
31388
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31389
+ filter: { limit: LEGACY_READ_LIMIT }
31390
+ }));
31391
+ } catch (err) {
31392
+ deps.logger.warn("viewer-settings legacy adoption skipped — legacy read failed", { meta: {
31393
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31394
+ error: errText(err)
31395
+ } });
31396
+ return 0;
31397
+ }
31398
+ if (legacy.length === 0) return 0;
31399
+ let existing;
31400
+ try {
31401
+ existing = new Set(asRows(await deps.store.query.query({
31402
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31403
+ filter: { limit: LEGACY_READ_LIMIT }
31404
+ })).map((r) => r.id));
31405
+ } catch (err) {
31406
+ deps.logger.warn("viewer-settings legacy adoption skipped — successor read failed", { meta: {
31407
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31408
+ error: errText(err)
31409
+ } });
31410
+ return 0;
31411
+ }
31412
+ let written = 0;
31413
+ for (const row of legacy) {
31414
+ if (existing.has(row.id)) continue;
31415
+ try {
31416
+ await deps.store.set.mutate({
31417
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31418
+ key: row.id,
31419
+ value: row.data
31420
+ });
31421
+ written += 1;
31422
+ } catch (err) {
31423
+ deps.logger.warn("viewer-settings legacy adoption — one row failed to write", { meta: {
31424
+ snapshotId: row.id,
31425
+ error: errText(err)
31426
+ } });
31427
+ }
31428
+ }
31429
+ let landed;
31430
+ try {
31431
+ landed = new Set(asRows(await deps.store.query.query({
31432
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31433
+ filter: { limit: LEGACY_READ_LIMIT }
31434
+ })).map((r) => r.id));
31435
+ } catch (err) {
31436
+ deps.logger.warn("viewer-settings legacy adoption — verification read failed, legacy rows kept", { meta: {
31437
+ written,
31438
+ error: errText(err)
31439
+ } });
31440
+ return 0;
31441
+ }
31442
+ let retired = 0;
31443
+ for (const row of legacy) {
31444
+ if (!landed.has(row.id)) continue;
31445
+ try {
31446
+ await deps.store.delete.mutate({
31447
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31448
+ key: row.id
31449
+ });
31450
+ retired += 1;
31451
+ } catch (err) {
31452
+ deps.logger.warn("viewer-settings legacy adoption — one legacy row failed to delete", { meta: {
31453
+ snapshotId: row.id,
31454
+ error: errText(err)
31455
+ } });
31456
+ }
31457
+ }
31458
+ deps.logger.warn("viewer-settings snapshots adopted from the legacy bare collection", { meta: {
31459
+ from: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31460
+ to: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31461
+ found: legacy.length,
31462
+ written,
31463
+ retired,
31464
+ kept: legacy.length - retired
31465
+ } });
31466
+ return retired;
31467
+ }
31261
31468
  /**
31262
31469
  * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
31263
31470
  *
@@ -32065,7 +32272,11 @@ var AudioDetectionSettingsSchema = require_dist.object({
32065
32272
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
32066
32273
  * only ever cost the operator this many markers a day. */
32067
32274
  audioMarkerMaxPerDay: require_dist.number().int().positive().default(12),
32068
- /** 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). */
32069
32280
  audioEpisodeEnabled: require_dist.boolean().default(true),
32070
32281
  /** Class must be heard continuously this long before the episode opens. */
32071
32282
  audioEpisodeOpenHoldSec: require_dist.number().nonnegative().default(1.5),
@@ -34285,11 +34496,44 @@ async function resolveDefaultEventMedia(deps) {
34285
34496
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
34286
34497
  }
34287
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
34288
34530
  //#region src/pipeline-analytics/face-recognizer.ts
34289
34531
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
34290
34532
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
34291
34533
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
34292
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;
34293
34537
  /**
34294
34538
  * arcface model id stamped on a detail-plane face candidate when the gallery is
34295
34539
  * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
@@ -34304,6 +34548,8 @@ var FaceRecognizer = class {
34304
34548
  names = /* @__PURE__ */ new Map();
34305
34549
  aggregates = /* @__PURE__ */ new Map();
34306
34550
  bestFace = /* @__PURE__ */ new Map();
34551
+ /** Per-device throttle clock for the magnitude sample line. */
34552
+ lastMagnitudeLogAt = /* @__PURE__ */ new Map();
34307
34553
  /**
34308
34554
  * Peak identity-MATCH cosine per track for the currently-assigned identity
34309
34555
  * (NOT the detection confidence held in {@link bestFace}). Reset on an
@@ -34353,6 +34599,21 @@ var FaceRecognizer = class {
34353
34599
  * input source moves from the per-frame plane to this per-track call.
34354
34600
  */
34355
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);
34356
34617
  const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
34357
34618
  const candidate = {
34358
34619
  trackId: input.trackId,
@@ -34379,6 +34640,30 @@ var FaceRecognizer = class {
34379
34640
  ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
34380
34641
  });
34381
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
+ }
34382
34667
  async processFrame(input) {
34383
34668
  const { settings } = input;
34384
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);
@@ -34440,7 +34725,8 @@ var FaceRecognizer = class {
34440
34725
  }
34441
34726
  const recognizedIdentityId = state.assignedIdentityId ?? void 0;
34442
34727
  const held = this.bestFace.get(c.trackId);
34443
- 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));
34444
34730
  const needsCrop = held !== void 0 && held.crop === void 0;
34445
34731
  if (isNewBest || needsCrop) {
34446
34732
  const cropBbox = c.faceBbox ?? c.bbox;
@@ -34700,14 +34986,38 @@ var FaceSettingsSchema = require_dist.object({
34700
34986
  * stripped from the per-device schema).
34701
34987
  */
34702
34988
  enabled: require_dist.boolean().default(true),
34703
- /** Cosine similarity (on L2-normalized arcface vectors) required to match.
34704
- * Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
34705
- * loose against thin/low-quality galleries and produced ~25-40% visually
34706
- * wrong auto-assignments in the 12h audit. */
34707
- similarityThreshold: require_dist.number().min(0).max(1).default(.62),
34708
- /** Reject ambiguous matches: require best secondBest margin. Raised
34709
- * 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
34710
- 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),
34711
35021
  /** Minimum face-detection confidence for a face to be considered. */
34712
35022
  minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
34713
35023
  /**
@@ -34751,6 +35061,33 @@ var FaceSettingsSchema = require_dist.object({
34751
35061
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
34752
35062
  * such a value falls back to the default). */
34753
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),
34754
35091
  /** Max buffered faces kept per device. */
34755
35092
  bufferMaxPerDevice: require_dist.number().int().min(0).default(50)
34756
35093
  });
@@ -34769,6 +35106,7 @@ function resolveFaceSettings(raw) {
34769
35106
  recognitionMinFacePx: pick("recognitionMinFacePx"),
34770
35107
  suggestionMinCosine: pick("suggestionMinCosine"),
34771
35108
  minIdentitySamples: pick("minIdentitySamples"),
35109
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
34772
35110
  confirmFrames: pick("confirmFrames"),
34773
35111
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
34774
35112
  };
@@ -39240,6 +39578,147 @@ async function applyTrackFlags(deps, input) {
39240
39578
  };
39241
39579
  }
39242
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
39243
39722
  //#region src/pipeline-analytics/pipeline/plate-matcher.ts
39244
39723
  /**
39245
39724
  * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
@@ -39288,11 +39767,11 @@ function matchPlateText(probe, gallery, opts) {
39288
39767
  //#endregion
39289
39768
  //#region src/pipeline-analytics/plate-recognizer.ts
39290
39769
  /**
39291
- * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
39292
- * for the plate gallery. Unlike faces there is NO gallery matching: the OCR text
39293
- * IS the label and already rides on the event via the detection pipeline. This
39294
- * only collects per-track reads (+ a tight plate crop) so the gallery can group,
39295
- * 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.
39296
39775
  */
39297
39776
  var PlateRecognizer = class {
39298
39777
  deps;
@@ -39355,7 +39834,7 @@ var PlateRecognizer = class {
39355
39834
  const minConfidence = input.minConfidence ?? 0;
39356
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);
39357
39836
  if (candidates.length === 0) return;
39358
- for (const c of candidates) await this.holdBest({
39837
+ for (const c of candidates) await this.observeRead({
39359
39838
  deviceId: input.deviceId,
39360
39839
  trackId: c.trackId,
39361
39840
  text: c.plateText,
@@ -39378,15 +39857,50 @@ var PlateRecognizer = class {
39378
39857
  * gallery stays empty (0 plateCrop) — the observed live gap.
39379
39858
  */
39380
39859
  async observePlateRead(input) {
39381
- if (!isPlausiblePlateRead(input.text, input.score)) return;
39382
- if (input.score < (input.minConfidence ?? 0)) return;
39383
- 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);
39384
39878
  }
39385
- /** Hold the highest-scoring plate read per track, capturing a tight crop the
39386
- * first time a new best is seen (shared by the per-frame + detail-plane paths). */
39387
- 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) {
39388
39893
  const held = this.bestPlate.get(input.trackId);
39389
- 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);
39390
39904
  let crop;
39391
39905
  if (input.frameHandle !== void 0) try {
39392
39906
  crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
@@ -39402,6 +39916,7 @@ var PlateRecognizer = class {
39402
39916
  this.bestPlate.set(input.trackId, {
39403
39917
  text: input.text,
39404
39918
  score: input.score,
39919
+ reads,
39405
39920
  bbox: input.bbox,
39406
39921
  timestamp: input.timestamp,
39407
39922
  frameWidth: input.frameWidth,
@@ -39478,14 +39993,16 @@ var PlateRecognizer = class {
39478
39993
  }
39479
39994
  });
39480
39995
  }
39481
- 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);
39482
39999
  try {
39483
40000
  await this.deps.plateStore.insert({
39484
40001
  id: plateId,
39485
40002
  deviceId,
39486
40003
  trackId,
39487
40004
  timestamp: held.timestamp,
39488
- text: normalizePlate(held.text),
40005
+ text,
39489
40006
  score: held.score,
39490
40007
  ...mediaKey !== void 0 ? { mediaKey } : {},
39491
40008
  corrected: false,
@@ -39506,7 +40023,10 @@ var PlateRecognizer = class {
39506
40023
  },
39507
40024
  meta: {
39508
40025
  plateId,
39509
- 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,
39510
40030
  score: held.score,
39511
40031
  hasCrop: mediaKey !== void 0,
39512
40032
  recognizedVehicleId: match?.vehicleId ?? null
@@ -40020,6 +40540,129 @@ function buildRecordedStillPorts(input) {
40020
40540
  })
40021
40541
  };
40022
40542
  }
40543
+ /** Where the completion marker lives. */
40544
+ var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40545
+ var MIGRATION_MARKER_KEY = "label-tier-4g";
40546
+ /**
40547
+ * The decision for ONE row, exposed so the rule is testable without a store.
40548
+ *
40549
+ * `null` = leave the row alone.
40550
+ */
40551
+ function decideRowMigration(data, now) {
40552
+ const label = data["label"];
40553
+ if (typeof label !== "string" || label.length === 0) return null;
40554
+ if (typeof data["subLabel"] === "string" && data["subLabel"].length > 0) return null;
40555
+ if (data["labelMeta"] !== null && data["labelMeta"] !== void 0) return null;
40556
+ if (isMacroClass(label)) return {
40557
+ label: null,
40558
+ labelScore: null,
40559
+ labelMeta: null
40560
+ };
40561
+ return {
40562
+ label: null,
40563
+ labelScore: null,
40564
+ labelMeta: null,
40565
+ subLabel: label,
40566
+ subLabelScore: null,
40567
+ subLabelMeta: {
40568
+ stepId: MIGRATION_STEP_ID,
40569
+ decidedAt: now
40570
+ }
40571
+ };
40572
+ }
40573
+ /** Migrate one collection, paged and bounded. */
40574
+ async function migrateCollectionLabelTiers(deps, collection) {
40575
+ let scanned = 0;
40576
+ let demoted = 0;
40577
+ let cleared = 0;
40578
+ let skipped = 0;
40579
+ let offset = 0;
40580
+ let truncated = false;
40581
+ for (;;) {
40582
+ if (scanned >= 2e5) {
40583
+ truncated = true;
40584
+ break;
40585
+ }
40586
+ const rows = await deps.store.query.query({
40587
+ collection,
40588
+ filter: {
40589
+ limit: 500,
40590
+ offset
40591
+ }
40592
+ });
40593
+ if (rows.length === 0) break;
40594
+ for (const row of rows) {
40595
+ scanned++;
40596
+ const patch = decideRowMigration(row.data, deps.now());
40597
+ if (patch === null) {
40598
+ skipped++;
40599
+ continue;
40600
+ }
40601
+ try {
40602
+ await deps.store.update.mutate({
40603
+ collection,
40604
+ id: row.id,
40605
+ data: patch
40606
+ });
40607
+ if (patch["subLabel"] === void 0) cleared++;
40608
+ else demoted++;
40609
+ } catch (err) {
40610
+ deps.logger.warn("label tier migration row failed", { meta: {
40611
+ collection,
40612
+ id: row.id,
40613
+ error: String(err)
40614
+ } });
40615
+ skipped++;
40616
+ }
40617
+ }
40618
+ if (rows.length < 500) break;
40619
+ offset += rows.length;
40620
+ }
40621
+ return {
40622
+ collection,
40623
+ scanned,
40624
+ demoted,
40625
+ cleared,
40626
+ skipped,
40627
+ truncated
40628
+ };
40629
+ }
40630
+ /**
40631
+ * Run the migration across the collections that carry the tiered columns, once.
40632
+ *
40633
+ * The marker short-circuits a completed migration; it is written only when
40634
+ * NEITHER collection was truncated, so an interrupted pass resumes on the next
40635
+ * boot instead of declaring itself done.
40636
+ */
40637
+ async function runLabelTierMigration(deps, collections) {
40638
+ try {
40639
+ const marker = await deps.store.get.query({
40640
+ collection: MIGRATION_MARKER_COLLECTION,
40641
+ key: MIGRATION_MARKER_KEY
40642
+ });
40643
+ if (marker !== null && marker !== void 0) return [];
40644
+ } catch {}
40645
+ const stats = [];
40646
+ for (const collection of collections) stats.push(await migrateCollectionLabelTiers(deps, collection));
40647
+ if (stats.every((s) => !s.truncated)) try {
40648
+ await deps.store.set.mutate({
40649
+ collection: MIGRATION_MARKER_COLLECTION,
40650
+ key: MIGRATION_MARKER_KEY,
40651
+ value: { completedAt: deps.now() }
40652
+ });
40653
+ } catch (err) {
40654
+ deps.logger.warn("label tier migration marker failed", { meta: { error: String(err) } });
40655
+ }
40656
+ for (const s of stats) deps.logger.info("label tier migration", { meta: {
40657
+ collection: s.collection,
40658
+ scanned: s.scanned,
40659
+ demoted: s.demoted,
40660
+ cleared: s.cleared,
40661
+ skipped: s.skipped,
40662
+ truncated: s.truncated
40663
+ } });
40664
+ return stats;
40665
+ }
40023
40666
  //#endregion
40024
40667
  //#region src/durable/lts-aggregator.ts
40025
40668
  /** Wall-clock bucket width. Five minutes, matching HA's statistics tier. */
@@ -45211,6 +45854,55 @@ var ANALYTICS_COLLECTIONS = [
45211
45854
  why: "a five-minute statistics row summarises an INTERVAL, not an entity — there is no owner it can be orphaned from. Its bound is a ROW CAP per (subject, series, scope) enforced by the aggregator itself (105,120 rows ≈ one year), deliberately not an age sweep: D61 collapsed ten disagreeing age clocks into one, and an LTS series has no clock but its own."
45212
45855
  }
45213
45856
  },
45857
+ {
45858
+ collection: RETRAIN_ANNOTATIONS_COLLECTION,
45859
+ classification: {
45860
+ kind: "never-orphaned",
45861
+ why: "the ground truth an operator DREW. It declares `trackId` and must be classified by hand for that reason: the id is provenance, not ownership. The dataset addresses COPIES (D81), which is precisely what makes a 'trained' track evictable again — so a track ageing out leaves the annotations standing. Rows go only through `deleteForFrame`, when the operator deselects the frame. Deleting one because its track expired would destroy labelling work no sweep can recreate."
45862
+ }
45863
+ },
45864
+ {
45865
+ collection: RETRAIN_FRAMES_COLLECTION,
45866
+ classification: {
45867
+ kind: "never-orphaned",
45868
+ why: "the frame COPIES the dataset owns outright, under a `retrain` prefix no owner cascade and no age sweep walks (D81). Also declares `trackId` as provenance. Rows go only through `deleteFrame`, after their annotations, on operator deselection. NOTE: nothing caps this table or the disk it consumes — that is a deliberate decision, not an oversight, and it is the one collection here whose bound is an operator's habit."
45869
+ }
45870
+ },
45871
+ {
45872
+ collection: SCENE_COLLECTION,
45873
+ classification: {
45874
+ kind: "never-orphaned",
45875
+ why: "operator intent — one row per (deviceId, monitorId), created and destroyed by scene CRUD or by a device-removal sweep. Nothing retention does can orphan it, and no age clock applies: a scene the operator configured and never revisits is not stale, it is configured."
45876
+ }
45877
+ },
45878
+ {
45879
+ collection: SCENE_LATCH_COLLECTION,
45880
+ classification: {
45881
+ kind: "never-orphaned",
45882
+ why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves exactly one indirection, that of `pipeline-analytics:media`."
45883
+ }
45884
+ },
45885
+ {
45886
+ collection: MIGRATION_MARKER_COLLECTION,
45887
+ classification: {
45888
+ kind: "never-orphaned",
45889
+ why: "a one-row completion marker for the label-tier 4g pass. It summarises a one-time event, so there is no entity it can be orphaned from — and its PRESENCE is what stops the migration running again, so deleting it is not cleanup, it is a re-run over every track, face and object-event row."
45890
+ }
45891
+ },
45892
+ {
45893
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
45894
+ classification: {
45895
+ kind: "never-orphaned",
45896
+ why: "a hub user's named snapshot of their own viewer layout. It is not analytics data — it happens to live in this addon because the actions ride `addons.custom`. Owned by the user who saved it and deleted only by them; no track, device or event can orphan it, and no age applies."
45897
+ }
45898
+ },
45899
+ {
45900
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
45901
+ classification: {
45902
+ kind: "never-orphaned",
45903
+ why: "the drained predecessor of the row above, declared only so its rows can be read once and moved (`viewer-settings-legacy-adoption.ts`). Retention must never touch it: its rows go one way only, to the successor, gated on a re-read that confirms they landed. Delete this entry together with that module."
45904
+ }
45905
+ },
45214
45906
  {
45215
45907
  collection: STATIONARY_COLLECTION,
45216
45908
  classification: {
@@ -49687,7 +50379,7 @@ function buildGlobalSettingsSchema() {
49687
50379
  type: "boolean",
49688
50380
  key: "audioEpisodeEnabled",
49689
50381
  label: "Create audio episodes",
49690
- 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).",
49691
50383
  default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
49692
50384
  },
49693
50385
  {
@@ -49813,6 +50505,15 @@ function buildGlobalSettingsSchema() {
49813
50505
  step: 1,
49814
50506
  default: FACE_DEFAULTS.confirmFrames
49815
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
+ },
49816
50517
  {
49817
50518
  type: "number",
49818
50519
  key: "bufferMaxPerDevice",
@@ -49890,129 +50591,6 @@ function evaluatePeriodicSnapshot(input) {
49890
50591
  movedFraction
49891
50592
  };
49892
50593
  }
49893
- /** Where the completion marker lives. */
49894
- var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
49895
- var MIGRATION_MARKER_KEY = "label-tier-4g";
49896
- /**
49897
- * The decision for ONE row, exposed so the rule is testable without a store.
49898
- *
49899
- * `null` = leave the row alone.
49900
- */
49901
- function decideRowMigration(data, now) {
49902
- const label = data["label"];
49903
- if (typeof label !== "string" || label.length === 0) return null;
49904
- if (typeof data["subLabel"] === "string" && data["subLabel"].length > 0) return null;
49905
- if (data["labelMeta"] !== null && data["labelMeta"] !== void 0) return null;
49906
- if (isMacroClass(label)) return {
49907
- label: null,
49908
- labelScore: null,
49909
- labelMeta: null
49910
- };
49911
- return {
49912
- label: null,
49913
- labelScore: null,
49914
- labelMeta: null,
49915
- subLabel: label,
49916
- subLabelScore: null,
49917
- subLabelMeta: {
49918
- stepId: MIGRATION_STEP_ID,
49919
- decidedAt: now
49920
- }
49921
- };
49922
- }
49923
- /** Migrate one collection, paged and bounded. */
49924
- async function migrateCollectionLabelTiers(deps, collection) {
49925
- let scanned = 0;
49926
- let demoted = 0;
49927
- let cleared = 0;
49928
- let skipped = 0;
49929
- let offset = 0;
49930
- let truncated = false;
49931
- for (;;) {
49932
- if (scanned >= 2e5) {
49933
- truncated = true;
49934
- break;
49935
- }
49936
- const rows = await deps.store.query.query({
49937
- collection,
49938
- filter: {
49939
- limit: 500,
49940
- offset
49941
- }
49942
- });
49943
- if (rows.length === 0) break;
49944
- for (const row of rows) {
49945
- scanned++;
49946
- const patch = decideRowMigration(row.data, deps.now());
49947
- if (patch === null) {
49948
- skipped++;
49949
- continue;
49950
- }
49951
- try {
49952
- await deps.store.update.mutate({
49953
- collection,
49954
- id: row.id,
49955
- data: patch
49956
- });
49957
- if (patch["subLabel"] === void 0) cleared++;
49958
- else demoted++;
49959
- } catch (err) {
49960
- deps.logger.warn("label tier migration row failed", { meta: {
49961
- collection,
49962
- id: row.id,
49963
- error: String(err)
49964
- } });
49965
- skipped++;
49966
- }
49967
- }
49968
- if (rows.length < 500) break;
49969
- offset += rows.length;
49970
- }
49971
- return {
49972
- collection,
49973
- scanned,
49974
- demoted,
49975
- cleared,
49976
- skipped,
49977
- truncated
49978
- };
49979
- }
49980
- /**
49981
- * Run the migration across the collections that carry the tiered columns, once.
49982
- *
49983
- * The marker short-circuits a completed migration; it is written only when
49984
- * NEITHER collection was truncated, so an interrupted pass resumes on the next
49985
- * boot instead of declaring itself done.
49986
- */
49987
- async function runLabelTierMigration(deps, collections) {
49988
- try {
49989
- const marker = await deps.store.get.query({
49990
- collection: MIGRATION_MARKER_COLLECTION,
49991
- key: MIGRATION_MARKER_KEY
49992
- });
49993
- if (marker !== null && marker !== void 0) return [];
49994
- } catch {}
49995
- const stats = [];
49996
- for (const collection of collections) stats.push(await migrateCollectionLabelTiers(deps, collection));
49997
- if (stats.every((s) => !s.truncated)) try {
49998
- await deps.store.set.mutate({
49999
- collection: MIGRATION_MARKER_COLLECTION,
50000
- key: MIGRATION_MARKER_KEY,
50001
- value: { completedAt: deps.now() }
50002
- });
50003
- } catch (err) {
50004
- deps.logger.warn("label tier migration marker failed", { meta: { error: String(err) } });
50005
- }
50006
- for (const s of stats) deps.logger.info("label tier migration", { meta: {
50007
- collection: s.collection,
50008
- scanned: s.scanned,
50009
- demoted: s.demoted,
50010
- cleared: s.cleared,
50011
- skipped: s.skipped,
50012
- truncated: s.truncated
50013
- } });
50014
- return stats;
50015
- }
50016
50594
  //#endregion
50017
50595
  //#region src/pipeline-analytics/store/object-embedding-store.ts
50018
50596
  /**
@@ -51316,6 +51894,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
51316
51894
  var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
51317
51895
  /** Persist lastSeen on an open synthetic audio track at most this often. */
51318
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;
51319
51907
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
51320
51908
  /**
51321
51909
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -52424,6 +53012,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52424
53012
  ViewerSettingsSnapshotStore.declare(api.settingsStore)
52425
53013
  ]);
52426
53014
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
53015
+ try {
53016
+ await adoptLegacyViewerSettingsSnapshots({
53017
+ store: api.settingsStore,
53018
+ logger: this.ctx.logger.child("viewer-settings")
53019
+ });
53020
+ } catch (err) {
53021
+ this.ctx.logger.warn("viewer-settings legacy adoption threw", { meta: { error: require_dist.errMsg(err) } });
53022
+ }
52427
53023
  runLabelTierMigration({
52428
53024
  store: api.settingsStore,
52429
53025
  logger: this.ctx.logger,
@@ -55163,6 +55759,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55163
55759
  frameHeight: frame.frameHeight,
55164
55760
  score: detail.score,
55165
55761
  embedding: decodeEmbeddingBase64(detail.embedding),
55762
+ ...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
55166
55763
  parentBbox: { ...frame.bbox },
55167
55764
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
55168
55765
  ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
@@ -55630,7 +56227,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55630
56227
  return;
55631
56228
  }
55632
56229
  }
55633
- 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) {
55634
56242
  const ev = {
55635
56243
  id: (0, node_crypto.randomUUID)(),
55636
56244
  deviceId: input.deviceId,
@@ -55639,18 +56247,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55639
56247
  rms: input.rms,
55640
56248
  dbfs: input.dbfs,
55641
56249
  classification: {
55642
- className: ref.className,
56250
+ className: input.className,
55643
56251
  ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
55644
56252
  score: input.score
55645
56253
  }
55646
56254
  };
55647
56255
  this.lastAudioInsertByDevice.set(input.deviceId, {
55648
- className: ref.className,
56256
+ className: input.className,
55649
56257
  atMs: input.timestamp
55650
56258
  });
55651
56259
  await this.eventStore.insertAudio(ev);
55652
56260
  this.notificationCenter?.onAudioEventPersisted(ev);
55653
- this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
56261
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
55654
56262
  this.ctx.eventBus.emit({
55655
56263
  id: `pa-${ev.id}`,
55656
56264
  timestamp: new Date(ev.timestamp),
@@ -55667,6 +56275,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55667
56275
  timestamp: ev.timestamp
55668
56276
  }
55669
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
+ });
55670
56297
  if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
55671
56298
  const materializer = this.syntheticTracks;
55672
56299
  if (materializer === null) return;
@@ -55778,19 +56405,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55778
56405
  });
55779
56406
  return;
55780
56407
  }
55781
- await this.driveAudioEpisode({
56408
+ const classified = {
55782
56409
  deviceId,
55783
56410
  timestamp,
55784
56411
  className: route.className,
55785
56412
  score: topClassification.score,
55786
- originalClass: topClassification.debug?.originalClass,
56413
+ ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
55787
56414
  rms: level?.rms ?? 0,
55788
- 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,
55789
56423
  knobs: this.audioEpisodeKnobs(settings)
55790
56424
  });
55791
56425
  return;
55792
56426
  }
55793
- await this.driveAudioEpisode({
56427
+ if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
55794
56428
  deviceId,
55795
56429
  timestamp,
55796
56430
  className: null,