@camstack/addon-post-analysis 1.2.148 → 1.2.151

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-1xYuXmDS.js");
5
+ const require_dist = require("../dist-DpQno5NK.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");
@@ -6270,12 +6270,86 @@ var NcDeviceStateCache = class {
6270
6270
  }
6271
6271
  };
6272
6272
  //#endregion
6273
+ //#region src/notification-center/system-event-tap-url.ts
6274
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
6275
+ var ADMIN_ADDONS_PATH = "/system/addons";
6276
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
6277
+ var ADMIN_DEVICES_PATH = "/devices";
6278
+ /**
6279
+ * The query parameter naming a node.
6280
+ *
6281
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
6282
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
6283
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
6284
+ */
6285
+ var NODE_PARAM = "node";
6286
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
6287
+ var TAB_PARAM = "tab";
6288
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
6289
+ var STREAM_BROKER_TAB = "stream-broker";
6290
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
6291
+ * string map with no device-model dependency; the guard pins it. */
6292
+ var CAMERA_DEVICE_TYPE = "camera";
6293
+ /** Kinds whose action is "go and apply an ADDON update". */
6294
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6295
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6296
+ var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6297
+ /**
6298
+ * Kinds whose action is "go and look at why this camera stopped producing".
6299
+ *
6300
+ * The legacy `camera-offline` is here for the same reason it is still in the
6301
+ * enum: outbox and history rows enqueued before the rename carry it, and an
6302
+ * old row is exactly the one whose action has to keep working.
6303
+ */
6304
+ var DEVICE_DOWN_KINDS = new Set([
6305
+ "device-offline",
6306
+ "stream-offline",
6307
+ "camera-offline"
6308
+ ]);
6309
+ /**
6310
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6311
+ *
6312
+ * A stream belongs to a camera and nothing else. The legacy `camera-offline`
6313
+ * is one too — that name is precisely the claim it made, and it is why the
6314
+ * kind was split into `device-offline` + a `deviceTypes` condition.
6315
+ */
6316
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set(["stream-offline", "camera-offline"]);
6317
+ function withParam(path, key, value) {
6318
+ if (value === void 0 || value.length === 0) return path;
6319
+ return `${path}?${key}=${encodeURIComponent(value)}`;
6320
+ }
6321
+ /**
6322
+ * The admin path (with its query) a system event taps through to, or `null`
6323
+ * when the kind has no action.
6324
+ *
6325
+ * Exported for the cross-package guard and for tests that want the path
6326
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
6327
+ */
6328
+ function systemEventTapPath(subject) {
6329
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6330
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6331
+ if (DEVICE_DOWN_KINDS.has(subject.kind)) {
6332
+ const deviceId = subject.deviceId;
6333
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6334
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
6335
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
6336
+ }
6337
+ return null;
6338
+ }
6339
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
6340
+ function systemEventTapUrl(subject, baseUrl) {
6341
+ if (baseUrl === null || baseUrl.length === 0) return null;
6342
+ const path = systemEventTapPath(subject);
6343
+ if (path === null) return null;
6344
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
6345
+ }
6346
+ //#endregion
6273
6347
  //#region src/notification-center/default-tap-url.ts
6274
6348
  /** The viewer's hosted base path under the hub. */
6275
6349
  var VIEWER_BASE_PATH = "/viewer/camstack";
6276
6350
  function defaultTapUrl(input) {
6277
- if (input.systemEvent === true) return null;
6278
6351
  if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6352
+ if (input.systemEvent !== void 0) return systemEventTapUrl(input.systemEvent, input.baseUrl);
6279
6353
  if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6280
6354
  return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6281
6355
  }
@@ -8868,13 +8942,14 @@ function trailAt(trail, timestamp) {
8868
8942
  * cannot be asked (no trail, no polygon) or when nobody qualifies.
8869
8943
  */
8870
8944
  function selectTriggerZoneCandidates(input) {
8871
- const { candidates, trail, polygons } = input;
8945
+ const { candidates, trail, polygons, strict } = input;
8872
8946
  if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8873
8947
  const qualifying = candidates.filter((candidate) => {
8874
8948
  const point = trailAt(trail, candidate.timestamp);
8875
8949
  return point !== null && zoneOverlap(point, polygons) > 0;
8876
8950
  });
8877
- return qualifying.length > 0 ? qualifying : candidates;
8951
+ if (qualifying.length > 0) return qualifying;
8952
+ return strict === true ? [] : candidates;
8878
8953
  }
8879
8954
  //#endregion
8880
8955
  //#region src/notification-center/dispatcher.ts
@@ -8912,6 +8987,24 @@ var NC_STILL_WAIT_MS = 3e3;
8912
8987
  * is the instant; the ladder then runs among the frames that are about it.
8913
8988
  */
8914
8989
  var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
8990
+ /**
8991
+ * Whether this row's subject can still be photographed AGAIN after its trigger.
8992
+ *
8993
+ * The one thing that separates a row worth waiting for from a row whose only
8994
+ * frame already exists. A live visual track keeps writing media for as long as
8995
+ * it is open — its first frame, its periodic snapshots, its last frame all land
8996
+ * after the instant a zone condition fired. Every other record is photographed
8997
+ * once: an audio window, a device event and a package event are served by the
8998
+ * still shelf, which D178 makes take ONE picture for a whole burst, and a
8999
+ * `track-end` row is by definition past the last of them.
9000
+ *
9001
+ * Waiting on the wrong side of this line has cost both ways in production — 3 s
9002
+ * of latency on a scream when nothing more was coming, and an empty doorstep on
9003
+ * 'Persona su Uscio' when something was.
9004
+ */
9005
+ function canStillGainMedia(entry) {
9006
+ return entry.recordKind === "object-event";
9007
+ }
8915
9008
  var NcDispatcher = class {
8916
9009
  deps;
8917
9010
  targetCache = null;
@@ -9391,7 +9484,12 @@ var NcDispatcher = class {
9391
9484
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9392
9485
  timestampMs: subject.timestamp,
9393
9486
  baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9394
- systemEvent: systemEvent !== void 0
9487
+ ...systemEvent !== void 0 ? { systemEvent: {
9488
+ kind: systemEvent.kind,
9489
+ ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
9490
+ ...systemEvent.deviceType !== void 0 ? { deviceType: systemEvent.deviceType } : {},
9491
+ ...systemEvent.nodeId !== void 0 ? { nodeId: systemEvent.nodeId } : {}
9492
+ } } : {}
9395
9493
  }) : null;
9396
9494
  const actions = await this.mintActions(entry, target);
9397
9495
  return {
@@ -9526,7 +9624,7 @@ var NcDispatcher = class {
9526
9624
  const out = [];
9527
9625
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9528
9626
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9529
- const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9627
+ const firstLook = await this.resolveAttachment(entry, stillOverride, canStillGainMedia(entry) ? "trigger-only" : "any");
9530
9628
  const footagePending = this.renderFootage(entry);
9531
9629
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9532
9630
  const footage = await footagePending;
@@ -9770,20 +9868,34 @@ var NcDispatcher = class {
9770
9868
  * throw. And when nothing qualifies, the full list comes back: a real picture
9771
9869
  * beats no picture (the same rule D212 settled for the best shot).
9772
9870
  */
9773
- async narrowToTriggerZone(entry, ordered) {
9871
+ async narrowToTriggerZone(entry, ordered, freshness) {
9774
9872
  const zoneIds = entry.payload.triggerZoneIds;
9775
9873
  const trackId = entry.payload.subject.trackId;
9776
9874
  const getTrail = this.deps.getSubjectTrail;
9777
9875
  const deviceId = entry.payload.subject.deviceId;
9778
- if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9876
+ const strict = freshness === "trigger-only";
9877
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2 && !strict) return ordered;
9779
9878
  try {
9780
9879
  const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9781
9880
  const kept = selectTriggerZoneCandidates({
9782
9881
  candidates: ordered,
9783
9882
  trail,
9784
9883
  polygons,
9785
- triggerAt: entry.payload.subject.timestamp
9884
+ triggerAt: entry.payload.subject.timestamp,
9885
+ strict
9786
9886
  });
9887
+ if (kept.length === 0) {
9888
+ this.deps.logger.debug("no still from the zone that fired the rule — still waiting", {
9889
+ tags: { deviceId },
9890
+ meta: {
9891
+ ruleId: entry.ruleId,
9892
+ trackId,
9893
+ zoneIds: zoneIds.join(","),
9894
+ rejected: ordered.length
9895
+ }
9896
+ });
9897
+ return kept;
9898
+ }
9787
9899
  if (kept.length === ordered.length) return kept;
9788
9900
  this.deps.logger.info("still narrowed to the zone that fired the rule", {
9789
9901
  tags: { deviceId },
@@ -9863,7 +9975,8 @@ var NcDispatcher = class {
9863
9975
  for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9864
9976
  const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9865
9977
  if (inBand.length === 0) continue;
9866
- const shortlist = await this.narrowToTriggerZone(entry, inBand);
9978
+ const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
9979
+ if (shortlist.length === 0) continue;
9867
9980
  for (const file of shortlist) {
9868
9981
  const raw = Buffer.from(file.base64, "base64");
9869
9982
  if (raw.byteLength === 0) continue;
@@ -25509,13 +25622,46 @@ var MEDIA_COLUMNS = [
25509
25622
  type: "TEXT"
25510
25623
  })
25511
25624
  ];
25512
- var MEDIA_INDEXES = [{
25513
- name: "idx_media_owner",
25514
- columns: ["ownerKind", "ownerId"]
25515
- }, {
25516
- name: "idx_media_device_ts",
25517
- columns: ["deviceId", "timestamp"]
25518
- }];
25625
+ var MEDIA_INDEXES = [
25626
+ {
25627
+ name: "idx_media_owner",
25628
+ columns: ["ownerKind", "ownerId"]
25629
+ },
25630
+ {
25631
+ name: "idx_media_device_ts",
25632
+ columns: ["deviceId", "timestamp"]
25633
+ },
25634
+ (
25635
+ /**
25636
+ * The `locationId IS NULL` seal gate — see `media-relocate-engine.ts`.
25637
+ *
25638
+ * NULLs sort FIRST in a SQLite index, so `WHERE locationId IS NULL` is a
25639
+ * contiguous range from the head of the b-tree: its cost is proportional to
25640
+ * the ANSWER, not to the table. That is the property the gate needs. The
25641
+ * question it must answer fastest is "are there none left", and with this
25642
+ * index that is one seek that immediately falls off the end of an empty
25643
+ * range — regardless of how many million stamped rows sit behind it.
25644
+ *
25645
+ * **What it costs.** One extra b-tree maintained by every insert into a
25646
+ * table taking ~92 k rows/day, and roughly 25 bytes of index per row (~30 MB
25647
+ * at 1.25 M rows). At ~1 insert/s sustained that maintenance is noise
25648
+ * against the JPEG write the same call performs. The one-off cost is real
25649
+ * and worth naming: the FIRST declare after this ships builds the index over
25650
+ * every existing row, on a recordings volume that is currently saturated —
25651
+ * expect the analytics addon's boot to stall for tens of seconds to a couple
25652
+ * of minutes, once.
25653
+ *
25654
+ * A PARTIAL index (`WHERE locationId IS NULL`) would be smaller still and
25655
+ * would cost nothing at all once sealed. It is not used because
25656
+ * `CollectionIndexSchema` has no predicate and the schema fingerprint does
25657
+ * not hash one — adding both to serve one query is a bigger change than the
25658
+ * query deserves, and the plain index already makes the gate O(log n).
25659
+ */
25660
+ {
25661
+ name: "idx_media_location",
25662
+ columns: ["locationId"]
25663
+ })
25664
+ ];
25519
25665
  /** The storage location of a media row/record: its stamped `locationId`, or
25520
25666
  * the default `eventMedia` location for NULL (pre-multi-location) rows. */
25521
25667
  var DEFAULT_MEDIA_LOCATION = "eventMedia";
@@ -25812,6 +25958,112 @@ var MediaStore = class {
25812
25958
  }
25813
25959
  }
25814
25960
  /**
25961
+ * Delete one row and its blob, and CONFIRM the blob is gone before the row
25962
+ * is forgotten (D148, read for this store).
25963
+ *
25964
+ * Every other delete on this class is best-effort: `deleteByKeyUnchecked`
25965
+ * swallows the unlink error and drops the row anyway, and the underlying
25966
+ * `fs.rm(…, { force: true })` cannot distinguish "removed it" from "there
25967
+ * was nothing there". That is tolerable for a cascade whose whole subtree is
25968
+ * going, and it is NOT tolerable for a bulk reclaim: a row deleted while its
25969
+ * blob survives is 95 KiB that nothing will ever name again — the orphan
25970
+ * class this codebase has spent the week sweeping.
25971
+ *
25972
+ * So the order is: unlink, re-probe, and only then delete the row. A probe
25973
+ * that cannot answer (unreachable volume) THROWS, exactly like
25974
+ * {@link referencedBlobState} — the caller must refuse, not assume absence.
25975
+ *
25976
+ * A row with no `path` is a row whose blob was never written; there is
25977
+ * nothing to confirm and the row is removable.
25978
+ */
25979
+ async deleteConfirmed(key) {
25980
+ return this.gated(async () => {
25981
+ const row = await this.store.get.query({
25982
+ collection: MEDIA_COLLECTION,
25983
+ key
25984
+ });
25985
+ if (!row) return {
25986
+ rows: 0,
25987
+ bytes: 0
25988
+ };
25989
+ const data = row;
25990
+ const relativePath = String(data["path"] ?? "");
25991
+ const bytes = Number(data["sizeBytes"] ?? 0);
25992
+ if (relativePath.length > 0) {
25993
+ const location = mediaRowLocation(data);
25994
+ await this.storage.delete({
25995
+ location,
25996
+ relativePath
25997
+ });
25998
+ if (this.storage.exists) {
25999
+ if (await this.storage.exists({
26000
+ location,
26001
+ relativePath
26002
+ })) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
26003
+ } else {
26004
+ let readable = false;
26005
+ try {
26006
+ await this.storage.read({
26007
+ location,
26008
+ relativePath
26009
+ });
26010
+ readable = true;
26011
+ } catch {}
26012
+ if (readable) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
26013
+ }
26014
+ }
26015
+ await this.store.delete.mutate({
26016
+ collection: MEDIA_COLLECTION,
26017
+ key
26018
+ });
26019
+ return {
26020
+ rows: 1,
26021
+ bytes: Number.isFinite(bytes) ? bytes : 0
26022
+ };
26023
+ });
26024
+ }
26025
+ /**
26026
+ * A page of media rows of ONE kind for ONE device, timestamp-ascending from
26027
+ * `afterMs`. The reclaim's cursor.
26028
+ *
26029
+ * `(deviceId, timestamp)` is `idx_media_device_ts`, so this is an index
26030
+ * range scan — unlike a fleet-wide `kind = 'snapshot'` predicate, which has
26031
+ * no index at all (`MEDIA_INDEXES`) and would table-scan 1.26 M rows on
26032
+ * hub-main's single synchronous SQLite thread.
26033
+ */
26034
+ async pageByKindForDevice(input) {
26035
+ return (await this.store.query.query({
26036
+ collection: MEDIA_COLLECTION,
26037
+ columns: [
26038
+ "id",
26039
+ "ownerId",
26040
+ "timestamp",
26041
+ "sizeBytes"
26042
+ ],
26043
+ filter: {
26044
+ where: {
26045
+ deviceId: input.deviceId,
26046
+ ownerKind: input.ownerKind,
26047
+ kind: input.kind
26048
+ },
26049
+ whereBetween: { timestamp: [input.afterMs, Number.MAX_SAFE_INTEGER] },
26050
+ orderBy: {
26051
+ field: "timestamp",
26052
+ direction: "asc"
26053
+ },
26054
+ limit: input.limit
26055
+ }
26056
+ })).map((row) => {
26057
+ const data = row.data;
26058
+ return {
26059
+ key: row.id,
26060
+ ownerId: String(data["ownerId"] ?? ""),
26061
+ timestamp: Number(data["timestamp"] ?? 0),
26062
+ sizeBytes: Number(data["sizeBytes"] ?? 0)
26063
+ };
26064
+ });
26065
+ }
26066
+ /**
25815
26067
  * `deviceId` is the AUTHORIZATION filter: every media row is stamped with
25816
26068
  * its device at write time, so scoping the query by it makes a media read
25817
26069
  * for a device the caller cannot name return nothing — the DB itself
@@ -28255,6 +28507,212 @@ function updateTrackAggregate(prev, match, opts) {
28255
28507
  };
28256
28508
  }
28257
28509
  //#endregion
28510
+ //#region src/pipeline-analytics/store/embedding-vector-index.ts
28511
+ /**
28512
+ * The vector indexes this addon owns, and the one client that talks to them.
28513
+ *
28514
+ * ## The three indexes, and what a row in each one IS
28515
+ *
28516
+ * | index | dim | encoder | one row is | ranked against |
28517
+ * | --- | --- | --- | --- | --- |
28518
+ * | `object-clip` | 512 | MobileCLIP image tower (`mobileclip-*`) | a TRACK's best-confidence object crop | a CLIP **text** vector — semantic search over footage |
28519
+ * | `face-arcface` | 512 | ArcFace (`arcface-r100` and successors) | the best face read of a TRACK, aligned to 112×112 | the enrolled gallery, to suggest or auto-confirm an identity |
28520
+ * | `identity-arcface` | 512 | the SAME ArcFace model | one ENROLLED face of a person | every live face detection, at recognition time |
28521
+ *
28522
+ * `plates`, `vehicles`, `vehicle-samples` and `identities` hold no vector of
28523
+ * any kind — a plate is a STRING and a score, a vehicle and an identity are a
28524
+ * name. They are named here because `vector-store.cap.ts` once claimed six
28525
+ * stores were waiting to migrate, and five of them being absent from this table
28526
+ * is the answer to "where are the other ones".
28527
+ *
28528
+ * ## Why `face-arcface` and `identity-arcface` are two indexes
28529
+ *
28530
+ * They hold the same 512 dims from the same model, and they are still separate.
28531
+ * An index is a set of rows that get ranked against each other and that live and
28532
+ * die together, and these are neither:
28533
+ *
28534
+ * - a `face-arcface` row is TRACK-OWNED. It cascades away with its track and is
28535
+ * capped per camera; it is a CANDIDATE.
28536
+ * - an `identity-arcface` row is retention-EXEMPT forever. It is the ANSWER
28537
+ * every candidate is measured against.
28538
+ *
28539
+ * Merged, every gallery load and every reconcile would carry a discriminating
28540
+ * filter, and the two ways that filter can fail are "a face is ranked against
28541
+ * itself" and "an enrolled person's only sample is reclaimed as a track orphan".
28542
+ * Sharing a dimension is not a reason to share an index.
28543
+ */
28544
+ /** The buffered per-track ArcFace face vector (`pipeline-analytics:faces`). */
28545
+ var FACE_ARCFACE_INDEX = "face-arcface";
28546
+ /** The ENROLLED gallery (`pipeline-analytics:identity-samples`). */
28547
+ var IDENTITY_ARCFACE_INDEX = "identity-arcface";
28548
+ /**
28549
+ * The `className` each index stamps on its rows.
28550
+ *
28551
+ * A constant per index, exactly as `object-clip` stamps `embedding`. It is not
28552
+ * a subject class and must never be filtered on as one: it says which PRODUCER
28553
+ * wrote the row, which is what keeps a face vector out of a CLIP ranking.
28554
+ */
28555
+ var FACE_VECTOR_CLASS = "face";
28556
+ var IDENTITY_VECTOR_CLASS = "identity";
28557
+ /**
28558
+ * One index, plus the lazy declaration every caller would otherwise re-derive.
28559
+ *
28560
+ * The dimension is not known at construction — it comes from whatever encoder
28561
+ * is active — so the index is declared on the first vector, exactly as
28562
+ * `ObjectEmbeddingStore` does it, and a failed declaration DISABLES writes for
28563
+ * that dim rather than letting an upsert fail against an index the provider has
28564
+ * never seen. A failed write clears the cache so the next one re-declares:
28565
+ * a provider swap under a running hub once left that cache warm and every write
28566
+ * throwing for hours.
28567
+ */
28568
+ var EmbeddingVectorIndex = class {
28569
+ index;
28570
+ client;
28571
+ logger;
28572
+ declaredDim = null;
28573
+ constructor(deps) {
28574
+ this.index = deps.index;
28575
+ this.client = deps.client;
28576
+ this.logger = deps.logger;
28577
+ }
28578
+ /** The index name, for a log line that has to name which store dropped work. */
28579
+ get name() {
28580
+ return this.index;
28581
+ }
28582
+ async ensureIndex(dim) {
28583
+ if (this.declaredDim === dim) return true;
28584
+ try {
28585
+ await this.client.declareIndex.mutate({
28586
+ index: this.index,
28587
+ dim,
28588
+ metric: "cosine"
28589
+ });
28590
+ this.declaredDim = dim;
28591
+ return true;
28592
+ } catch (err) {
28593
+ this.logger.warn("vector index declaration failed — writes disabled for this dim", { meta: {
28594
+ index: this.index,
28595
+ dim,
28596
+ error: String(err)
28597
+ } });
28598
+ return false;
28599
+ }
28600
+ }
28601
+ /**
28602
+ * Write one vector. Returns whether it LANDED, never whether the call
28603
+ * returned — the caller uses this to decide it may stop keeping its own copy,
28604
+ * and "the mutate resolved" is not the same statement (D58).
28605
+ */
28606
+ async put(input) {
28607
+ if (input.embedding.length === 0) {
28608
+ this.logger.warn("refused to index an empty vector", { meta: {
28609
+ index: this.index,
28610
+ id: input.id
28611
+ } });
28612
+ return false;
28613
+ }
28614
+ if (!await this.ensureIndex(input.embedding.length)) return false;
28615
+ const metadata = {
28616
+ ...input.extra,
28617
+ className: input.className,
28618
+ modelId: input.modelId,
28619
+ timestamp: input.timestamp,
28620
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
28621
+ };
28622
+ try {
28623
+ const result = await this.client.upsert.mutate({
28624
+ index: this.index,
28625
+ items: [{
28626
+ id: input.id,
28627
+ vector: require_dist.encodeVectorBase64(input.embedding),
28628
+ metadata
28629
+ }]
28630
+ });
28631
+ if (result.upserted === 0) {
28632
+ this.logger.warn("vector upsert rejected", {
28633
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
28634
+ meta: {
28635
+ index: this.index,
28636
+ id: input.id,
28637
+ rejected: result.rejected
28638
+ }
28639
+ });
28640
+ return false;
28641
+ }
28642
+ return true;
28643
+ } catch (err) {
28644
+ this.declaredDim = null;
28645
+ this.logger.warn("vector upsert failed", {
28646
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
28647
+ meta: {
28648
+ index: this.index,
28649
+ id: input.id,
28650
+ error: String(err)
28651
+ }
28652
+ });
28653
+ return false;
28654
+ }
28655
+ }
28656
+ /**
28657
+ * Read named vectors back as float arrays.
28658
+ *
28659
+ * An id with no row is simply ABSENT from the map — never a zero vector. A
28660
+ * zero vector scores 0 against everything and would quietly un-enrol a person
28661
+ * instead of failing, which is the reason `fetchByIds` drops an unreadable
28662
+ * row rather than padding it.
28663
+ *
28664
+ * On a read failure the map is EMPTY and the failure is logged. An empty
28665
+ * gallery is a visible outage; a partial one silently forgets people.
28666
+ */
28667
+ async load(ids) {
28668
+ const out = /* @__PURE__ */ new Map();
28669
+ if (ids.length === 0) return out;
28670
+ try {
28671
+ const result = await this.client.fetchByIds.query({
28672
+ index: this.index,
28673
+ ids: [...ids]
28674
+ });
28675
+ for (const item of result.items) {
28676
+ const buf = Buffer.from(item.vector, "base64");
28677
+ if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) continue;
28678
+ const view = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
28679
+ out.set(item.id, Array.from(view));
28680
+ }
28681
+ } catch (err) {
28682
+ this.logger.warn("vector fetch failed — no vectors returned for this call", { meta: {
28683
+ index: this.index,
28684
+ ids: ids.length,
28685
+ error: String(err)
28686
+ } });
28687
+ return /* @__PURE__ */ new Map();
28688
+ }
28689
+ return out;
28690
+ }
28691
+ /**
28692
+ * Drop named vectors.
28693
+ *
28694
+ * Best-effort and LOUD: these rows have exactly one owner and it is being
28695
+ * deleted, so a delete that quietly did nothing leaves a vector nothing can
28696
+ * ever reach by ownership again.
28697
+ */
28698
+ async drop(ids) {
28699
+ if (ids.length === 0) return;
28700
+ try {
28701
+ await this.client.deleteByIds.mutate({
28702
+ index: this.index,
28703
+ ids: [...ids]
28704
+ });
28705
+ } catch (err) {
28706
+ this.declaredDim = null;
28707
+ this.logger.warn("vector delete failed — rows left behind", { meta: {
28708
+ index: this.index,
28709
+ ids: ids.length,
28710
+ error: String(err)
28711
+ } });
28712
+ }
28713
+ }
28714
+ };
28715
+ //#endregion
28258
28716
  //#region src/pipeline-analytics/store/face-sort.ts
28259
28717
  var DEFAULT_FACE_SORT = {
28260
28718
  by: "timestamp",
@@ -28320,6 +28778,15 @@ function rankAndCap(faces, order, limit) {
28320
28778
  //#endregion
28321
28779
  //#region src/pipeline-analytics/store/face-store.ts
28322
28780
  /**
28781
+ * The model a row written before 2026-08-20 was produced by.
28782
+ *
28783
+ * Those rows predate `embeddingModelId`, and they were all this. The vector
28784
+ * index stamps a `modelId` on every row it holds, so a converted legacy row has
28785
+ * to be stamped with something true rather than left blank — a blank modelId in
28786
+ * the gallery is a vector the matcher cannot decide is comparable.
28787
+ */
28788
+ var LEGACY_FACE_MODEL_ID = "arcface-r100";
28789
+ /**
28323
28790
  * @durable class=ledger owner=pipeline-analytics
28324
28791
  * write="one UPSERT per track that produced a face detail — the id is
28325
28792
  * 'face-<trackId>', so a track has at most one row and the recognizer
@@ -28401,11 +28868,23 @@ var FACE_COLUMNS = [
28401
28868
  type: "INTEGER",
28402
28869
  notNull: true
28403
28870
  },
28871
+ (
28872
+ /**
28873
+ * LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
28874
+ * `face-arcface` vector index now, keyed by this row's id.
28875
+ *
28876
+ * The column stays declared and stays `notNull` because rows written before
28877
+ * the conversion still carry their array and the conversion has to be able to
28878
+ * read them. A CONVERTED row holds `[]` — two bytes, and the marker that says
28879
+ * "the vector is in the index", which is what makes the conversion idempotent
28880
+ * and observable. Nothing reads this column except the conversion and the
28881
+ * fallback in {@link FaceStore.getWithEmbedding}.
28882
+ */
28404
28883
  {
28405
28884
  name: "embedding",
28406
28885
  type: "JSON",
28407
28886
  notNull: true
28408
- },
28887
+ }),
28409
28888
  {
28410
28889
  name: "embeddingModelId",
28411
28890
  type: "TEXT"
@@ -28489,8 +28968,9 @@ function buildTimestampWindow(input) {
28489
28968
  */
28490
28969
  function rowToFace(id, raw) {
28491
28970
  const data = raw;
28971
+ const { embedding: _legacyEmbedding, ...rest } = data;
28492
28972
  return {
28493
- ...data,
28973
+ ...rest,
28494
28974
  id,
28495
28975
  assigned: Boolean(data.assigned),
28496
28976
  mediaKey: data.mediaKey ?? void 0,
@@ -28506,9 +28986,11 @@ function rowToFace(id, raw) {
28506
28986
  }
28507
28987
  var FaceStore = class {
28508
28988
  store;
28989
+ vectors;
28509
28990
  logger;
28510
28991
  constructor(deps) {
28511
28992
  this.store = deps.store;
28993
+ this.vectors = deps.vectors;
28512
28994
  this.logger = deps.logger;
28513
28995
  }
28514
28996
  static async declare(store) {
@@ -28523,12 +29005,31 @@ var FaceStore = class {
28523
29005
  * id per track → one entry per track). `set` replaces an existing row.
28524
29006
  */
28525
29007
  async insert(face) {
28526
- const { id, ...rest } = face;
29008
+ const { id, embedding, ...rest } = face;
29009
+ if (!await this.vectors.put({
29010
+ id,
29011
+ embedding,
29012
+ className: "face",
29013
+ modelId: face.embeddingModelId ?? "arcface-r100",
29014
+ timestamp: face.timestamp,
29015
+ deviceId: face.deviceId,
29016
+ extra: { trackId: face.trackId }
29017
+ })) this.logger.warn("face row written WITHOUT a searchable vector", {
29018
+ tags: { deviceId: face.deviceId },
29019
+ meta: {
29020
+ faceId: id,
29021
+ index: this.vectors.name,
29022
+ trackId: face.trackId
29023
+ }
29024
+ });
28527
29025
  try {
28528
29026
  await this.store.set.mutate({
28529
29027
  collection: FACES_COLLECTION,
28530
29028
  key: id,
28531
- value: rest
29029
+ value: {
29030
+ ...rest,
29031
+ embedding: []
29032
+ }
28532
29033
  });
28533
29034
  } catch (err) {
28534
29035
  this.logger.warn("FaceStore.insert failed", {
@@ -28541,6 +29042,111 @@ var FaceStore = class {
28541
29042
  }
28542
29043
  }
28543
29044
  /**
29045
+ * How an UNCONVERTED `faces` row becomes an index row.
29046
+ *
29047
+ * The only judgement here is the modelId: a row written before 2026-08-20
29048
+ * carries no `embeddingModelId`, and those rows were all `arcface-r100`, so
29049
+ * that is what they are stamped with — a KNOWN historical fact, not a
29050
+ * fallback guess. A row that names some other model keeps its own.
29051
+ */
29052
+ conversionSpec() {
29053
+ return {
29054
+ collection: FACES_COLLECTION,
29055
+ vectors: this.vectors,
29056
+ write: (id, embedding, data) => {
29057
+ const deviceId = data["deviceId"];
29058
+ const timestamp = data["timestamp"];
29059
+ const trackId = data["trackId"];
29060
+ const modelId = data["embeddingModelId"];
29061
+ return {
29062
+ id,
29063
+ embedding,
29064
+ className: FACE_VECTOR_CLASS,
29065
+ modelId: typeof modelId === "string" && modelId.length > 0 ? modelId : LEGACY_FACE_MODEL_ID,
29066
+ timestamp: typeof timestamp === "number" ? timestamp : 0,
29067
+ ...typeof deviceId === "number" ? { deviceId } : {},
29068
+ ...typeof trackId === "string" ? { extra: { trackId } } : {}
29069
+ };
29070
+ }
29071
+ };
29072
+ }
29073
+ /**
29074
+ * One face WITH its ArcFace vector — the enrolment and re-assignment read.
29075
+ *
29076
+ * Falls back to the row's legacy JSON column when the index has no vector for
29077
+ * this id, which is exactly the window in which a row written before the
29078
+ * conversion has not been converted yet. The fallback is LOGGED: it is
29079
+ * correct, it is temporary, and a fallback nobody can count is a fallback that
29080
+ * becomes permanent.
29081
+ */
29082
+ async getWithEmbedding(faceId) {
29083
+ const raw = await this.store.get.query({
29084
+ collection: FACES_COLLECTION,
29085
+ key: faceId
29086
+ });
29087
+ if (!raw) return null;
29088
+ const face = rowToFace(faceId, raw);
29089
+ const fromIndex = (await this.vectors.load([faceId])).get(faceId);
29090
+ if (fromIndex !== void 0) return {
29091
+ ...face,
29092
+ embedding: fromIndex
29093
+ };
29094
+ const legacy = raw.embedding;
29095
+ if (Array.isArray(legacy) && legacy.length > 0 && legacy.every((n) => typeof n === "number")) {
29096
+ this.logger.info("face vector served from the legacy JSON column", {
29097
+ tags: { deviceId: face.deviceId },
29098
+ meta: {
29099
+ faceId,
29100
+ why: "this row has not been converted into the vector index yet"
29101
+ }
29102
+ });
29103
+ return {
29104
+ ...face,
29105
+ embedding: legacy
29106
+ };
29107
+ }
29108
+ this.logger.warn("face row has no vector in either home", {
29109
+ tags: { deviceId: face.deviceId },
29110
+ meta: {
29111
+ faceId,
29112
+ index: this.vectors.name
29113
+ }
29114
+ });
29115
+ return null;
29116
+ }
29117
+ /**
29118
+ * The ArcFace vectors of named rows, for the CLUSTERING pass.
29119
+ *
29120
+ * Ids with no vector are ABSENT from the map, never present with an empty
29121
+ * array — the clustering loop skips a zero-length vector, so an empty array
29122
+ * would turn "the index did not answer" into "these faces are not clusterable"
29123
+ * and produce a confident zero.
29124
+ */
29125
+ async loadEmbeddings(faceIds) {
29126
+ const indexed = await this.vectors.load(faceIds);
29127
+ const missing = faceIds.filter((id) => !indexed.has(id));
29128
+ if (missing.length === 0) return indexed;
29129
+ const out = new Map(indexed);
29130
+ let fromLegacyColumn = 0;
29131
+ for (const id of missing) {
29132
+ const legacy = (await this.store.get.query({
29133
+ collection: FACES_COLLECTION,
29134
+ key: id
29135
+ }))?.embedding;
29136
+ if (!Array.isArray(legacy) || legacy.length === 0 || !legacy.every((n) => typeof n === "number")) continue;
29137
+ out.set(id, legacy);
29138
+ fromLegacyColumn += 1;
29139
+ }
29140
+ this.logger.info("face vectors served partly from the legacy JSON column", { meta: {
29141
+ index: this.vectors.name,
29142
+ asked: faceIds.length,
29143
+ fromIndex: indexed.size,
29144
+ fromLegacyColumn,
29145
+ unavailable: missing.length - fromLegacyColumn
29146
+ } });
29147
+ return out;
29148
+ }
29149
+ /**
28544
29150
  * Recent UNASSIGNED faces (the enrollment buffer), newest first.
28545
29151
  *
28546
29152
  * NOTE: `assigned` is normalized via `Boolean()` on read so the production
@@ -28660,6 +29266,7 @@ var FaceStore = class {
28660
29266
  error: String(err)
28661
29267
  } });
28662
29268
  }
29269
+ await this.vectors.drop([...deleted]);
28663
29270
  this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
28664
29271
  return [...deleted];
28665
29272
  }
@@ -28690,6 +29297,45 @@ var FaceStore = class {
28690
29297
  });
28691
29298
  }
28692
29299
  }
29300
+ /**
29301
+ * Replace a buffered face's vector AND its model stamp, then confirm BOTH.
29302
+ *
29303
+ * The vector and the stamp live in two homes — the index and the row — and
29304
+ * they must move together or not at all: a row stamped with the cluster model
29305
+ * beside a vector from the previous one is a face the matcher believes is
29306
+ * comparable and is not, which is worse than a face it refuses to compare.
29307
+ *
29308
+ * So: vector first (refuse if it does not land), row second, then a read-back
29309
+ * of the stamp. Returns whether the whole move landed — never whether the
29310
+ * calls returned (D58).
29311
+ */
29312
+ async restampEmbedding(input) {
29313
+ if (!await this.vectors.put({
29314
+ id: input.faceId,
29315
+ embedding: input.embedding,
29316
+ className: "face",
29317
+ modelId: input.modelId,
29318
+ timestamp: input.timestamp,
29319
+ deviceId: input.deviceId,
29320
+ extra: { trackId: input.trackId }
29321
+ })) {
29322
+ this.logger.warn("face re-embed refused: the vector did not reach the index", {
29323
+ tags: { deviceId: input.deviceId },
29324
+ meta: {
29325
+ faceId: input.faceId,
29326
+ index: this.vectors.name
29327
+ }
29328
+ });
29329
+ return false;
29330
+ }
29331
+ await this.update(input.faceId, { embeddingModelId: input.modelId });
29332
+ await this.store.update.mutate({
29333
+ collection: FACES_COLLECTION,
29334
+ id: input.faceId,
29335
+ data: { embedding: [] }
29336
+ });
29337
+ return (await this.get(input.faceId))?.embeddingModelId === input.modelId;
29338
+ }
28693
29339
  /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
28694
29340
  async markAssigned(faceId, identityId, sampleId) {
28695
29341
  const data = {
@@ -28775,11 +29421,13 @@ var FaceStore = class {
28775
29421
  collection: FACES_COLLECTION,
28776
29422
  filter: { where: { trackId } }
28777
29423
  })).filter((r) => !r.data.assigned);
29424
+ const dropped = [];
28778
29425
  for (const row of eligible) try {
28779
29426
  await this.store.delete.mutate({
28780
29427
  collection: FACES_COLLECTION,
28781
29428
  key: row.id
28782
29429
  });
29430
+ dropped.push(row.id);
28783
29431
  removed++;
28784
29432
  } catch (err) {
28785
29433
  this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
@@ -28787,6 +29435,7 @@ var FaceStore = class {
28787
29435
  error: String(err)
28788
29436
  } });
28789
29437
  }
29438
+ await this.vectors.drop(dropped);
28790
29439
  } catch (err) {
28791
29440
  this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
28792
29441
  trackId,
@@ -28812,7 +29461,9 @@ var FaceStore = class {
28812
29461
  faceId,
28813
29462
  error: String(err)
28814
29463
  } });
29464
+ return;
28815
29465
  }
29466
+ await this.vectors.drop([faceId]);
28816
29467
  }
28817
29468
  /**
28818
29469
  * Retrieve a single face by id. Returns null if not found.
@@ -29231,7 +29882,7 @@ var FaceGalleryProvider = class {
29231
29882
  await this.faceStore.clearAssignment(faceId);
29232
29883
  await this.faceStore.update(faceId, { mediaKey: null });
29233
29884
  }
29234
- const currentFace = await this.faceStore.get(faceId);
29885
+ const currentFace = await this.faceStore.getWithEmbedding(faceId);
29235
29886
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
29236
29887
  const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
29237
29888
  const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
@@ -29416,10 +30067,21 @@ var FaceGalleryProvider = class {
29416
30067
  threshold,
29417
30068
  minClusterSize
29418
30069
  } });
29419
- const clusters = clusterByEmbedding(faces.filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
29420
- id: f.id,
29421
- embedding: f.embedding
29422
- })), threshold).filter((c) => c.size >= minClusterSize);
30070
+ const vectors = await this.faceStore.loadEmbeddings(faces.map((f) => f.id));
30071
+ const items = [];
30072
+ for (const face of faces) {
30073
+ const embedding = vectors.get(face.id);
30074
+ if (embedding === void 0 || embedding.length === 0) continue;
30075
+ items.push({
30076
+ id: face.id,
30077
+ embedding
30078
+ });
30079
+ }
30080
+ if (items.length < faces.length) this.logger.warn("suggestFaceClusters skipped faces with no vector in the index", { meta: {
30081
+ scanned: faces.length,
30082
+ clusterable: items.length
30083
+ } });
30084
+ const clusters = clusterByEmbedding(items, threshold).filter((c) => c.size >= minClusterSize);
29423
30085
  return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
29424
30086
  }
29425
30087
  };
@@ -32181,7 +32843,19 @@ async function runPreviewUpgrade(deps, input) {
32181
32843
  });
32182
32844
  return "no-bbox";
32183
32845
  }
32846
+ if (pending.bestSeen.timestamp === void 0) {
32847
+ deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
32848
+ tags: { deviceId },
32849
+ meta: {
32850
+ trackId,
32851
+ keyFrameKey: input.keyFrameKey,
32852
+ heldProvenance: held
32853
+ }
32854
+ });
32855
+ return "no-bbox";
32856
+ }
32184
32857
  const derived = await deps.derive({
32858
+ deviceId,
32185
32859
  mediaKey: input.keyFrameKey,
32186
32860
  bbox: {
32187
32861
  x: pending.bestSeen.x,
@@ -32191,7 +32865,7 @@ async function runPreviewUpgrade(deps, input) {
32191
32865
  },
32192
32866
  frameWidth: pending.bestSeen.frameWidth,
32193
32867
  frameHeight: pending.bestSeen.frameHeight,
32194
- timestamp: input.timestamp
32868
+ timestamp: pending.bestSeen.timestamp
32195
32869
  });
32196
32870
  if (derived === null) {
32197
32871
  deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
@@ -32555,6 +33229,28 @@ async function cascadeDeleteTracks(registry, trackStore, trackIds) {
32555
33229
  * `onInitialize`, not because their families are optional — a missing one means
32556
33230
  * the addon is not fully up, and the sweep does not run in that state.
32557
33231
  */
33232
+ /**
33233
+ * The optional legs that are NOT in the registry this call would build.
33234
+ *
33235
+ * `buildAnalyticsCascadeRegistry` pushes each optional store only `if` it is
33236
+ * there, and that `if` is silent. A hub whose `vector-store` capability was
33237
+ * unavailable at boot builds an embedding-less cascade and then prunes tracks
33238
+ * with it — every pass, for the whole life of the process — while the only
33239
+ * evidence is one warn at startup that scrolled away hours earlier. Every CLIP
33240
+ * vector for every track deleted in that window becomes an orphan that nothing
33241
+ * can reach by ownership again, because its owner is gone.
33242
+ *
33243
+ * So the omission is named and returned, and the caller LOGS it. A branch that
33244
+ * drops work says so; silence reads as "there was nothing to drop".
33245
+ */
33246
+ function absentCascadeLegs(stores) {
33247
+ const absent = [];
33248
+ if (!stores.faceStore) absent.push("faces");
33249
+ if (!stores.plateStore) absent.push("plates");
33250
+ if (!stores.objectEmbeddingStore) absent.push("embeddings");
33251
+ if (!stores.groupStore) absent.push("groups");
33252
+ return absent;
33253
+ }
32558
33254
  function buildAnalyticsCascadeRegistry(stores) {
32559
33255
  const registry = [stores.eventStore, stores.mediaStore];
32560
33256
  if (stores.faceStore) registry.push(stores.faceStore);
@@ -32831,7 +33527,8 @@ var TrackCloser = class {
32831
33527
  w: bestSeen.w,
32832
33528
  h: bestSeen.h,
32833
33529
  frameWidth: bestSeen.frameWidth,
32834
- frameHeight: bestSeen.frameHeight
33530
+ frameHeight: bestSeen.frameHeight,
33531
+ timestamp: bestSeen.timestamp
32835
33532
  } } : {},
32836
33533
  recordedAtMs: this.now()
32837
33534
  });
@@ -32847,7 +33544,7 @@ var TrackCloser = class {
32847
33544
  });
32848
33545
  const keyFrameMediaKey = this.deps.residents.keyFrameKey(t.trackId);
32849
33546
  const labelConfidence = this.bestLabelMatchConfidence(t.deviceId, t.trackId);
32850
- this.fireRecognizerEnds(t.deviceId, t.trackId);
33547
+ this.fireRecognizerEnds(t.deviceId, t.trackId, t.positions);
32851
33548
  const trackerPeak = this.deps.residents.bestFramePeak(t.trackId);
32852
33549
  const importanceSummary = await this.scoreImportance(t, duration, trackerPeak?.confidence);
32853
33550
  this.deps.residents.close(t.trackId, "end");
@@ -32906,14 +33603,14 @@ var TrackCloser = class {
32906
33603
  /** Start face/plate `onTrackEnd` and wire the Design-B DEFERRED
32907
33604
  * keyFrame-key drop (drop only once the face promise settles — or
32908
33605
  * immediately when no face recognizer is active). */
32909
- fireRecognizerEnds(deviceId, trackId) {
33606
+ fireRecognizerEnds(deviceId, trackId, subjectPath) {
32910
33607
  const faceEnd = this.deps.faceRecognizer()?.onTrackEnd(deviceId, trackId);
32911
33608
  const dropKeyFrameKey = () => {
32912
33609
  this.deps.residents.dropKeyFrameKey(trackId);
32913
33610
  };
32914
33611
  if (faceEnd) faceEnd.finally(dropKeyFrameKey);
32915
33612
  else dropKeyFrameKey();
32916
- this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId);
33613
+ this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId, subjectPath);
32917
33614
  }
32918
33615
  /** Last-resort preview: commit the retained detection-raster subject crop
32919
33616
  * as the track's `thumbnail` (honest sub-native, never upscaled). */
@@ -32983,6 +33680,7 @@ var TrackCloser = class {
32983
33680
  }
32984
33681
  try {
32985
33682
  const derived = await derive({
33683
+ deviceId: t.deviceId,
32986
33684
  mediaKey: keyFrame.key,
32987
33685
  bbox: {
32988
33686
  x: bestSeen.x,
@@ -32992,7 +33690,8 @@ var TrackCloser = class {
32992
33690
  },
32993
33691
  frameWidth: bestSeen.frameWidth,
32994
33692
  frameHeight: bestSeen.frameHeight,
32995
- timestamp: keyFrame.timestamp
33693
+ timestamp: bestSeen.timestamp,
33694
+ subjectPath: t.positions
32996
33695
  });
32997
33696
  if (!derived) {
32998
33697
  if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
@@ -33033,6 +33732,8 @@ var TrackCloser = class {
33033
33732
  trackId: t.trackId,
33034
33733
  keyFrameKey: keyFrame.key,
33035
33734
  skewMs: derived.skewMs,
33735
+ motion: derived.motion ?? null,
33736
+ subjectIou: derived.subjectIou ?? null,
33036
33737
  replaced: held?.provenance ?? null
33037
33738
  }
33038
33739
  });
@@ -34336,6 +35037,304 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
34336
35037
  };
34337
35038
  }
34338
35039
  //#endregion
35040
+ //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35041
+ /**
35042
+ * The operator button for the debug-media reclaim.
35043
+ *
35044
+ * The mechanism ships armed with nothing: **arming it is the operator's
35045
+ * call**. `mode` omitted means `report` — it counts, logs one line per camera
35046
+ * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35047
+ * that has to be typed.
35048
+ *
35049
+ * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35050
+ * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35051
+ * report you can read instead of a log line you have to find.
35052
+ */
35053
+ /**
35054
+ * `report` counts; `reclaim` deletes.
35055
+ *
35056
+ * `.default('report')` is load-bearing: the settings form sends `{}` for a
35057
+ * button with no fields, and the safe reading of an unstated intent is the one
35058
+ * that destroys nothing.
35059
+ */
35060
+ var MediaReclaimActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
35061
+ var MediaReclaimScopeSchema = require_dist._enum(["motion-still", "track-filmstrip"]);
35062
+ var MediaReclaimActionInputSchema = require_dist.object({
35063
+ mode: MediaReclaimActionModeSchema,
35064
+ /** Which populations to walk. Omitted = both. */
35065
+ scopes: require_dist.array(MediaReclaimScopeSchema).min(1).optional(),
35066
+ /** Restrict to these cameras. Omitted = every camera in retention scope. */
35067
+ deviceIds: require_dist.array(require_dist.number().int()).min(1).optional(),
35068
+ /** Start every device from the top instead of resuming its cursor. */
35069
+ restart: require_dist.boolean().optional(),
35070
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
35071
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
35072
+ /** Rows EXAMINED per camera in this pass — the pacing bound. */
35073
+ maxRowsPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35074
+ /** Rows DELETED per camera in this pass — the blast-radius bound. */
35075
+ maxReclaimPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35076
+ /** Bytes released across the whole run — the disk-contention bound. */
35077
+ maxBytesPerRun: require_dist.number().int().min(1).optional(),
35078
+ /** Wall-clock ceiling for the run. */
35079
+ budgetMinutes: require_dist.number().int().min(1).max(720).optional(),
35080
+ /**
35081
+ * Bytes per second the pass may release. The recordings volume sustains
35082
+ * ~4 MB/s and the archive needs it more than this does; the default is half
35083
+ * of that.
35084
+ */
35085
+ throttleBytesPerSec: require_dist.number().int().min(64 * 1024).optional(),
35086
+ /**
35087
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
35088
+ * exclusively save rows, never select them.
35089
+ */
35090
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
35091
+ });
35092
+ var MediaReclaimDeviceReportSchema = require_dist.object({
35093
+ deviceId: require_dist.number(),
35094
+ scope: MediaReclaimScopeSchema,
35095
+ examined: require_dist.number(),
35096
+ eligible: require_dist.number(),
35097
+ reclaimed: require_dist.number(),
35098
+ bytesReclaimed: require_dist.number(),
35099
+ /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35100
+ held: require_dist.number(),
35101
+ /** Rows kept because the owner could not be shown to be a motion event. */
35102
+ unresolved: require_dist.number(),
35103
+ withinGrace: require_dist.number(),
35104
+ /** Deletes that REFUSED — the blob survived or the volume could not answer.
35105
+ * The row was kept and the cursor did not pass it. */
35106
+ refused: require_dist.number(),
35107
+ stoppedBy: require_dist.string(),
35108
+ /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35109
+ nextCursor: require_dist.number()
35110
+ });
35111
+ var MediaReclaimActionResultSchema = require_dist.object({
35112
+ /** `false` when a pass was already in flight — nothing was done. */
35113
+ ran: require_dist.boolean(),
35114
+ mode: require_dist._enum(["report", "reclaim"]),
35115
+ devices: require_dist.array(MediaReclaimDeviceReportSchema),
35116
+ totalExamined: require_dist.number(),
35117
+ totalEligible: require_dist.number(),
35118
+ totalReclaimed: require_dist.number(),
35119
+ totalBytesReclaimed: require_dist.number(),
35120
+ totalRefused: require_dist.number(),
35121
+ durationMs: require_dist.number(),
35122
+ /** Every device+scope reported `exhausted` — there is nothing left to do. */
35123
+ complete: require_dist.boolean()
35124
+ });
35125
+ var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
35126
+ //#endregion
35127
+ //#region src/pipeline-analytics/retention/media-reclaim.ts
35128
+ var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
35129
+ var DEFAULT_MEDIA_RECLAIM_LIMITS = {
35130
+ mode: "report",
35131
+ pageSize: 500,
35132
+ maxRowsPerDevice: 5e4,
35133
+ maxReclaimPerDevice: 5e4,
35134
+ maxBytesPerRun: 20 * 1024 * 1024 * 1024,
35135
+ budgetMs: 30 * 6e4,
35136
+ graceMs: 60 * 6e4,
35137
+ throttleBytesPerSec: 2 * 1024 * 1024
35138
+ };
35139
+ function emptyTally() {
35140
+ return {
35141
+ examined: 0,
35142
+ eligible: 0,
35143
+ reclaimed: 0,
35144
+ bytesReclaimed: 0,
35145
+ held: 0,
35146
+ unresolved: 0,
35147
+ withinGrace: 0,
35148
+ refused: 0
35149
+ };
35150
+ }
35151
+ /**
35152
+ * Walk ONE device within ONE scope. Never throws: a probe failure ends the
35153
+ * device with what it has and a cursor that has not moved past anything
35154
+ * unfinished.
35155
+ */
35156
+ async function reclaimDeviceScope(input) {
35157
+ const { deviceId, scope, deps, limits } = input;
35158
+ const tally = emptyTally();
35159
+ const graceFloor = deps.now() - limits.graceMs;
35160
+ let cursor = Math.max(0, input.cursor);
35161
+ let byteBudget = input.byteBudget;
35162
+ let stoppedBy = "exhausted";
35163
+ for (;;) {
35164
+ if (deps.now() >= input.deadlineMs) {
35165
+ stoppedBy = "budget";
35166
+ break;
35167
+ }
35168
+ if (tally.examined >= limits.maxRowsPerDevice) {
35169
+ stoppedBy = "row-ceiling";
35170
+ break;
35171
+ }
35172
+ const rows = await deps.page({
35173
+ deviceId,
35174
+ scope,
35175
+ afterMs: cursor,
35176
+ limit: limits.pageSize
35177
+ });
35178
+ if (rows.length === 0) break;
35179
+ const ownerIds = [...new Set(rows.map((r) => r.ownerId).filter((id) => id.length > 0))];
35180
+ let motionOwners = /* @__PURE__ */ new Set();
35181
+ let pinnedOwners = /* @__PURE__ */ new Set();
35182
+ try {
35183
+ if (scope === "motion-still") motionOwners = ownerIds.length > 0 ? await deps.motionEventIds(ownerIds) : /* @__PURE__ */ new Set();
35184
+ else pinnedOwners = ownerIds.length > 0 ? await deps.pinnedTrackIds(ownerIds) : /* @__PURE__ */ new Set();
35185
+ } catch (err) {
35186
+ deps.logger.warn("media reclaim: ownership probe failed — device abandoned for this run", {
35187
+ tags: { deviceId },
35188
+ meta: {
35189
+ scope,
35190
+ cursor,
35191
+ error: String(err)
35192
+ }
35193
+ });
35194
+ stoppedBy = "probe-failed";
35195
+ break;
35196
+ }
35197
+ let pageBytes = 0;
35198
+ let halted = null;
35199
+ for (const row of rows) {
35200
+ tally.examined += 1;
35201
+ const advanceTo = row.timestamp;
35202
+ if (row.timestamp > graceFloor) {
35203
+ tally.withinGrace += 1;
35204
+ cursor = advanceTo;
35205
+ continue;
35206
+ }
35207
+ if (scope === "motion-still" && !motionOwners.has(row.ownerId)) {
35208
+ tally.unresolved += 1;
35209
+ cursor = advanceTo;
35210
+ continue;
35211
+ }
35212
+ if (scope === "track-filmstrip" && pinnedOwners.has(row.ownerId)) {
35213
+ tally.held += 1;
35214
+ cursor = advanceTo;
35215
+ continue;
35216
+ }
35217
+ tally.eligible += 1;
35218
+ if (limits.mode === "report") {
35219
+ cursor = advanceTo;
35220
+ continue;
35221
+ }
35222
+ if (tally.reclaimed >= limits.maxReclaimPerDevice) {
35223
+ halted = "reclaim-ceiling";
35224
+ break;
35225
+ }
35226
+ if (byteBudget <= 0) {
35227
+ halted = "byte-ceiling";
35228
+ break;
35229
+ }
35230
+ try {
35231
+ const gone = await deps.reclaim(row.key);
35232
+ tally.reclaimed += gone.rows;
35233
+ tally.bytesReclaimed += gone.bytes;
35234
+ byteBudget -= gone.bytes;
35235
+ pageBytes += gone.bytes;
35236
+ cursor = advanceTo;
35237
+ } catch (err) {
35238
+ tally.refused += 1;
35239
+ deps.logger.warn("media reclaim: delete refused — row kept", {
35240
+ tags: { deviceId },
35241
+ meta: {
35242
+ scope,
35243
+ key: row.key,
35244
+ error: String(err)
35245
+ }
35246
+ });
35247
+ halted = "delete-refused";
35248
+ break;
35249
+ }
35250
+ }
35251
+ if (halted !== null) {
35252
+ stoppedBy = halted;
35253
+ break;
35254
+ }
35255
+ if (limits.throttleBytesPerSec > 0 && pageBytes > 0 && deps.pace) await deps.pace(pageBytes / limits.throttleBytesPerSec * 1e3);
35256
+ if (deps.breathe) await deps.breathe();
35257
+ if (rows.length < limits.pageSize) break;
35258
+ }
35259
+ return {
35260
+ deviceId,
35261
+ scope,
35262
+ examined: tally.examined,
35263
+ eligible: tally.eligible,
35264
+ reclaimed: tally.reclaimed,
35265
+ bytesReclaimed: tally.bytesReclaimed,
35266
+ held: tally.held,
35267
+ unresolved: tally.unresolved,
35268
+ withinGrace: tally.withinGrace,
35269
+ refused: tally.refused,
35270
+ stoppedBy,
35271
+ nextCursor: cursor
35272
+ };
35273
+ }
35274
+ /** Cursor key for one `(device, scope)` pair. */
35275
+ function mediaReclaimCursorKey(deviceId, scope) {
35276
+ return `${scope}:${deviceId}`;
35277
+ }
35278
+ /**
35279
+ * Walk every `(device, scope)` pair under one shared byte budget and one
35280
+ * shared deadline, and log ONE summary line per device — never a line per row.
35281
+ */
35282
+ async function runMediaReclaim(input) {
35283
+ const startedAt = input.deps.now();
35284
+ const deadlineMs = startedAt + input.limits.budgetMs;
35285
+ const nextCursors = new Map(input.cursors);
35286
+ const devices = [];
35287
+ let byteBudget = input.limits.maxBytesPerRun;
35288
+ for (const scope of input.scopes) for (const deviceId of input.deviceIds) {
35289
+ if (input.deps.now() >= deadlineMs) break;
35290
+ const key = mediaReclaimCursorKey(deviceId, scope);
35291
+ const report = await reclaimDeviceScope({
35292
+ deviceId,
35293
+ scope,
35294
+ cursor: input.cursors.get(key) ?? 0,
35295
+ deps: input.deps,
35296
+ limits: input.limits,
35297
+ deadlineMs,
35298
+ byteBudget
35299
+ });
35300
+ byteBudget -= report.bytesReclaimed;
35301
+ nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
35302
+ devices.push(report);
35303
+ if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
35304
+ tags: { deviceId },
35305
+ meta: {
35306
+ scope,
35307
+ mode: input.limits.mode,
35308
+ examined: report.examined,
35309
+ eligible: report.eligible,
35310
+ reclaimed: report.reclaimed,
35311
+ bytesReclaimed: report.bytesReclaimed,
35312
+ held: report.held,
35313
+ unresolved: report.unresolved,
35314
+ withinGrace: report.withinGrace,
35315
+ refused: report.refused,
35316
+ stoppedBy: report.stoppedBy,
35317
+ nextCursor: report.nextCursor
35318
+ }
35319
+ });
35320
+ }
35321
+ const total = (pick) => devices.reduce((acc, r) => acc + pick(r), 0);
35322
+ return {
35323
+ report: {
35324
+ mode: input.limits.mode,
35325
+ devices,
35326
+ totalExamined: total((r) => r.examined),
35327
+ totalEligible: total((r) => r.eligible),
35328
+ totalReclaimed: total((r) => r.reclaimed),
35329
+ totalBytesReclaimed: total((r) => r.bytesReclaimed),
35330
+ totalRefused: total((r) => r.refused),
35331
+ durationMs: input.deps.now() - startedAt,
35332
+ complete: devices.every((r) => r.stoppedBy === "exhausted")
35333
+ },
35334
+ cursors: nextCursors
35335
+ };
35336
+ }
35337
+ //#endregion
34339
35338
  //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
34340
35339
  /**
34341
35340
  * The operator button for the orphan audit.
@@ -40277,13 +41276,29 @@ var RETRAIN_FRAME_COLUMNS = [
40277
41276
  type: "TEXT"
40278
41277
  })
40279
41278
  ];
40280
- var RETRAIN_FRAME_INDEXES = [{
40281
- name: "idx_retrain_frames_track",
40282
- columns: ["trackId"]
40283
- }, {
40284
- name: "idx_retrain_frames_device",
40285
- columns: ["deviceId", "copiedAt"]
40286
- }];
41279
+ var RETRAIN_FRAME_INDEXES = [
41280
+ {
41281
+ name: "idx_retrain_frames_track",
41282
+ columns: ["trackId"]
41283
+ },
41284
+ {
41285
+ name: "idx_retrain_frames_device",
41286
+ columns: ["deviceId", "copiedAt"]
41287
+ },
41288
+ (
41289
+ /**
41290
+ * The seal gate's other half. This collection is orders of magnitude smaller
41291
+ * than `pipeline-analytics:media` — every row is an operator-staged frame —
41292
+ * so the index buys much less here. It exists anyway because the gate reads
41293
+ * the two lanes with ONE query shape, and a lane that answers by scanning is
41294
+ * a lane that starts timing out the day the dataset grows. See
41295
+ * `MEDIA_INDEXES` for the cost this is a small multiple of.
41296
+ */
41297
+ {
41298
+ name: "idx_retrain_frames_location",
41299
+ columns: ["locationId"]
41300
+ })
41301
+ ];
40287
41302
  /**
40288
41303
  * The copy's id, derived from `(trackId, sourceMediaKey)`.
40289
41304
  *
@@ -43542,21 +44557,34 @@ function snapshot(j) {
43542
44557
  entities: [...entitiesFor(j.mode)],
43543
44558
  filesMoved: j.filesMoved,
43544
44559
  bytesMoved: j.bytesMoved,
43545
- filesTotal: null,
44560
+ filesTotal: j.filesTotal,
43546
44561
  startedAt: j.startedAt,
43547
44562
  finishedAt: j.finishedAt,
43548
44563
  error: j.error
43549
44564
  };
43550
44565
  }
43551
44566
  /**
44567
+ * The column both walked collections use for the storage location.
44568
+ *
44569
+ * Named once because it is now a QUERY PREDICATE (`where: { [it]: null }`) as
44570
+ * well as a row field. A predicate naming a column the collection cannot
44571
+ * express is refused by `count` rather than dropped, so a rename that misses
44572
+ * one site fails loudly instead of counting the whole table.
44573
+ */
44574
+ var LOCATION_ID_FIELD = "locationId";
44575
+ /**
43552
44576
  * True when the row names NO location — the population `seal` exists for.
43553
44577
  *
43554
44578
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
43555
44579
  * helper collapses "unstamped" and "stamped with the bare type ref" into one
43556
44580
  * answer, and only the first is a row whose meaning changes under a repoint.
44581
+ *
44582
+ * The in-JS twin of the `IS NULL` predicate {@link MediaRelocateEngine.countUnstamped}
44583
+ * sends. It stays because the PASS still walks rows and must decide per row;
44584
+ * the two must agree, and `unstamped-count-matches-the-pass` asserts they do.
43557
44585
  */
43558
44586
  function rowIsUnstamped(data) {
43559
- const id = data["locationId"];
44587
+ const id = data[LOCATION_ID_FIELD];
43560
44588
  return !(typeof id === "string" && id.length > 0);
43561
44589
  }
43562
44590
  /** True for the enrolled-gallery rows `move` skips and `gallery` selects. */
@@ -43564,6 +44592,40 @@ function rowIsGallery(data) {
43564
44592
  const ownerKind = String(data["ownerKind"] ?? "");
43565
44593
  return isMediaOwnerKind(ownerKind) && isRetentionExemptOwnerKind(ownerKind);
43566
44594
  }
44595
+ /**
44596
+ * Which collections a pass walks.
44597
+ *
44598
+ * ONE derivation, shared by the pass and by its pre-count. Two lists would let
44599
+ * an M counted over both collections face an N moved across one — the shape
44600
+ * D295 calls "a guard nobody believes".
44601
+ */
44602
+ function collectionsFor(mode) {
44603
+ if (mode === "gallery") return [{
44604
+ name: MEDIA_COLLECTION,
44605
+ order: "timestamp"
44606
+ }];
44607
+ return [{
44608
+ name: MEDIA_COLLECTION,
44609
+ order: "timestamp"
44610
+ }, {
44611
+ name: RETRAIN_FRAMES_COLLECTION,
44612
+ order: "copiedAt"
44613
+ }];
44614
+ }
44615
+ /**
44616
+ * Does this pass act on this row at all?
44617
+ *
44618
+ * A free function of `(mode, target)` rather than a method on the job, so the
44619
+ * pre-count that produces `filesTotal` and the walk that produces `filesMoved`
44620
+ * ask the SAME question. A separately-written predicate is how a denominator
44621
+ * ends up counting a different population than the numerator.
44622
+ */
44623
+ function selectsRow(mode, toLocationId, data, isRetrain) {
44624
+ if (mode === "seal") return rowIsUnstamped(data);
44625
+ if (mediaRowLocation(data) === toLocationId) return false;
44626
+ if (mode === "gallery") return !isRetrain && rowIsGallery(data);
44627
+ return isRetrain || !rowIsGallery(data);
44628
+ }
43567
44629
  var MediaRelocateEngine = class {
43568
44630
  deps;
43569
44631
  jobs = /* @__PURE__ */ new Map();
@@ -43580,26 +44642,91 @@ var MediaRelocateEngine = class {
43580
44642
  return true;
43581
44643
  }
43582
44644
  /**
43583
- * How many rows still carry no `locationId`, per collection.
44645
+ * Rows that still carry no `locationId`, per collection.
43584
44646
  *
43585
- * Read-only, and the ONLY number the migration planner's non-blocking gate
43586
- * is allowed to trust: it is counted here, against the rows themselves,
44647
+ * Read-only, and the ONLY answer the migration planner's non-blocking gate
44648
+ * is allowed to trust: it is measured here, against the rows themselves,
43587
44649
  * rather than inferred from "a seal job reported done" — a seal that failed
43588
44650
  * some rows still reports a terminal state.
44651
+ *
44652
+ * ── Why this is not the walk the rest of this file uses ──────────────────
44653
+ *
44654
+ * It used to be. It paged both collections through {@link pageRows} — 200
44655
+ * rows per RPC, ordered by `timestamp`, a column that leads no index — and
44656
+ * counted `rowIsUnstamped` in JS. On the live hub that is ~6 300 round trips
44657
+ * over 1 254 576 rows, each paying its own sort, and it hit the 60 s RPC
44658
+ * deadline **every time it was called since it shipped**. The gate it exists
44659
+ * to serve could therefore never open, and 152 GB of event media could not
44660
+ * be migrated.
44661
+ *
44662
+ * The walk is right for the PASS, which must visit every row to act on it.
44663
+ * It was never right for a question the store can answer with a predicate,
44664
+ * and the predicate is now expressible: `where: { locationId: null }`
44665
+ * compiles to `IS NULL` (it used to compile to `= NULL`, which matches
44666
+ * nothing and reports it as an answer — the number the gate treats as "safe
44667
+ * to cut over"). With `idx_media_location` in place both statements read a
44668
+ * contiguous range from the head of a b-tree.
44669
+ *
44670
+ * The cheap question is asked FIRST and on its own. `present` is a single
44671
+ * seek and decides the gate; `rows` is a second statement and only decorates
44672
+ * the refusal. So a count that cannot be taken still leaves a usable answer
44673
+ * — `{ present: true, rows: null }` refuses just as hard — and `null` never
44674
+ * degrades into `0`.
43589
44675
  */
43590
44676
  async countUnstamped() {
43591
- const media = await this.countUnstampedIn(MEDIA_COLLECTION, "timestamp");
43592
- const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION, "copiedAt");
43593
- return {
43594
- media,
43595
- retrainFrames,
43596
- total: media + retrainFrames
43597
- };
44677
+ try {
44678
+ const media = await this.countUnstampedIn(MEDIA_COLLECTION);
44679
+ const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION);
44680
+ return {
44681
+ media,
44682
+ retrainFrames,
44683
+ anyPresent: media.present || retrainFrames.present,
44684
+ total: media.rows === null || retrainFrames.rows === null ? null : media.rows + retrainFrames.rows
44685
+ };
44686
+ } catch (err) {
44687
+ this.deps.logger.warn("event-media seal gate: could not determine whether any row is unstamped — a migration must treat this as unknown, never as sealed", { meta: { error: String(err) } });
44688
+ return null;
44689
+ }
43598
44690
  }
43599
- async countUnstampedIn(collection, orderField) {
43600
- let unstamped = 0;
43601
- for await (const row of this.pageRows(collection, orderField)) if (rowIsUnstamped(row.data)) unstamped++;
43602
- return unstamped;
44691
+ /**
44692
+ * One collection's unstamped population: existence, then size.
44693
+ *
44694
+ * The `limit: 1` probe is deliberately a `query` and not a `count`: a count
44695
+ * of a million matching rows still walks a million index entries, while the
44696
+ * probe stops at the first one. Absence is proved by the same seek finding
44697
+ * nothing, which is where the index earns its keep — after a seal, the range
44698
+ * is empty and the answer is one b-tree descent.
44699
+ */
44700
+ async countUnstampedIn(collection) {
44701
+ if ((await this.deps.store.query.query({
44702
+ collection,
44703
+ filter: {
44704
+ where: { [LOCATION_ID_FIELD]: null },
44705
+ limit: 1
44706
+ },
44707
+ columns: [LOCATION_ID_FIELD]
44708
+ })).length === 0) return {
44709
+ present: false,
44710
+ rows: 0
44711
+ };
44712
+ try {
44713
+ return {
44714
+ present: true,
44715
+ rows: await this.deps.store.count.query({
44716
+ collection,
44717
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
44718
+ })
44719
+ };
44720
+ } catch (err) {
44721
+ this.deps.logger.warn("event-media seal gate: unstamped rows exist but could not be counted — reporting the population as unknown, not as empty", { meta: {
44722
+ collection,
44723
+ error: String(err)
44724
+ } });
44725
+ return {
44726
+ present: true,
44727
+ rows: null
44728
+ };
44729
+ }
43603
44730
  }
43604
44731
  start(input) {
43605
44732
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
@@ -43612,6 +44739,7 @@ var MediaRelocateEngine = class {
43612
44739
  deviceId: null,
43613
44740
  filesMoved: 0,
43614
44741
  bytesMoved: 0,
44742
+ filesTotal: null,
43615
44743
  startedAt: this.deps.now(),
43616
44744
  finishedAt: null,
43617
44745
  error: null,
@@ -43660,12 +44788,39 @@ var MediaRelocateEngine = class {
43660
44788
  if (rows.length < PAGE_SIZE) return;
43661
44789
  }
43662
44790
  }
43663
- /** Does this pass act on this row at all? */
43664
- selects(job, data, isRetrain) {
43665
- if (job.mode === "seal") return rowIsUnstamped(data);
43666
- if (mediaRowLocation(data) === job.toLocationId) return false;
43667
- if (job.mode === "gallery") return !isRetrain && rowIsGallery(data);
43668
- return isRetrain || !rowIsGallery(data);
44791
+ /**
44792
+ * How many rows a pass would still act on against `toLocationId`.
44793
+ *
44794
+ * The media lane's **M**, and — read on its own — its residue: "how much of
44795
+ * `eventMedia` is still not on the current default". Both come from here so
44796
+ * neither can drift from what the pass actually moves.
44797
+ *
44798
+ * `null` is "the count could not be taken", never `0`. A zero here reads as
44799
+ * "there is nothing left to move", which is precisely the claim a failed read
44800
+ * must not be allowed to make (D295).
44801
+ */
44802
+ async countSelectable(input) {
44803
+ return this.countSelected(input.mode ?? "move", input.toLocationId, () => false);
44804
+ }
44805
+ async countSelected(mode, toLocationId, stop) {
44806
+ try {
44807
+ let rows = 0;
44808
+ for (const collection of collectionsFor(mode)) {
44809
+ const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
44810
+ for await (const row of this.pageRows(collection.name, collection.order)) {
44811
+ if (stop()) return null;
44812
+ if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
44813
+ }
44814
+ }
44815
+ return { rows };
44816
+ } catch (err) {
44817
+ this.deps.logger.warn("media relocate: row count failed — this pass cannot say how much of the collection it saw", { meta: {
44818
+ mode,
44819
+ toLocationId,
44820
+ error: String(err)
44821
+ } });
44822
+ return null;
44823
+ }
43669
44824
  }
43670
44825
  async run(job, throttleMbps) {
43671
44826
  const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -43679,23 +44834,14 @@ var MediaRelocateEngine = class {
43679
44834
  * rows in silence reads as "there were none", which is how a migration
43680
44835
  * gets believed. */
43681
44836
  let pathlessRows = 0;
43682
- const collections = job.mode === "gallery" ? [{
43683
- name: MEDIA_COLLECTION,
43684
- order: "timestamp"
43685
- }] : [{
43686
- name: MEDIA_COLLECTION,
43687
- order: "timestamp"
43688
- }, {
43689
- name: RETRAIN_FRAMES_COLLECTION,
43690
- order: "copiedAt"
43691
- }];
43692
- for (const collection of collections) {
44837
+ job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
44838
+ for (const collection of collectionsFor(job.mode)) {
43693
44839
  if (job.cancelRequested) break;
43694
44840
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
43695
44841
  for await (const row of this.pageRows(collection.name, collection.order)) {
43696
44842
  if (job.cancelRequested) break;
43697
44843
  const data = row.data;
43698
- if (!this.selects(job, data, isRetrain)) continue;
44844
+ if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
43699
44845
  if (job.mode === "seal") {
43700
44846
  try {
43701
44847
  await this.stamp(collection.name, row.id, data, job.toLocationId);
@@ -43808,10 +44954,38 @@ var MediaSettingsSchema = require_dist.object({
43808
44954
  * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
43809
44955
  * max 2 (200%). */
43810
44956
  cropPadding: require_dist.number().min(0).max(2).default(.15),
43811
- /** Master switch for periodic per-track snapshots (the timeline filmstrip +
43812
- * the rolling `lastFrame` + the best `thumbnail`). When false, only the
43813
- * per-track `firstFrame` and per-event media are produced. */
43814
- saveThumbnails: require_dist.boolean().default(true),
44957
+ /**
44958
+ * Per-camera DEBUG media. Default **false**, and it absorbs the retired
44959
+ * `saveThumbnails` rather than sitting beside it (D62: a second switch that
44960
+ * disagrees with the first is worse than no switch).
44961
+ *
44962
+ * What it gates, and only this:
44963
+ * - the periodic per-track `snapshot` filmstrip (`periodic-media-plan.ts`);
44964
+ * - the per-EDGE motion still — the debug surplus that
44965
+ * {@link decideMotionStill} otherwise reduces to one marker per
44966
+ * {@link MediaSettings.motionMarkerQuietMs} drought window.
44967
+ *
44968
+ * What it never gates, at any value: `firstFrame`, the rolling `lastFrame`,
44969
+ * `thumbnail`/`thumbnailSmall`, `keyFrame`/`keyFrameSmall`, the face/plate
44970
+ * buffers, gallery media, the CLIP search `crop` — and the quiet-period
44971
+ * motion marker itself, which is the operator's fallback and must survive
44972
+ * the default (a blank timeline on a quiet camera reads as broken).
44973
+ *
44974
+ * The rolling `lastFrame` deliberately moved OUT from under the old
44975
+ * `saveThumbnails`: it is in the keep-set, and a debug switch may not take
44976
+ * it away.
44977
+ */
44978
+ debugMediaEnabled: require_dist.boolean().default(false),
44979
+ /**
44980
+ * The detection-drought window N for the motion marker: with debug media
44981
+ * off, a motion off→on edge produces a still only when NO detection has run
44982
+ * on this camera for this long AND no marker was produced in the last such
44983
+ * window. Chosen from the observed fleet distribution of detection-free
44984
+ * stretches (median 42 s, p75 201 s, p90 770 s) — 900 s sits just past p90,
44985
+ * so nine stretches in ten produce nothing. See
44986
+ * `services/motion-marker-policy.ts`.
44987
+ */
44988
+ motionMarkerQuietMs: require_dist.number().int().min(6e4).max(360 * 6e4).default(9e5),
43815
44989
  /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
43816
44990
  * A snapshot is captured for an active track only after this much wall-clock
43817
44991
  * has elapsed since its previous one. */
@@ -43840,7 +45014,8 @@ function resolveMediaSettings(raw) {
43840
45014
  };
43841
45015
  return {
43842
45016
  cropPadding: pick("cropPadding"),
43843
- saveThumbnails: pick("saveThumbnails"),
45017
+ debugMediaEnabled: pick("debugMediaEnabled"),
45018
+ motionMarkerQuietMs: pick("motionMarkerQuietMs"),
43844
45019
  snapshotIntervalMs: pick("snapshotIntervalMs"),
43845
45020
  snapshotMovementThreshold: pick("snapshotMovementThreshold"),
43846
45021
  snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
@@ -44084,9 +45259,9 @@ function resolvePackageDropSettings(raw) {
44084
45259
  * can never duplicate one.
44085
45260
  */
44086
45261
  function planPeriodicMedia(input) {
44087
- const appendSnapshot = input.dueSnapshot;
45262
+ const appendSnapshot = input.debugMediaEnabled && input.dueSnapshot;
44088
45263
  const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
44089
- const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
45264
+ const lastFrameElapsed = Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
44090
45265
  const firstFrameLanded = input.firstFrameLanded ?? true;
44091
45266
  const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
44092
45267
  const thumbnailLanded = input.thumbnailLanded ?? true;
@@ -44757,6 +45932,90 @@ function padBbox(bbox, padding) {
44757
45932
  };
44758
45933
  }
44759
45934
  //#endregion
45935
+ //#region src/pipeline-analytics/pipeline/subject-agreement.ts
45936
+ /**
45937
+ * `subjectAgreement` — the ONE predicate for "a rectangle measured on frame A
45938
+ * is still honest against the pixels of frame B".
45939
+ *
45940
+ * Two durable display crops cut a rectangle out of a frame that is NOT the
45941
+ * frame the rectangle was measured on: the close-time thumbnail derive (the
45942
+ * best-seen subject box, cut from the persisted keyFrame) and the plate
45943
+ * gallery's last-resort crop (the plate rect at the best OCR read, cut from the
45944
+ * same keyFrame). Both were guarded on TIME alone, and time is the wrong
45945
+ * quantity: 1.5 s is nothing for a parked car and hundreds of native pixels for
45946
+ * a car crossing the frame. Confirmed on the operator's debug-marked tracks
45947
+ * (2026-08-30 triage): `e9c024bb` passed the time bound at 531–1179 ms and
45948
+ * stored a windshield and a patch of gravel, while `7613c0fa` — the same path,
45949
+ * 2579 ms of skew, a stationary vehicle — stored a perfectly framed plate.
45950
+ *
45951
+ * The quantity that separates those cases is MOTION, and the track's own
45952
+ * `positions` trail already records it. This asks the trail one question:
45953
+ *
45954
+ * between the instant the rectangle was measured and the instant the pixels
45955
+ * were captured, did the subject stay where it was?
45956
+ *
45957
+ * IoU over the two subject boxes answers it, and because both boxes come from
45958
+ * the SAME trail they are in the same detection-frame pixel space by
45959
+ * construction — there is no cross-space normalization to get wrong.
45960
+ *
45961
+ * D52's one-derivation discipline applied to the guard: this is the only place
45962
+ * the question is answered, and {@link createKeyFrameCrop} is its only caller,
45963
+ * so both defects share one contract instead of two drifting copies.
45964
+ *
45965
+ * `unknown` is a real verdict, not a failure: a track with no trail, or a trail
45966
+ * downsampled away from one of the two instants, carries NO motion evidence.
45967
+ * The caller then falls back to the temporal bound it always had — an absent
45968
+ * read changes nothing (D49).
45969
+ */
45970
+ function nearestSample(path, atMs, maxSampleGapMs) {
45971
+ let best;
45972
+ let bestGap = Number.POSITIVE_INFINITY;
45973
+ for (const sample of path) {
45974
+ const gap = Math.abs(sample.timestamp - atMs);
45975
+ if (gap < bestGap) {
45976
+ bestGap = gap;
45977
+ best = sample;
45978
+ }
45979
+ }
45980
+ if (best === void 0 || bestGap > maxSampleGapMs) return void 0;
45981
+ return {
45982
+ bbox: best.bbox,
45983
+ gapMs: bestGap
45984
+ };
45985
+ }
45986
+ /**
45987
+ * Answer "did the subject stay put between the two instants?" from the track's
45988
+ * own position trail. Pure — no IO, no clock, no logging: the caller owns the
45989
+ * consequence and the line it writes.
45990
+ */
45991
+ function subjectAgreement(query) {
45992
+ if (query.path.length === 0) return {
45993
+ kind: "unknown",
45994
+ reason: "no-path"
45995
+ };
45996
+ const atMeasure = nearestSample(query.path, query.measuredAtMs, query.maxSampleGapMs);
45997
+ if (atMeasure === void 0) return {
45998
+ kind: "unknown",
45999
+ reason: "no-sample-near-measure"
46000
+ };
46001
+ const atCut = nearestSample(query.path, query.cutFromMs, query.maxSampleGapMs);
46002
+ if (atCut === void 0) return {
46003
+ kind: "unknown",
46004
+ reason: "no-sample-near-cut"
46005
+ };
46006
+ const overlap = iou$1(atMeasure.bbox, atCut.bbox);
46007
+ const sampleGapMs = Math.max(atMeasure.gapMs, atCut.gapMs);
46008
+ return overlap >= query.minIou ? {
46009
+ kind: "agrees",
46010
+ iou: overlap,
46011
+ sampleGapMs
46012
+ } : {
46013
+ kind: "moved",
46014
+ iou: overlap,
46015
+ sampleGapMs
46016
+ };
46017
+ }
46018
+ //#endregion
44760
46019
  //#region src/pipeline-analytics/pipeline/keyframe-crop.ts
44761
46020
  function createKeyFrameCrop(deps) {
44762
46021
  return async (input) => {
@@ -44764,12 +46023,36 @@ function createKeyFrameCrop(deps) {
44764
46023
  const media = await deps.getMedia(input.mediaKey);
44765
46024
  if (!media) return null;
44766
46025
  const skewMs = Math.abs(media.timestamp - input.timestamp);
44767
- if (skewMs > deps.maxSkewMs) {
44768
- deps.logger.debug("keyframe crop rejected — temporal skew too large", { meta: {
44769
- mediaKey: input.mediaKey,
44770
- skewMs,
44771
- maxSkewMs: deps.maxSkewMs
44772
- } });
46026
+ const verdict = subjectAgreement({
46027
+ path: input.subjectPath ?? [],
46028
+ measuredAtMs: input.timestamp,
46029
+ cutFromMs: media.timestamp,
46030
+ minIou: deps.minSubjectIou,
46031
+ maxSampleGapMs: deps.maxSubjectSampleGapMs
46032
+ });
46033
+ if (verdict.kind === "moved") {
46034
+ deps.logger.info("keyframe crop refused — subject moved between the two instants", {
46035
+ tags: { deviceId: input.deviceId },
46036
+ meta: {
46037
+ mediaKey: input.mediaKey,
46038
+ skewMs,
46039
+ subjectIou: verdict.iou,
46040
+ minSubjectIou: deps.minSubjectIou,
46041
+ sampleGapMs: verdict.sampleGapMs
46042
+ }
46043
+ });
46044
+ return null;
46045
+ }
46046
+ if (verdict.kind === "unknown" && skewMs > deps.maxSkewMs) {
46047
+ deps.logger.info("keyframe crop refused — temporal skew too large, no motion evidence", {
46048
+ tags: { deviceId: input.deviceId },
46049
+ meta: {
46050
+ mediaKey: input.mediaKey,
46051
+ skewMs,
46052
+ maxSkewMs: deps.maxSkewMs,
46053
+ unknownReason: verdict.reason
46054
+ }
46055
+ });
44773
46056
  return null;
44774
46057
  }
44775
46058
  try {
@@ -44781,15 +46064,25 @@ function createKeyFrameCrop(deps) {
44781
46064
  w: input.bbox.w / input.frameWidth,
44782
46065
  h: input.bbox.h / input.frameHeight
44783
46066
  }, input.padding);
44784
- return {
44785
- jpeg: await deps.cropRegionToJpeg(Buffer.from(rgb.bytes), rgb.width, rgb.height, norm),
44786
- skewMs
46067
+ const jpeg = await deps.cropRegionToJpeg(Buffer.from(rgb.bytes), rgb.width, rgb.height, norm);
46068
+ return verdict.kind === "agrees" ? {
46069
+ jpeg,
46070
+ skewMs,
46071
+ motion: "agrees",
46072
+ subjectIou: verdict.iou
46073
+ } : {
46074
+ jpeg,
46075
+ skewMs,
46076
+ motion: "unknown"
44787
46077
  };
44788
46078
  } catch (err) {
44789
- deps.logger.debug("keyframe crop derive failed", { meta: {
44790
- mediaKey: input.mediaKey,
44791
- error: err instanceof Error ? err.message : String(err)
44792
- } });
46079
+ deps.logger.debug("keyframe crop derive failed", {
46080
+ tags: { deviceId: input.deviceId },
46081
+ meta: {
46082
+ mediaKey: input.mediaKey,
46083
+ error: err instanceof Error ? err.message : String(err)
46084
+ }
46085
+ });
44793
46086
  return null;
44794
46087
  }
44795
46088
  };
@@ -47632,6 +48925,52 @@ async function applyTrackFlags(deps, input) {
47632
48925
  };
47633
48926
  }
47634
48927
  //#endregion
48928
+ //#region src/pipeline-analytics/pipeline/plate-matcher.ts
48929
+ /**
48930
+ * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
48931
+ *
48932
+ * Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
48933
+ * distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
48934
+ * There is no model-version gate (text has no embedding drift) and no ambiguity
48935
+ * `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
48936
+ * conservatively to NO match. The read's `score` weights how far it may match:
48937
+ * a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
48938
+ * a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
48939
+ * "Prefer unassigned over wrong vehicle."
48940
+ */
48941
+ /** Calibration placeholders — re-tune against real OCR score distributions. */
48942
+ var DEFAULT_PLATE_MATCH_OPTS = {
48943
+ maxDistance: 1,
48944
+ lowScoreFloor: .5,
48945
+ lowScoreMaxDistance: 0
48946
+ };
48947
+ function matchPlateText(probe, gallery, opts) {
48948
+ const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
48949
+ const bestByVehicle = /* @__PURE__ */ new Map();
48950
+ for (const s of gallery) {
48951
+ const dist = plateDistance(probe.text, s.text);
48952
+ const prev = bestByVehicle.get(s.vehicleId);
48953
+ if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
48954
+ }
48955
+ if (bestByVehicle.size === 0) return null;
48956
+ let bestId = null;
48957
+ let bestDist = Infinity;
48958
+ let tie = false;
48959
+ for (const [vehicleId, dist] of bestByVehicle) {
48960
+ if (dist > allowed) continue;
48961
+ if (dist < bestDist) {
48962
+ bestDist = dist;
48963
+ bestId = vehicleId;
48964
+ tie = false;
48965
+ } else if (dist === bestDist) tie = true;
48966
+ }
48967
+ if (bestId === null || tie) return null;
48968
+ return {
48969
+ vehicleId: bestId,
48970
+ distance: bestDist
48971
+ };
48972
+ }
48973
+ //#endregion
47635
48974
  //#region src/pipeline-analytics/pipeline/plate-vote.ts
47636
48975
  /**
47637
48976
  * Per-character VOTE over a track's plate reads.
@@ -47773,52 +49112,6 @@ function votePlateText(reads) {
47773
49112
  return out;
47774
49113
  }
47775
49114
  //#endregion
47776
- //#region src/pipeline-analytics/pipeline/plate-matcher.ts
47777
- /**
47778
- * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
47779
- *
47780
- * Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
47781
- * distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
47782
- * There is no model-version gate (text has no embedding drift) and no ambiguity
47783
- * `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
47784
- * conservatively to NO match. The read's `score` weights how far it may match:
47785
- * a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
47786
- * a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
47787
- * "Prefer unassigned over wrong vehicle."
47788
- */
47789
- /** Calibration placeholders — re-tune against real OCR score distributions. */
47790
- var DEFAULT_PLATE_MATCH_OPTS = {
47791
- maxDistance: 1,
47792
- lowScoreFloor: .5,
47793
- lowScoreMaxDistance: 0
47794
- };
47795
- function matchPlateText(probe, gallery, opts) {
47796
- const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
47797
- const bestByVehicle = /* @__PURE__ */ new Map();
47798
- for (const s of gallery) {
47799
- const dist = plateDistance(probe.text, s.text);
47800
- const prev = bestByVehicle.get(s.vehicleId);
47801
- if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
47802
- }
47803
- if (bestByVehicle.size === 0) return null;
47804
- let bestId = null;
47805
- let bestDist = Infinity;
47806
- let tie = false;
47807
- for (const [vehicleId, dist] of bestByVehicle) {
47808
- if (dist > allowed) continue;
47809
- if (dist < bestDist) {
47810
- bestDist = dist;
47811
- bestId = vehicleId;
47812
- tie = false;
47813
- } else if (dist === bestDist) tie = true;
47814
- }
47815
- if (bestId === null || tie) return null;
47816
- return {
47817
- vehicleId: bestId,
47818
- distance: bestDist
47819
- };
47820
- }
47821
- //#endregion
47822
49115
  //#region src/pipeline-analytics/runtime/failure-report.ts
47823
49116
  /**
47824
49117
  * What `pipeline-analytics` reports through `failure-contribution` — the four
@@ -48150,9 +49443,16 @@ var PlateRecognizer = class {
48150
49443
  bestLabelMatchConfidence(trackId) {
48151
49444
  return this.bestPlate.get(trackId)?.score;
48152
49445
  }
48153
- /** Persist the held best plate for a finished track as one PlateStore row
48154
- * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
48155
- async onTrackEnd(deviceId, trackId) {
49446
+ /**
49447
+ * Persist the held best plate for a finished track as one PlateStore row
49448
+ * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state.
49449
+ *
49450
+ * @param subjectPath the closing track's position trail, supplied by the
49451
+ * closer. It is what tells a parked car (whose plate rect is still honest
49452
+ * seconds later) from a crossing one (whose rect points at the road it has
49453
+ * already left) — the distinction the pure time bound could not make.
49454
+ */
49455
+ async onTrackEnd(deviceId, trackId, subjectPath) {
48156
49456
  const held = this.bestPlate.get(trackId);
48157
49457
  this.bestPlate.delete(trackId);
48158
49458
  if (held === void 0) return;
@@ -48161,12 +49461,14 @@ var PlateRecognizer = class {
48161
49461
  let cropData = held.crop;
48162
49462
  if (cropData === void 0 && keyFrameMediaKey !== void 0 && this.deps.deriveCropFromKeyFrame) try {
48163
49463
  const derived = await this.deps.deriveCropFromKeyFrame({
49464
+ deviceId,
48164
49465
  mediaKey: keyFrameMediaKey,
48165
49466
  bbox: held.bbox,
48166
49467
  frameWidth: held.frameWidth,
48167
49468
  frameHeight: held.frameHeight,
48168
49469
  padding: held.cropPadding,
48169
- timestamp: held.timestamp
49470
+ timestamp: held.timestamp,
49471
+ ...subjectPath !== void 0 ? { subjectPath } : {}
48170
49472
  });
48171
49473
  if (derived) {
48172
49474
  cropData = derived.jpeg;
@@ -48177,10 +49479,18 @@ var PlateRecognizer = class {
48177
49479
  },
48178
49480
  meta: {
48179
49481
  plateId,
48180
- skewMs: derived.skewMs
49482
+ skewMs: derived.skewMs,
49483
+ motion: derived.motion,
49484
+ subjectIou: derived.subjectIou ?? null
48181
49485
  }
48182
49486
  });
48183
- }
49487
+ } else this.deps.logger.info("plate crop not recovered — keyFrame derive refused or missed", {
49488
+ tags: {
49489
+ deviceId,
49490
+ trackId
49491
+ },
49492
+ meta: { plateId }
49493
+ });
48184
49494
  } catch (err) {
48185
49495
  this.deps.logger.debug("PlateRecognizer keyFrame crop derive failed", {
48186
49496
  tags: { deviceId },
@@ -48781,6 +50091,122 @@ async function reconcileGallerySamples(deps, options) {
48781
50091
  stoppedByCeiling
48782
50092
  };
48783
50093
  }
50094
+ //#endregion
50095
+ //#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
50096
+ /** The scope id this leg reports and persists its cursor under. */
50097
+ var EMBEDDING_ORPHAN_SCOPE = "vector-index:object-clip";
50098
+ /**
50099
+ * Walk the index once, resuming at `cursor`.
50100
+ *
50101
+ * Never throws: a probe failure ends the pass with what it has and says which
50102
+ * bound stopped it, so a short pass can never be read as a clean one.
50103
+ */
50104
+ async function scanEmbeddingOrphans(deps, limits, cursor) {
50105
+ const tally = {
50106
+ examined: 0,
50107
+ orphans: 0,
50108
+ reclaimed: 0,
50109
+ kept: 0,
50110
+ withinGrace: 0,
50111
+ unresolvable: 0,
50112
+ notReclaimed: 0
50113
+ };
50114
+ const deviceIds = /* @__PURE__ */ new Set();
50115
+ const graceFloor = deps.now() - limits.graceMs;
50116
+ let offset = Math.max(0, cursor);
50117
+ let stoppedBy = "exhausted";
50118
+ for (;;) {
50119
+ if (tally.examined >= limits.maxRows) {
50120
+ stoppedBy = "row-ceiling";
50121
+ break;
50122
+ }
50123
+ let page;
50124
+ try {
50125
+ page = await deps.scanPage(offset, limits.pageSize);
50126
+ } catch (err) {
50127
+ deps.logger.warn("embedding orphan scan: index page failed — pass abandoned", { meta: {
50128
+ scope: EMBEDDING_ORPHAN_SCOPE,
50129
+ offset,
50130
+ error: String(err)
50131
+ } });
50132
+ stoppedBy = "page-stalled";
50133
+ break;
50134
+ }
50135
+ const named = page.rows.filter((r) => r.trackId !== null).map((r) => r.trackId ?? "");
50136
+ let live;
50137
+ try {
50138
+ live = named.length > 0 ? await deps.liveTrackIds(named) : /* @__PURE__ */ new Set();
50139
+ } catch (err) {
50140
+ deps.logger.warn("embedding orphan scan: liveness probe failed — pass abandoned", { meta: {
50141
+ scope: EMBEDDING_ORPHAN_SCOPE,
50142
+ offset,
50143
+ error: String(err)
50144
+ } });
50145
+ stoppedBy = "probe-failed";
50146
+ break;
50147
+ }
50148
+ const doomed = [];
50149
+ let ceilingHit = false;
50150
+ for (const row of page.rows) {
50151
+ tally.examined += 1;
50152
+ if (row.trackId === null) {
50153
+ tally.unresolvable += 1;
50154
+ continue;
50155
+ }
50156
+ if (live.has(row.trackId)) {
50157
+ tally.kept += 1;
50158
+ continue;
50159
+ }
50160
+ if (row.timestamp === null || row.timestamp > graceFloor) {
50161
+ tally.withinGrace += 1;
50162
+ continue;
50163
+ }
50164
+ tally.orphans += 1;
50165
+ if (row.deviceId !== null) deviceIds.add(row.deviceId);
50166
+ if (limits.mode !== "reclaim") continue;
50167
+ if (tally.reclaimed + doomed.length >= limits.maxReclaim) {
50168
+ ceilingHit = true;
50169
+ break;
50170
+ }
50171
+ doomed.push(row.id);
50172
+ }
50173
+ let reclaimedInPage = 0;
50174
+ if (doomed.length > 0) {
50175
+ const outcome = await deps.reclaim(doomed);
50176
+ reclaimedInPage = outcome.deleted.length;
50177
+ tally.reclaimed += outcome.deleted.length;
50178
+ tally.notReclaimed += outcome.stillPresent.length;
50179
+ }
50180
+ if (deps.breathe) await deps.breathe();
50181
+ if (ceilingHit) {
50182
+ stoppedBy = "reclaim-ceiling";
50183
+ offset += page.rows.length - reclaimedInPage;
50184
+ break;
50185
+ }
50186
+ offset += page.rows.length - reclaimedInPage;
50187
+ if (page.nextCursor === null) {
50188
+ offset = 0;
50189
+ break;
50190
+ }
50191
+ if (page.rows.length === 0) {
50192
+ stoppedBy = "page-stalled";
50193
+ break;
50194
+ }
50195
+ }
50196
+ return {
50197
+ scope: EMBEDDING_ORPHAN_SCOPE,
50198
+ examined: tally.examined,
50199
+ orphans: tally.orphans,
50200
+ reclaimed: tally.reclaimed,
50201
+ kept: tally.kept,
50202
+ withinGrace: tally.withinGrace,
50203
+ unresolvable: tally.unresolvable,
50204
+ notReclaimed: tally.notReclaimed,
50205
+ deviceIds: [...deviceIds],
50206
+ stoppedBy,
50207
+ nextCursor: Math.max(0, offset)
50208
+ };
50209
+ }
48784
50210
  /**
48785
50211
  * Where the completion marker lives.
48786
50212
  *
@@ -50300,6 +51726,37 @@ var EventStore = class {
50300
51726
  return found;
50301
51727
  }
50302
51728
  /**
51729
+ * The SUBSET of `eventIds` that are MOTION events specifically.
51730
+ *
51731
+ * {@link existingEventIds} answers "is this event alive", which is the
51732
+ * orphan audit's question. The media reclaim asks a narrower one: *is this
51733
+ * event-owned snapshot a MOTION-VISIT still* — because the selection shape
51734
+ * (`ownerKind: 'event', kind: 'snapshot'`) is not itself a guarantee.
51735
+ * Nothing structurally prevents another writer landing an event-owned
51736
+ * snapshot on an object or audio row, and the reclaim must not be the place
51737
+ * that discovers it. A per-page indexed `whereIn` on one table is cheap
51738
+ * where that mistake would not be.
51739
+ *
51740
+ * THROWS on a query failure, same contract and same reason.
51741
+ */
51742
+ async existingMotionEventIds(eventIds) {
51743
+ const found = /* @__PURE__ */ new Set();
51744
+ for (let i = 0; i < eventIds.length; i += 500) {
51745
+ const chunk = eventIds.slice(i, i + 500);
51746
+ if (chunk.length === 0) continue;
51747
+ const rows = await this.store.query.query({
51748
+ collection: MOTION_EVENTS_COLLECTION,
51749
+ columns: ["id"],
51750
+ filter: {
51751
+ whereIn: { id: [...chunk] },
51752
+ limit: chunk.length
51753
+ }
51754
+ });
51755
+ for (const row of rows) found.add(row.id);
51756
+ }
51757
+ return found;
51758
+ }
51759
+ /**
50303
51760
  * Delete all events for a specific device with `timestamp < cutoffMs`
50304
51761
  * (exclusive upper bound) across all three kinds, returning the per-kind
50305
51762
  * deleted counts and the flattened list of deleted ids (so the caller can
@@ -50572,11 +52029,21 @@ var SAMPLE_COLUMNS$1 = [
50572
52029
  type: "TEXT",
50573
52030
  notNull: true
50574
52031
  },
52032
+ (
52033
+ /**
52034
+ * LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
52035
+ * `identity-arcface` vector index now, keyed by this row's id.
52036
+ *
52037
+ * Still declared and still `notNull`: rows written before the conversion carry
52038
+ * their array, and `loadGallery` falls back to it for exactly as long as one
52039
+ * of them has not been converted. A CONVERTED row holds `[]`, which is both
52040
+ * the idempotence marker and the only way to see the conversion is done.
52041
+ */
50575
52042
  {
50576
52043
  name: "embedding",
50577
52044
  type: "JSON",
50578
52045
  notNull: true
50579
- },
52046
+ }),
50580
52047
  {
50581
52048
  name: "mediaKey",
50582
52049
  type: "TEXT"
@@ -50623,9 +52090,11 @@ var SAMPLE_COLUMNS$1 = [
50623
52090
  ];
50624
52091
  var IdentityStore = class {
50625
52092
  store;
52093
+ vectors;
50626
52094
  logger;
50627
52095
  constructor(deps) {
50628
52096
  this.store = deps.store;
52097
+ this.vectors = deps.vectors;
50629
52098
  this.logger = deps.logger;
50630
52099
  }
50631
52100
  static async declare(store) {
@@ -50643,6 +52112,41 @@ var IdentityStore = class {
50643
52112
  }]
50644
52113
  });
50645
52114
  }
52115
+ /**
52116
+ * How an UNCONVERTED `identity-samples` row becomes an index row.
52117
+ *
52118
+ * `modelId` is `notNull` on this collection, so unlike the faces buffer there
52119
+ * is nothing to infer — a row that somehow holds no model is REFUSED and
52120
+ * keeps its JSON. An enrolled vector stamped with a guessed model is a sample
52121
+ * the matcher would compare across feature spaces, which is the one mistake
52122
+ * this gallery cannot survive.
52123
+ */
52124
+ conversionSpec() {
52125
+ return {
52126
+ collection: IDENTITY_SAMPLES_COLLECTION,
52127
+ vectors: this.vectors,
52128
+ write: (id, embedding, data) => {
52129
+ const modelId = data["modelId"];
52130
+ if (typeof modelId !== "string" || modelId.length === 0) return null;
52131
+ const identityId = data["identityId"];
52132
+ const deviceId = data["deviceId"];
52133
+ const addedAt = data["addedAt"];
52134
+ const source = data["source"];
52135
+ return {
52136
+ id,
52137
+ embedding,
52138
+ className: IDENTITY_VECTOR_CLASS,
52139
+ modelId,
52140
+ timestamp: typeof addedAt === "number" ? addedAt : 0,
52141
+ ...typeof deviceId === "number" ? { deviceId } : {},
52142
+ extra: {
52143
+ ...typeof identityId === "string" ? { identityId } : {},
52144
+ ...typeof source === "string" ? { source } : {}
52145
+ }
52146
+ };
52147
+ }
52148
+ };
52149
+ }
50646
52150
  async createIdentity(input) {
50647
52151
  const now = Date.now();
50648
52152
  const identity = {
@@ -50677,11 +52181,13 @@ var IdentityStore = class {
50677
52181
  collection: IDENTITY_SAMPLES_COLLECTION,
50678
52182
  filter: { where: { identityId: id } }
50679
52183
  });
52184
+ const droppedSamples = [];
50680
52185
  for (const s of samples) try {
50681
52186
  await this.store.delete.mutate({
50682
52187
  collection: IDENTITY_SAMPLES_COLLECTION,
50683
52188
  key: s.id
50684
52189
  });
52190
+ droppedSamples.push(s.id);
50685
52191
  } catch (err) {
50686
52192
  this.logger.warn("deleteIdentity sample delete failed", { meta: {
50687
52193
  identityId: id,
@@ -50689,6 +52195,7 @@ var IdentityStore = class {
50689
52195
  error: String(err)
50690
52196
  } });
50691
52197
  }
52198
+ await this.vectors.drop(droppedSamples);
50692
52199
  await this.store.delete.mutate({
50693
52200
  collection: IDENTITIES_COLLECTION,
50694
52201
  key: id
@@ -50714,7 +52221,6 @@ var IdentityStore = class {
50714
52221
  const sample = {
50715
52222
  id: (0, node_crypto.randomUUID)(),
50716
52223
  identityId: input.identityId,
50717
- embedding: input.embedding,
50718
52224
  ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
50719
52225
  ...input.sourceMediaKey !== void 0 ? { sourceMediaKey: input.sourceMediaKey } : {},
50720
52226
  source: input.source ?? "detected",
@@ -50723,12 +52229,27 @@ var IdentityStore = class {
50723
52229
  dim: input.embedding.length,
50724
52230
  addedAt: Date.now()
50725
52231
  };
52232
+ if (!await this.vectors.put({
52233
+ id: sample.id,
52234
+ embedding: input.embedding,
52235
+ className: "identity",
52236
+ modelId: input.modelId,
52237
+ timestamp: sample.addedAt,
52238
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
52239
+ extra: {
52240
+ identityId: input.identityId,
52241
+ source: sample.source
52242
+ }
52243
+ })) throw new Error(`identity sample was not enrolled: its ${input.embedding.length}-dim vector could not be written to the gallery index`);
50726
52244
  const { id, ...rest } = sample;
50727
52245
  await this.store.insert.mutate({
50728
52246
  collection: IDENTITY_SAMPLES_COLLECTION,
50729
52247
  record: {
50730
52248
  id,
50731
- data: rest
52249
+ data: {
52250
+ ...rest,
52251
+ embedding: []
52252
+ }
50732
52253
  }
50733
52254
  });
50734
52255
  const identity = (await this.listIdentities()).find((i) => i.id === input.identityId);
@@ -50777,6 +52298,7 @@ var IdentityStore = class {
50777
52298
  collection: IDENTITY_SAMPLES_COLLECTION,
50778
52299
  key: sampleId
50779
52300
  });
52301
+ await this.vectors.drop([sampleId]);
50780
52302
  const identity = (await this.listIdentities()).find((i) => i.id === identityId);
50781
52303
  if (identity) {
50782
52304
  const patch = {
@@ -50804,16 +52326,46 @@ var IdentityStore = class {
50804
52326
  collection: IDENTITY_SAMPLES_COLLECTION,
50805
52327
  filter: {}
50806
52328
  });
50807
- const gallery = [];
52329
+ const wanted = [];
50808
52330
  for (const r of rows) {
50809
52331
  const d = r.data;
50810
- if (!Array.isArray(d.embedding) || typeof d.modelId !== "string" || typeof d.identityId !== "string") continue;
50811
- gallery.push({
50812
- identityId: d.identityId,
50813
- embedding: d.embedding,
50814
- modelId: d.modelId
50815
- });
52332
+ if (typeof d.modelId === "string" && typeof d.identityId === "string") wanted.push(r.id);
50816
52333
  }
52334
+ const vectors = await this.vectors.load(wanted);
52335
+ const gallery = [];
52336
+ let fromLegacyColumn = 0;
52337
+ let missing = 0;
52338
+ for (const r of rows) {
52339
+ const d = r.data;
52340
+ if (typeof d.modelId !== "string" || typeof d.identityId !== "string") continue;
52341
+ const indexed = vectors.get(r.id);
52342
+ if (indexed !== void 0) {
52343
+ gallery.push({
52344
+ identityId: d.identityId,
52345
+ embedding: indexed,
52346
+ modelId: d.modelId
52347
+ });
52348
+ continue;
52349
+ }
52350
+ const legacy = d.embedding;
52351
+ if (Array.isArray(legacy) && legacy.length > 0) {
52352
+ fromLegacyColumn += 1;
52353
+ gallery.push({
52354
+ identityId: d.identityId,
52355
+ embedding: legacy,
52356
+ modelId: d.modelId
52357
+ });
52358
+ continue;
52359
+ }
52360
+ missing += 1;
52361
+ }
52362
+ if (fromLegacyColumn > 0 || missing > 0) this.logger.warn("identity gallery did not come entirely from the vector index", { meta: {
52363
+ index: this.vectors.name,
52364
+ enrolled: rows.length,
52365
+ loaded: gallery.length,
52366
+ fromLegacyColumn,
52367
+ missing
52368
+ } });
50817
52369
  return gallery;
50818
52370
  }
50819
52371
  /**
@@ -50849,12 +52401,23 @@ var IdentityStore = class {
50849
52401
  * holding 0).
50850
52402
  */
50851
52403
  async restampSample(input) {
52404
+ if (!await this.vectors.put({
52405
+ id: input.sampleId,
52406
+ embedding: input.embedding,
52407
+ className: "identity",
52408
+ modelId: input.modelId,
52409
+ timestamp: Date.now(),
52410
+ extra: { identityId: input.identityId }
52411
+ })) return {
52412
+ landed: false,
52413
+ reason: "the re-embedded vector did not reach the gallery index"
52414
+ };
50852
52415
  try {
50853
52416
  await this.store.update.mutate({
50854
52417
  collection: IDENTITY_SAMPLES_COLLECTION,
50855
52418
  id: input.sampleId,
50856
52419
  data: {
50857
- embedding: input.embedding,
52420
+ embedding: [],
50858
52421
  modelId: input.modelId,
50859
52422
  dim: input.dim
50860
52423
  }
@@ -53142,6 +54705,53 @@ var TrackStore = class {
53142
54705
  return found;
53143
54706
  }
53144
54707
  /**
54708
+ * The SUBSET of `trackIds` that are PINNED — `retrainStatus: 'staging'` or
54709
+ * `favourited: true`.
54710
+ *
54711
+ * A pin is the operator saying "keep this one". It is enforced against the
54712
+ * age sweep (`listIdsBefore` excludes both), and it must be enforced against
54713
+ * a bulk reclaim for exactly the same reason: a starred track whose
54714
+ * filmstrip is gone is a pin that did not hold.
54715
+ *
54716
+ * **The ACTIVE (in-RAM) tracks count as pinned here.** A running track has
54717
+ * no persisted row yet, so the table cannot answer for it; treating an
54718
+ * unanswerable id as unpinned would delete the media of the camera's current
54719
+ * subject. Unknown means protected — the inverse of {@link existingTrackIds},
54720
+ * where unknown means alive, and the same safe direction.
54721
+ *
54722
+ * THROWS on a query failure. A probe that quietly under-reports here reads as
54723
+ * "nothing is pinned".
54724
+ */
54725
+ async pinnedTrackIds(trackIds) {
54726
+ const PAGE = 500;
54727
+ const found = /* @__PURE__ */ new Set();
54728
+ for (const id of trackIds) if (this.active.has(id)) found.add(id);
54729
+ for (let i = 0; i < trackIds.length; i += PAGE) {
54730
+ const chunk = trackIds.slice(i, i + PAGE);
54731
+ if (chunk.length === 0) continue;
54732
+ const [staging, favourited] = await Promise.all([this.store.query.query({
54733
+ collection: TRACKS_COLLECTION,
54734
+ columns: ["id"],
54735
+ filter: {
54736
+ whereIn: { id: [...chunk] },
54737
+ where: { retrainStatus: "staging" },
54738
+ limit: chunk.length
54739
+ }
54740
+ }), this.store.query.query({
54741
+ collection: TRACKS_COLLECTION,
54742
+ columns: ["id"],
54743
+ filter: {
54744
+ whereIn: { id: [...chunk] },
54745
+ where: { favourited: true },
54746
+ limit: chunk.length
54747
+ }
54748
+ })]);
54749
+ for (const row of staging) found.add(row.id);
54750
+ for (const row of favourited) found.add(row.id);
54751
+ }
54752
+ return found;
54753
+ }
54754
+ /**
53145
54755
  * The distinct set of deviceIds that currently have persisted tracks. Feeds
53146
54756
  * the per-device retention sweep (design §6) so a device that stopped
53147
54757
  * producing frames still has its aged track debris pruned. Paged scan +
@@ -54544,13 +56154,45 @@ async function runOrphanAudit(input) {
54544
56154
  * sees the audit in a flame graph needs to be able to make it smaller without a
54545
56155
  * release.
54546
56156
  *
54547
- * `mode` defaults to `'report'`. That default is the whole safety argument (see
54548
- * `orphan-audit.ts`): the audit observes before it is allowed to act.
56157
+ * `mode` defaults to `'reclaim'` as of 2026-08-30 (D304). It defaulted to
56158
+ * `'report'` from the day the engine shipped, and that default was right for
56159
+ * exactly as long as its premise held — see {@link OrphanAuditModeSchema}.
54549
56160
  */
54550
56161
  var ORPHAN_AUDIT_MODE_SETTING = "orphanAuditMode";
54551
56162
  var ORPHAN_AUDIT_INTERVAL_SETTING = "orphanAuditIntervalHours";
54552
56163
  var ORPHAN_AUDIT_PAGE_SIZE_SETTING = "orphanAuditPageSize";
54553
- var OrphanAuditModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
56164
+ /**
56165
+ * `'reclaim'` is the default. Here is the argument, because reversing a
56166
+ * destructive default needs one on the record.
56167
+ *
56168
+ * The original default was `'report'`, and `orphan-audit.ts` states its reason
56169
+ * precisely: *"this engine's correctness rests entirely on an ownership model
56170
+ * that is being rewritten in the same change"*. Report mode existed to make
56171
+ * that model observable BEFORE it was load-bearing.
56172
+ *
56173
+ * That premise is spent. The model has run in production for weeks and has been
56174
+ * read: the `faces` scope reported 99 orphans among its oldest 300 rows and all
56175
+ * 99 were `assigned`, i.e. the exemption doing exactly what it claims; the
56176
+ * vector index reported 956 orphans per pass, of which the D58 re-derivability
56177
+ * check (a vector whose track is gone can neither be re-embedded nor returned)
56178
+ * accounts for all of them. What report mode had left to prove, it proved.
56179
+ *
56180
+ * What it did instead was nothing, forever. `reclaimed: 0` was logged every six
56181
+ * hours against a standing backlog, and a backstop that never acts is not a
56182
+ * backstop — it is a leftover that describes the right design, which this repo
56183
+ * has decided reads as verification and is worse than an absence.
56184
+ *
56185
+ * The blast radius is unchanged and bounded five ways (exempt owner kinds, the
56186
+ * grace window, probe-failure abandons the scope, an unresolvable row is kept,
56187
+ * and a per-scope reclaim ceiling that shows up in the report as `stoppedBy`).
56188
+ * An operator who wants observation-only sets `orphanAuditMode: 'report'` and
56189
+ * gets exactly the old behaviour; the knob did not move, only its default.
56190
+ *
56191
+ * NOTE what this does NOT change: an OPERATOR-triggered pass
56192
+ * (`retention.orphanAudit`) is still dry-run unless it names `mode` — it never
56193
+ * inherits this setting. Reclaiming on demand is still a word you have to type.
56194
+ */
56195
+ var OrphanAuditModeSchema = require_dist._enum(["report", "reclaim"]).default("reclaim");
54554
56196
  /** Hours between passes. Hours, not minutes — this walks whole collections. */
54555
56197
  var OrphanAuditIntervalHoursSchema = require_dist.number().min(1).max(168).default(6);
54556
56198
  var OrphanAuditPageSizeSchema = require_dist.number().int().min(50).max(5e3).default(500);
@@ -54836,8 +56478,12 @@ var BindingCache = class {
54836
56478
  *
54837
56479
  * `confirm` (S4) is the confirmation-gate re-detect crop: it GATES track birth
54838
56480
  * (the frame path awaits it), so it ranks WITH `firstFrame` at 0 — it must
54839
- * jump every queued lower-priority capture and its call site never sets a
54840
- * `staleAfterMs` (a late confirmation still confirms). `face`/`plate` (S4) are
56481
+ * jump every queued lower-priority capture. It DOES set a `staleAfterMs`, and
56482
+ * it must: priority 0 means it is inserted at the head, so an entry here that
56483
+ * cannot expire makes the whole lane unrecoverable — every later arrival is
56484
+ * inserted at the tail and shed on admission. Its deadline is the gate's own
56485
+ * `timeoutMs`, past which its caller has already discarded the answer; see the
56486
+ * `fetchCrop` note in `pipeline-analytics/index.ts`. `face`/`plate` (S4) are
54841
56487
  * enrichment gallery crops — same rank as `embed`: nice-to-have media that
54842
56488
  * must never displace the track-media ladder above.
54843
56489
  *
@@ -54882,7 +56528,8 @@ var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
54882
56528
  var DEFAULT_LOG_WINDOW_MS = 6e4;
54883
56529
  /** One `capture queue full` line per device per this interval. The condition
54884
56530
  * holds for as long as the lane is saturated; one line per shed request would
54885
- * be thousands. */
56531
+ * be thousands. What the line REPORTS is the whole interval's shed count, not
56532
+ * the one call that happened to break the throttle — see {@link OverflowWindow}. */
54886
56533
  var OVERFLOW_WARN_THROTTLE_MS = 6e4;
54887
56534
  var CaptureScheduler = class {
54888
56535
  logger;
@@ -54896,8 +56543,8 @@ var CaptureScheduler = class {
54896
56543
  /** holdKey → refcount of queued/running requests holding it. */
54897
56544
  heldKeys = /* @__PURE__ */ new Map();
54898
56545
  windows = /* @__PURE__ */ new Map();
54899
- /** Per-device throttle for the `capture queue full` warn. */
54900
- lastOverflowWarnAt = /* @__PURE__ */ new Map();
56546
+ /** Per-device throttle + shed accumulator for the `capture queue full` warn. */
56547
+ overflowWindows = /* @__PURE__ */ new Map();
54901
56548
  onDropped;
54902
56549
  onStarted;
54903
56550
  constructor(options) {
@@ -54982,6 +56629,7 @@ var CaptureScheduler = class {
54982
56629
  this.coalesced.clear();
54983
56630
  this.heldKeys.clear();
54984
56631
  this.windows.clear();
56632
+ this.overflowWindows.clear();
54985
56633
  }
54986
56634
  /** Snapshot of the CURRENT window's counters (tests/ops introspection). */
54987
56635
  counters(deviceId) {
@@ -55095,8 +56743,18 @@ var CaptureScheduler = class {
55095
56743
  if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
55096
56744
  });
55097
56745
  }
55098
- /** Drop every queued entry already past its `staleAfterMs`. Entries without
55099
- * one (e.g. `confirm`, which gates track birth) are never expired here. */
56746
+ /**
56747
+ * Drop every queued entry already past its `staleAfterMs`.
56748
+ *
56749
+ * An entry without one is never expired here — and that is a real hazard, not
56750
+ * a convenience: combined with {@link insertByPriority} + {@link shedToCap},
56751
+ * an unexpirable entry near the HEAD makes a saturated lane unrecoverable,
56752
+ * because every later arrival lands at the tail and is shed on admission.
56753
+ * `confirm` was the case that bit (see the `fetchCrop` note in
56754
+ * `pipeline-analytics/index.ts`); `keyFrame` is the one kind that still omits
56755
+ * a deadline, and it may, because nothing abandons it — a late keyFrame can
56756
+ * still be served from the worker's scene-tile store and lands for real.
56757
+ */
55100
56758
  dropExpired(deviceId, lane) {
55101
56759
  const nowMs = this.now();
55102
56760
  for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
@@ -55109,9 +56767,14 @@ var CaptureScheduler = class {
55109
56767
  entry.drop();
55110
56768
  }
55111
56769
  }
55112
- /** Shed from the tail (lowest priority, newest within it) until the lane is
55113
- * at its cap. Loud: a capture that is dropped rather than merely late is a
55114
- * missing thumbnail or first frame, and silence would read as "never asked". */
56770
+ /**
56771
+ * Shed from the tail (lowest priority, newest within it) until the lane is at
56772
+ * its cap. Loud: a capture that is dropped rather than merely late is a
56773
+ * missing thumbnail or first frame, and silence would read as "never asked".
56774
+ *
56775
+ * The warn is throttled but its `shed` count is NOT the throttled call's — it
56776
+ * is every entry shed since the last line. See {@link OverflowWindow}.
56777
+ */
55115
56778
  shedToCap(deviceId, lane) {
55116
56779
  let shed = 0;
55117
56780
  while (lane.queue.length > this.maxQueued) {
@@ -55124,19 +56787,41 @@ var CaptureScheduler = class {
55124
56787
  }
55125
56788
  if (shed === 0) return;
55126
56789
  const nowMs = this.now();
55127
- if (nowMs - (this.lastOverflowWarnAt.get(deviceId) ?? Number.NEGATIVE_INFINITY) < OVERFLOW_WARN_THROTTLE_MS) return;
55128
- this.lastOverflowWarnAt.set(deviceId, nowMs);
56790
+ const window = this.overflowWindow(deviceId, nowMs);
56791
+ window.shed += shed;
56792
+ if (nowMs - window.lastWarnAt < OVERFLOW_WARN_THROTTLE_MS) return;
56793
+ const reported = window.shed;
56794
+ const sinceMs = window.sinceMs;
56795
+ window.shed = 0;
56796
+ window.sinceMs = nowMs;
56797
+ window.lastWarnAt = nowMs;
55129
56798
  this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
55130
56799
  tags: { deviceId },
55131
56800
  meta: {
55132
- shed,
56801
+ shed: reported,
56802
+ windowSec: Math.round((nowMs - sinceMs) / 1e3),
55133
56803
  cap: this.maxQueued,
55134
56804
  queueDepth: lane.queue.length,
56805
+ unexpirable: lane.queue.reduce((total, entry) => total + (entry.staleAfterMs === void 0 ? 1 : 0), 0),
55135
56806
  inFlight: lane.inFlight,
55136
56807
  budget: this.budget
55137
56808
  }
55138
56809
  });
55139
56810
  }
56811
+ /** The device's shed accumulator, created on first shed. A device that has
56812
+ * never overflowed carries no entry — and its first shed reports
56813
+ * immediately, because the condition is new. */
56814
+ overflowWindow(deviceId, nowMs) {
56815
+ const existing = this.overflowWindows.get(deviceId);
56816
+ if (existing !== void 0) return existing;
56817
+ const created = {
56818
+ sinceMs: nowMs,
56819
+ shed: 0,
56820
+ lastWarnAt: Number.NEGATIVE_INFINITY
56821
+ };
56822
+ this.overflowWindows.set(deviceId, created);
56823
+ return created;
56824
+ }
55140
56825
  insertByPriority(lane, entry) {
55141
56826
  let index = lane.queue.length;
55142
56827
  while (index > 0) {
@@ -57426,6 +59111,36 @@ var EventMediaDispatcher = class {
57426
59111
  }
57427
59112
  }
57428
59113
  };
59114
+ //#endregion
59115
+ //#region src/pipeline-analytics/services/motion-marker-policy.ts
59116
+ /**
59117
+ * Pure. No clock of its own, no store read, no await — it is called on the
59118
+ * motion event path at up to the provider's keepalive rate.
59119
+ *
59120
+ * A non-positive `quietMs` would make every edge a marker (`now - x >= 0` is
59121
+ * true for any mirror), which is the flood this module removes; it is clamped
59122
+ * by the schema (`motionMarkerQuietMs.min`), and the ordering below puts the
59123
+ * detection check first so the reason names the most useful fact.
59124
+ */
59125
+ function decideMotionStill(input) {
59126
+ if (input.debugMediaEnabled) return {
59127
+ capture: true,
59128
+ reason: "debug"
59129
+ };
59130
+ const { now, quietMs } = input;
59131
+ if (input.lastDetectionAtMs !== void 0 && now - input.lastDetectionAtMs < quietMs) return {
59132
+ capture: false,
59133
+ reason: "detection-recent"
59134
+ };
59135
+ if (input.lastMarkerAtMs !== void 0 && now - input.lastMarkerAtMs < quietMs) return {
59136
+ capture: false,
59137
+ reason: "marker-recent"
59138
+ };
59139
+ return {
59140
+ capture: true,
59141
+ reason: "quiet-marker"
59142
+ };
59143
+ }
57429
59144
  var SnapshotTimeoutError$1 = class extends Error {
57430
59145
  constructor(ms) {
57431
59146
  super(`snapshot cap timed out after ${ms}ms`);
@@ -57447,24 +59162,65 @@ function withTimeout$1(promise, ms) {
57447
59162
  var MotionEventSnapshotCapturer = class {
57448
59163
  deps;
57449
59164
  snapshotTimeoutMs;
59165
+ /** Last ms a marker was written, per device. */
59166
+ lastMarkerAt = /* @__PURE__ */ new Map();
59167
+ /** Edges dropped since that device's last marker — reported on its line. */
59168
+ suppressed = /* @__PURE__ */ new Map();
57450
59169
  constructor(deps) {
57451
59170
  this.deps = deps;
57452
59171
  this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
57453
59172
  }
57454
59173
  /**
57455
- * Fetch a still and persist it as the motion event's snapshot. Never
57456
- * throws — the durable record is the motion row, already inserted by
57457
- * the caller.
59174
+ * Judge the edge, and only if it survives, fetch a still and persist it as
59175
+ * the motion event's snapshot. Never throws — the durable record is the
59176
+ * motion row, already inserted by the caller.
59177
+ *
59178
+ * The two clocks the decision reads live HERE, per device: `lastMarkerAt`
59179
+ * (so a drought yields one marker per window, not one per provider
59180
+ * keepalive) and `suppressed` (so the drop is reported ON the next marker's
59181
+ * line rather than 92 400 times a day).
57458
59182
  */
57459
59183
  async capture(input) {
59184
+ const decision = decideMotionStill({
59185
+ debugMediaEnabled: input.policy.debugMediaEnabled,
59186
+ now: input.timestamp,
59187
+ lastDetectionAtMs: input.policy.lastDetectionAtMs,
59188
+ lastMarkerAtMs: this.lastMarkerAt.get(input.deviceId),
59189
+ quietMs: input.policy.quietMs
59190
+ });
59191
+ if (!decision.capture) {
59192
+ this.suppressed.set(input.deviceId, (this.suppressed.get(input.deviceId) ?? 0) + 1);
59193
+ return {
59194
+ captured: false,
59195
+ reason: decision.reason
59196
+ };
59197
+ }
59198
+ this.lastMarkerAt.set(input.deviceId, input.timestamp);
59199
+ const suppressed = this.suppressed.get(input.deviceId) ?? 0;
59200
+ this.suppressed.delete(input.deviceId);
59201
+ if (decision.reason === "quiet-marker") this.deps.logger.info("motion marker still: detection drought", {
59202
+ tags: { deviceId: input.deviceId },
59203
+ meta: {
59204
+ eventId: input.eventId,
59205
+ quietMs: input.policy.quietMs,
59206
+ suppressedSinceLastMarker: suppressed,
59207
+ lastDetectionAtMs: input.policy.lastDetectionAtMs ?? null
59208
+ }
59209
+ });
57460
59210
  let snap;
57461
59211
  try {
57462
59212
  snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
57463
59213
  } catch (err) {
57464
59214
  this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
57465
- return;
59215
+ return {
59216
+ captured: false,
59217
+ reason: decision.reason
59218
+ };
57466
59219
  }
57467
- if (snap === null) return;
59220
+ if (snap === null) return {
59221
+ captured: false,
59222
+ reason: decision.reason
59223
+ };
57468
59224
  const raw = Buffer.from(snap.base64, "base64");
57469
59225
  let data = raw;
57470
59226
  try {
@@ -57483,7 +59239,21 @@ var MotionEventSnapshotCapturer = class {
57483
59239
  });
57484
59240
  } catch (err) {
57485
59241
  this.deps.onError?.("snapshotMedia", err, input.deviceId);
59242
+ return {
59243
+ captured: false,
59244
+ reason: decision.reason
59245
+ };
57486
59246
  }
59247
+ return {
59248
+ captured: true,
59249
+ reason: decision.reason
59250
+ };
59251
+ }
59252
+ /** Drop a device's clocks when it unbinds — same lifetime as every other
59253
+ * per-device mirror on the addon. */
59254
+ forgetDevice(deviceId) {
59255
+ this.lastMarkerAt.delete(deviceId);
59256
+ this.suppressed.delete(deviceId);
57487
59257
  }
57488
59258
  };
57489
59259
  /**
@@ -57550,6 +59320,78 @@ async function projectSensorMarkers(deps, data, timestamp) {
57550
59320
  }
57551
59321
  return landed;
57552
59322
  }
59323
+ var SensorTransitionGate = class {
59324
+ /** Last observed boolean, keyed by `${deviceId}:${capName}`. */
59325
+ lastValues = /* @__PURE__ */ new Map();
59326
+ /** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
59327
+ drops = /* @__PURE__ */ new Map();
59328
+ windowStartedAt;
59329
+ startedAtMs;
59330
+ now;
59331
+ freshnessMs;
59332
+ summaryWindowMs;
59333
+ constructor(options) {
59334
+ this.startedAtMs = options.startedAtMs;
59335
+ this.now = options.now;
59336
+ this.freshnessMs = options.firstSightingFreshnessMs ?? 3e4;
59337
+ this.summaryWindowMs = options.summaryWindowMs ?? 6e5;
59338
+ this.windowStartedAt = options.now();
59339
+ }
59340
+ /**
59341
+ * Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
59342
+ *
59343
+ * A cap with no boolean level passes through unconditionally. A cap that HAS
59344
+ * one passes only when its boolean moved — in either direction.
59345
+ */
59346
+ observe(deviceId, capName, slice) {
59347
+ if (!require_dist.isSourceCap(capName)) return { changed: true };
59348
+ const key = `${deviceId}:${capName}`;
59349
+ const verdict = require_dist.evaluateSensorEdge({
59350
+ capName,
59351
+ slice,
59352
+ prior: this.lastValues.get(key),
59353
+ startedAtMs: this.startedAtMs,
59354
+ nowMs: this.now(),
59355
+ firstSightingFreshnessMs: this.freshnessMs
59356
+ });
59357
+ if (verdict.edge === "rising") {
59358
+ this.lastValues.set(key, true);
59359
+ return { changed: true };
59360
+ }
59361
+ if (verdict.value !== null) this.lastValues.set(key, verdict.value);
59362
+ if (verdict.reason === "falling-edge") return { changed: true };
59363
+ this.noteDrop(deviceId, capName, verdict.reason);
59364
+ return {
59365
+ changed: false,
59366
+ reason: verdict.reason
59367
+ };
59368
+ }
59369
+ /**
59370
+ * The window's drops, or `[]` while the window is still open. Draining opens
59371
+ * the next window.
59372
+ *
59373
+ * Sorted by `(deviceId, capName, reason)` so a line is comparable with the
59374
+ * previous window's without the reader re-sorting it.
59375
+ */
59376
+ takeDropSummary(nowMs) {
59377
+ if (nowMs - this.windowStartedAt < this.summaryWindowMs) return [];
59378
+ this.windowStartedAt = nowMs;
59379
+ if (this.drops.size === 0) return [];
59380
+ const out = [...this.drops.values()].sort((a, b) => a.deviceId - b.deviceId || a.capName.localeCompare(b.capName) || a.reason.localeCompare(b.reason));
59381
+ this.drops.clear();
59382
+ return out;
59383
+ }
59384
+ noteDrop(deviceId, capName, reason) {
59385
+ const key = `${deviceId}:${capName}:${reason}`;
59386
+ const prior = this.drops.get(key);
59387
+ this.drops.set(key, {
59388
+ deviceId,
59389
+ capName,
59390
+ reason,
59391
+ count: (prior?.count ?? 0) + 1
59392
+ });
59393
+ }
59394
+ };
57553
59395
  //#endregion
57554
59396
  //#region src/pipeline-analytics/services/synthetic-track.ts
57555
59397
  /**
@@ -58733,10 +60575,23 @@ function buildGlobalSettingsSchema(cap) {
58733
60575
  fields: [
58734
60576
  {
58735
60577
  type: "boolean",
58736
- key: "saveThumbnails",
58737
- label: "Save track thumbnails",
58738
- description: "Periodic snapshot crops attached to tracks for timeline review.",
58739
- default: true
60578
+ key: "debugMediaEnabled",
60579
+ label: "Debug media",
60580
+ description: "Off (the default) keeps first/last frame, key frames, thumbnails and face/plate crops, and reduces motion-only stills to one marker per detection drought. On also stores the per-track filmstrip and a still on EVERY motion transition — diagnostic volume, roughly 95 KiB per transition.",
60581
+ default: false
60582
+ },
60583
+ {
60584
+ type: "slider",
60585
+ key: "motionMarkerQuietMs",
60586
+ label: "Motion marker after",
60587
+ description: "With debug media off: a motion still is kept only when no detection has run on this camera for this long, and at most one per window. Fleet p90 of detection-free stretches is ~13 min.",
60588
+ min: 6e4,
60589
+ max: 216e5,
60590
+ step: 6e4,
60591
+ default: 9e5,
60592
+ showValue: true,
60593
+ unit: "min",
60594
+ displayScale: 6e4
58740
60595
  },
58741
60596
  {
58742
60597
  type: "slider",
@@ -59245,7 +61100,7 @@ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
59245
61100
  }
59246
61101
  /**
59247
61102
  * Decide whether the periodic `snapshot` should be captured for a track THIS
59248
- * frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
61103
+ * frame. Pure: no side effects. The caller keeps the `debugMediaEnabled` master
59249
61104
  * switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
59250
61105
  * actually lands — so a skipped (stationary) frame leaves the clock untouched,
59251
61106
  * which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
@@ -59411,6 +61266,129 @@ async function runAudioTrackClassMigration(deps, collection) {
59411
61266
  deps.logger.info("audio track class migration", { meta: { ...stats } });
59412
61267
  return stats;
59413
61268
  }
61269
+ /** Rows one pass will touch. A ceiling, not a target. */
61270
+ var CONVERSION_MAX_ROWS = 2e4;
61271
+ /** Ids named in the "could not convert" line before it stops. */
61272
+ var REFUSED_IDS_LOGGED = 10;
61273
+ /** A stored `embedding` column, narrowed. */
61274
+ function floatsOf(raw) {
61275
+ if (!Array.isArray(raw)) return null;
61276
+ const out = [];
61277
+ for (const v of raw) {
61278
+ if (typeof v !== "number" || !Number.isFinite(v)) return null;
61279
+ out.push(v);
61280
+ }
61281
+ return out;
61282
+ }
61283
+ /**
61284
+ * Convert one collection. Never throws — a failed page ends the pass with what
61285
+ * it has, and the next boot picks the rest up.
61286
+ */
61287
+ async function convertJsonEmbeddings(spec, deps) {
61288
+ let examined = 0;
61289
+ let alreadyConverted = 0;
61290
+ let converted = 0;
61291
+ let refused = 0;
61292
+ let unusable = 0;
61293
+ let offset = 0;
61294
+ let stoppedBy = "exhausted";
61295
+ const refusedIds = [];
61296
+ while (examined < CONVERSION_MAX_ROWS) {
61297
+ let rows;
61298
+ try {
61299
+ rows = await deps.store.query.query({
61300
+ collection: spec.collection,
61301
+ filter: {
61302
+ orderBy: {
61303
+ field: "id",
61304
+ direction: "asc"
61305
+ },
61306
+ limit: 200,
61307
+ offset
61308
+ }
61309
+ });
61310
+ } catch (err) {
61311
+ deps.logger.warn("embedding conversion: page failed — pass abandoned", { meta: {
61312
+ collection: spec.collection,
61313
+ offset,
61314
+ error: String(err)
61315
+ } });
61316
+ stoppedBy = "page-failed";
61317
+ break;
61318
+ }
61319
+ if (rows.length === 0) break;
61320
+ for (const row of rows) {
61321
+ examined += 1;
61322
+ const data = row.data;
61323
+ const raw = data["embedding"];
61324
+ if (Array.isArray(raw) && raw.length === 0) {
61325
+ alreadyConverted += 1;
61326
+ continue;
61327
+ }
61328
+ const embedding = floatsOf(raw);
61329
+ if (embedding === null || embedding.length === 0) {
61330
+ unusable += 1;
61331
+ continue;
61332
+ }
61333
+ const write = spec.write(row.id, embedding, data);
61334
+ if (write === null || !await spec.vectors.put(write)) {
61335
+ refused += 1;
61336
+ if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
61337
+ continue;
61338
+ }
61339
+ if (((await spec.vectors.load([row.id])).get(row.id)?.length ?? 0) !== embedding.length) {
61340
+ refused += 1;
61341
+ if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
61342
+ continue;
61343
+ }
61344
+ try {
61345
+ await deps.store.update.mutate({
61346
+ collection: spec.collection,
61347
+ id: row.id,
61348
+ data: { embedding: [] }
61349
+ });
61350
+ converted += 1;
61351
+ } catch (err) {
61352
+ refused += 1;
61353
+ deps.logger.warn("embedding conversion: row kept its JSON after a successful index write", { meta: {
61354
+ collection: spec.collection,
61355
+ id: row.id,
61356
+ error: String(err)
61357
+ } });
61358
+ }
61359
+ }
61360
+ if (deps.breathe) await deps.breathe();
61361
+ offset += rows.length;
61362
+ if (rows.length < 200) break;
61363
+ }
61364
+ if (examined >= 2e4) stoppedBy = "row-ceiling";
61365
+ if (refused > 0) deps.logger.warn("embedding conversion: rows could not be moved into the index", { meta: {
61366
+ collection: spec.collection,
61367
+ index: spec.vectors.name,
61368
+ refused,
61369
+ ids: refusedIds,
61370
+ why: "the index refused the write or the row named no model — these rows KEEP their JSON vector and are retried on the next pass"
61371
+ } });
61372
+ deps.logger.info("embedding conversion", { meta: {
61373
+ collection: spec.collection,
61374
+ index: spec.vectors.name,
61375
+ examined,
61376
+ alreadyConverted,
61377
+ converted,
61378
+ refused,
61379
+ unusable,
61380
+ stoppedBy
61381
+ } });
61382
+ return {
61383
+ collection: spec.collection,
61384
+ examined,
61385
+ alreadyConverted,
61386
+ converted,
61387
+ refused,
61388
+ unusable,
61389
+ stoppedBy
61390
+ };
61391
+ }
59414
61392
  //#endregion
59415
61393
  //#region src/pipeline-analytics/store/object-embedding-store.ts
59416
61394
  /**
@@ -59662,10 +61640,19 @@ var ObjectEmbeddingStore = class {
59662
61640
  score: match.score
59663
61641
  });
59664
61642
  }
61643
+ if (result.effectiveTopK < input.limit) this.logger.warn("vector search ran with a smaller topK than requested", {
61644
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
61645
+ meta: {
61646
+ index: OBJECT_CLIP_INDEX,
61647
+ requested: input.limit,
61648
+ effectiveTopK: result.effectiveTopK
61649
+ }
61650
+ });
59665
61651
  return {
59666
61652
  hits,
59667
61653
  scanned: result.scanned,
59668
- truncated: result.truncated
61654
+ truncated: result.truncated,
61655
+ effectiveTopK: result.effectiveTopK
59669
61656
  };
59670
61657
  } catch (err) {
59671
61658
  this.logger.warn("ObjectEmbeddingStore.searchByVector failed", {
@@ -59675,7 +61662,109 @@ var ObjectEmbeddingStore = class {
59675
61662
  return {
59676
61663
  hits: [],
59677
61664
  scanned: 0,
59678
- truncated: false
61665
+ truncated: false,
61666
+ effectiveTopK: 0
61667
+ };
61668
+ }
61669
+ }
61670
+ /**
61671
+ * ENUMERATE one page of the index, unranked.
61672
+ *
61673
+ * The orphan scan used to enumerate by asking for a KNN of 20,000 against a
61674
+ * ZERO probe vector. Both halves were wrong. sqlite-vec clamps `k` to 4,096,
61675
+ * so the scan saw 18.5% of the index and logged `examined: 4096` as though it
61676
+ * had walked it; and a zero vector has no direction, so whatever the 4,096
61677
+ * were, they were not a meaningful sample of anything. This asks the question
61678
+ * the reconcile actually has — *what is in here* — through `vectorStore.scan`,
61679
+ * which has no `k` ceiling because it computes no distance.
61680
+ *
61681
+ * An empty page is NOT the end of the index; `nextCursor === null` is.
61682
+ */
61683
+ async scanRefs(cursor, limit) {
61684
+ const page = await this.vectorStore.scan.query({
61685
+ index: OBJECT_CLIP_INDEX,
61686
+ cursor,
61687
+ limit
61688
+ });
61689
+ const rows = [];
61690
+ for (const item of page.items) {
61691
+ const trackId = item.metadata["trackId"];
61692
+ const deviceId = item.metadata["deviceId"];
61693
+ const timestamp = item.metadata["timestamp"];
61694
+ rows.push({
61695
+ id: item.id,
61696
+ trackId: typeof trackId === "string" && trackId.length > 0 ? trackId : item.id.length > 0 ? item.id : null,
61697
+ deviceId: typeof deviceId === "number" ? deviceId : null,
61698
+ timestamp: typeof timestamp === "number" ? timestamp : null
61699
+ });
61700
+ }
61701
+ return {
61702
+ rows,
61703
+ nextCursor: page.nextCursor
61704
+ };
61705
+ }
61706
+ /**
61707
+ * Delete named vectors and CONFIRM they are gone.
61708
+ *
61709
+ * `deleteByIds` reports a row count the `vec0` backend cannot always produce,
61710
+ * so "the call returned" has never been the same statement as "the row left"
61711
+ * — and this store has already shipped one counter that could not tell those
61712
+ * apart (`rebuilt: 704` against an index that moved by 0). The read-back is
61713
+ * `getByIds`, which returns metadata and no vectors, so confirming a page of
61714
+ * deletions costs one indexed lookup and not one byte of float.
61715
+ *
61716
+ * Returns the ids that are VERIFIABLY gone. Anything still present is
61717
+ * returned as `stillPresent` for the caller to log — a delete that silently
61718
+ * did nothing is how an orphan becomes permanent.
61719
+ */
61720
+ async deleteVerified(ids) {
61721
+ if (ids.length === 0) return {
61722
+ deleted: [],
61723
+ stillPresent: []
61724
+ };
61725
+ try {
61726
+ await this.vectorStore.deleteByIds.mutate({
61727
+ index: OBJECT_CLIP_INDEX,
61728
+ ids: [...ids]
61729
+ });
61730
+ } catch (err) {
61731
+ this.declaredDim = null;
61732
+ this.logger.warn("object embedding delete failed — vectors kept", { meta: {
61733
+ index: OBJECT_CLIP_INDEX,
61734
+ ids: ids.length,
61735
+ error: String(err)
61736
+ } });
61737
+ return {
61738
+ deleted: [],
61739
+ stillPresent: [...ids]
61740
+ };
61741
+ }
61742
+ try {
61743
+ const readBack = await this.vectorStore.getByIds.query({
61744
+ index: OBJECT_CLIP_INDEX,
61745
+ ids: [...ids]
61746
+ });
61747
+ const survivors = new Set(readBack.items.map((i) => i.id));
61748
+ const deleted = ids.filter((id) => !survivors.has(id));
61749
+ const stillPresent = ids.filter((id) => survivors.has(id));
61750
+ if (stillPresent.length > 0) this.logger.warn("object embedding delete did not land — rows survived the delete", { meta: {
61751
+ index: OBJECT_CLIP_INDEX,
61752
+ asked: ids.length,
61753
+ survived: stillPresent.length
61754
+ } });
61755
+ return {
61756
+ deleted,
61757
+ stillPresent
61758
+ };
61759
+ } catch (err) {
61760
+ this.logger.warn("object embedding delete could not be verified", { meta: {
61761
+ index: OBJECT_CLIP_INDEX,
61762
+ ids: ids.length,
61763
+ error: String(err)
61764
+ } });
61765
+ return {
61766
+ deleted: [],
61767
+ stillPresent: [...ids]
59679
61768
  };
59680
61769
  }
59681
61770
  }
@@ -59722,13 +61811,15 @@ var ObjectEmbeddingStore = class {
59722
61811
  deleted: 0,
59723
61812
  refused: false
59724
61813
  };
59725
- await this.deleteByTracks(orphans);
61814
+ const { deleted, stillPresent } = await this.deleteVerified(orphans);
59726
61815
  this.logger.info("object embedding orphans pruned", { meta: {
59727
61816
  orphans: orphans.length,
61817
+ deleted: deleted.length,
61818
+ kept: stillPresent.length,
59728
61819
  candidates: input.candidates.length
59729
61820
  } });
59730
61821
  return {
59731
- deleted: orphans.length,
61822
+ deleted: deleted.length,
59732
61823
  refused: false
59733
61824
  };
59734
61825
  }
@@ -59765,25 +61856,15 @@ var ObjectEmbeddingStore = class {
59765
61856
  rows: 0,
59766
61857
  bytes: 0
59767
61858
  }];
59768
- try {
59769
- await this.vectorStore.deleteByIds.mutate({
59770
- index: OBJECT_CLIP_INDEX,
59771
- ids: [...trackIds]
59772
- });
59773
- } catch (err) {
59774
- this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
59775
- count: trackIds.length,
59776
- error: String(err)
59777
- } });
59778
- return [{
59779
- family: "embeddings",
59780
- rows: 0,
59781
- bytes: 0
59782
- }];
59783
- }
61859
+ const { deleted, stillPresent } = await this.deleteVerified(trackIds);
61860
+ if (stillPresent.length > 0) this.logger.warn("cascade left CLIP vectors behind — they are now unreachable by ownership", { meta: {
61861
+ asked: trackIds.length,
61862
+ kept: stillPresent.length,
61863
+ why: "the track rows are deleted immediately after this leaf, so these vectors can only be reclaimed by the orphan audit"
61864
+ } });
59784
61865
  return [{
59785
61866
  family: "embeddings",
59786
- rows: trackIds.length,
61867
+ rows: deleted.length,
59787
61868
  bytes: 0
59788
61869
  }];
59789
61870
  }
@@ -60729,15 +62810,19 @@ function buildStationarySnapshot(input) {
60729
62810
  */
60730
62811
  var ENCODER_INFO_TTL_MS = 6e4;
60731
62812
  /**
60732
- * Rows the orphan sweep considers per pass, on both sides of the comparison.
62813
+ * Rows of the vector index one orphan pass may examine.
62814
+ *
62815
+ * Bounded so a retention tick cannot turn into a full-index scan; the rest is
62816
+ * left for the next tick, resumed from a cursor. Orphans are not urgent, they
62817
+ * are dead weight the search has to skip.
60733
62818
  *
60734
- * Bounded so a retention tick cannot turn into a full-index scan. A pass that
60735
- * does not reach everything simply leaves the rest for the next tick — orphans
60736
- * are not urgent, they are just dead weight the search has to skip.
62819
+ * **This number is now real.** It used to be handed to a KNN as `topK`, where
62820
+ * sqlite-vec silently clamped it to 4,096 — so the audit saw 18.5% of a 22,128-
62821
+ * row index and reported `examined: 4096` as if it had walked the whole thing.
62822
+ * The pass enumerates through `vectorStore.scan` instead, which has no `k`
62823
+ * ceiling because it computes no distance. See `embedding-orphan-scan.ts`.
60737
62824
  */
60738
62825
  var EMBEDDING_ORPHAN_SCAN_LIMIT = 2e4;
60739
- /** Dimension of the zero probe vector used to enumerate the index. */
60740
- var EMBEDDING_ORPHAN_PROBE_DIM = 512;
60741
62826
  /**
60742
62827
  * Tracks fetched per page while rebuilding.
60743
62828
  *
@@ -60805,6 +62890,7 @@ var customActions = {
60805
62890
  ...embeddingActions,
60806
62891
  ...debugActions,
60807
62892
  ...orphanAuditActions,
62893
+ ...mediaReclaimActions,
60808
62894
  ...viewerSettingsActions,
60809
62895
  ...photoEnrollActions,
60810
62896
  ...faceRescoreActions,
@@ -60845,11 +62931,38 @@ var FACE_REEMBED_AUTO_CHECK_MS = 6e4;
60845
62931
  /** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
60846
62932
  * scheduled detail call's frameHandle lease is already gone. */
60847
62933
  var DETAIL_FALLBACK_CROP_PADDING = .15;
60848
- /** Max |keyFrame − plate-read| skew (ms) accepted for the DURABLE keyFrame-
60849
- * derived plate-crop last resort. A readable plate implies a slow/stopping
60850
- * vehicle, so within this window the subject barely moves off its bbox;
60851
- * beyond it the crop would show empty scene, so it is rejected (no image). */
62934
+ /** Max |keyFrame − measured| skew (ms) accepted for a DURABLE keyFrame-derived
62935
+ * crop when the track's position trail carries NO motion evidence about the
62936
+ * two instants. With evidence the motion guard decides instead — time was
62937
+ * never the quantity that separated a right crop from a wrong one. */
60852
62938
  var KEYFRAME_CROP_MAX_SKEW_MS = 1500;
62939
+ /**
62940
+ * Subject-box IoU across the two instants at or above which a keyFrame-derived
62941
+ * crop is accepted whatever the skew.
62942
+ *
62943
+ * Chosen from the 2026-08-30 debug-marked tracks, where each crop's verdict
62944
+ * came from LOOKING at the stored image (`docs/design/
62945
+ * 2026-08-30-debug-marked-tracks-media-triage.md`):
62946
+ *
62947
+ * `e9c024bb` windshield, wrong skew 1179 ms IoU 0.063
62948
+ * `4126f977` gravel, wrong skew 531 ms IoU 0.318
62949
+ * `7613c0fa` plate framed, RIGHT skew 2579 ms IoU 1.000
62950
+ *
62951
+ * Note the ordering: the only correct crop of the three has the LARGEST skew.
62952
+ * The healthy population sits far above the wrong pair too — subject IoU across
62953
+ * a 1500 ms lag is 0.83–1.00 on the eight debug tracks whose tiles frame their
62954
+ * subject. 0.5 is the midpoint of the empirical gap (0.318 → 1.000) and stays
62955
+ * well under that 0.83 floor. A refusal is not a lost tile: the close path
62956
+ * falls through to the retained detection-raster crop, which is right-moment
62957
+ * pixels by construction, so a false refusal costs resolution, not the image.
62958
+ */
62959
+ var KEYFRAME_CROP_MIN_SUBJECT_IOU = .5;
62960
+ /** How far a position sample may sit from an instant and still be evidence
62961
+ * about it. The trail is one sample per emitted frame and is downsampled only
62962
+ * on long tracks (≈280–385 ms spacing at 60–95 s of life), so a full second
62963
+ * covers a healthy trail many times over; past it the honest answer is "no
62964
+ * evidence" and the temporal bound takes over. */
62965
+ var KEYFRAME_CROP_MAX_SAMPLE_GAP_MS = 1e3;
60853
62966
  /** How long the active CLIP model id (from the embedding-encoder) is cached
60854
62967
  * before re-reading. */
60855
62968
  var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
@@ -60861,7 +62974,9 @@ var CONFIRMATION_DETECTOR_CACHE_TTL_MS = 6e4;
60861
62974
  * around the subject improves the confirmation detector's recall. */
60862
62975
  var CONFIRMATION_CROP_PADDING = .15;
60863
62976
  /** Cap the confirmation crop width so a 4K native surface never floods the
60864
- * transport just to confirm one small box. */
62977
+ * transport just to confirm one small box. EXPORTED so a test can recognise a
62978
+ * confirmation round trip among the runner's requests by the real constant
62979
+ * rather than by a copy of the number that would silently stop matching. */
60865
62980
  var CONFIRMATION_CROP_MAX_WIDTH = 320;
60866
62981
  /** Staleness bound for QUEUED enrichment captures (face/plate gallery crops,
60867
62982
  * object-embedding best crops, embedding full-frame fetches — S4). These are
@@ -61044,6 +63159,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61044
63159
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
61045
63160
  * so the DeviceStateChanged handler stays cheap. */
61046
63161
  linkedCamerasCache = null;
63162
+ /**
63163
+ * Whether a mapped sensor cap's slice carried a real TRANSITION.
63164
+ *
63165
+ * `DeviceStateChanged` is emitted on a slice diff, and a contact slice
63166
+ * carries `lastChangedAt` beside `entryOpen` — so an availability flap or a
63167
+ * provider-side device rebuild re-emits with the boolean unmoved. Every one
63168
+ * of those became a `SensorEvent` row and a synthetic track until this gate
63169
+ * existed. Built in the field initializer: `startedAtMs` is the moment this
63170
+ * process began watching, which is the floor for a first sighting that is
63171
+ * already active. See `services/sensor-transition-gate.ts`.
63172
+ */
63173
+ sensorTransitions = new SensorTransitionGate({
63174
+ startedAtMs: Date.now(),
63175
+ now: () => Date.now()
63176
+ });
61047
63177
  /** The ONE projector of non-spatial events into synthetic snapshot-carrying
61048
63178
  * tracks — linked sensor/control state changes AND audio markers. */
61049
63179
  syntheticTracks = null;
@@ -61725,6 +63855,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61725
63855
  decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
61726
63856
  cropRegionToJpeg: (bytes, w, h, norm) => cropWideCentralSquareFromRgb(bytes, w, h, norm),
61727
63857
  maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
63858
+ minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
63859
+ maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
61728
63860
  logger: this.ctx.logger.child("CloseKeyFrameThumbnail")
61729
63861
  });
61730
63862
  this._keyFrameThumbnailDerive = async (input) => {
@@ -61736,7 +63868,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61736
63868
  return {
61737
63869
  thumbnail: derived.jpeg,
61738
63870
  thumbnailSmall: await deriveThumbnailSmall(derived.jpeg),
61739
- skewMs: derived.skewMs
63871
+ skewMs: derived.skewMs,
63872
+ motion: derived.motion,
63873
+ ...derived.subjectIou !== void 0 ? { subjectIou: derived.subjectIou } : {}
61740
63874
  };
61741
63875
  };
61742
63876
  }
@@ -61823,6 +63957,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61823
63957
  * page rather than a full rescan. In RAM: losing them costs a rescan, and
61824
63958
  * persisting them would be a durable record of a purely advisory position. */
61825
63959
  orphanAuditCursors = /* @__PURE__ */ new Map();
63960
+ /** Resume points for the debug-media reclaim, keyed `<scope>:<deviceId>`.
63961
+ * In RAM like the audit's: the pass is resumable from zero at no cost
63962
+ * beyond re-walking a head that is already empty. */
63963
+ mediaReclaimCursors = /* @__PURE__ */ new Map();
63964
+ /** Single-flight latch — two concurrent passes would double the disk
63965
+ * pressure the pacing exists to avoid. */
63966
+ mediaReclaimRunning = false;
63967
+ /**
63968
+ * The vector-index leg's resume cursor, kept in its OWN field.
63969
+ *
63970
+ * Not a key in {@link orphanAuditCursors}: `runOrphanAudit` returns a map
63971
+ * built from ITS scopes and the caller assigns it wholesale, so a key added
63972
+ * afterwards is dropped on the next run — the walk would silently restart at
63973
+ * row 0 every six hours and never reach the tail of the index, which is the
63974
+ * same failure the `k` clamp already caused once.
63975
+ */
63976
+ orphanEmbeddingCursor = 0;
61826
63977
  /** Re-entrancy guard — two whole-database scans must never overlap on the
61827
63978
  * thread that also serves frames. */
61828
63979
  orphanAuditRunning = false;
@@ -61909,6 +64060,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61909
64060
  designatedNode: designated
61910
64061
  } });
61911
64062
  }
64063
+ if (this.isPostProcessingNode) this.convertLegacyEmbeddingColumns(api);
61912
64064
  const transport = createNativeFrameTransport({
61913
64065
  api,
61914
64066
  ownNodeId,
@@ -61960,6 +64112,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61960
64112
  ...embeddingActions,
61961
64113
  ...debugActions,
61962
64114
  ...orphanAuditActions,
64115
+ ...mediaReclaimActions,
61963
64116
  ...viewerSettingsActions,
61964
64117
  ...photoEnrollActions,
61965
64118
  ...faceRescoreActions,
@@ -62155,6 +64308,37 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62155
64308
  });
62156
64309
  this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
62157
64310
  }
64311
+ /**
64312
+ * Move `faces.embedding` and `identity-samples.embedding` out of JSON text
64313
+ * and into their vector indexes — once, in place, idempotently.
64314
+ *
64315
+ * Not awaited by the boot path and not a schema migration: see
64316
+ * `embedding-json-conversion.ts` for why "converted" is a property of a row
64317
+ * rather than a version number. Every failure mode here is safe — a row whose
64318
+ * index write did not land keeps its array, and both readers fall back to it.
64319
+ */
64320
+ async convertLegacyEmbeddingColumns(api) {
64321
+ const faceStore = this.faceStore;
64322
+ const identityStore = this.identityStore;
64323
+ if (!faceStore || !identityStore) {
64324
+ this.ctx.logger.warn("embedding conversion skipped — the gallery stores are not up", { meta: {
64325
+ faceStore: faceStore !== null,
64326
+ identityStore: identityStore !== null
64327
+ } });
64328
+ return;
64329
+ }
64330
+ const deps = {
64331
+ store: api.settingsStore,
64332
+ logger: this.ctx.logger.child("EmbeddingConversion"),
64333
+ breathe: () => new Promise((resolve) => setImmediate(resolve))
64334
+ };
64335
+ try {
64336
+ await convertJsonEmbeddings(faceStore.conversionSpec(), deps);
64337
+ await convertJsonEmbeddings(identityStore.conversionSpec(), deps);
64338
+ } catch (err) {
64339
+ this.ctx.logger.warn("embedding conversion pass failed", { meta: { error: String(err) } });
64340
+ }
64341
+ }
62158
64342
  async declareCollections(api) {
62159
64343
  this.settingsStore = api.settingsStore;
62160
64344
  await Promise.all([
@@ -62487,6 +64671,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62487
64671
  this.motionEventSnapshots = new MotionEventSnapshotCapturer({
62488
64672
  snapshot: { getSnapshot: (i) => api.snapshot?.getSnapshot.query(i) ?? Promise.resolve(null) },
62489
64673
  media: mediaStore,
64674
+ logger: logger.child("MotionMarkerStill"),
62490
64675
  onError: (scope, err, deviceId) => logger.warn("motion event snapshot failed", {
62491
64676
  ...deviceId !== void 0 ? { tags: { deviceId } } : {},
62492
64677
  meta: {
@@ -62504,13 +64689,25 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62504
64689
  error: require_dist.errMsg(err)
62505
64690
  } })
62506
64691
  });
64692
+ const galleryVectorStore = api.vectorStore;
64693
+ if (galleryVectorStore === void 0) throw new Error("pipeline-analytics: the vector-store capability is unavailable, so the face and identity galleries have no home for their vectors — refusing to start recognition without one");
62507
64694
  const identityStore = new IdentityStore({
62508
64695
  store: api.settingsStore,
64696
+ vectors: new EmbeddingVectorIndex({
64697
+ index: IDENTITY_ARCFACE_INDEX,
64698
+ client: galleryVectorStore,
64699
+ logger: logger.child("IdentityVectors")
64700
+ }),
62509
64701
  logger: logger.child("IdentityStore")
62510
64702
  });
62511
64703
  this.identityStore = identityStore;
62512
64704
  const faceStore = new FaceStore({
62513
64705
  store: api.settingsStore,
64706
+ vectors: new EmbeddingVectorIndex({
64707
+ index: FACE_ARCFACE_INDEX,
64708
+ client: galleryVectorStore,
64709
+ logger: logger.child("FaceVectors")
64710
+ }),
62514
64711
  logger: logger.child("FaceStore")
62515
64712
  });
62516
64713
  this.faceStore = faceStore;
@@ -62632,6 +64829,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62632
64829
  decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
62633
64830
  cropRegionToJpeg: async (bytes, w, h, norm) => (await extractCrop(bytes, w, h, norm)).crop,
62634
64831
  maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
64832
+ minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
64833
+ maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
62635
64834
  logger: logger.child("KeyFrameCrop")
62636
64835
  });
62637
64836
  this.plateRecognizer = new PlateRecognizer({
@@ -63459,10 +65658,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63459
65658
  const limit = input.candidateLimit ?? 25;
63460
65659
  const asked = input.deviceIds;
63461
65660
  const known = new Set([...registry.deviceIds(), ...evidence?.deviceIdsWithCandidates() ?? []]);
63462
- return { devices: (asked === void 0 || asked.length === 0 ? [...known].sort((a, b) => a - b) : asked.filter((id) => known.has(id))).map((deviceId) => {
65661
+ return { devices: (asked === void 0 || asked.length === 0 ? [...known].toSorted((a, b) => a - b) : asked.filter((id) => known.has(id))).map((deviceId) => {
63463
65662
  const observedAt = registry.observedAt(deviceId);
63464
65663
  const sessionOpenedAt = registry.sessionOpenedAt(deviceId);
63465
- const ordered = [...evidence?.candidatesFor(deviceId) ?? []].sort((a, b) => b.agreed - a.agreed);
65664
+ const ordered = [...evidence?.candidatesFor(deviceId) ?? []].toSorted((a, b) => b.agreed - a.agreed);
63466
65665
  return {
63467
65666
  deviceId,
63468
65667
  ...sessionOpenedAt !== void 0 ? { sessionOpenedAt } : {},
@@ -63751,6 +65950,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63751
65950
  this.forgetDeviceProcessors(deviceId);
63752
65951
  this.levelStateByDevice.delete(deviceId);
63753
65952
  this.lastTrackActivityMs.delete(deviceId);
65953
+ this.motionEventSnapshots?.forgetDevice(deviceId);
63754
65954
  this.audioEpisodeByDevice.delete(deviceId);
63755
65955
  for (const [episodeId, track] of this.audioEpisodeTracks) if (track.deviceId === deviceId) {
63756
65956
  this.audioEpisodeTracks.delete(episodeId);
@@ -65163,12 +67363,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65163
67363
  deviceId,
65164
67364
  trackId: candidate.trackId,
65165
67365
  kind: "confirm",
67366
+ staleAfterMs: config.timeoutMs,
65166
67367
  exec: () => captureCrop(frameHandle, {
65167
67368
  x: candidate.bbox.x,
65168
67369
  y: candidate.bbox.y,
65169
67370
  w: candidate.bbox.w,
65170
67371
  h: candidate.bbox.h
65171
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, CONFIRMATION_CROP_MAX_WIDTH, deviceId)
67372
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
65172
67373
  }),
65173
67374
  fetchFallbackCrop: (candidate) => {
65174
67375
  const displayCrop = this.captureDisplayCropFn;
@@ -65178,7 +67379,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65178
67379
  y: candidate.bbox.y,
65179
67380
  w: candidate.bbox.w,
65180
67381
  h: candidate.bbox.h
65181
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, CONFIRMATION_CROP_MAX_WIDTH);
67382
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
65182
67383
  },
65183
67384
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
65184
67385
  minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
@@ -65541,12 +67742,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65541
67742
  /**
65542
67743
  * §5 — decide which active tracks need periodic media THIS frame. Pure over
65543
67744
  * TrackStore.lastSnapshotAt + the per-track best-confidence map:
65544
- * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
65545
- * the snapshotIntervalMs cadence, gated by `saveThumbnails`;
67745
+ * • `snapshot` (append) fires on the snapshotIntervalMs cadence, gated by
67746
+ * `debugMediaEnabled`; the rolling `lastFrame` fires on its own cadence
67747
+ * and is NOT gated (it is in the keep-set);
65546
67748
  * • `thumbnail` (best) fires when confidence beats the held best per
65547
67749
  * `BEST_FRAME_POLICY` (hysteresis margin, rate-limited to one per
65548
67750
  * `minGapMs` — see `pipeline/best-selection-policies.ts`), and is
65549
- * NOT gated by saveThumbnails (a single best still-frame is always useful).
67751
+ * never gated (a single best still-frame is always useful).
65550
67752
  * The per-track best-confidence map is updated here as a side effect.
65551
67753
  */
65552
67754
  /**
@@ -65958,9 +68160,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65958
68160
  });
65959
68161
  continue;
65960
68162
  }
65961
- const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
65962
- const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
65963
- lastSnapshotAt: lastSnap,
68163
+ const dueSnapshot = evaluatePeriodicSnapshot({
68164
+ lastSnapshotAt: this.trackStore.lastSnapshotAt(t.trackId),
65964
68165
  lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
65965
68166
  currentBbox: t.bbox,
65966
68167
  now: timestamp,
@@ -65985,7 +68186,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65985
68186
  const isNewBest = this.residents.observeBestFrame(deviceId, t.trackId, t.confidence, timestamp, edgeClear, centerScore, containment, sizeScore, squareFraction, false);
65986
68187
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
65987
68188
  const plan = planPeriodicMedia({
65988
- saveThumbnails: media.saveThumbnails,
68189
+ debugMediaEnabled: media.debugMediaEnabled,
65989
68190
  dueSnapshot,
65990
68191
  isNewBest,
65991
68192
  thumbnailLanded: this.residents.isThumbnailLanded(t.trackId),
@@ -66414,11 +68615,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66414
68615
  timestamp: ev.timestamp
66415
68616
  }
66416
68617
  });
66417
- if (transitionedOn) await this.motionEventSnapshots?.capture({
66418
- deviceId,
66419
- eventId: ev.id,
66420
- timestamp: ev.timestamp
66421
- });
68618
+ if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
66422
68619
  }
66423
68620
  /**
66424
68621
  * Persist firmware-driven (onboard) motion events. Mirrors
@@ -66493,10 +68690,38 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66493
68690
  timestamp: ev.timestamp
66494
68691
  }
66495
68692
  });
66496
- if (transitionedOn) await this.motionEventSnapshots?.capture({
68693
+ if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
68694
+ }
68695
+ /**
68696
+ * The gate in front of the motion still. Both motion handlers reach it, so
68697
+ * there is exactly one place that decides.
68698
+ *
68699
+ * With debug media OFF (the default) the still is the operator's fallback
68700
+ * marker: produced only during a DETECTION DROUGHT, once per
68701
+ * `motionMarkerQuietMs` window. See `services/motion-marker-policy.ts` for
68702
+ * why the raw edge is not a scene fact — it is the provider's ~1 Hz
68703
+ * keepalive beating against the analyzer's per-frame sample through one
68704
+ * shared edge map.
68705
+ *
68706
+ * A dropped write is REPORTED, but as a per-window summary and never per
68707
+ * event: the suppressed count since the last marker rides on the marker's
68708
+ * own line. At 92 400 suppressions/day a line each would be the flood the
68709
+ * logging rules exist to prevent, and a silent branch reads as "never
68710
+ * happened".
68711
+ */
68712
+ async captureMotionMarkerStill(deviceId, eventId, timestamp) {
68713
+ const capturer = this.motionEventSnapshots;
68714
+ if (!capturer) return;
68715
+ const media = await this.resolveDeviceMediaSettings(deviceId);
68716
+ await capturer.capture({
66497
68717
  deviceId,
66498
- eventId: ev.id,
66499
- timestamp: ev.timestamp
68718
+ eventId,
68719
+ timestamp,
68720
+ policy: {
68721
+ debugMediaEnabled: media.debugMediaEnabled,
68722
+ quietMs: media.motionMarkerQuietMs,
68723
+ lastDetectionAtMs: this.lastTrackActivityMs.get(deviceId)
68724
+ }
66500
68725
  });
66501
68726
  }
66502
68727
  async sweepExpiredTracks() {
@@ -67235,7 +69460,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67235
69460
  };
67236
69461
  if (faceEnd) faceEnd.finally(dropKeyFrame);
67237
69462
  else dropKeyFrame();
67238
- this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
69463
+ const promotedPath = this.trackStore?.getActiveByTrack(track.trackId)?.positions;
69464
+ this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
67239
69465
  this.residents.close(track.trackId, "stationary");
67240
69466
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
67241
69467
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -67573,11 +69799,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67573
69799
  * press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
67574
69800
  * event is a missing history row and a missing notification — never wrong
67575
69801
  * durable state.
69802
+ *
69803
+ * The FIRST question is whether the sensor changed at all. This event is
69804
+ * emitted on a slice diff, and a level cap's slice carries its transition
69805
+ * timestamp beside its boolean — so an availability flap or a device rebuild
69806
+ * re-emits with the boolean unmoved. Both the row and the marker are gated on
69807
+ * the same verdict, in that one place: two derivations of "did this happen"
69808
+ * is one too many, and it is the mistake this handler already made once with
69809
+ * "which cameras" (`resolveSensorEventCameras`).
67576
69810
  */
67577
69811
  async handleSensorStateChanged(data, timestamp) {
67578
69812
  const store = this.sensorEventStore;
67579
69813
  const cache = this.linkedCamerasCache;
67580
69814
  if (store === null || cache === null) return;
69815
+ if (!this.sensorSliceCarriedATransition(data)) return;
67581
69816
  const persisted = [];
67582
69817
  try {
67583
69818
  await ingestSensorStateChange({
@@ -67641,6 +69876,55 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67641
69876
  for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
67642
69877
  }
67643
69878
  /**
69879
+ * True when this slice carried a real transition of its sensor.
69880
+ *
69881
+ * A cap with no boolean level (`doorbell`, `button`, `lock-control`,
69882
+ * `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
69883
+ * the event, and gating it would re-ship the silence of 2026-08-08. A cap
69884
+ * that HAS one passes only when the boolean moved, in either direction.
69885
+ *
69886
+ * A slice that is not an object at all is refused rather than defaulted to
69887
+ * `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
69888
+ * object, which looks like a sensor reporting nonsense when what happened is
69889
+ * that there was nothing to read. Same discipline as the recorder's
69890
+ * `parseSensorStateChanged`.
69891
+ *
69892
+ * The drops are SUMMARISED, never logged one by one: this path carries every
69893
+ * camera's native `motion` slice at motion rate.
69894
+ */
69895
+ sensorSliceCarriedATransition(data) {
69896
+ const slice = data.slice;
69897
+ if (typeof slice !== "object" || slice === null || Array.isArray(slice)) {
69898
+ this.ctx.logger.debug("sensor state change carried no slice — dropped", {
69899
+ tags: { deviceId: data.deviceId },
69900
+ meta: { capName: data.capName }
69901
+ });
69902
+ return false;
69903
+ }
69904
+ const verdict = this.sensorTransitions.observe(data.deviceId, data.capName, { ...slice });
69905
+ this.flushSensorTransitionDrops();
69906
+ return verdict.changed;
69907
+ }
69908
+ /**
69909
+ * One `info` line per (device, cap, reason) per window, or none.
69910
+ *
69911
+ * A branch that drops work says so — but this one drops ~0.6 events/s
69912
+ * fleet-wide in its steady state (every camera's native `motion`), so what it
69913
+ * says is a COUNT. The line an operator greps when a real contact produced no
69914
+ * marker is here, with the numeric `deviceId` of the SENSOR, and it says
69915
+ * which verdict swallowed it.
69916
+ */
69917
+ flushSensorTransitionDrops() {
69918
+ for (const drop of this.sensorTransitions.takeDropSummary(Date.now())) this.ctx.logger.info("sensor state changes dropped — no transition", {
69919
+ tags: { deviceId: drop.deviceId },
69920
+ meta: {
69921
+ capName: drop.capName,
69922
+ reason: drop.reason,
69923
+ count: drop.count
69924
+ }
69925
+ });
69926
+ }
69927
+ /**
67644
69928
  * Say out loud that a MAPPED sensor cap ingested nothing, and why.
67645
69929
  *
67646
69930
  * BOTH branches are `debug`, deliberately. After the self-camera fix neither
@@ -67819,25 +70103,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67819
70103
  return runner.runStatelessStep.mutate(input, require_dist.nodePin(nodeId));
67820
70104
  }
67821
70105
  /**
67822
- * Every track id the track store still holds, across the given devices.
67823
- *
67824
- * Returned as a Set for the orphan sweep, which REFUSES an empty one — a
67825
- * store that failed to answer must not be read as "no tracks exist".
67826
- */
67827
- async collectLiveTrackIds(deviceIds) {
67828
- const live = /* @__PURE__ */ new Set();
67829
- const trackStore = this.trackStore;
67830
- if (!trackStore) return live;
67831
- for (const deviceId of deviceIds) {
67832
- const page = await trackStore.queryRecentIds({
67833
- deviceIds: [deviceId],
67834
- limit: EMBEDDING_ORPHAN_SCAN_LIMIT
67835
- });
67836
- for (const trackId of page.trackIds) live.add(trackId);
67837
- }
67838
- return live;
67839
- }
67840
- /**
67841
70106
  * The periodic ORPHAN AUDIT — ownership-keyed, wide interval, report-first.
67842
70107
  *
67843
70108
  * This is NOT a fifth age sweep and must never be mistaken for one. It takes
@@ -67938,7 +70203,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67938
70203
  }
67939
70204
  });
67940
70205
  }
67941
- await this.auditEmbeddingOrphans(limits);
70206
+ await this.auditEmbeddingOrphans(limits, overrides?.restart === true);
67942
70207
  if (report.totalOrphans > 0) this.eventsOpsLog?.append({
67943
70208
  op: "orphan-audit",
67944
70209
  reason: "maintenance",
@@ -67968,43 +70233,53 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67968
70233
  * that failed to answer must never be read as "no tracks exist", which would
67969
70234
  * wipe the whole index. In report mode nothing is deleted at all.
67970
70235
  */
67971
- async auditEmbeddingOrphans(limits) {
70236
+ async auditEmbeddingOrphans(limits, restart) {
67972
70237
  const embeddingStore = this.objectEmbeddingStore;
67973
- if (!embeddingStore) return;
67974
- try {
67975
- const deviceIds = await this.retentionDeviceIds();
67976
- const live = await this.collectLiveTrackIds(deviceIds);
67977
- const candidates = (await embeddingStore.searchByVector({
67978
- vector: new Float32Array(EMBEDDING_ORPHAN_PROBE_DIM),
67979
- limit: EMBEDDING_ORPHAN_SCAN_LIMIT
67980
- })).hits.map((h) => h.row.trackId);
67981
- const orphans = candidates.filter((id) => !live.has(id));
67982
- if (limits.mode !== "reclaim") {
67983
- this.ctx.logger.info("analytics orphan audit", { meta: {
67984
- mode: limits.mode,
67985
- collection: "vector-index:object-clip",
67986
- examined: candidates.length,
67987
- orphans: live.size === 0 ? 0 : orphans.length,
67988
- reclaimed: 0,
67989
- refused: live.size === 0
67990
- } });
67991
- return;
67992
- }
67993
- const result = await embeddingStore.pruneOrphans({
67994
- liveTrackIds: live,
67995
- candidates
67996
- });
67997
- this.ctx.logger.info("analytics orphan audit", { meta: {
67998
- mode: limits.mode,
67999
- collection: "vector-index:object-clip",
68000
- examined: candidates.length,
68001
- orphans: result.deleted,
68002
- reclaimed: result.deleted,
68003
- refused: result.refused
70238
+ const trackStore = this.trackStore;
70239
+ if (!embeddingStore || !trackStore) {
70240
+ this.ctx.logger.warn("analytics orphan audit: vector-index leg skipped — stores not up", { meta: {
70241
+ collection: EMBEDDING_ORPHAN_SCOPE,
70242
+ embeddingStore: embeddingStore !== null,
70243
+ trackStore: trackStore !== null
68004
70244
  } });
68005
- } catch (err) {
68006
- this.ctx.logger.debug("analytics orphan audit (vector index) failed", { meta: { error: String(err) } });
70245
+ return;
68007
70246
  }
70247
+ const report = await scanEmbeddingOrphans({
70248
+ scanPage: (cursor, limit) => embeddingStore.scanRefs(cursor, limit),
70249
+ liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
70250
+ reclaim: (ids) => embeddingStore.deleteVerified(ids),
70251
+ now: () => Date.now(),
70252
+ logger: this.ctx.logger,
70253
+ breathe: () => new Promise((resolve) => setImmediate(resolve))
70254
+ }, {
70255
+ mode: limits.mode,
70256
+ graceMs: limits.graceMs,
70257
+ pageSize: limits.pageSize,
70258
+ maxRows: Math.max(limits.maxRowsPerScope, EMBEDDING_ORPHAN_SCAN_LIMIT),
70259
+ maxReclaim: limits.maxReclaimPerScope
70260
+ }, restart ? 0 : this.orphanEmbeddingCursor);
70261
+ this.orphanEmbeddingCursor = report.nextCursor;
70262
+ this.ctx.logger.info("analytics orphan audit", { meta: {
70263
+ mode: limits.mode,
70264
+ collection: report.scope,
70265
+ examined: report.examined,
70266
+ orphans: report.orphans,
70267
+ reclaimed: report.reclaimed,
70268
+ kept: report.kept,
70269
+ withinGrace: report.withinGrace,
70270
+ unresolvable: report.unresolvable,
70271
+ notReclaimed: report.notReclaimed,
70272
+ stoppedBy: report.stoppedBy,
70273
+ nextCursor: report.nextCursor
70274
+ } });
70275
+ for (const deviceId of report.deviceIds) this.ctx.logger.info("analytics orphan audit (device)", {
70276
+ tags: { deviceId },
70277
+ meta: {
70278
+ deviceId,
70279
+ collection: report.scope,
70280
+ mode: limits.mode
70281
+ }
70282
+ });
68008
70283
  }
68009
70284
  /**
68010
70285
  * Operator-tunable audit limits. `mode` and `pageSize` are SETTINGS because
@@ -68482,11 +70757,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68482
70757
  }, Buffer.from(file.base64, "base64"));
68483
70758
  if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
68484
70759
  if (outcome.kind === "failed") return refuse("failed", outcome.reason);
68485
- await faceStore.update(face.id, {
70760
+ if (!await faceStore.restampEmbedding({
70761
+ faceId: face.id,
70762
+ deviceId: face.deviceId,
70763
+ trackId: face.trackId,
70764
+ timestamp: face.timestamp,
68486
70765
  embedding: outcome.embedding,
68487
- embeddingModelId: clusterModelId
68488
- });
68489
- if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
70766
+ modelId: clusterModelId
70767
+ })) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
68490
70768
  this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
68491
70769
  tags: { deviceId: face.deviceId },
68492
70770
  meta: {
@@ -68547,7 +70825,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68547
70825
  const identityStore = this.identityStore;
68548
70826
  if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
68549
70827
  const faceId = `face-${input.trackId}`;
68550
- const face = await faceStore.get(faceId);
70828
+ const face = await faceStore.getWithEmbedding(faceId);
68551
70829
  if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
68552
70830
  if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
68553
70831
  const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
@@ -68759,9 +71037,95 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68759
71037
  "embedding.wipe": async () => this.wipeObjectEmbeddings(),
68760
71038
  "embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
68761
71039
  "embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
68762
- "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
71040
+ "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
71041
+ "retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
68763
71042
  };
68764
71043
  }
71044
+ /**
71045
+ * One operator-driven debug-media reclaim pass — the entry point behind
71046
+ * `retention.mediaReclaim`.
71047
+ *
71048
+ * DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
71049
+ * `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
71050
+ * from the per-device cursors this method keeps, and a run that is cut short
71051
+ * has still only forgotten rows whose blobs it proved gone.
71052
+ *
71053
+ * Single-flight: a second call while a pass is in flight returns
71054
+ * `ran: false` rather than doubling the disk pressure the pacing exists to
71055
+ * avoid.
71056
+ */
71057
+ async runMediaReclaimNow(rawInput) {
71058
+ const input = MediaReclaimActionInputSchema.parse(rawInput);
71059
+ const mediaStore = this.mediaStore;
71060
+ const eventStore = this.eventStore;
71061
+ const trackStore = this.trackStore;
71062
+ const empty = {
71063
+ ran: false,
71064
+ mode: input.mode,
71065
+ devices: [],
71066
+ totalExamined: 0,
71067
+ totalEligible: 0,
71068
+ totalReclaimed: 0,
71069
+ totalBytesReclaimed: 0,
71070
+ totalRefused: 0,
71071
+ durationMs: 0,
71072
+ complete: false
71073
+ };
71074
+ if (!mediaStore || !eventStore || !trackStore) return empty;
71075
+ if (this.mediaReclaimRunning) return empty;
71076
+ this.mediaReclaimRunning = true;
71077
+ try {
71078
+ const limits = {
71079
+ mode: input.mode,
71080
+ pageSize: input.pageSize ?? DEFAULT_MEDIA_RECLAIM_LIMITS.pageSize,
71081
+ maxRowsPerDevice: input.maxRowsPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxRowsPerDevice,
71082
+ maxReclaimPerDevice: input.maxReclaimPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxReclaimPerDevice,
71083
+ maxBytesPerRun: input.maxBytesPerRun ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxBytesPerRun,
71084
+ budgetMs: input.budgetMinutes !== void 0 ? input.budgetMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.budgetMs,
71085
+ graceMs: input.graceMinutes !== void 0 ? input.graceMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.graceMs,
71086
+ throttleBytesPerSec: input.throttleBytesPerSec ?? DEFAULT_MEDIA_RECLAIM_LIMITS.throttleBytesPerSec
71087
+ };
71088
+ const scopes = input.scopes ?? MEDIA_RECLAIM_SCOPES;
71089
+ const { report, cursors } = await runMediaReclaim({
71090
+ deviceIds: input.deviceIds ?? await this.retentionDeviceIds(),
71091
+ scopes,
71092
+ limits,
71093
+ cursors: input.restart === true ? /* @__PURE__ */ new Map() : this.mediaReclaimCursors,
71094
+ deps: {
71095
+ page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
71096
+ deviceId,
71097
+ ownerKind: scope === "motion-still" ? "event" : "track",
71098
+ kind: "snapshot",
71099
+ afterMs,
71100
+ limit
71101
+ }),
71102
+ motionEventIds: (ids) => eventStore.existingMotionEventIds(ids),
71103
+ pinnedTrackIds: (ids) => trackStore.pinnedTrackIds(ids),
71104
+ reclaim: (key) => mediaStore.deleteConfirmed(key),
71105
+ now: () => Date.now(),
71106
+ logger: this.ctx.logger,
71107
+ breathe: () => new Promise((resolve) => setImmediate(resolve)),
71108
+ pace: (ms) => ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms))
71109
+ }
71110
+ });
71111
+ this.mediaReclaimCursors = cursors;
71112
+ if (limits.mode === "reclaim" && report.totalReclaimed > 0) await this.eventsOpsLog?.append({
71113
+ op: "prune",
71114
+ reason: "operator",
71115
+ deviceId: null,
71116
+ itemsAffected: report.totalReclaimed,
71117
+ bytesReclaimed: report.totalBytesReclaimed,
71118
+ detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
71119
+ });
71120
+ return {
71121
+ ran: true,
71122
+ ...report,
71123
+ devices: [...report.devices]
71124
+ };
71125
+ } finally {
71126
+ this.mediaReclaimRunning = false;
71127
+ }
71128
+ }
68765
71129
  /** Progress of the detached rebuild — the only way to observe a long pass. */
68766
71130
  async getObjectEmbeddingRebuildStatus() {
68767
71131
  const state = this.rebuildState;
@@ -68813,7 +71177,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68813
71177
  ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {}
68814
71178
  });
68815
71179
  const ranked = search.hits;
68816
- if (ranked.length === 0) return [];
71180
+ if (ranked.length === 0) {
71181
+ if (input.classFilter !== void 0) this.ctx.logger.warn("searchObjectEvents: classFilter matched nothing", {
71182
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
71183
+ meta: {
71184
+ classFilter: input.classFilter,
71185
+ why: "the index's className column carries the CLIP step's output class, not the track's class — only that value can match"
71186
+ }
71187
+ });
71188
+ return [];
71189
+ }
68817
71190
  const embeddingMediaKeyByTrackId = /* @__PURE__ */ new Map();
68818
71191
  const keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
68819
71192
  for (const { row } of ranked) {
@@ -68879,15 +71252,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68879
71252
  const mediaStore = this.mediaStore;
68880
71253
  const trackStore = this.trackStore;
68881
71254
  if (!eventStore || !mediaStore || !trackStore) return null;
71255
+ const stores = {
71256
+ eventStore,
71257
+ mediaStore,
71258
+ faceStore: this.faceStore,
71259
+ plateStore: this.plateStore,
71260
+ objectEmbeddingStore: this.objectEmbeddingStore,
71261
+ groupStore: this.groupStore
71262
+ };
71263
+ const absent = absentCascadeLegs(stores);
71264
+ if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {
71265
+ absent,
71266
+ why: "an optional store was not bound at boot; the track root is still deleted, so these families can only be reclaimed by the orphan audit"
71267
+ } });
68882
71268
  return {
68883
- registry: buildAnalyticsCascadeRegistry({
68884
- eventStore,
68885
- mediaStore,
68886
- faceStore: this.faceStore,
68887
- plateStore: this.plateStore,
68888
- objectEmbeddingStore: this.objectEmbeddingStore,
68889
- groupStore: this.groupStore
68890
- }),
71269
+ registry: buildAnalyticsCascadeRegistry(stores),
68891
71270
  trackStore
68892
71271
  };
68893
71272
  }
@@ -69042,6 +71421,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
69042
71421
  if (!this.mediaRelocate) throw new Error("media relocation unavailable");
69043
71422
  return this.mediaRelocate.countUnstamped();
69044
71423
  }
71424
+ /**
71425
+ * How many rows a pass would still act on against `toLocationId`.
71426
+ *
71427
+ * The media lane's denominator AND its residue, from the one predicate the
71428
+ * pass itself uses. `null` means the count could not be taken — never `0`,
71429
+ * which would read as "there is nothing left to move".
71430
+ */
71431
+ async countRelocatableMedia(input) {
71432
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
71433
+ return this.mediaRelocate.countSelectable(input);
71434
+ }
69045
71435
  /** Every relocate job this addon knows about, newest first (in RAM: the
69046
71436
  * move is resumable, so a lost list costs nothing but the display). */
69047
71437
  async listRelocateMediaJobs() {
@@ -69450,6 +71840,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
69450
71840
  this.sensitivitySettingsCache.delete(input.deviceId);
69451
71841
  this.trackingSettingsCache.delete(input.deviceId);
69452
71842
  this.confirmationGateSettingsCache.delete(input.deviceId);
71843
+ this.mediaSettingsCache.delete(input.deviceId);
69453
71844
  this.forgetDeviceProcessors(input.deviceId);
69454
71845
  return { success: true };
69455
71846
  }
@@ -69483,6 +71874,7 @@ function withMean(row) {
69483
71874
  };
69484
71875
  }
69485
71876
  //#endregion
71877
+ exports.CONFIRMATION_CROP_MAX_WIDTH = CONFIRMATION_CROP_MAX_WIDTH;
69486
71878
  exports.DETECTION_PIPELINE_SECTION_IDS = DETECTION_PIPELINE_SECTION_IDS;
69487
71879
  exports.customActions = customActions;
69488
71880
  exports.default = PipelineAnalyticsAddon;