@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.
@@ -1,4 +1,4 @@
1
- import { $ as buildEventKindDescriptor, A as NcSnoozeSuppressedSchema, At as hydrateSchema, B as SCENE_DIVERGED, Bt as object, C as NcRuleInputSchema, Ct as vectorDimFromBase64, D as NcScheduleSchema, Dt as BaseAddon, E as NcRuleTargetSchema, Et as errMsg, F as RECORDING_EXPORT_MAX_READ_BYTES, Ft as array, G as TimelapseRuleSchema, Gt as EventCategory, H as TIMELAPSE_DENSE_FLOOR_SEC, Ht as record, I as RetrainStatusSchema, It as boolean, J as addonWidgetsSourceCapability, K as TrackSourceSchema, Lt as discriminatedUnion, M as NcTaxonomySchema, Mt as nodePin, N as OpsLogEntrySchema, Nt as sleep, O as NcSnoozeInputSchema, Ot as DeviceType, Pt as _enum, Q as audioModeOf, R as SCENE_DEFAULT_ANCHOR_THRESHOLD, Rt as literal, S as NcConditionDescriptorSchema, St as systemEventFilterApplies, T as NcRuleSchema, Tt as zoneAnalyticsCapability, U as TimelapseRuleInputSchema, Ut as string, V as SceneMonitorSchema, Vt as partialRecord, W as TimelapseRulePatchSchema, Wt as unknown, X as assertTimelapseCadences, Y as alarmPanelCapability, Z as audioMetricsCapability, _ as NC_ALARM_SYSTEM_EVENT_KINDS, _t as readDeviceStateFrom, a as COCO_TO_MACRO, at as encodeVectorBase64, bt as sceneMonitorCapability, c as DETECTION_MACRO_CLASSES, d as EVENT_KIND_BY_CAP, dt as kebabToCamel, et as cosineSimilarity$1, f as EVENT_PAD_MS, ft as notificationRulesCapability, g as MACRO_LABELS, gt as plateGalleryCapability, h as LabelAttributionSchema, ht as pipelineAnalyticsCapability, i as CLUSTER_MODEL_SCOPED_STEPS, j as NcSystemEventKindSchema, jt as isDeviceScopedCap, k as NcSnoozeSchema, kt as createEvent, lt as isDetectionMacroClass, m as FailureCounters, mt as pickClusterStepModels, n as AnalyticsGroupRecordSchema, nt as defineCustomActions, o as DEFAULT_EVENT_COLOR, ot as faceGalleryCapability, p as FULL_IMAGE_BBOX, q as VISIT_MERGE_GAP_MS, r as BaseDevice, rt as deriveRecordingMode, st as failureContributionCapability, t as AnalyticsGroupDetailSchema, tt as customAction, u as DeclaredDevices, ut as isScheduleActive, v as NC_CONDITION_CATALOG, vt as readTimelapseGeneratedAt, w as NcRulePatchSchema, wt as videoclipsCapability, x as NC_TAXONOMY, xt as subKindsOf, y as NC_DEFAULT_SNOOZE_MINUTES, z as SCENE_DEFAULT_UNCOVERED_POLICY, zt as number } from "../dist-4eP-xmjY.mjs";
1
+ import { $ as audioModeOf, A as NcSnoozeSchema, At as BaseAddon, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as discriminatedUnion, C as NcConditionDescriptorSchema, Ct as sceneMonitorCapability, D as NcRuleTargetSchema, Dt as videoclipsCapability, E as NcRuleSchema, Et as vectorDimFromBase64, Ft as nodePin, G as TimelapseRulePatchSchema, Gt as record, H as SceneMonitorSchema, Ht as number, I as RECORDING_EXPORT_MAX_READ_BYTES, It as sleep, J as VISIT_MERGE_GAP_MS, Jt as EventCategory, K as TimelapseRuleSchema, Kt as string, L as RetrainStatusSchema, Lt as _enum, M as NcSystemEventKindSchema, Mt as createEvent, N as NcTaxonomySchema, Nt as hydrateSchema, O as NcScheduleSchema, Ot as zoneAnalyticsCapability, P as OpsLogEntrySchema, Pt as isDeviceScopedCap, Q as audioMetricsCapability, Rt as array, S as NC_TAXONOMY, T as NcRulePatchSchema, Tt as systemEventFilterApplies, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as object, V as SCENE_DIVERGED, Vt as literal, W as TimelapseRuleInputSchema, Wt as partialRecord, X as alarmPanelCapability, Y as addonWidgetsSourceCapability, Z as assertTimelapseCadences, _ as MACRO_LABELS, _t as pickClusterStepModels, a as COCO_TO_MACRO, b as NC_DEFAULT_SNOOZE_MINUTES, bt as readDeviceStateFrom, ct as faceGalleryCapability, d as DeclaredDevices, dt as isDetectionMacroClass, et as buildEventKindDescriptor, f as EVENT_KIND_BY_CAP, ft as isScheduleActive, g as LabelAttributionSchema, h as FailureCounters, ht as notificationRulesCapability, i as CLUSTER_MODEL_SCOPED_STEPS, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as DeviceType, k as NcSnoozeInputSchema, kt as errMsg, l as DETECTION_MACRO_CLASSES, lt as failureContributionCapability, m as FULL_IMAGE_BBOX, mt as kebabToCamel, n as AnalyticsGroupRecordSchema, nt as customAction, o as DEFAULT_EVENT_COLOR, ot as encodeVectorBase64, p as EVENT_PAD_MS, pt as isSourceCap, q as TrackSourceSchema, qt as unknown, r as BaseDevice, rt as defineCustomActions, st as evaluateSensorEdge, t as AnalyticsGroupDetailSchema, tt as cosineSimilarity$1, v as NC_ALARM_SYSTEM_EVENT_KINDS, vt as pipelineAnalyticsCapability, w as NcRuleInputSchema, wt as subKindsOf, xt as readTimelapseGeneratedAt, y as NC_CONDITION_CATALOG, yt as plateGalleryCapability, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as boolean } from "../dist-B5K63Zmt.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -6263,12 +6263,86 @@ var NcDeviceStateCache = class {
6263
6263
  }
6264
6264
  };
6265
6265
  //#endregion
6266
+ //#region src/notification-center/system-event-tap-url.ts
6267
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
6268
+ var ADMIN_ADDONS_PATH = "/system/addons";
6269
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
6270
+ var ADMIN_DEVICES_PATH = "/devices";
6271
+ /**
6272
+ * The query parameter naming a node.
6273
+ *
6274
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
6275
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
6276
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
6277
+ */
6278
+ var NODE_PARAM = "node";
6279
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
6280
+ var TAB_PARAM = "tab";
6281
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
6282
+ var STREAM_BROKER_TAB = "stream-broker";
6283
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
6284
+ * string map with no device-model dependency; the guard pins it. */
6285
+ var CAMERA_DEVICE_TYPE = "camera";
6286
+ /** Kinds whose action is "go and apply an ADDON update". */
6287
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6288
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6289
+ var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6290
+ /**
6291
+ * Kinds whose action is "go and look at why this camera stopped producing".
6292
+ *
6293
+ * The legacy `camera-offline` is here for the same reason it is still in the
6294
+ * enum: outbox and history rows enqueued before the rename carry it, and an
6295
+ * old row is exactly the one whose action has to keep working.
6296
+ */
6297
+ var DEVICE_DOWN_KINDS = new Set([
6298
+ "device-offline",
6299
+ "stream-offline",
6300
+ "camera-offline"
6301
+ ]);
6302
+ /**
6303
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6304
+ *
6305
+ * A stream belongs to a camera and nothing else. The legacy `camera-offline`
6306
+ * is one too — that name is precisely the claim it made, and it is why the
6307
+ * kind was split into `device-offline` + a `deviceTypes` condition.
6308
+ */
6309
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set(["stream-offline", "camera-offline"]);
6310
+ function withParam(path, key, value) {
6311
+ if (value === void 0 || value.length === 0) return path;
6312
+ return `${path}?${key}=${encodeURIComponent(value)}`;
6313
+ }
6314
+ /**
6315
+ * The admin path (with its query) a system event taps through to, or `null`
6316
+ * when the kind has no action.
6317
+ *
6318
+ * Exported for the cross-package guard and for tests that want the path
6319
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
6320
+ */
6321
+ function systemEventTapPath(subject) {
6322
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6323
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6324
+ if (DEVICE_DOWN_KINDS.has(subject.kind)) {
6325
+ const deviceId = subject.deviceId;
6326
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6327
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
6328
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
6329
+ }
6330
+ return null;
6331
+ }
6332
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
6333
+ function systemEventTapUrl(subject, baseUrl) {
6334
+ if (baseUrl === null || baseUrl.length === 0) return null;
6335
+ const path = systemEventTapPath(subject);
6336
+ if (path === null) return null;
6337
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
6338
+ }
6339
+ //#endregion
6266
6340
  //#region src/notification-center/default-tap-url.ts
6267
6341
  /** The viewer's hosted base path under the hub. */
6268
6342
  var VIEWER_BASE_PATH = "/viewer/camstack";
6269
6343
  function defaultTapUrl(input) {
6270
- if (input.systemEvent === true) return null;
6271
6344
  if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6345
+ if (input.systemEvent !== void 0) return systemEventTapUrl(input.systemEvent, input.baseUrl);
6272
6346
  if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6273
6347
  return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6274
6348
  }
@@ -8861,13 +8935,14 @@ function trailAt(trail, timestamp) {
8861
8935
  * cannot be asked (no trail, no polygon) or when nobody qualifies.
8862
8936
  */
8863
8937
  function selectTriggerZoneCandidates(input) {
8864
- const { candidates, trail, polygons } = input;
8938
+ const { candidates, trail, polygons, strict } = input;
8865
8939
  if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8866
8940
  const qualifying = candidates.filter((candidate) => {
8867
8941
  const point = trailAt(trail, candidate.timestamp);
8868
8942
  return point !== null && zoneOverlap(point, polygons) > 0;
8869
8943
  });
8870
- return qualifying.length > 0 ? qualifying : candidates;
8944
+ if (qualifying.length > 0) return qualifying;
8945
+ return strict === true ? [] : candidates;
8871
8946
  }
8872
8947
  //#endregion
8873
8948
  //#region src/notification-center/dispatcher.ts
@@ -8905,6 +8980,24 @@ var NC_STILL_WAIT_MS = 3e3;
8905
8980
  * is the instant; the ladder then runs among the frames that are about it.
8906
8981
  */
8907
8982
  var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
8983
+ /**
8984
+ * Whether this row's subject can still be photographed AGAIN after its trigger.
8985
+ *
8986
+ * The one thing that separates a row worth waiting for from a row whose only
8987
+ * frame already exists. A live visual track keeps writing media for as long as
8988
+ * it is open — its first frame, its periodic snapshots, its last frame all land
8989
+ * after the instant a zone condition fired. Every other record is photographed
8990
+ * once: an audio window, a device event and a package event are served by the
8991
+ * still shelf, which D178 makes take ONE picture for a whole burst, and a
8992
+ * `track-end` row is by definition past the last of them.
8993
+ *
8994
+ * Waiting on the wrong side of this line has cost both ways in production — 3 s
8995
+ * of latency on a scream when nothing more was coming, and an empty doorstep on
8996
+ * 'Persona su Uscio' when something was.
8997
+ */
8998
+ function canStillGainMedia(entry) {
8999
+ return entry.recordKind === "object-event";
9000
+ }
8908
9001
  var NcDispatcher = class {
8909
9002
  deps;
8910
9003
  targetCache = null;
@@ -9384,7 +9477,12 @@ var NcDispatcher = class {
9384
9477
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9385
9478
  timestampMs: subject.timestamp,
9386
9479
  baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9387
- systemEvent: systemEvent !== void 0
9480
+ ...systemEvent !== void 0 ? { systemEvent: {
9481
+ kind: systemEvent.kind,
9482
+ ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
9483
+ ...systemEvent.deviceType !== void 0 ? { deviceType: systemEvent.deviceType } : {},
9484
+ ...systemEvent.nodeId !== void 0 ? { nodeId: systemEvent.nodeId } : {}
9485
+ } } : {}
9388
9486
  }) : null;
9389
9487
  const actions = await this.mintActions(entry, target);
9390
9488
  return {
@@ -9519,7 +9617,7 @@ var NcDispatcher = class {
9519
9617
  const out = [];
9520
9618
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9521
9619
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9522
- const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9620
+ const firstLook = await this.resolveAttachment(entry, stillOverride, canStillGainMedia(entry) ? "trigger-only" : "any");
9523
9621
  const footagePending = this.renderFootage(entry);
9524
9622
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9525
9623
  const footage = await footagePending;
@@ -9763,20 +9861,34 @@ var NcDispatcher = class {
9763
9861
  * throw. And when nothing qualifies, the full list comes back: a real picture
9764
9862
  * beats no picture (the same rule D212 settled for the best shot).
9765
9863
  */
9766
- async narrowToTriggerZone(entry, ordered) {
9864
+ async narrowToTriggerZone(entry, ordered, freshness) {
9767
9865
  const zoneIds = entry.payload.triggerZoneIds;
9768
9866
  const trackId = entry.payload.subject.trackId;
9769
9867
  const getTrail = this.deps.getSubjectTrail;
9770
9868
  const deviceId = entry.payload.subject.deviceId;
9771
- if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9869
+ const strict = freshness === "trigger-only";
9870
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2 && !strict) return ordered;
9772
9871
  try {
9773
9872
  const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9774
9873
  const kept = selectTriggerZoneCandidates({
9775
9874
  candidates: ordered,
9776
9875
  trail,
9777
9876
  polygons,
9778
- triggerAt: entry.payload.subject.timestamp
9877
+ triggerAt: entry.payload.subject.timestamp,
9878
+ strict
9779
9879
  });
9880
+ if (kept.length === 0) {
9881
+ this.deps.logger.debug("no still from the zone that fired the rule — still waiting", {
9882
+ tags: { deviceId },
9883
+ meta: {
9884
+ ruleId: entry.ruleId,
9885
+ trackId,
9886
+ zoneIds: zoneIds.join(","),
9887
+ rejected: ordered.length
9888
+ }
9889
+ });
9890
+ return kept;
9891
+ }
9780
9892
  if (kept.length === ordered.length) return kept;
9781
9893
  this.deps.logger.info("still narrowed to the zone that fired the rule", {
9782
9894
  tags: { deviceId },
@@ -9856,7 +9968,8 @@ var NcDispatcher = class {
9856
9968
  for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9857
9969
  const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9858
9970
  if (inBand.length === 0) continue;
9859
- const shortlist = await this.narrowToTriggerZone(entry, inBand);
9971
+ const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
9972
+ if (shortlist.length === 0) continue;
9860
9973
  for (const file of shortlist) {
9861
9974
  const raw = Buffer.from(file.base64, "base64");
9862
9975
  if (raw.byteLength === 0) continue;
@@ -25487,13 +25600,46 @@ var MEDIA_COLUMNS = [
25487
25600
  type: "TEXT"
25488
25601
  })
25489
25602
  ];
25490
- var MEDIA_INDEXES = [{
25491
- name: "idx_media_owner",
25492
- columns: ["ownerKind", "ownerId"]
25493
- }, {
25494
- name: "idx_media_device_ts",
25495
- columns: ["deviceId", "timestamp"]
25496
- }];
25603
+ var MEDIA_INDEXES = [
25604
+ {
25605
+ name: "idx_media_owner",
25606
+ columns: ["ownerKind", "ownerId"]
25607
+ },
25608
+ {
25609
+ name: "idx_media_device_ts",
25610
+ columns: ["deviceId", "timestamp"]
25611
+ },
25612
+ (
25613
+ /**
25614
+ * The `locationId IS NULL` seal gate — see `media-relocate-engine.ts`.
25615
+ *
25616
+ * NULLs sort FIRST in a SQLite index, so `WHERE locationId IS NULL` is a
25617
+ * contiguous range from the head of the b-tree: its cost is proportional to
25618
+ * the ANSWER, not to the table. That is the property the gate needs. The
25619
+ * question it must answer fastest is "are there none left", and with this
25620
+ * index that is one seek that immediately falls off the end of an empty
25621
+ * range — regardless of how many million stamped rows sit behind it.
25622
+ *
25623
+ * **What it costs.** One extra b-tree maintained by every insert into a
25624
+ * table taking ~92 k rows/day, and roughly 25 bytes of index per row (~30 MB
25625
+ * at 1.25 M rows). At ~1 insert/s sustained that maintenance is noise
25626
+ * against the JPEG write the same call performs. The one-off cost is real
25627
+ * and worth naming: the FIRST declare after this ships builds the index over
25628
+ * every existing row, on a recordings volume that is currently saturated —
25629
+ * expect the analytics addon's boot to stall for tens of seconds to a couple
25630
+ * of minutes, once.
25631
+ *
25632
+ * A PARTIAL index (`WHERE locationId IS NULL`) would be smaller still and
25633
+ * would cost nothing at all once sealed. It is not used because
25634
+ * `CollectionIndexSchema` has no predicate and the schema fingerprint does
25635
+ * not hash one — adding both to serve one query is a bigger change than the
25636
+ * query deserves, and the plain index already makes the gate O(log n).
25637
+ */
25638
+ {
25639
+ name: "idx_media_location",
25640
+ columns: ["locationId"]
25641
+ })
25642
+ ];
25497
25643
  /** The storage location of a media row/record: its stamped `locationId`, or
25498
25644
  * the default `eventMedia` location for NULL (pre-multi-location) rows. */
25499
25645
  var DEFAULT_MEDIA_LOCATION = "eventMedia";
@@ -25790,6 +25936,112 @@ var MediaStore = class {
25790
25936
  }
25791
25937
  }
25792
25938
  /**
25939
+ * Delete one row and its blob, and CONFIRM the blob is gone before the row
25940
+ * is forgotten (D148, read for this store).
25941
+ *
25942
+ * Every other delete on this class is best-effort: `deleteByKeyUnchecked`
25943
+ * swallows the unlink error and drops the row anyway, and the underlying
25944
+ * `fs.rm(…, { force: true })` cannot distinguish "removed it" from "there
25945
+ * was nothing there". That is tolerable for a cascade whose whole subtree is
25946
+ * going, and it is NOT tolerable for a bulk reclaim: a row deleted while its
25947
+ * blob survives is 95 KiB that nothing will ever name again — the orphan
25948
+ * class this codebase has spent the week sweeping.
25949
+ *
25950
+ * So the order is: unlink, re-probe, and only then delete the row. A probe
25951
+ * that cannot answer (unreachable volume) THROWS, exactly like
25952
+ * {@link referencedBlobState} — the caller must refuse, not assume absence.
25953
+ *
25954
+ * A row with no `path` is a row whose blob was never written; there is
25955
+ * nothing to confirm and the row is removable.
25956
+ */
25957
+ async deleteConfirmed(key) {
25958
+ return this.gated(async () => {
25959
+ const row = await this.store.get.query({
25960
+ collection: MEDIA_COLLECTION,
25961
+ key
25962
+ });
25963
+ if (!row) return {
25964
+ rows: 0,
25965
+ bytes: 0
25966
+ };
25967
+ const data = row;
25968
+ const relativePath = String(data["path"] ?? "");
25969
+ const bytes = Number(data["sizeBytes"] ?? 0);
25970
+ if (relativePath.length > 0) {
25971
+ const location = mediaRowLocation(data);
25972
+ await this.storage.delete({
25973
+ location,
25974
+ relativePath
25975
+ });
25976
+ if (this.storage.exists) {
25977
+ if (await this.storage.exists({
25978
+ location,
25979
+ relativePath
25980
+ })) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
25981
+ } else {
25982
+ let readable = false;
25983
+ try {
25984
+ await this.storage.read({
25985
+ location,
25986
+ relativePath
25987
+ });
25988
+ readable = true;
25989
+ } catch {}
25990
+ if (readable) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
25991
+ }
25992
+ }
25993
+ await this.store.delete.mutate({
25994
+ collection: MEDIA_COLLECTION,
25995
+ key
25996
+ });
25997
+ return {
25998
+ rows: 1,
25999
+ bytes: Number.isFinite(bytes) ? bytes : 0
26000
+ };
26001
+ });
26002
+ }
26003
+ /**
26004
+ * A page of media rows of ONE kind for ONE device, timestamp-ascending from
26005
+ * `afterMs`. The reclaim's cursor.
26006
+ *
26007
+ * `(deviceId, timestamp)` is `idx_media_device_ts`, so this is an index
26008
+ * range scan — unlike a fleet-wide `kind = 'snapshot'` predicate, which has
26009
+ * no index at all (`MEDIA_INDEXES`) and would table-scan 1.26 M rows on
26010
+ * hub-main's single synchronous SQLite thread.
26011
+ */
26012
+ async pageByKindForDevice(input) {
26013
+ return (await this.store.query.query({
26014
+ collection: MEDIA_COLLECTION,
26015
+ columns: [
26016
+ "id",
26017
+ "ownerId",
26018
+ "timestamp",
26019
+ "sizeBytes"
26020
+ ],
26021
+ filter: {
26022
+ where: {
26023
+ deviceId: input.deviceId,
26024
+ ownerKind: input.ownerKind,
26025
+ kind: input.kind
26026
+ },
26027
+ whereBetween: { timestamp: [input.afterMs, Number.MAX_SAFE_INTEGER] },
26028
+ orderBy: {
26029
+ field: "timestamp",
26030
+ direction: "asc"
26031
+ },
26032
+ limit: input.limit
26033
+ }
26034
+ })).map((row) => {
26035
+ const data = row.data;
26036
+ return {
26037
+ key: row.id,
26038
+ ownerId: String(data["ownerId"] ?? ""),
26039
+ timestamp: Number(data["timestamp"] ?? 0),
26040
+ sizeBytes: Number(data["sizeBytes"] ?? 0)
26041
+ };
26042
+ });
26043
+ }
26044
+ /**
25793
26045
  * `deviceId` is the AUTHORIZATION filter: every media row is stamped with
25794
26046
  * its device at write time, so scoping the query by it makes a media read
25795
26047
  * for a device the caller cannot name return nothing — the DB itself
@@ -28232,6 +28484,212 @@ function updateTrackAggregate(prev, match, opts) {
28232
28484
  };
28233
28485
  }
28234
28486
  //#endregion
28487
+ //#region src/pipeline-analytics/store/embedding-vector-index.ts
28488
+ /**
28489
+ * The vector indexes this addon owns, and the one client that talks to them.
28490
+ *
28491
+ * ## The three indexes, and what a row in each one IS
28492
+ *
28493
+ * | index | dim | encoder | one row is | ranked against |
28494
+ * | --- | --- | --- | --- | --- |
28495
+ * | `object-clip` | 512 | MobileCLIP image tower (`mobileclip-*`) | a TRACK's best-confidence object crop | a CLIP **text** vector — semantic search over footage |
28496
+ * | `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 |
28497
+ * | `identity-arcface` | 512 | the SAME ArcFace model | one ENROLLED face of a person | every live face detection, at recognition time |
28498
+ *
28499
+ * `plates`, `vehicles`, `vehicle-samples` and `identities` hold no vector of
28500
+ * any kind — a plate is a STRING and a score, a vehicle and an identity are a
28501
+ * name. They are named here because `vector-store.cap.ts` once claimed six
28502
+ * stores were waiting to migrate, and five of them being absent from this table
28503
+ * is the answer to "where are the other ones".
28504
+ *
28505
+ * ## Why `face-arcface` and `identity-arcface` are two indexes
28506
+ *
28507
+ * They hold the same 512 dims from the same model, and they are still separate.
28508
+ * An index is a set of rows that get ranked against each other and that live and
28509
+ * die together, and these are neither:
28510
+ *
28511
+ * - a `face-arcface` row is TRACK-OWNED. It cascades away with its track and is
28512
+ * capped per camera; it is a CANDIDATE.
28513
+ * - an `identity-arcface` row is retention-EXEMPT forever. It is the ANSWER
28514
+ * every candidate is measured against.
28515
+ *
28516
+ * Merged, every gallery load and every reconcile would carry a discriminating
28517
+ * filter, and the two ways that filter can fail are "a face is ranked against
28518
+ * itself" and "an enrolled person's only sample is reclaimed as a track orphan".
28519
+ * Sharing a dimension is not a reason to share an index.
28520
+ */
28521
+ /** The buffered per-track ArcFace face vector (`pipeline-analytics:faces`). */
28522
+ var FACE_ARCFACE_INDEX = "face-arcface";
28523
+ /** The ENROLLED gallery (`pipeline-analytics:identity-samples`). */
28524
+ var IDENTITY_ARCFACE_INDEX = "identity-arcface";
28525
+ /**
28526
+ * The `className` each index stamps on its rows.
28527
+ *
28528
+ * A constant per index, exactly as `object-clip` stamps `embedding`. It is not
28529
+ * a subject class and must never be filtered on as one: it says which PRODUCER
28530
+ * wrote the row, which is what keeps a face vector out of a CLIP ranking.
28531
+ */
28532
+ var FACE_VECTOR_CLASS = "face";
28533
+ var IDENTITY_VECTOR_CLASS = "identity";
28534
+ /**
28535
+ * One index, plus the lazy declaration every caller would otherwise re-derive.
28536
+ *
28537
+ * The dimension is not known at construction — it comes from whatever encoder
28538
+ * is active — so the index is declared on the first vector, exactly as
28539
+ * `ObjectEmbeddingStore` does it, and a failed declaration DISABLES writes for
28540
+ * that dim rather than letting an upsert fail against an index the provider has
28541
+ * never seen. A failed write clears the cache so the next one re-declares:
28542
+ * a provider swap under a running hub once left that cache warm and every write
28543
+ * throwing for hours.
28544
+ */
28545
+ var EmbeddingVectorIndex = class {
28546
+ index;
28547
+ client;
28548
+ logger;
28549
+ declaredDim = null;
28550
+ constructor(deps) {
28551
+ this.index = deps.index;
28552
+ this.client = deps.client;
28553
+ this.logger = deps.logger;
28554
+ }
28555
+ /** The index name, for a log line that has to name which store dropped work. */
28556
+ get name() {
28557
+ return this.index;
28558
+ }
28559
+ async ensureIndex(dim) {
28560
+ if (this.declaredDim === dim) return true;
28561
+ try {
28562
+ await this.client.declareIndex.mutate({
28563
+ index: this.index,
28564
+ dim,
28565
+ metric: "cosine"
28566
+ });
28567
+ this.declaredDim = dim;
28568
+ return true;
28569
+ } catch (err) {
28570
+ this.logger.warn("vector index declaration failed — writes disabled for this dim", { meta: {
28571
+ index: this.index,
28572
+ dim,
28573
+ error: String(err)
28574
+ } });
28575
+ return false;
28576
+ }
28577
+ }
28578
+ /**
28579
+ * Write one vector. Returns whether it LANDED, never whether the call
28580
+ * returned — the caller uses this to decide it may stop keeping its own copy,
28581
+ * and "the mutate resolved" is not the same statement (D58).
28582
+ */
28583
+ async put(input) {
28584
+ if (input.embedding.length === 0) {
28585
+ this.logger.warn("refused to index an empty vector", { meta: {
28586
+ index: this.index,
28587
+ id: input.id
28588
+ } });
28589
+ return false;
28590
+ }
28591
+ if (!await this.ensureIndex(input.embedding.length)) return false;
28592
+ const metadata = {
28593
+ ...input.extra,
28594
+ className: input.className,
28595
+ modelId: input.modelId,
28596
+ timestamp: input.timestamp,
28597
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
28598
+ };
28599
+ try {
28600
+ const result = await this.client.upsert.mutate({
28601
+ index: this.index,
28602
+ items: [{
28603
+ id: input.id,
28604
+ vector: encodeVectorBase64(input.embedding),
28605
+ metadata
28606
+ }]
28607
+ });
28608
+ if (result.upserted === 0) {
28609
+ this.logger.warn("vector upsert rejected", {
28610
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
28611
+ meta: {
28612
+ index: this.index,
28613
+ id: input.id,
28614
+ rejected: result.rejected
28615
+ }
28616
+ });
28617
+ return false;
28618
+ }
28619
+ return true;
28620
+ } catch (err) {
28621
+ this.declaredDim = null;
28622
+ this.logger.warn("vector upsert failed", {
28623
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
28624
+ meta: {
28625
+ index: this.index,
28626
+ id: input.id,
28627
+ error: String(err)
28628
+ }
28629
+ });
28630
+ return false;
28631
+ }
28632
+ }
28633
+ /**
28634
+ * Read named vectors back as float arrays.
28635
+ *
28636
+ * An id with no row is simply ABSENT from the map — never a zero vector. A
28637
+ * zero vector scores 0 against everything and would quietly un-enrol a person
28638
+ * instead of failing, which is the reason `fetchByIds` drops an unreadable
28639
+ * row rather than padding it.
28640
+ *
28641
+ * On a read failure the map is EMPTY and the failure is logged. An empty
28642
+ * gallery is a visible outage; a partial one silently forgets people.
28643
+ */
28644
+ async load(ids) {
28645
+ const out = /* @__PURE__ */ new Map();
28646
+ if (ids.length === 0) return out;
28647
+ try {
28648
+ const result = await this.client.fetchByIds.query({
28649
+ index: this.index,
28650
+ ids: [...ids]
28651
+ });
28652
+ for (const item of result.items) {
28653
+ const buf = Buffer.from(item.vector, "base64");
28654
+ if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) continue;
28655
+ const view = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
28656
+ out.set(item.id, Array.from(view));
28657
+ }
28658
+ } catch (err) {
28659
+ this.logger.warn("vector fetch failed — no vectors returned for this call", { meta: {
28660
+ index: this.index,
28661
+ ids: ids.length,
28662
+ error: String(err)
28663
+ } });
28664
+ return /* @__PURE__ */ new Map();
28665
+ }
28666
+ return out;
28667
+ }
28668
+ /**
28669
+ * Drop named vectors.
28670
+ *
28671
+ * Best-effort and LOUD: these rows have exactly one owner and it is being
28672
+ * deleted, so a delete that quietly did nothing leaves a vector nothing can
28673
+ * ever reach by ownership again.
28674
+ */
28675
+ async drop(ids) {
28676
+ if (ids.length === 0) return;
28677
+ try {
28678
+ await this.client.deleteByIds.mutate({
28679
+ index: this.index,
28680
+ ids: [...ids]
28681
+ });
28682
+ } catch (err) {
28683
+ this.declaredDim = null;
28684
+ this.logger.warn("vector delete failed — rows left behind", { meta: {
28685
+ index: this.index,
28686
+ ids: ids.length,
28687
+ error: String(err)
28688
+ } });
28689
+ }
28690
+ }
28691
+ };
28692
+ //#endregion
28235
28693
  //#region src/pipeline-analytics/store/face-sort.ts
28236
28694
  var DEFAULT_FACE_SORT = {
28237
28695
  by: "timestamp",
@@ -28297,6 +28755,15 @@ function rankAndCap(faces, order, limit) {
28297
28755
  //#endregion
28298
28756
  //#region src/pipeline-analytics/store/face-store.ts
28299
28757
  /**
28758
+ * The model a row written before 2026-08-20 was produced by.
28759
+ *
28760
+ * Those rows predate `embeddingModelId`, and they were all this. The vector
28761
+ * index stamps a `modelId` on every row it holds, so a converted legacy row has
28762
+ * to be stamped with something true rather than left blank — a blank modelId in
28763
+ * the gallery is a vector the matcher cannot decide is comparable.
28764
+ */
28765
+ var LEGACY_FACE_MODEL_ID = "arcface-r100";
28766
+ /**
28300
28767
  * @durable class=ledger owner=pipeline-analytics
28301
28768
  * write="one UPSERT per track that produced a face detail — the id is
28302
28769
  * 'face-<trackId>', so a track has at most one row and the recognizer
@@ -28378,11 +28845,23 @@ var FACE_COLUMNS = [
28378
28845
  type: "INTEGER",
28379
28846
  notNull: true
28380
28847
  },
28848
+ (
28849
+ /**
28850
+ * LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
28851
+ * `face-arcface` vector index now, keyed by this row's id.
28852
+ *
28853
+ * The column stays declared and stays `notNull` because rows written before
28854
+ * the conversion still carry their array and the conversion has to be able to
28855
+ * read them. A CONVERTED row holds `[]` — two bytes, and the marker that says
28856
+ * "the vector is in the index", which is what makes the conversion idempotent
28857
+ * and observable. Nothing reads this column except the conversion and the
28858
+ * fallback in {@link FaceStore.getWithEmbedding}.
28859
+ */
28381
28860
  {
28382
28861
  name: "embedding",
28383
28862
  type: "JSON",
28384
28863
  notNull: true
28385
- },
28864
+ }),
28386
28865
  {
28387
28866
  name: "embeddingModelId",
28388
28867
  type: "TEXT"
@@ -28466,8 +28945,9 @@ function buildTimestampWindow(input) {
28466
28945
  */
28467
28946
  function rowToFace(id, raw) {
28468
28947
  const data = raw;
28948
+ const { embedding: _legacyEmbedding, ...rest } = data;
28469
28949
  return {
28470
- ...data,
28950
+ ...rest,
28471
28951
  id,
28472
28952
  assigned: Boolean(data.assigned),
28473
28953
  mediaKey: data.mediaKey ?? void 0,
@@ -28483,9 +28963,11 @@ function rowToFace(id, raw) {
28483
28963
  }
28484
28964
  var FaceStore = class {
28485
28965
  store;
28966
+ vectors;
28486
28967
  logger;
28487
28968
  constructor(deps) {
28488
28969
  this.store = deps.store;
28970
+ this.vectors = deps.vectors;
28489
28971
  this.logger = deps.logger;
28490
28972
  }
28491
28973
  static async declare(store) {
@@ -28500,12 +28982,31 @@ var FaceStore = class {
28500
28982
  * id per track → one entry per track). `set` replaces an existing row.
28501
28983
  */
28502
28984
  async insert(face) {
28503
- const { id, ...rest } = face;
28985
+ const { id, embedding, ...rest } = face;
28986
+ if (!await this.vectors.put({
28987
+ id,
28988
+ embedding,
28989
+ className: "face",
28990
+ modelId: face.embeddingModelId ?? "arcface-r100",
28991
+ timestamp: face.timestamp,
28992
+ deviceId: face.deviceId,
28993
+ extra: { trackId: face.trackId }
28994
+ })) this.logger.warn("face row written WITHOUT a searchable vector", {
28995
+ tags: { deviceId: face.deviceId },
28996
+ meta: {
28997
+ faceId: id,
28998
+ index: this.vectors.name,
28999
+ trackId: face.trackId
29000
+ }
29001
+ });
28504
29002
  try {
28505
29003
  await this.store.set.mutate({
28506
29004
  collection: FACES_COLLECTION,
28507
29005
  key: id,
28508
- value: rest
29006
+ value: {
29007
+ ...rest,
29008
+ embedding: []
29009
+ }
28509
29010
  });
28510
29011
  } catch (err) {
28511
29012
  this.logger.warn("FaceStore.insert failed", {
@@ -28518,6 +29019,111 @@ var FaceStore = class {
28518
29019
  }
28519
29020
  }
28520
29021
  /**
29022
+ * How an UNCONVERTED `faces` row becomes an index row.
29023
+ *
29024
+ * The only judgement here is the modelId: a row written before 2026-08-20
29025
+ * carries no `embeddingModelId`, and those rows were all `arcface-r100`, so
29026
+ * that is what they are stamped with — a KNOWN historical fact, not a
29027
+ * fallback guess. A row that names some other model keeps its own.
29028
+ */
29029
+ conversionSpec() {
29030
+ return {
29031
+ collection: FACES_COLLECTION,
29032
+ vectors: this.vectors,
29033
+ write: (id, embedding, data) => {
29034
+ const deviceId = data["deviceId"];
29035
+ const timestamp = data["timestamp"];
29036
+ const trackId = data["trackId"];
29037
+ const modelId = data["embeddingModelId"];
29038
+ return {
29039
+ id,
29040
+ embedding,
29041
+ className: FACE_VECTOR_CLASS,
29042
+ modelId: typeof modelId === "string" && modelId.length > 0 ? modelId : LEGACY_FACE_MODEL_ID,
29043
+ timestamp: typeof timestamp === "number" ? timestamp : 0,
29044
+ ...typeof deviceId === "number" ? { deviceId } : {},
29045
+ ...typeof trackId === "string" ? { extra: { trackId } } : {}
29046
+ };
29047
+ }
29048
+ };
29049
+ }
29050
+ /**
29051
+ * One face WITH its ArcFace vector — the enrolment and re-assignment read.
29052
+ *
29053
+ * Falls back to the row's legacy JSON column when the index has no vector for
29054
+ * this id, which is exactly the window in which a row written before the
29055
+ * conversion has not been converted yet. The fallback is LOGGED: it is
29056
+ * correct, it is temporary, and a fallback nobody can count is a fallback that
29057
+ * becomes permanent.
29058
+ */
29059
+ async getWithEmbedding(faceId) {
29060
+ const raw = await this.store.get.query({
29061
+ collection: FACES_COLLECTION,
29062
+ key: faceId
29063
+ });
29064
+ if (!raw) return null;
29065
+ const face = rowToFace(faceId, raw);
29066
+ const fromIndex = (await this.vectors.load([faceId])).get(faceId);
29067
+ if (fromIndex !== void 0) return {
29068
+ ...face,
29069
+ embedding: fromIndex
29070
+ };
29071
+ const legacy = raw.embedding;
29072
+ if (Array.isArray(legacy) && legacy.length > 0 && legacy.every((n) => typeof n === "number")) {
29073
+ this.logger.info("face vector served from the legacy JSON column", {
29074
+ tags: { deviceId: face.deviceId },
29075
+ meta: {
29076
+ faceId,
29077
+ why: "this row has not been converted into the vector index yet"
29078
+ }
29079
+ });
29080
+ return {
29081
+ ...face,
29082
+ embedding: legacy
29083
+ };
29084
+ }
29085
+ this.logger.warn("face row has no vector in either home", {
29086
+ tags: { deviceId: face.deviceId },
29087
+ meta: {
29088
+ faceId,
29089
+ index: this.vectors.name
29090
+ }
29091
+ });
29092
+ return null;
29093
+ }
29094
+ /**
29095
+ * The ArcFace vectors of named rows, for the CLUSTERING pass.
29096
+ *
29097
+ * Ids with no vector are ABSENT from the map, never present with an empty
29098
+ * array — the clustering loop skips a zero-length vector, so an empty array
29099
+ * would turn "the index did not answer" into "these faces are not clusterable"
29100
+ * and produce a confident zero.
29101
+ */
29102
+ async loadEmbeddings(faceIds) {
29103
+ const indexed = await this.vectors.load(faceIds);
29104
+ const missing = faceIds.filter((id) => !indexed.has(id));
29105
+ if (missing.length === 0) return indexed;
29106
+ const out = new Map(indexed);
29107
+ let fromLegacyColumn = 0;
29108
+ for (const id of missing) {
29109
+ const legacy = (await this.store.get.query({
29110
+ collection: FACES_COLLECTION,
29111
+ key: id
29112
+ }))?.embedding;
29113
+ if (!Array.isArray(legacy) || legacy.length === 0 || !legacy.every((n) => typeof n === "number")) continue;
29114
+ out.set(id, legacy);
29115
+ fromLegacyColumn += 1;
29116
+ }
29117
+ this.logger.info("face vectors served partly from the legacy JSON column", { meta: {
29118
+ index: this.vectors.name,
29119
+ asked: faceIds.length,
29120
+ fromIndex: indexed.size,
29121
+ fromLegacyColumn,
29122
+ unavailable: missing.length - fromLegacyColumn
29123
+ } });
29124
+ return out;
29125
+ }
29126
+ /**
28521
29127
  * Recent UNASSIGNED faces (the enrollment buffer), newest first.
28522
29128
  *
28523
29129
  * NOTE: `assigned` is normalized via `Boolean()` on read so the production
@@ -28637,6 +29243,7 @@ var FaceStore = class {
28637
29243
  error: String(err)
28638
29244
  } });
28639
29245
  }
29246
+ await this.vectors.drop([...deleted]);
28640
29247
  this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
28641
29248
  return [...deleted];
28642
29249
  }
@@ -28667,6 +29274,45 @@ var FaceStore = class {
28667
29274
  });
28668
29275
  }
28669
29276
  }
29277
+ /**
29278
+ * Replace a buffered face's vector AND its model stamp, then confirm BOTH.
29279
+ *
29280
+ * The vector and the stamp live in two homes — the index and the row — and
29281
+ * they must move together or not at all: a row stamped with the cluster model
29282
+ * beside a vector from the previous one is a face the matcher believes is
29283
+ * comparable and is not, which is worse than a face it refuses to compare.
29284
+ *
29285
+ * So: vector first (refuse if it does not land), row second, then a read-back
29286
+ * of the stamp. Returns whether the whole move landed — never whether the
29287
+ * calls returned (D58).
29288
+ */
29289
+ async restampEmbedding(input) {
29290
+ if (!await this.vectors.put({
29291
+ id: input.faceId,
29292
+ embedding: input.embedding,
29293
+ className: "face",
29294
+ modelId: input.modelId,
29295
+ timestamp: input.timestamp,
29296
+ deviceId: input.deviceId,
29297
+ extra: { trackId: input.trackId }
29298
+ })) {
29299
+ this.logger.warn("face re-embed refused: the vector did not reach the index", {
29300
+ tags: { deviceId: input.deviceId },
29301
+ meta: {
29302
+ faceId: input.faceId,
29303
+ index: this.vectors.name
29304
+ }
29305
+ });
29306
+ return false;
29307
+ }
29308
+ await this.update(input.faceId, { embeddingModelId: input.modelId });
29309
+ await this.store.update.mutate({
29310
+ collection: FACES_COLLECTION,
29311
+ id: input.faceId,
29312
+ data: { embedding: [] }
29313
+ });
29314
+ return (await this.get(input.faceId))?.embeddingModelId === input.modelId;
29315
+ }
28670
29316
  /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
28671
29317
  async markAssigned(faceId, identityId, sampleId) {
28672
29318
  const data = {
@@ -28752,11 +29398,13 @@ var FaceStore = class {
28752
29398
  collection: FACES_COLLECTION,
28753
29399
  filter: { where: { trackId } }
28754
29400
  })).filter((r) => !r.data.assigned);
29401
+ const dropped = [];
28755
29402
  for (const row of eligible) try {
28756
29403
  await this.store.delete.mutate({
28757
29404
  collection: FACES_COLLECTION,
28758
29405
  key: row.id
28759
29406
  });
29407
+ dropped.push(row.id);
28760
29408
  removed++;
28761
29409
  } catch (err) {
28762
29410
  this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
@@ -28764,6 +29412,7 @@ var FaceStore = class {
28764
29412
  error: String(err)
28765
29413
  } });
28766
29414
  }
29415
+ await this.vectors.drop(dropped);
28767
29416
  } catch (err) {
28768
29417
  this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
28769
29418
  trackId,
@@ -28789,7 +29438,9 @@ var FaceStore = class {
28789
29438
  faceId,
28790
29439
  error: String(err)
28791
29440
  } });
29441
+ return;
28792
29442
  }
29443
+ await this.vectors.drop([faceId]);
28793
29444
  }
28794
29445
  /**
28795
29446
  * Retrieve a single face by id. Returns null if not found.
@@ -29208,7 +29859,7 @@ var FaceGalleryProvider = class {
29208
29859
  await this.faceStore.clearAssignment(faceId);
29209
29860
  await this.faceStore.update(faceId, { mediaKey: null });
29210
29861
  }
29211
- const currentFace = await this.faceStore.get(faceId);
29862
+ const currentFace = await this.faceStore.getWithEmbedding(faceId);
29212
29863
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
29213
29864
  const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
29214
29865
  const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
@@ -29393,10 +30044,21 @@ var FaceGalleryProvider = class {
29393
30044
  threshold,
29394
30045
  minClusterSize
29395
30046
  } });
29396
- const clusters = clusterByEmbedding(faces.filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
29397
- id: f.id,
29398
- embedding: f.embedding
29399
- })), threshold).filter((c) => c.size >= minClusterSize);
30047
+ const vectors = await this.faceStore.loadEmbeddings(faces.map((f) => f.id));
30048
+ const items = [];
30049
+ for (const face of faces) {
30050
+ const embedding = vectors.get(face.id);
30051
+ if (embedding === void 0 || embedding.length === 0) continue;
30052
+ items.push({
30053
+ id: face.id,
30054
+ embedding
30055
+ });
30056
+ }
30057
+ if (items.length < faces.length) this.logger.warn("suggestFaceClusters skipped faces with no vector in the index", { meta: {
30058
+ scanned: faces.length,
30059
+ clusterable: items.length
30060
+ } });
30061
+ const clusters = clusterByEmbedding(items, threshold).filter((c) => c.size >= minClusterSize);
29400
30062
  return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
29401
30063
  }
29402
30064
  };
@@ -32156,7 +32818,19 @@ async function runPreviewUpgrade(deps, input) {
32156
32818
  });
32157
32819
  return "no-bbox";
32158
32820
  }
32821
+ if (pending.bestSeen.timestamp === void 0) {
32822
+ deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
32823
+ tags: { deviceId },
32824
+ meta: {
32825
+ trackId,
32826
+ keyFrameKey: input.keyFrameKey,
32827
+ heldProvenance: held
32828
+ }
32829
+ });
32830
+ return "no-bbox";
32831
+ }
32159
32832
  const derived = await deps.derive({
32833
+ deviceId,
32160
32834
  mediaKey: input.keyFrameKey,
32161
32835
  bbox: {
32162
32836
  x: pending.bestSeen.x,
@@ -32166,7 +32840,7 @@ async function runPreviewUpgrade(deps, input) {
32166
32840
  },
32167
32841
  frameWidth: pending.bestSeen.frameWidth,
32168
32842
  frameHeight: pending.bestSeen.frameHeight,
32169
- timestamp: input.timestamp
32843
+ timestamp: pending.bestSeen.timestamp
32170
32844
  });
32171
32845
  if (derived === null) {
32172
32846
  deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
@@ -32530,6 +33204,28 @@ async function cascadeDeleteTracks(registry, trackStore, trackIds) {
32530
33204
  * `onInitialize`, not because their families are optional — a missing one means
32531
33205
  * the addon is not fully up, and the sweep does not run in that state.
32532
33206
  */
33207
+ /**
33208
+ * The optional legs that are NOT in the registry this call would build.
33209
+ *
33210
+ * `buildAnalyticsCascadeRegistry` pushes each optional store only `if` it is
33211
+ * there, and that `if` is silent. A hub whose `vector-store` capability was
33212
+ * unavailable at boot builds an embedding-less cascade and then prunes tracks
33213
+ * with it — every pass, for the whole life of the process — while the only
33214
+ * evidence is one warn at startup that scrolled away hours earlier. Every CLIP
33215
+ * vector for every track deleted in that window becomes an orphan that nothing
33216
+ * can reach by ownership again, because its owner is gone.
33217
+ *
33218
+ * So the omission is named and returned, and the caller LOGS it. A branch that
33219
+ * drops work says so; silence reads as "there was nothing to drop".
33220
+ */
33221
+ function absentCascadeLegs(stores) {
33222
+ const absent = [];
33223
+ if (!stores.faceStore) absent.push("faces");
33224
+ if (!stores.plateStore) absent.push("plates");
33225
+ if (!stores.objectEmbeddingStore) absent.push("embeddings");
33226
+ if (!stores.groupStore) absent.push("groups");
33227
+ return absent;
33228
+ }
32533
33229
  function buildAnalyticsCascadeRegistry(stores) {
32534
33230
  const registry = [stores.eventStore, stores.mediaStore];
32535
33231
  if (stores.faceStore) registry.push(stores.faceStore);
@@ -32806,7 +33502,8 @@ var TrackCloser = class {
32806
33502
  w: bestSeen.w,
32807
33503
  h: bestSeen.h,
32808
33504
  frameWidth: bestSeen.frameWidth,
32809
- frameHeight: bestSeen.frameHeight
33505
+ frameHeight: bestSeen.frameHeight,
33506
+ timestamp: bestSeen.timestamp
32810
33507
  } } : {},
32811
33508
  recordedAtMs: this.now()
32812
33509
  });
@@ -32822,7 +33519,7 @@ var TrackCloser = class {
32822
33519
  });
32823
33520
  const keyFrameMediaKey = this.deps.residents.keyFrameKey(t.trackId);
32824
33521
  const labelConfidence = this.bestLabelMatchConfidence(t.deviceId, t.trackId);
32825
- this.fireRecognizerEnds(t.deviceId, t.trackId);
33522
+ this.fireRecognizerEnds(t.deviceId, t.trackId, t.positions);
32826
33523
  const trackerPeak = this.deps.residents.bestFramePeak(t.trackId);
32827
33524
  const importanceSummary = await this.scoreImportance(t, duration, trackerPeak?.confidence);
32828
33525
  this.deps.residents.close(t.trackId, "end");
@@ -32881,14 +33578,14 @@ var TrackCloser = class {
32881
33578
  /** Start face/plate `onTrackEnd` and wire the Design-B DEFERRED
32882
33579
  * keyFrame-key drop (drop only once the face promise settles — or
32883
33580
  * immediately when no face recognizer is active). */
32884
- fireRecognizerEnds(deviceId, trackId) {
33581
+ fireRecognizerEnds(deviceId, trackId, subjectPath) {
32885
33582
  const faceEnd = this.deps.faceRecognizer()?.onTrackEnd(deviceId, trackId);
32886
33583
  const dropKeyFrameKey = () => {
32887
33584
  this.deps.residents.dropKeyFrameKey(trackId);
32888
33585
  };
32889
33586
  if (faceEnd) faceEnd.finally(dropKeyFrameKey);
32890
33587
  else dropKeyFrameKey();
32891
- this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId);
33588
+ this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId, subjectPath);
32892
33589
  }
32893
33590
  /** Last-resort preview: commit the retained detection-raster subject crop
32894
33591
  * as the track's `thumbnail` (honest sub-native, never upscaled). */
@@ -32958,6 +33655,7 @@ var TrackCloser = class {
32958
33655
  }
32959
33656
  try {
32960
33657
  const derived = await derive({
33658
+ deviceId: t.deviceId,
32961
33659
  mediaKey: keyFrame.key,
32962
33660
  bbox: {
32963
33661
  x: bestSeen.x,
@@ -32967,7 +33665,8 @@ var TrackCloser = class {
32967
33665
  },
32968
33666
  frameWidth: bestSeen.frameWidth,
32969
33667
  frameHeight: bestSeen.frameHeight,
32970
- timestamp: keyFrame.timestamp
33668
+ timestamp: bestSeen.timestamp,
33669
+ subjectPath: t.positions
32971
33670
  });
32972
33671
  if (!derived) {
32973
33672
  if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
@@ -33008,6 +33707,8 @@ var TrackCloser = class {
33008
33707
  trackId: t.trackId,
33009
33708
  keyFrameKey: keyFrame.key,
33010
33709
  skewMs: derived.skewMs,
33710
+ motion: derived.motion ?? null,
33711
+ subjectIou: derived.subjectIou ?? null,
33011
33712
  replaced: held?.provenance ?? null
33012
33713
  }
33013
33714
  });
@@ -34310,6 +35011,303 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
34310
35011
  events: doomedEvents.length
34311
35012
  };
34312
35013
  }
35014
+ //#endregion
35015
+ //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35016
+ /**
35017
+ * The operator button for the debug-media reclaim.
35018
+ *
35019
+ * The mechanism ships armed with nothing: **arming it is the operator's
35020
+ * call**. `mode` omitted means `report` — it counts, logs one line per camera
35021
+ * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35022
+ * that has to be typed.
35023
+ *
35024
+ * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35025
+ * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35026
+ * report you can read instead of a log line you have to find.
35027
+ */
35028
+ /**
35029
+ * `report` counts; `reclaim` deletes.
35030
+ *
35031
+ * `.default('report')` is load-bearing: the settings form sends `{}` for a
35032
+ * button with no fields, and the safe reading of an unstated intent is the one
35033
+ * that destroys nothing.
35034
+ */
35035
+ var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
35036
+ var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
35037
+ var MediaReclaimActionInputSchema = object({
35038
+ mode: MediaReclaimActionModeSchema,
35039
+ /** Which populations to walk. Omitted = both. */
35040
+ scopes: array(MediaReclaimScopeSchema).min(1).optional(),
35041
+ /** Restrict to these cameras. Omitted = every camera in retention scope. */
35042
+ deviceIds: array(number().int()).min(1).optional(),
35043
+ /** Start every device from the top instead of resuming its cursor. */
35044
+ restart: boolean().optional(),
35045
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
35046
+ pageSize: number().int().min(50).max(5e3).optional(),
35047
+ /** Rows EXAMINED per camera in this pass — the pacing bound. */
35048
+ maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
35049
+ /** Rows DELETED per camera in this pass — the blast-radius bound. */
35050
+ maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
35051
+ /** Bytes released across the whole run — the disk-contention bound. */
35052
+ maxBytesPerRun: number().int().min(1).optional(),
35053
+ /** Wall-clock ceiling for the run. */
35054
+ budgetMinutes: number().int().min(1).max(720).optional(),
35055
+ /**
35056
+ * Bytes per second the pass may release. The recordings volume sustains
35057
+ * ~4 MB/s and the archive needs it more than this does; the default is half
35058
+ * of that.
35059
+ */
35060
+ throttleBytesPerSec: number().int().min(64 * 1024).optional(),
35061
+ /**
35062
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
35063
+ * exclusively save rows, never select them.
35064
+ */
35065
+ graceMinutes: number().int().min(1).max(10080).optional()
35066
+ });
35067
+ var MediaReclaimDeviceReportSchema = object({
35068
+ deviceId: number(),
35069
+ scope: MediaReclaimScopeSchema,
35070
+ examined: number(),
35071
+ eligible: number(),
35072
+ reclaimed: number(),
35073
+ bytesReclaimed: number(),
35074
+ /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35075
+ held: number(),
35076
+ /** Rows kept because the owner could not be shown to be a motion event. */
35077
+ unresolved: number(),
35078
+ withinGrace: number(),
35079
+ /** Deletes that REFUSED — the blob survived or the volume could not answer.
35080
+ * The row was kept and the cursor did not pass it. */
35081
+ refused: number(),
35082
+ stoppedBy: string(),
35083
+ /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35084
+ nextCursor: number()
35085
+ });
35086
+ var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
35087
+ /** `false` when a pass was already in flight — nothing was done. */
35088
+ ran: boolean(),
35089
+ mode: _enum(["report", "reclaim"]),
35090
+ devices: array(MediaReclaimDeviceReportSchema),
35091
+ totalExamined: number(),
35092
+ totalEligible: number(),
35093
+ totalReclaimed: number(),
35094
+ totalBytesReclaimed: number(),
35095
+ totalRefused: number(),
35096
+ durationMs: number(),
35097
+ /** Every device+scope reported `exhausted` — there is nothing left to do. */
35098
+ complete: boolean()
35099
+ }), { auth: "admin" }) });
35100
+ //#endregion
35101
+ //#region src/pipeline-analytics/retention/media-reclaim.ts
35102
+ var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
35103
+ var DEFAULT_MEDIA_RECLAIM_LIMITS = {
35104
+ mode: "report",
35105
+ pageSize: 500,
35106
+ maxRowsPerDevice: 5e4,
35107
+ maxReclaimPerDevice: 5e4,
35108
+ maxBytesPerRun: 20 * 1024 * 1024 * 1024,
35109
+ budgetMs: 30 * 6e4,
35110
+ graceMs: 60 * 6e4,
35111
+ throttleBytesPerSec: 2 * 1024 * 1024
35112
+ };
35113
+ function emptyTally() {
35114
+ return {
35115
+ examined: 0,
35116
+ eligible: 0,
35117
+ reclaimed: 0,
35118
+ bytesReclaimed: 0,
35119
+ held: 0,
35120
+ unresolved: 0,
35121
+ withinGrace: 0,
35122
+ refused: 0
35123
+ };
35124
+ }
35125
+ /**
35126
+ * Walk ONE device within ONE scope. Never throws: a probe failure ends the
35127
+ * device with what it has and a cursor that has not moved past anything
35128
+ * unfinished.
35129
+ */
35130
+ async function reclaimDeviceScope(input) {
35131
+ const { deviceId, scope, deps, limits } = input;
35132
+ const tally = emptyTally();
35133
+ const graceFloor = deps.now() - limits.graceMs;
35134
+ let cursor = Math.max(0, input.cursor);
35135
+ let byteBudget = input.byteBudget;
35136
+ let stoppedBy = "exhausted";
35137
+ for (;;) {
35138
+ if (deps.now() >= input.deadlineMs) {
35139
+ stoppedBy = "budget";
35140
+ break;
35141
+ }
35142
+ if (tally.examined >= limits.maxRowsPerDevice) {
35143
+ stoppedBy = "row-ceiling";
35144
+ break;
35145
+ }
35146
+ const rows = await deps.page({
35147
+ deviceId,
35148
+ scope,
35149
+ afterMs: cursor,
35150
+ limit: limits.pageSize
35151
+ });
35152
+ if (rows.length === 0) break;
35153
+ const ownerIds = [...new Set(rows.map((r) => r.ownerId).filter((id) => id.length > 0))];
35154
+ let motionOwners = /* @__PURE__ */ new Set();
35155
+ let pinnedOwners = /* @__PURE__ */ new Set();
35156
+ try {
35157
+ if (scope === "motion-still") motionOwners = ownerIds.length > 0 ? await deps.motionEventIds(ownerIds) : /* @__PURE__ */ new Set();
35158
+ else pinnedOwners = ownerIds.length > 0 ? await deps.pinnedTrackIds(ownerIds) : /* @__PURE__ */ new Set();
35159
+ } catch (err) {
35160
+ deps.logger.warn("media reclaim: ownership probe failed — device abandoned for this run", {
35161
+ tags: { deviceId },
35162
+ meta: {
35163
+ scope,
35164
+ cursor,
35165
+ error: String(err)
35166
+ }
35167
+ });
35168
+ stoppedBy = "probe-failed";
35169
+ break;
35170
+ }
35171
+ let pageBytes = 0;
35172
+ let halted = null;
35173
+ for (const row of rows) {
35174
+ tally.examined += 1;
35175
+ const advanceTo = row.timestamp;
35176
+ if (row.timestamp > graceFloor) {
35177
+ tally.withinGrace += 1;
35178
+ cursor = advanceTo;
35179
+ continue;
35180
+ }
35181
+ if (scope === "motion-still" && !motionOwners.has(row.ownerId)) {
35182
+ tally.unresolved += 1;
35183
+ cursor = advanceTo;
35184
+ continue;
35185
+ }
35186
+ if (scope === "track-filmstrip" && pinnedOwners.has(row.ownerId)) {
35187
+ tally.held += 1;
35188
+ cursor = advanceTo;
35189
+ continue;
35190
+ }
35191
+ tally.eligible += 1;
35192
+ if (limits.mode === "report") {
35193
+ cursor = advanceTo;
35194
+ continue;
35195
+ }
35196
+ if (tally.reclaimed >= limits.maxReclaimPerDevice) {
35197
+ halted = "reclaim-ceiling";
35198
+ break;
35199
+ }
35200
+ if (byteBudget <= 0) {
35201
+ halted = "byte-ceiling";
35202
+ break;
35203
+ }
35204
+ try {
35205
+ const gone = await deps.reclaim(row.key);
35206
+ tally.reclaimed += gone.rows;
35207
+ tally.bytesReclaimed += gone.bytes;
35208
+ byteBudget -= gone.bytes;
35209
+ pageBytes += gone.bytes;
35210
+ cursor = advanceTo;
35211
+ } catch (err) {
35212
+ tally.refused += 1;
35213
+ deps.logger.warn("media reclaim: delete refused — row kept", {
35214
+ tags: { deviceId },
35215
+ meta: {
35216
+ scope,
35217
+ key: row.key,
35218
+ error: String(err)
35219
+ }
35220
+ });
35221
+ halted = "delete-refused";
35222
+ break;
35223
+ }
35224
+ }
35225
+ if (halted !== null) {
35226
+ stoppedBy = halted;
35227
+ break;
35228
+ }
35229
+ if (limits.throttleBytesPerSec > 0 && pageBytes > 0 && deps.pace) await deps.pace(pageBytes / limits.throttleBytesPerSec * 1e3);
35230
+ if (deps.breathe) await deps.breathe();
35231
+ if (rows.length < limits.pageSize) break;
35232
+ }
35233
+ return {
35234
+ deviceId,
35235
+ scope,
35236
+ examined: tally.examined,
35237
+ eligible: tally.eligible,
35238
+ reclaimed: tally.reclaimed,
35239
+ bytesReclaimed: tally.bytesReclaimed,
35240
+ held: tally.held,
35241
+ unresolved: tally.unresolved,
35242
+ withinGrace: tally.withinGrace,
35243
+ refused: tally.refused,
35244
+ stoppedBy,
35245
+ nextCursor: cursor
35246
+ };
35247
+ }
35248
+ /** Cursor key for one `(device, scope)` pair. */
35249
+ function mediaReclaimCursorKey(deviceId, scope) {
35250
+ return `${scope}:${deviceId}`;
35251
+ }
35252
+ /**
35253
+ * Walk every `(device, scope)` pair under one shared byte budget and one
35254
+ * shared deadline, and log ONE summary line per device — never a line per row.
35255
+ */
35256
+ async function runMediaReclaim(input) {
35257
+ const startedAt = input.deps.now();
35258
+ const deadlineMs = startedAt + input.limits.budgetMs;
35259
+ const nextCursors = new Map(input.cursors);
35260
+ const devices = [];
35261
+ let byteBudget = input.limits.maxBytesPerRun;
35262
+ for (const scope of input.scopes) for (const deviceId of input.deviceIds) {
35263
+ if (input.deps.now() >= deadlineMs) break;
35264
+ const key = mediaReclaimCursorKey(deviceId, scope);
35265
+ const report = await reclaimDeviceScope({
35266
+ deviceId,
35267
+ scope,
35268
+ cursor: input.cursors.get(key) ?? 0,
35269
+ deps: input.deps,
35270
+ limits: input.limits,
35271
+ deadlineMs,
35272
+ byteBudget
35273
+ });
35274
+ byteBudget -= report.bytesReclaimed;
35275
+ nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
35276
+ devices.push(report);
35277
+ if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
35278
+ tags: { deviceId },
35279
+ meta: {
35280
+ scope,
35281
+ mode: input.limits.mode,
35282
+ examined: report.examined,
35283
+ eligible: report.eligible,
35284
+ reclaimed: report.reclaimed,
35285
+ bytesReclaimed: report.bytesReclaimed,
35286
+ held: report.held,
35287
+ unresolved: report.unresolved,
35288
+ withinGrace: report.withinGrace,
35289
+ refused: report.refused,
35290
+ stoppedBy: report.stoppedBy,
35291
+ nextCursor: report.nextCursor
35292
+ }
35293
+ });
35294
+ }
35295
+ const total = (pick) => devices.reduce((acc, r) => acc + pick(r), 0);
35296
+ return {
35297
+ report: {
35298
+ mode: input.limits.mode,
35299
+ devices,
35300
+ totalExamined: total((r) => r.examined),
35301
+ totalEligible: total((r) => r.eligible),
35302
+ totalReclaimed: total((r) => r.reclaimed),
35303
+ totalBytesReclaimed: total((r) => r.bytesReclaimed),
35304
+ totalRefused: total((r) => r.refused),
35305
+ durationMs: input.deps.now() - startedAt,
35306
+ complete: devices.every((r) => r.stoppedBy === "exhausted")
35307
+ },
35308
+ cursors: nextCursors
35309
+ };
35310
+ }
34313
35311
  var OrphanAuditActionInputSchema = object({
34314
35312
  mode: _enum(["report", "reclaim"]).default("report"),
34315
35313
  /**
@@ -40214,13 +41212,29 @@ var RETRAIN_FRAME_COLUMNS = [
40214
41212
  type: "TEXT"
40215
41213
  })
40216
41214
  ];
40217
- var RETRAIN_FRAME_INDEXES = [{
40218
- name: "idx_retrain_frames_track",
40219
- columns: ["trackId"]
40220
- }, {
40221
- name: "idx_retrain_frames_device",
40222
- columns: ["deviceId", "copiedAt"]
40223
- }];
41215
+ var RETRAIN_FRAME_INDEXES = [
41216
+ {
41217
+ name: "idx_retrain_frames_track",
41218
+ columns: ["trackId"]
41219
+ },
41220
+ {
41221
+ name: "idx_retrain_frames_device",
41222
+ columns: ["deviceId", "copiedAt"]
41223
+ },
41224
+ (
41225
+ /**
41226
+ * The seal gate's other half. This collection is orders of magnitude smaller
41227
+ * than `pipeline-analytics:media` — every row is an operator-staged frame —
41228
+ * so the index buys much less here. It exists anyway because the gate reads
41229
+ * the two lanes with ONE query shape, and a lane that answers by scanning is
41230
+ * a lane that starts timing out the day the dataset grows. See
41231
+ * `MEDIA_INDEXES` for the cost this is a small multiple of.
41232
+ */
41233
+ {
41234
+ name: "idx_retrain_frames_location",
41235
+ columns: ["locationId"]
41236
+ })
41237
+ ];
40224
41238
  /**
40225
41239
  * The copy's id, derived from `(trackId, sourceMediaKey)`.
40226
41240
  *
@@ -43478,21 +44492,34 @@ function snapshot(j) {
43478
44492
  entities: [...entitiesFor(j.mode)],
43479
44493
  filesMoved: j.filesMoved,
43480
44494
  bytesMoved: j.bytesMoved,
43481
- filesTotal: null,
44495
+ filesTotal: j.filesTotal,
43482
44496
  startedAt: j.startedAt,
43483
44497
  finishedAt: j.finishedAt,
43484
44498
  error: j.error
43485
44499
  };
43486
44500
  }
43487
44501
  /**
44502
+ * The column both walked collections use for the storage location.
44503
+ *
44504
+ * Named once because it is now a QUERY PREDICATE (`where: { [it]: null }`) as
44505
+ * well as a row field. A predicate naming a column the collection cannot
44506
+ * express is refused by `count` rather than dropped, so a rename that misses
44507
+ * one site fails loudly instead of counting the whole table.
44508
+ */
44509
+ var LOCATION_ID_FIELD = "locationId";
44510
+ /**
43488
44511
  * True when the row names NO location — the population `seal` exists for.
43489
44512
  *
43490
44513
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
43491
44514
  * helper collapses "unstamped" and "stamped with the bare type ref" into one
43492
44515
  * answer, and only the first is a row whose meaning changes under a repoint.
44516
+ *
44517
+ * The in-JS twin of the `IS NULL` predicate {@link MediaRelocateEngine.countUnstamped}
44518
+ * sends. It stays because the PASS still walks rows and must decide per row;
44519
+ * the two must agree, and `unstamped-count-matches-the-pass` asserts they do.
43493
44520
  */
43494
44521
  function rowIsUnstamped(data) {
43495
- const id = data["locationId"];
44522
+ const id = data[LOCATION_ID_FIELD];
43496
44523
  return !(typeof id === "string" && id.length > 0);
43497
44524
  }
43498
44525
  /** True for the enrolled-gallery rows `move` skips and `gallery` selects. */
@@ -43500,6 +44527,40 @@ function rowIsGallery(data) {
43500
44527
  const ownerKind = String(data["ownerKind"] ?? "");
43501
44528
  return isMediaOwnerKind(ownerKind) && isRetentionExemptOwnerKind(ownerKind);
43502
44529
  }
44530
+ /**
44531
+ * Which collections a pass walks.
44532
+ *
44533
+ * ONE derivation, shared by the pass and by its pre-count. Two lists would let
44534
+ * an M counted over both collections face an N moved across one — the shape
44535
+ * D295 calls "a guard nobody believes".
44536
+ */
44537
+ function collectionsFor(mode) {
44538
+ if (mode === "gallery") return [{
44539
+ name: MEDIA_COLLECTION,
44540
+ order: "timestamp"
44541
+ }];
44542
+ return [{
44543
+ name: MEDIA_COLLECTION,
44544
+ order: "timestamp"
44545
+ }, {
44546
+ name: RETRAIN_FRAMES_COLLECTION,
44547
+ order: "copiedAt"
44548
+ }];
44549
+ }
44550
+ /**
44551
+ * Does this pass act on this row at all?
44552
+ *
44553
+ * A free function of `(mode, target)` rather than a method on the job, so the
44554
+ * pre-count that produces `filesTotal` and the walk that produces `filesMoved`
44555
+ * ask the SAME question. A separately-written predicate is how a denominator
44556
+ * ends up counting a different population than the numerator.
44557
+ */
44558
+ function selectsRow(mode, toLocationId, data, isRetrain) {
44559
+ if (mode === "seal") return rowIsUnstamped(data);
44560
+ if (mediaRowLocation(data) === toLocationId) return false;
44561
+ if (mode === "gallery") return !isRetrain && rowIsGallery(data);
44562
+ return isRetrain || !rowIsGallery(data);
44563
+ }
43503
44564
  var MediaRelocateEngine = class {
43504
44565
  deps;
43505
44566
  jobs = /* @__PURE__ */ new Map();
@@ -43516,26 +44577,91 @@ var MediaRelocateEngine = class {
43516
44577
  return true;
43517
44578
  }
43518
44579
  /**
43519
- * How many rows still carry no `locationId`, per collection.
44580
+ * Rows that still carry no `locationId`, per collection.
43520
44581
  *
43521
- * Read-only, and the ONLY number the migration planner's non-blocking gate
43522
- * is allowed to trust: it is counted here, against the rows themselves,
44582
+ * Read-only, and the ONLY answer the migration planner's non-blocking gate
44583
+ * is allowed to trust: it is measured here, against the rows themselves,
43523
44584
  * rather than inferred from "a seal job reported done" — a seal that failed
43524
44585
  * some rows still reports a terminal state.
44586
+ *
44587
+ * ── Why this is not the walk the rest of this file uses ──────────────────
44588
+ *
44589
+ * It used to be. It paged both collections through {@link pageRows} — 200
44590
+ * rows per RPC, ordered by `timestamp`, a column that leads no index — and
44591
+ * counted `rowIsUnstamped` in JS. On the live hub that is ~6 300 round trips
44592
+ * over 1 254 576 rows, each paying its own sort, and it hit the 60 s RPC
44593
+ * deadline **every time it was called since it shipped**. The gate it exists
44594
+ * to serve could therefore never open, and 152 GB of event media could not
44595
+ * be migrated.
44596
+ *
44597
+ * The walk is right for the PASS, which must visit every row to act on it.
44598
+ * It was never right for a question the store can answer with a predicate,
44599
+ * and the predicate is now expressible: `where: { locationId: null }`
44600
+ * compiles to `IS NULL` (it used to compile to `= NULL`, which matches
44601
+ * nothing and reports it as an answer — the number the gate treats as "safe
44602
+ * to cut over"). With `idx_media_location` in place both statements read a
44603
+ * contiguous range from the head of a b-tree.
44604
+ *
44605
+ * The cheap question is asked FIRST and on its own. `present` is a single
44606
+ * seek and decides the gate; `rows` is a second statement and only decorates
44607
+ * the refusal. So a count that cannot be taken still leaves a usable answer
44608
+ * — `{ present: true, rows: null }` refuses just as hard — and `null` never
44609
+ * degrades into `0`.
43525
44610
  */
43526
44611
  async countUnstamped() {
43527
- const media = await this.countUnstampedIn(MEDIA_COLLECTION, "timestamp");
43528
- const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION, "copiedAt");
43529
- return {
43530
- media,
43531
- retrainFrames,
43532
- total: media + retrainFrames
43533
- };
44612
+ try {
44613
+ const media = await this.countUnstampedIn(MEDIA_COLLECTION);
44614
+ const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION);
44615
+ return {
44616
+ media,
44617
+ retrainFrames,
44618
+ anyPresent: media.present || retrainFrames.present,
44619
+ total: media.rows === null || retrainFrames.rows === null ? null : media.rows + retrainFrames.rows
44620
+ };
44621
+ } catch (err) {
44622
+ 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) } });
44623
+ return null;
44624
+ }
43534
44625
  }
43535
- async countUnstampedIn(collection, orderField) {
43536
- let unstamped = 0;
43537
- for await (const row of this.pageRows(collection, orderField)) if (rowIsUnstamped(row.data)) unstamped++;
43538
- return unstamped;
44626
+ /**
44627
+ * One collection's unstamped population: existence, then size.
44628
+ *
44629
+ * The `limit: 1` probe is deliberately a `query` and not a `count`: a count
44630
+ * of a million matching rows still walks a million index entries, while the
44631
+ * probe stops at the first one. Absence is proved by the same seek finding
44632
+ * nothing, which is where the index earns its keep — after a seal, the range
44633
+ * is empty and the answer is one b-tree descent.
44634
+ */
44635
+ async countUnstampedIn(collection) {
44636
+ if ((await this.deps.store.query.query({
44637
+ collection,
44638
+ filter: {
44639
+ where: { [LOCATION_ID_FIELD]: null },
44640
+ limit: 1
44641
+ },
44642
+ columns: [LOCATION_ID_FIELD]
44643
+ })).length === 0) return {
44644
+ present: false,
44645
+ rows: 0
44646
+ };
44647
+ try {
44648
+ return {
44649
+ present: true,
44650
+ rows: await this.deps.store.count.query({
44651
+ collection,
44652
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
44653
+ })
44654
+ };
44655
+ } catch (err) {
44656
+ 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: {
44657
+ collection,
44658
+ error: String(err)
44659
+ } });
44660
+ return {
44661
+ present: true,
44662
+ rows: null
44663
+ };
44664
+ }
43539
44665
  }
43540
44666
  start(input) {
43541
44667
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
@@ -43548,6 +44674,7 @@ var MediaRelocateEngine = class {
43548
44674
  deviceId: null,
43549
44675
  filesMoved: 0,
43550
44676
  bytesMoved: 0,
44677
+ filesTotal: null,
43551
44678
  startedAt: this.deps.now(),
43552
44679
  finishedAt: null,
43553
44680
  error: null,
@@ -43596,12 +44723,39 @@ var MediaRelocateEngine = class {
43596
44723
  if (rows.length < PAGE_SIZE) return;
43597
44724
  }
43598
44725
  }
43599
- /** Does this pass act on this row at all? */
43600
- selects(job, data, isRetrain) {
43601
- if (job.mode === "seal") return rowIsUnstamped(data);
43602
- if (mediaRowLocation(data) === job.toLocationId) return false;
43603
- if (job.mode === "gallery") return !isRetrain && rowIsGallery(data);
43604
- return isRetrain || !rowIsGallery(data);
44726
+ /**
44727
+ * How many rows a pass would still act on against `toLocationId`.
44728
+ *
44729
+ * The media lane's **M**, and — read on its own — its residue: "how much of
44730
+ * `eventMedia` is still not on the current default". Both come from here so
44731
+ * neither can drift from what the pass actually moves.
44732
+ *
44733
+ * `null` is "the count could not be taken", never `0`. A zero here reads as
44734
+ * "there is nothing left to move", which is precisely the claim a failed read
44735
+ * must not be allowed to make (D295).
44736
+ */
44737
+ async countSelectable(input) {
44738
+ return this.countSelected(input.mode ?? "move", input.toLocationId, () => false);
44739
+ }
44740
+ async countSelected(mode, toLocationId, stop) {
44741
+ try {
44742
+ let rows = 0;
44743
+ for (const collection of collectionsFor(mode)) {
44744
+ const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
44745
+ for await (const row of this.pageRows(collection.name, collection.order)) {
44746
+ if (stop()) return null;
44747
+ if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
44748
+ }
44749
+ }
44750
+ return { rows };
44751
+ } catch (err) {
44752
+ this.deps.logger.warn("media relocate: row count failed — this pass cannot say how much of the collection it saw", { meta: {
44753
+ mode,
44754
+ toLocationId,
44755
+ error: String(err)
44756
+ } });
44757
+ return null;
44758
+ }
43605
44759
  }
43606
44760
  async run(job, throttleMbps) {
43607
44761
  const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -43615,23 +44769,14 @@ var MediaRelocateEngine = class {
43615
44769
  * rows in silence reads as "there were none", which is how a migration
43616
44770
  * gets believed. */
43617
44771
  let pathlessRows = 0;
43618
- const collections = job.mode === "gallery" ? [{
43619
- name: MEDIA_COLLECTION,
43620
- order: "timestamp"
43621
- }] : [{
43622
- name: MEDIA_COLLECTION,
43623
- order: "timestamp"
43624
- }, {
43625
- name: RETRAIN_FRAMES_COLLECTION,
43626
- order: "copiedAt"
43627
- }];
43628
- for (const collection of collections) {
44772
+ job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
44773
+ for (const collection of collectionsFor(job.mode)) {
43629
44774
  if (job.cancelRequested) break;
43630
44775
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
43631
44776
  for await (const row of this.pageRows(collection.name, collection.order)) {
43632
44777
  if (job.cancelRequested) break;
43633
44778
  const data = row.data;
43634
- if (!this.selects(job, data, isRetrain)) continue;
44779
+ if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
43635
44780
  if (job.mode === "seal") {
43636
44781
  try {
43637
44782
  await this.stamp(collection.name, row.id, data, job.toLocationId);
@@ -43744,10 +44889,38 @@ var MediaSettingsSchema = object({
43744
44889
  * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
43745
44890
  * max 2 (200%). */
43746
44891
  cropPadding: number().min(0).max(2).default(.15),
43747
- /** Master switch for periodic per-track snapshots (the timeline filmstrip +
43748
- * the rolling `lastFrame` + the best `thumbnail`). When false, only the
43749
- * per-track `firstFrame` and per-event media are produced. */
43750
- saveThumbnails: boolean().default(true),
44892
+ /**
44893
+ * Per-camera DEBUG media. Default **false**, and it absorbs the retired
44894
+ * `saveThumbnails` rather than sitting beside it (D62: a second switch that
44895
+ * disagrees with the first is worse than no switch).
44896
+ *
44897
+ * What it gates, and only this:
44898
+ * - the periodic per-track `snapshot` filmstrip (`periodic-media-plan.ts`);
44899
+ * - the per-EDGE motion still — the debug surplus that
44900
+ * {@link decideMotionStill} otherwise reduces to one marker per
44901
+ * {@link MediaSettings.motionMarkerQuietMs} drought window.
44902
+ *
44903
+ * What it never gates, at any value: `firstFrame`, the rolling `lastFrame`,
44904
+ * `thumbnail`/`thumbnailSmall`, `keyFrame`/`keyFrameSmall`, the face/plate
44905
+ * buffers, gallery media, the CLIP search `crop` — and the quiet-period
44906
+ * motion marker itself, which is the operator's fallback and must survive
44907
+ * the default (a blank timeline on a quiet camera reads as broken).
44908
+ *
44909
+ * The rolling `lastFrame` deliberately moved OUT from under the old
44910
+ * `saveThumbnails`: it is in the keep-set, and a debug switch may not take
44911
+ * it away.
44912
+ */
44913
+ debugMediaEnabled: boolean().default(false),
44914
+ /**
44915
+ * The detection-drought window N for the motion marker: with debug media
44916
+ * off, a motion off→on edge produces a still only when NO detection has run
44917
+ * on this camera for this long AND no marker was produced in the last such
44918
+ * window. Chosen from the observed fleet distribution of detection-free
44919
+ * stretches (median 42 s, p75 201 s, p90 770 s) — 900 s sits just past p90,
44920
+ * so nine stretches in ten produce nothing. See
44921
+ * `services/motion-marker-policy.ts`.
44922
+ */
44923
+ motionMarkerQuietMs: number().int().min(6e4).max(360 * 6e4).default(9e5),
43751
44924
  /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
43752
44925
  * A snapshot is captured for an active track only after this much wall-clock
43753
44926
  * has elapsed since its previous one. */
@@ -43776,7 +44949,8 @@ function resolveMediaSettings(raw) {
43776
44949
  };
43777
44950
  return {
43778
44951
  cropPadding: pick("cropPadding"),
43779
- saveThumbnails: pick("saveThumbnails"),
44952
+ debugMediaEnabled: pick("debugMediaEnabled"),
44953
+ motionMarkerQuietMs: pick("motionMarkerQuietMs"),
43780
44954
  snapshotIntervalMs: pick("snapshotIntervalMs"),
43781
44955
  snapshotMovementThreshold: pick("snapshotMovementThreshold"),
43782
44956
  snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
@@ -44020,9 +45194,9 @@ function resolvePackageDropSettings(raw) {
44020
45194
  * can never duplicate one.
44021
45195
  */
44022
45196
  function planPeriodicMedia(input) {
44023
- const appendSnapshot = input.dueSnapshot;
45197
+ const appendSnapshot = input.debugMediaEnabled && input.dueSnapshot;
44024
45198
  const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
44025
- const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
45199
+ const lastFrameElapsed = Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
44026
45200
  const firstFrameLanded = input.firstFrameLanded ?? true;
44027
45201
  const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
44028
45202
  const thumbnailLanded = input.thumbnailLanded ?? true;
@@ -44693,6 +45867,90 @@ function padBbox(bbox, padding) {
44693
45867
  };
44694
45868
  }
44695
45869
  //#endregion
45870
+ //#region src/pipeline-analytics/pipeline/subject-agreement.ts
45871
+ /**
45872
+ * `subjectAgreement` — the ONE predicate for "a rectangle measured on frame A
45873
+ * is still honest against the pixels of frame B".
45874
+ *
45875
+ * Two durable display crops cut a rectangle out of a frame that is NOT the
45876
+ * frame the rectangle was measured on: the close-time thumbnail derive (the
45877
+ * best-seen subject box, cut from the persisted keyFrame) and the plate
45878
+ * gallery's last-resort crop (the plate rect at the best OCR read, cut from the
45879
+ * same keyFrame). Both were guarded on TIME alone, and time is the wrong
45880
+ * quantity: 1.5 s is nothing for a parked car and hundreds of native pixels for
45881
+ * a car crossing the frame. Confirmed on the operator's debug-marked tracks
45882
+ * (2026-08-30 triage): `e9c024bb` passed the time bound at 531–1179 ms and
45883
+ * stored a windshield and a patch of gravel, while `7613c0fa` — the same path,
45884
+ * 2579 ms of skew, a stationary vehicle — stored a perfectly framed plate.
45885
+ *
45886
+ * The quantity that separates those cases is MOTION, and the track's own
45887
+ * `positions` trail already records it. This asks the trail one question:
45888
+ *
45889
+ * between the instant the rectangle was measured and the instant the pixels
45890
+ * were captured, did the subject stay where it was?
45891
+ *
45892
+ * IoU over the two subject boxes answers it, and because both boxes come from
45893
+ * the SAME trail they are in the same detection-frame pixel space by
45894
+ * construction — there is no cross-space normalization to get wrong.
45895
+ *
45896
+ * D52's one-derivation discipline applied to the guard: this is the only place
45897
+ * the question is answered, and {@link createKeyFrameCrop} is its only caller,
45898
+ * so both defects share one contract instead of two drifting copies.
45899
+ *
45900
+ * `unknown` is a real verdict, not a failure: a track with no trail, or a trail
45901
+ * downsampled away from one of the two instants, carries NO motion evidence.
45902
+ * The caller then falls back to the temporal bound it always had — an absent
45903
+ * read changes nothing (D49).
45904
+ */
45905
+ function nearestSample(path, atMs, maxSampleGapMs) {
45906
+ let best;
45907
+ let bestGap = Number.POSITIVE_INFINITY;
45908
+ for (const sample of path) {
45909
+ const gap = Math.abs(sample.timestamp - atMs);
45910
+ if (gap < bestGap) {
45911
+ bestGap = gap;
45912
+ best = sample;
45913
+ }
45914
+ }
45915
+ if (best === void 0 || bestGap > maxSampleGapMs) return void 0;
45916
+ return {
45917
+ bbox: best.bbox,
45918
+ gapMs: bestGap
45919
+ };
45920
+ }
45921
+ /**
45922
+ * Answer "did the subject stay put between the two instants?" from the track's
45923
+ * own position trail. Pure — no IO, no clock, no logging: the caller owns the
45924
+ * consequence and the line it writes.
45925
+ */
45926
+ function subjectAgreement(query) {
45927
+ if (query.path.length === 0) return {
45928
+ kind: "unknown",
45929
+ reason: "no-path"
45930
+ };
45931
+ const atMeasure = nearestSample(query.path, query.measuredAtMs, query.maxSampleGapMs);
45932
+ if (atMeasure === void 0) return {
45933
+ kind: "unknown",
45934
+ reason: "no-sample-near-measure"
45935
+ };
45936
+ const atCut = nearestSample(query.path, query.cutFromMs, query.maxSampleGapMs);
45937
+ if (atCut === void 0) return {
45938
+ kind: "unknown",
45939
+ reason: "no-sample-near-cut"
45940
+ };
45941
+ const overlap = iou$1(atMeasure.bbox, atCut.bbox);
45942
+ const sampleGapMs = Math.max(atMeasure.gapMs, atCut.gapMs);
45943
+ return overlap >= query.minIou ? {
45944
+ kind: "agrees",
45945
+ iou: overlap,
45946
+ sampleGapMs
45947
+ } : {
45948
+ kind: "moved",
45949
+ iou: overlap,
45950
+ sampleGapMs
45951
+ };
45952
+ }
45953
+ //#endregion
44696
45954
  //#region src/pipeline-analytics/pipeline/keyframe-crop.ts
44697
45955
  function createKeyFrameCrop(deps) {
44698
45956
  return async (input) => {
@@ -44700,12 +45958,36 @@ function createKeyFrameCrop(deps) {
44700
45958
  const media = await deps.getMedia(input.mediaKey);
44701
45959
  if (!media) return null;
44702
45960
  const skewMs = Math.abs(media.timestamp - input.timestamp);
44703
- if (skewMs > deps.maxSkewMs) {
44704
- deps.logger.debug("keyframe crop rejected — temporal skew too large", { meta: {
44705
- mediaKey: input.mediaKey,
44706
- skewMs,
44707
- maxSkewMs: deps.maxSkewMs
44708
- } });
45961
+ const verdict = subjectAgreement({
45962
+ path: input.subjectPath ?? [],
45963
+ measuredAtMs: input.timestamp,
45964
+ cutFromMs: media.timestamp,
45965
+ minIou: deps.minSubjectIou,
45966
+ maxSampleGapMs: deps.maxSubjectSampleGapMs
45967
+ });
45968
+ if (verdict.kind === "moved") {
45969
+ deps.logger.info("keyframe crop refused — subject moved between the two instants", {
45970
+ tags: { deviceId: input.deviceId },
45971
+ meta: {
45972
+ mediaKey: input.mediaKey,
45973
+ skewMs,
45974
+ subjectIou: verdict.iou,
45975
+ minSubjectIou: deps.minSubjectIou,
45976
+ sampleGapMs: verdict.sampleGapMs
45977
+ }
45978
+ });
45979
+ return null;
45980
+ }
45981
+ if (verdict.kind === "unknown" && skewMs > deps.maxSkewMs) {
45982
+ deps.logger.info("keyframe crop refused — temporal skew too large, no motion evidence", {
45983
+ tags: { deviceId: input.deviceId },
45984
+ meta: {
45985
+ mediaKey: input.mediaKey,
45986
+ skewMs,
45987
+ maxSkewMs: deps.maxSkewMs,
45988
+ unknownReason: verdict.reason
45989
+ }
45990
+ });
44709
45991
  return null;
44710
45992
  }
44711
45993
  try {
@@ -44717,15 +45999,25 @@ function createKeyFrameCrop(deps) {
44717
45999
  w: input.bbox.w / input.frameWidth,
44718
46000
  h: input.bbox.h / input.frameHeight
44719
46001
  }, input.padding);
44720
- return {
44721
- jpeg: await deps.cropRegionToJpeg(Buffer.from(rgb.bytes), rgb.width, rgb.height, norm),
44722
- skewMs
46002
+ const jpeg = await deps.cropRegionToJpeg(Buffer.from(rgb.bytes), rgb.width, rgb.height, norm);
46003
+ return verdict.kind === "agrees" ? {
46004
+ jpeg,
46005
+ skewMs,
46006
+ motion: "agrees",
46007
+ subjectIou: verdict.iou
46008
+ } : {
46009
+ jpeg,
46010
+ skewMs,
46011
+ motion: "unknown"
44723
46012
  };
44724
46013
  } catch (err) {
44725
- deps.logger.debug("keyframe crop derive failed", { meta: {
44726
- mediaKey: input.mediaKey,
44727
- error: err instanceof Error ? err.message : String(err)
44728
- } });
46014
+ deps.logger.debug("keyframe crop derive failed", {
46015
+ tags: { deviceId: input.deviceId },
46016
+ meta: {
46017
+ mediaKey: input.mediaKey,
46018
+ error: err instanceof Error ? err.message : String(err)
46019
+ }
46020
+ });
44729
46021
  return null;
44730
46022
  }
44731
46023
  };
@@ -47568,6 +48860,52 @@ async function applyTrackFlags(deps, input) {
47568
48860
  };
47569
48861
  }
47570
48862
  //#endregion
48863
+ //#region src/pipeline-analytics/pipeline/plate-matcher.ts
48864
+ /**
48865
+ * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
48866
+ *
48867
+ * Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
48868
+ * distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
48869
+ * There is no model-version gate (text has no embedding drift) and no ambiguity
48870
+ * `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
48871
+ * conservatively to NO match. The read's `score` weights how far it may match:
48872
+ * a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
48873
+ * a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
48874
+ * "Prefer unassigned over wrong vehicle."
48875
+ */
48876
+ /** Calibration placeholders — re-tune against real OCR score distributions. */
48877
+ var DEFAULT_PLATE_MATCH_OPTS = {
48878
+ maxDistance: 1,
48879
+ lowScoreFloor: .5,
48880
+ lowScoreMaxDistance: 0
48881
+ };
48882
+ function matchPlateText(probe, gallery, opts) {
48883
+ const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
48884
+ const bestByVehicle = /* @__PURE__ */ new Map();
48885
+ for (const s of gallery) {
48886
+ const dist = plateDistance(probe.text, s.text);
48887
+ const prev = bestByVehicle.get(s.vehicleId);
48888
+ if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
48889
+ }
48890
+ if (bestByVehicle.size === 0) return null;
48891
+ let bestId = null;
48892
+ let bestDist = Infinity;
48893
+ let tie = false;
48894
+ for (const [vehicleId, dist] of bestByVehicle) {
48895
+ if (dist > allowed) continue;
48896
+ if (dist < bestDist) {
48897
+ bestDist = dist;
48898
+ bestId = vehicleId;
48899
+ tie = false;
48900
+ } else if (dist === bestDist) tie = true;
48901
+ }
48902
+ if (bestId === null || tie) return null;
48903
+ return {
48904
+ vehicleId: bestId,
48905
+ distance: bestDist
48906
+ };
48907
+ }
48908
+ //#endregion
47571
48909
  //#region src/pipeline-analytics/pipeline/plate-vote.ts
47572
48910
  /**
47573
48911
  * Per-character VOTE over a track's plate reads.
@@ -47709,52 +49047,6 @@ function votePlateText(reads) {
47709
49047
  return out;
47710
49048
  }
47711
49049
  //#endregion
47712
- //#region src/pipeline-analytics/pipeline/plate-matcher.ts
47713
- /**
47714
- * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
47715
- *
47716
- * Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
47717
- * distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
47718
- * There is no model-version gate (text has no embedding drift) and no ambiguity
47719
- * `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
47720
- * conservatively to NO match. The read's `score` weights how far it may match:
47721
- * a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
47722
- * a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
47723
- * "Prefer unassigned over wrong vehicle."
47724
- */
47725
- /** Calibration placeholders — re-tune against real OCR score distributions. */
47726
- var DEFAULT_PLATE_MATCH_OPTS = {
47727
- maxDistance: 1,
47728
- lowScoreFloor: .5,
47729
- lowScoreMaxDistance: 0
47730
- };
47731
- function matchPlateText(probe, gallery, opts) {
47732
- const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
47733
- const bestByVehicle = /* @__PURE__ */ new Map();
47734
- for (const s of gallery) {
47735
- const dist = plateDistance(probe.text, s.text);
47736
- const prev = bestByVehicle.get(s.vehicleId);
47737
- if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
47738
- }
47739
- if (bestByVehicle.size === 0) return null;
47740
- let bestId = null;
47741
- let bestDist = Infinity;
47742
- let tie = false;
47743
- for (const [vehicleId, dist] of bestByVehicle) {
47744
- if (dist > allowed) continue;
47745
- if (dist < bestDist) {
47746
- bestDist = dist;
47747
- bestId = vehicleId;
47748
- tie = false;
47749
- } else if (dist === bestDist) tie = true;
47750
- }
47751
- if (bestId === null || tie) return null;
47752
- return {
47753
- vehicleId: bestId,
47754
- distance: bestDist
47755
- };
47756
- }
47757
- //#endregion
47758
49050
  //#region src/pipeline-analytics/runtime/failure-report.ts
47759
49051
  /**
47760
49052
  * What `pipeline-analytics` reports through `failure-contribution` — the four
@@ -48086,9 +49378,16 @@ var PlateRecognizer = class {
48086
49378
  bestLabelMatchConfidence(trackId) {
48087
49379
  return this.bestPlate.get(trackId)?.score;
48088
49380
  }
48089
- /** Persist the held best plate for a finished track as one PlateStore row
48090
- * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
48091
- async onTrackEnd(deviceId, trackId) {
49381
+ /**
49382
+ * Persist the held best plate for a finished track as one PlateStore row
49383
+ * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state.
49384
+ *
49385
+ * @param subjectPath the closing track's position trail, supplied by the
49386
+ * closer. It is what tells a parked car (whose plate rect is still honest
49387
+ * seconds later) from a crossing one (whose rect points at the road it has
49388
+ * already left) — the distinction the pure time bound could not make.
49389
+ */
49390
+ async onTrackEnd(deviceId, trackId, subjectPath) {
48092
49391
  const held = this.bestPlate.get(trackId);
48093
49392
  this.bestPlate.delete(trackId);
48094
49393
  if (held === void 0) return;
@@ -48097,12 +49396,14 @@ var PlateRecognizer = class {
48097
49396
  let cropData = held.crop;
48098
49397
  if (cropData === void 0 && keyFrameMediaKey !== void 0 && this.deps.deriveCropFromKeyFrame) try {
48099
49398
  const derived = await this.deps.deriveCropFromKeyFrame({
49399
+ deviceId,
48100
49400
  mediaKey: keyFrameMediaKey,
48101
49401
  bbox: held.bbox,
48102
49402
  frameWidth: held.frameWidth,
48103
49403
  frameHeight: held.frameHeight,
48104
49404
  padding: held.cropPadding,
48105
- timestamp: held.timestamp
49405
+ timestamp: held.timestamp,
49406
+ ...subjectPath !== void 0 ? { subjectPath } : {}
48106
49407
  });
48107
49408
  if (derived) {
48108
49409
  cropData = derived.jpeg;
@@ -48113,10 +49414,18 @@ var PlateRecognizer = class {
48113
49414
  },
48114
49415
  meta: {
48115
49416
  plateId,
48116
- skewMs: derived.skewMs
49417
+ skewMs: derived.skewMs,
49418
+ motion: derived.motion,
49419
+ subjectIou: derived.subjectIou ?? null
48117
49420
  }
48118
49421
  });
48119
- }
49422
+ } else this.deps.logger.info("plate crop not recovered — keyFrame derive refused or missed", {
49423
+ tags: {
49424
+ deviceId,
49425
+ trackId
49426
+ },
49427
+ meta: { plateId }
49428
+ });
48120
49429
  } catch (err) {
48121
49430
  this.deps.logger.debug("PlateRecognizer keyFrame crop derive failed", {
48122
49431
  tags: { deviceId },
@@ -48717,6 +50026,122 @@ async function reconcileGallerySamples(deps, options) {
48717
50026
  stoppedByCeiling
48718
50027
  };
48719
50028
  }
50029
+ //#endregion
50030
+ //#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
50031
+ /** The scope id this leg reports and persists its cursor under. */
50032
+ var EMBEDDING_ORPHAN_SCOPE = "vector-index:object-clip";
50033
+ /**
50034
+ * Walk the index once, resuming at `cursor`.
50035
+ *
50036
+ * Never throws: a probe failure ends the pass with what it has and says which
50037
+ * bound stopped it, so a short pass can never be read as a clean one.
50038
+ */
50039
+ async function scanEmbeddingOrphans(deps, limits, cursor) {
50040
+ const tally = {
50041
+ examined: 0,
50042
+ orphans: 0,
50043
+ reclaimed: 0,
50044
+ kept: 0,
50045
+ withinGrace: 0,
50046
+ unresolvable: 0,
50047
+ notReclaimed: 0
50048
+ };
50049
+ const deviceIds = /* @__PURE__ */ new Set();
50050
+ const graceFloor = deps.now() - limits.graceMs;
50051
+ let offset = Math.max(0, cursor);
50052
+ let stoppedBy = "exhausted";
50053
+ for (;;) {
50054
+ if (tally.examined >= limits.maxRows) {
50055
+ stoppedBy = "row-ceiling";
50056
+ break;
50057
+ }
50058
+ let page;
50059
+ try {
50060
+ page = await deps.scanPage(offset, limits.pageSize);
50061
+ } catch (err) {
50062
+ deps.logger.warn("embedding orphan scan: index page failed — pass abandoned", { meta: {
50063
+ scope: EMBEDDING_ORPHAN_SCOPE,
50064
+ offset,
50065
+ error: String(err)
50066
+ } });
50067
+ stoppedBy = "page-stalled";
50068
+ break;
50069
+ }
50070
+ const named = page.rows.filter((r) => r.trackId !== null).map((r) => r.trackId ?? "");
50071
+ let live;
50072
+ try {
50073
+ live = named.length > 0 ? await deps.liveTrackIds(named) : /* @__PURE__ */ new Set();
50074
+ } catch (err) {
50075
+ deps.logger.warn("embedding orphan scan: liveness probe failed — pass abandoned", { meta: {
50076
+ scope: EMBEDDING_ORPHAN_SCOPE,
50077
+ offset,
50078
+ error: String(err)
50079
+ } });
50080
+ stoppedBy = "probe-failed";
50081
+ break;
50082
+ }
50083
+ const doomed = [];
50084
+ let ceilingHit = false;
50085
+ for (const row of page.rows) {
50086
+ tally.examined += 1;
50087
+ if (row.trackId === null) {
50088
+ tally.unresolvable += 1;
50089
+ continue;
50090
+ }
50091
+ if (live.has(row.trackId)) {
50092
+ tally.kept += 1;
50093
+ continue;
50094
+ }
50095
+ if (row.timestamp === null || row.timestamp > graceFloor) {
50096
+ tally.withinGrace += 1;
50097
+ continue;
50098
+ }
50099
+ tally.orphans += 1;
50100
+ if (row.deviceId !== null) deviceIds.add(row.deviceId);
50101
+ if (limits.mode !== "reclaim") continue;
50102
+ if (tally.reclaimed + doomed.length >= limits.maxReclaim) {
50103
+ ceilingHit = true;
50104
+ break;
50105
+ }
50106
+ doomed.push(row.id);
50107
+ }
50108
+ let reclaimedInPage = 0;
50109
+ if (doomed.length > 0) {
50110
+ const outcome = await deps.reclaim(doomed);
50111
+ reclaimedInPage = outcome.deleted.length;
50112
+ tally.reclaimed += outcome.deleted.length;
50113
+ tally.notReclaimed += outcome.stillPresent.length;
50114
+ }
50115
+ if (deps.breathe) await deps.breathe();
50116
+ if (ceilingHit) {
50117
+ stoppedBy = "reclaim-ceiling";
50118
+ offset += page.rows.length - reclaimedInPage;
50119
+ break;
50120
+ }
50121
+ offset += page.rows.length - reclaimedInPage;
50122
+ if (page.nextCursor === null) {
50123
+ offset = 0;
50124
+ break;
50125
+ }
50126
+ if (page.rows.length === 0) {
50127
+ stoppedBy = "page-stalled";
50128
+ break;
50129
+ }
50130
+ }
50131
+ return {
50132
+ scope: EMBEDDING_ORPHAN_SCOPE,
50133
+ examined: tally.examined,
50134
+ orphans: tally.orphans,
50135
+ reclaimed: tally.reclaimed,
50136
+ kept: tally.kept,
50137
+ withinGrace: tally.withinGrace,
50138
+ unresolvable: tally.unresolvable,
50139
+ notReclaimed: tally.notReclaimed,
50140
+ deviceIds: [...deviceIds],
50141
+ stoppedBy,
50142
+ nextCursor: Math.max(0, offset)
50143
+ };
50144
+ }
48720
50145
  /**
48721
50146
  * Where the completion marker lives.
48722
50147
  *
@@ -50236,6 +51661,37 @@ var EventStore = class {
50236
51661
  return found;
50237
51662
  }
50238
51663
  /**
51664
+ * The SUBSET of `eventIds` that are MOTION events specifically.
51665
+ *
51666
+ * {@link existingEventIds} answers "is this event alive", which is the
51667
+ * orphan audit's question. The media reclaim asks a narrower one: *is this
51668
+ * event-owned snapshot a MOTION-VISIT still* — because the selection shape
51669
+ * (`ownerKind: 'event', kind: 'snapshot'`) is not itself a guarantee.
51670
+ * Nothing structurally prevents another writer landing an event-owned
51671
+ * snapshot on an object or audio row, and the reclaim must not be the place
51672
+ * that discovers it. A per-page indexed `whereIn` on one table is cheap
51673
+ * where that mistake would not be.
51674
+ *
51675
+ * THROWS on a query failure, same contract and same reason.
51676
+ */
51677
+ async existingMotionEventIds(eventIds) {
51678
+ const found = /* @__PURE__ */ new Set();
51679
+ for (let i = 0; i < eventIds.length; i += 500) {
51680
+ const chunk = eventIds.slice(i, i + 500);
51681
+ if (chunk.length === 0) continue;
51682
+ const rows = await this.store.query.query({
51683
+ collection: MOTION_EVENTS_COLLECTION,
51684
+ columns: ["id"],
51685
+ filter: {
51686
+ whereIn: { id: [...chunk] },
51687
+ limit: chunk.length
51688
+ }
51689
+ });
51690
+ for (const row of rows) found.add(row.id);
51691
+ }
51692
+ return found;
51693
+ }
51694
+ /**
50239
51695
  * Delete all events for a specific device with `timestamp < cutoffMs`
50240
51696
  * (exclusive upper bound) across all three kinds, returning the per-kind
50241
51697
  * deleted counts and the flattened list of deleted ids (so the caller can
@@ -50508,11 +51964,21 @@ var SAMPLE_COLUMNS$1 = [
50508
51964
  type: "TEXT",
50509
51965
  notNull: true
50510
51966
  },
51967
+ (
51968
+ /**
51969
+ * LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
51970
+ * `identity-arcface` vector index now, keyed by this row's id.
51971
+ *
51972
+ * Still declared and still `notNull`: rows written before the conversion carry
51973
+ * their array, and `loadGallery` falls back to it for exactly as long as one
51974
+ * of them has not been converted. A CONVERTED row holds `[]`, which is both
51975
+ * the idempotence marker and the only way to see the conversion is done.
51976
+ */
50511
51977
  {
50512
51978
  name: "embedding",
50513
51979
  type: "JSON",
50514
51980
  notNull: true
50515
- },
51981
+ }),
50516
51982
  {
50517
51983
  name: "mediaKey",
50518
51984
  type: "TEXT"
@@ -50559,9 +52025,11 @@ var SAMPLE_COLUMNS$1 = [
50559
52025
  ];
50560
52026
  var IdentityStore = class {
50561
52027
  store;
52028
+ vectors;
50562
52029
  logger;
50563
52030
  constructor(deps) {
50564
52031
  this.store = deps.store;
52032
+ this.vectors = deps.vectors;
50565
52033
  this.logger = deps.logger;
50566
52034
  }
50567
52035
  static async declare(store) {
@@ -50579,6 +52047,41 @@ var IdentityStore = class {
50579
52047
  }]
50580
52048
  });
50581
52049
  }
52050
+ /**
52051
+ * How an UNCONVERTED `identity-samples` row becomes an index row.
52052
+ *
52053
+ * `modelId` is `notNull` on this collection, so unlike the faces buffer there
52054
+ * is nothing to infer — a row that somehow holds no model is REFUSED and
52055
+ * keeps its JSON. An enrolled vector stamped with a guessed model is a sample
52056
+ * the matcher would compare across feature spaces, which is the one mistake
52057
+ * this gallery cannot survive.
52058
+ */
52059
+ conversionSpec() {
52060
+ return {
52061
+ collection: IDENTITY_SAMPLES_COLLECTION,
52062
+ vectors: this.vectors,
52063
+ write: (id, embedding, data) => {
52064
+ const modelId = data["modelId"];
52065
+ if (typeof modelId !== "string" || modelId.length === 0) return null;
52066
+ const identityId = data["identityId"];
52067
+ const deviceId = data["deviceId"];
52068
+ const addedAt = data["addedAt"];
52069
+ const source = data["source"];
52070
+ return {
52071
+ id,
52072
+ embedding,
52073
+ className: IDENTITY_VECTOR_CLASS,
52074
+ modelId,
52075
+ timestamp: typeof addedAt === "number" ? addedAt : 0,
52076
+ ...typeof deviceId === "number" ? { deviceId } : {},
52077
+ extra: {
52078
+ ...typeof identityId === "string" ? { identityId } : {},
52079
+ ...typeof source === "string" ? { source } : {}
52080
+ }
52081
+ };
52082
+ }
52083
+ };
52084
+ }
50582
52085
  async createIdentity(input) {
50583
52086
  const now = Date.now();
50584
52087
  const identity = {
@@ -50613,11 +52116,13 @@ var IdentityStore = class {
50613
52116
  collection: IDENTITY_SAMPLES_COLLECTION,
50614
52117
  filter: { where: { identityId: id } }
50615
52118
  });
52119
+ const droppedSamples = [];
50616
52120
  for (const s of samples) try {
50617
52121
  await this.store.delete.mutate({
50618
52122
  collection: IDENTITY_SAMPLES_COLLECTION,
50619
52123
  key: s.id
50620
52124
  });
52125
+ droppedSamples.push(s.id);
50621
52126
  } catch (err) {
50622
52127
  this.logger.warn("deleteIdentity sample delete failed", { meta: {
50623
52128
  identityId: id,
@@ -50625,6 +52130,7 @@ var IdentityStore = class {
50625
52130
  error: String(err)
50626
52131
  } });
50627
52132
  }
52133
+ await this.vectors.drop(droppedSamples);
50628
52134
  await this.store.delete.mutate({
50629
52135
  collection: IDENTITIES_COLLECTION,
50630
52136
  key: id
@@ -50650,7 +52156,6 @@ var IdentityStore = class {
50650
52156
  const sample = {
50651
52157
  id: randomUUID(),
50652
52158
  identityId: input.identityId,
50653
- embedding: input.embedding,
50654
52159
  ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
50655
52160
  ...input.sourceMediaKey !== void 0 ? { sourceMediaKey: input.sourceMediaKey } : {},
50656
52161
  source: input.source ?? "detected",
@@ -50659,12 +52164,27 @@ var IdentityStore = class {
50659
52164
  dim: input.embedding.length,
50660
52165
  addedAt: Date.now()
50661
52166
  };
52167
+ if (!await this.vectors.put({
52168
+ id: sample.id,
52169
+ embedding: input.embedding,
52170
+ className: "identity",
52171
+ modelId: input.modelId,
52172
+ timestamp: sample.addedAt,
52173
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
52174
+ extra: {
52175
+ identityId: input.identityId,
52176
+ source: sample.source
52177
+ }
52178
+ })) throw new Error(`identity sample was not enrolled: its ${input.embedding.length}-dim vector could not be written to the gallery index`);
50662
52179
  const { id, ...rest } = sample;
50663
52180
  await this.store.insert.mutate({
50664
52181
  collection: IDENTITY_SAMPLES_COLLECTION,
50665
52182
  record: {
50666
52183
  id,
50667
- data: rest
52184
+ data: {
52185
+ ...rest,
52186
+ embedding: []
52187
+ }
50668
52188
  }
50669
52189
  });
50670
52190
  const identity = (await this.listIdentities()).find((i) => i.id === input.identityId);
@@ -50713,6 +52233,7 @@ var IdentityStore = class {
50713
52233
  collection: IDENTITY_SAMPLES_COLLECTION,
50714
52234
  key: sampleId
50715
52235
  });
52236
+ await this.vectors.drop([sampleId]);
50716
52237
  const identity = (await this.listIdentities()).find((i) => i.id === identityId);
50717
52238
  if (identity) {
50718
52239
  const patch = {
@@ -50740,16 +52261,46 @@ var IdentityStore = class {
50740
52261
  collection: IDENTITY_SAMPLES_COLLECTION,
50741
52262
  filter: {}
50742
52263
  });
50743
- const gallery = [];
52264
+ const wanted = [];
50744
52265
  for (const r of rows) {
50745
52266
  const d = r.data;
50746
- if (!Array.isArray(d.embedding) || typeof d.modelId !== "string" || typeof d.identityId !== "string") continue;
50747
- gallery.push({
50748
- identityId: d.identityId,
50749
- embedding: d.embedding,
50750
- modelId: d.modelId
50751
- });
52267
+ if (typeof d.modelId === "string" && typeof d.identityId === "string") wanted.push(r.id);
50752
52268
  }
52269
+ const vectors = await this.vectors.load(wanted);
52270
+ const gallery = [];
52271
+ let fromLegacyColumn = 0;
52272
+ let missing = 0;
52273
+ for (const r of rows) {
52274
+ const d = r.data;
52275
+ if (typeof d.modelId !== "string" || typeof d.identityId !== "string") continue;
52276
+ const indexed = vectors.get(r.id);
52277
+ if (indexed !== void 0) {
52278
+ gallery.push({
52279
+ identityId: d.identityId,
52280
+ embedding: indexed,
52281
+ modelId: d.modelId
52282
+ });
52283
+ continue;
52284
+ }
52285
+ const legacy = d.embedding;
52286
+ if (Array.isArray(legacy) && legacy.length > 0) {
52287
+ fromLegacyColumn += 1;
52288
+ gallery.push({
52289
+ identityId: d.identityId,
52290
+ embedding: legacy,
52291
+ modelId: d.modelId
52292
+ });
52293
+ continue;
52294
+ }
52295
+ missing += 1;
52296
+ }
52297
+ if (fromLegacyColumn > 0 || missing > 0) this.logger.warn("identity gallery did not come entirely from the vector index", { meta: {
52298
+ index: this.vectors.name,
52299
+ enrolled: rows.length,
52300
+ loaded: gallery.length,
52301
+ fromLegacyColumn,
52302
+ missing
52303
+ } });
50753
52304
  return gallery;
50754
52305
  }
50755
52306
  /**
@@ -50785,12 +52336,23 @@ var IdentityStore = class {
50785
52336
  * holding 0).
50786
52337
  */
50787
52338
  async restampSample(input) {
52339
+ if (!await this.vectors.put({
52340
+ id: input.sampleId,
52341
+ embedding: input.embedding,
52342
+ className: "identity",
52343
+ modelId: input.modelId,
52344
+ timestamp: Date.now(),
52345
+ extra: { identityId: input.identityId }
52346
+ })) return {
52347
+ landed: false,
52348
+ reason: "the re-embedded vector did not reach the gallery index"
52349
+ };
50788
52350
  try {
50789
52351
  await this.store.update.mutate({
50790
52352
  collection: IDENTITY_SAMPLES_COLLECTION,
50791
52353
  id: input.sampleId,
50792
52354
  data: {
50793
- embedding: input.embedding,
52355
+ embedding: [],
50794
52356
  modelId: input.modelId,
50795
52357
  dim: input.dim
50796
52358
  }
@@ -53078,6 +54640,53 @@ var TrackStore = class {
53078
54640
  return found;
53079
54641
  }
53080
54642
  /**
54643
+ * The SUBSET of `trackIds` that are PINNED — `retrainStatus: 'staging'` or
54644
+ * `favourited: true`.
54645
+ *
54646
+ * A pin is the operator saying "keep this one". It is enforced against the
54647
+ * age sweep (`listIdsBefore` excludes both), and it must be enforced against
54648
+ * a bulk reclaim for exactly the same reason: a starred track whose
54649
+ * filmstrip is gone is a pin that did not hold.
54650
+ *
54651
+ * **The ACTIVE (in-RAM) tracks count as pinned here.** A running track has
54652
+ * no persisted row yet, so the table cannot answer for it; treating an
54653
+ * unanswerable id as unpinned would delete the media of the camera's current
54654
+ * subject. Unknown means protected — the inverse of {@link existingTrackIds},
54655
+ * where unknown means alive, and the same safe direction.
54656
+ *
54657
+ * THROWS on a query failure. A probe that quietly under-reports here reads as
54658
+ * "nothing is pinned".
54659
+ */
54660
+ async pinnedTrackIds(trackIds) {
54661
+ const PAGE = 500;
54662
+ const found = /* @__PURE__ */ new Set();
54663
+ for (const id of trackIds) if (this.active.has(id)) found.add(id);
54664
+ for (let i = 0; i < trackIds.length; i += PAGE) {
54665
+ const chunk = trackIds.slice(i, i + PAGE);
54666
+ if (chunk.length === 0) continue;
54667
+ const [staging, favourited] = await Promise.all([this.store.query.query({
54668
+ collection: TRACKS_COLLECTION,
54669
+ columns: ["id"],
54670
+ filter: {
54671
+ whereIn: { id: [...chunk] },
54672
+ where: { retrainStatus: "staging" },
54673
+ limit: chunk.length
54674
+ }
54675
+ }), this.store.query.query({
54676
+ collection: TRACKS_COLLECTION,
54677
+ columns: ["id"],
54678
+ filter: {
54679
+ whereIn: { id: [...chunk] },
54680
+ where: { favourited: true },
54681
+ limit: chunk.length
54682
+ }
54683
+ })]);
54684
+ for (const row of staging) found.add(row.id);
54685
+ for (const row of favourited) found.add(row.id);
54686
+ }
54687
+ return found;
54688
+ }
54689
+ /**
53081
54690
  * The distinct set of deviceIds that currently have persisted tracks. Feeds
53082
54691
  * the per-device retention sweep (design §6) so a device that stopped
53083
54692
  * producing frames still has its aged track debris pruned. Paged scan +
@@ -54480,13 +56089,45 @@ async function runOrphanAudit(input) {
54480
56089
  * sees the audit in a flame graph needs to be able to make it smaller without a
54481
56090
  * release.
54482
56091
  *
54483
- * `mode` defaults to `'report'`. That default is the whole safety argument (see
54484
- * `orphan-audit.ts`): the audit observes before it is allowed to act.
56092
+ * `mode` defaults to `'reclaim'` as of 2026-08-30 (D304). It defaulted to
56093
+ * `'report'` from the day the engine shipped, and that default was right for
56094
+ * exactly as long as its premise held — see {@link OrphanAuditModeSchema}.
54485
56095
  */
54486
56096
  var ORPHAN_AUDIT_MODE_SETTING = "orphanAuditMode";
54487
56097
  var ORPHAN_AUDIT_INTERVAL_SETTING = "orphanAuditIntervalHours";
54488
56098
  var ORPHAN_AUDIT_PAGE_SIZE_SETTING = "orphanAuditPageSize";
54489
- var OrphanAuditModeSchema = _enum(["report", "reclaim"]).default("report");
56099
+ /**
56100
+ * `'reclaim'` is the default. Here is the argument, because reversing a
56101
+ * destructive default needs one on the record.
56102
+ *
56103
+ * The original default was `'report'`, and `orphan-audit.ts` states its reason
56104
+ * precisely: *"this engine's correctness rests entirely on an ownership model
56105
+ * that is being rewritten in the same change"*. Report mode existed to make
56106
+ * that model observable BEFORE it was load-bearing.
56107
+ *
56108
+ * That premise is spent. The model has run in production for weeks and has been
56109
+ * read: the `faces` scope reported 99 orphans among its oldest 300 rows and all
56110
+ * 99 were `assigned`, i.e. the exemption doing exactly what it claims; the
56111
+ * vector index reported 956 orphans per pass, of which the D58 re-derivability
56112
+ * check (a vector whose track is gone can neither be re-embedded nor returned)
56113
+ * accounts for all of them. What report mode had left to prove, it proved.
56114
+ *
56115
+ * What it did instead was nothing, forever. `reclaimed: 0` was logged every six
56116
+ * hours against a standing backlog, and a backstop that never acts is not a
56117
+ * backstop — it is a leftover that describes the right design, which this repo
56118
+ * has decided reads as verification and is worse than an absence.
56119
+ *
56120
+ * The blast radius is unchanged and bounded five ways (exempt owner kinds, the
56121
+ * grace window, probe-failure abandons the scope, an unresolvable row is kept,
56122
+ * and a per-scope reclaim ceiling that shows up in the report as `stoppedBy`).
56123
+ * An operator who wants observation-only sets `orphanAuditMode: 'report'` and
56124
+ * gets exactly the old behaviour; the knob did not move, only its default.
56125
+ *
56126
+ * NOTE what this does NOT change: an OPERATOR-triggered pass
56127
+ * (`retention.orphanAudit`) is still dry-run unless it names `mode` — it never
56128
+ * inherits this setting. Reclaiming on demand is still a word you have to type.
56129
+ */
56130
+ var OrphanAuditModeSchema = _enum(["report", "reclaim"]).default("reclaim");
54490
56131
  /** Hours between passes. Hours, not minutes — this walks whole collections. */
54491
56132
  var OrphanAuditIntervalHoursSchema = number().min(1).max(168).default(6);
54492
56133
  var OrphanAuditPageSizeSchema = number().int().min(50).max(5e3).default(500);
@@ -54772,8 +56413,12 @@ var BindingCache = class {
54772
56413
  *
54773
56414
  * `confirm` (S4) is the confirmation-gate re-detect crop: it GATES track birth
54774
56415
  * (the frame path awaits it), so it ranks WITH `firstFrame` at 0 — it must
54775
- * jump every queued lower-priority capture and its call site never sets a
54776
- * `staleAfterMs` (a late confirmation still confirms). `face`/`plate` (S4) are
56416
+ * jump every queued lower-priority capture. It DOES set a `staleAfterMs`, and
56417
+ * it must: priority 0 means it is inserted at the head, so an entry here that
56418
+ * cannot expire makes the whole lane unrecoverable — every later arrival is
56419
+ * inserted at the tail and shed on admission. Its deadline is the gate's own
56420
+ * `timeoutMs`, past which its caller has already discarded the answer; see the
56421
+ * `fetchCrop` note in `pipeline-analytics/index.ts`. `face`/`plate` (S4) are
54777
56422
  * enrichment gallery crops — same rank as `embed`: nice-to-have media that
54778
56423
  * must never displace the track-media ladder above.
54779
56424
  *
@@ -54818,7 +56463,8 @@ var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
54818
56463
  var DEFAULT_LOG_WINDOW_MS = 6e4;
54819
56464
  /** One `capture queue full` line per device per this interval. The condition
54820
56465
  * holds for as long as the lane is saturated; one line per shed request would
54821
- * be thousands. */
56466
+ * be thousands. What the line REPORTS is the whole interval's shed count, not
56467
+ * the one call that happened to break the throttle — see {@link OverflowWindow}. */
54822
56468
  var OVERFLOW_WARN_THROTTLE_MS = 6e4;
54823
56469
  var CaptureScheduler = class {
54824
56470
  logger;
@@ -54832,8 +56478,8 @@ var CaptureScheduler = class {
54832
56478
  /** holdKey → refcount of queued/running requests holding it. */
54833
56479
  heldKeys = /* @__PURE__ */ new Map();
54834
56480
  windows = /* @__PURE__ */ new Map();
54835
- /** Per-device throttle for the `capture queue full` warn. */
54836
- lastOverflowWarnAt = /* @__PURE__ */ new Map();
56481
+ /** Per-device throttle + shed accumulator for the `capture queue full` warn. */
56482
+ overflowWindows = /* @__PURE__ */ new Map();
54837
56483
  onDropped;
54838
56484
  onStarted;
54839
56485
  constructor(options) {
@@ -54918,6 +56564,7 @@ var CaptureScheduler = class {
54918
56564
  this.coalesced.clear();
54919
56565
  this.heldKeys.clear();
54920
56566
  this.windows.clear();
56567
+ this.overflowWindows.clear();
54921
56568
  }
54922
56569
  /** Snapshot of the CURRENT window's counters (tests/ops introspection). */
54923
56570
  counters(deviceId) {
@@ -55031,8 +56678,18 @@ var CaptureScheduler = class {
55031
56678
  if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
55032
56679
  });
55033
56680
  }
55034
- /** Drop every queued entry already past its `staleAfterMs`. Entries without
55035
- * one (e.g. `confirm`, which gates track birth) are never expired here. */
56681
+ /**
56682
+ * Drop every queued entry already past its `staleAfterMs`.
56683
+ *
56684
+ * An entry without one is never expired here — and that is a real hazard, not
56685
+ * a convenience: combined with {@link insertByPriority} + {@link shedToCap},
56686
+ * an unexpirable entry near the HEAD makes a saturated lane unrecoverable,
56687
+ * because every later arrival lands at the tail and is shed on admission.
56688
+ * `confirm` was the case that bit (see the `fetchCrop` note in
56689
+ * `pipeline-analytics/index.ts`); `keyFrame` is the one kind that still omits
56690
+ * a deadline, and it may, because nothing abandons it — a late keyFrame can
56691
+ * still be served from the worker's scene-tile store and lands for real.
56692
+ */
55036
56693
  dropExpired(deviceId, lane) {
55037
56694
  const nowMs = this.now();
55038
56695
  for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
@@ -55045,9 +56702,14 @@ var CaptureScheduler = class {
55045
56702
  entry.drop();
55046
56703
  }
55047
56704
  }
55048
- /** Shed from the tail (lowest priority, newest within it) until the lane is
55049
- * at its cap. Loud: a capture that is dropped rather than merely late is a
55050
- * missing thumbnail or first frame, and silence would read as "never asked". */
56705
+ /**
56706
+ * Shed from the tail (lowest priority, newest within it) until the lane is at
56707
+ * its cap. Loud: a capture that is dropped rather than merely late is a
56708
+ * missing thumbnail or first frame, and silence would read as "never asked".
56709
+ *
56710
+ * The warn is throttled but its `shed` count is NOT the throttled call's — it
56711
+ * is every entry shed since the last line. See {@link OverflowWindow}.
56712
+ */
55051
56713
  shedToCap(deviceId, lane) {
55052
56714
  let shed = 0;
55053
56715
  while (lane.queue.length > this.maxQueued) {
@@ -55060,19 +56722,41 @@ var CaptureScheduler = class {
55060
56722
  }
55061
56723
  if (shed === 0) return;
55062
56724
  const nowMs = this.now();
55063
- if (nowMs - (this.lastOverflowWarnAt.get(deviceId) ?? Number.NEGATIVE_INFINITY) < OVERFLOW_WARN_THROTTLE_MS) return;
55064
- this.lastOverflowWarnAt.set(deviceId, nowMs);
56725
+ const window = this.overflowWindow(deviceId, nowMs);
56726
+ window.shed += shed;
56727
+ if (nowMs - window.lastWarnAt < OVERFLOW_WARN_THROTTLE_MS) return;
56728
+ const reported = window.shed;
56729
+ const sinceMs = window.sinceMs;
56730
+ window.shed = 0;
56731
+ window.sinceMs = nowMs;
56732
+ window.lastWarnAt = nowMs;
55065
56733
  this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
55066
56734
  tags: { deviceId },
55067
56735
  meta: {
55068
- shed,
56736
+ shed: reported,
56737
+ windowSec: Math.round((nowMs - sinceMs) / 1e3),
55069
56738
  cap: this.maxQueued,
55070
56739
  queueDepth: lane.queue.length,
56740
+ unexpirable: lane.queue.reduce((total, entry) => total + (entry.staleAfterMs === void 0 ? 1 : 0), 0),
55071
56741
  inFlight: lane.inFlight,
55072
56742
  budget: this.budget
55073
56743
  }
55074
56744
  });
55075
56745
  }
56746
+ /** The device's shed accumulator, created on first shed. A device that has
56747
+ * never overflowed carries no entry — and its first shed reports
56748
+ * immediately, because the condition is new. */
56749
+ overflowWindow(deviceId, nowMs) {
56750
+ const existing = this.overflowWindows.get(deviceId);
56751
+ if (existing !== void 0) return existing;
56752
+ const created = {
56753
+ sinceMs: nowMs,
56754
+ shed: 0,
56755
+ lastWarnAt: Number.NEGATIVE_INFINITY
56756
+ };
56757
+ this.overflowWindows.set(deviceId, created);
56758
+ return created;
56759
+ }
55076
56760
  insertByPriority(lane, entry) {
55077
56761
  let index = lane.queue.length;
55078
56762
  while (index > 0) {
@@ -57362,6 +59046,36 @@ var EventMediaDispatcher = class {
57362
59046
  }
57363
59047
  }
57364
59048
  };
59049
+ //#endregion
59050
+ //#region src/pipeline-analytics/services/motion-marker-policy.ts
59051
+ /**
59052
+ * Pure. No clock of its own, no store read, no await — it is called on the
59053
+ * motion event path at up to the provider's keepalive rate.
59054
+ *
59055
+ * A non-positive `quietMs` would make every edge a marker (`now - x >= 0` is
59056
+ * true for any mirror), which is the flood this module removes; it is clamped
59057
+ * by the schema (`motionMarkerQuietMs.min`), and the ordering below puts the
59058
+ * detection check first so the reason names the most useful fact.
59059
+ */
59060
+ function decideMotionStill(input) {
59061
+ if (input.debugMediaEnabled) return {
59062
+ capture: true,
59063
+ reason: "debug"
59064
+ };
59065
+ const { now, quietMs } = input;
59066
+ if (input.lastDetectionAtMs !== void 0 && now - input.lastDetectionAtMs < quietMs) return {
59067
+ capture: false,
59068
+ reason: "detection-recent"
59069
+ };
59070
+ if (input.lastMarkerAtMs !== void 0 && now - input.lastMarkerAtMs < quietMs) return {
59071
+ capture: false,
59072
+ reason: "marker-recent"
59073
+ };
59074
+ return {
59075
+ capture: true,
59076
+ reason: "quiet-marker"
59077
+ };
59078
+ }
57365
59079
  var SnapshotTimeoutError$1 = class extends Error {
57366
59080
  constructor(ms) {
57367
59081
  super(`snapshot cap timed out after ${ms}ms`);
@@ -57383,24 +59097,65 @@ function withTimeout$1(promise, ms) {
57383
59097
  var MotionEventSnapshotCapturer = class {
57384
59098
  deps;
57385
59099
  snapshotTimeoutMs;
59100
+ /** Last ms a marker was written, per device. */
59101
+ lastMarkerAt = /* @__PURE__ */ new Map();
59102
+ /** Edges dropped since that device's last marker — reported on its line. */
59103
+ suppressed = /* @__PURE__ */ new Map();
57386
59104
  constructor(deps) {
57387
59105
  this.deps = deps;
57388
59106
  this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
57389
59107
  }
57390
59108
  /**
57391
- * Fetch a still and persist it as the motion event's snapshot. Never
57392
- * throws — the durable record is the motion row, already inserted by
57393
- * the caller.
59109
+ * Judge the edge, and only if it survives, fetch a still and persist it as
59110
+ * the motion event's snapshot. Never throws — the durable record is the
59111
+ * motion row, already inserted by the caller.
59112
+ *
59113
+ * The two clocks the decision reads live HERE, per device: `lastMarkerAt`
59114
+ * (so a drought yields one marker per window, not one per provider
59115
+ * keepalive) and `suppressed` (so the drop is reported ON the next marker's
59116
+ * line rather than 92 400 times a day).
57394
59117
  */
57395
59118
  async capture(input) {
59119
+ const decision = decideMotionStill({
59120
+ debugMediaEnabled: input.policy.debugMediaEnabled,
59121
+ now: input.timestamp,
59122
+ lastDetectionAtMs: input.policy.lastDetectionAtMs,
59123
+ lastMarkerAtMs: this.lastMarkerAt.get(input.deviceId),
59124
+ quietMs: input.policy.quietMs
59125
+ });
59126
+ if (!decision.capture) {
59127
+ this.suppressed.set(input.deviceId, (this.suppressed.get(input.deviceId) ?? 0) + 1);
59128
+ return {
59129
+ captured: false,
59130
+ reason: decision.reason
59131
+ };
59132
+ }
59133
+ this.lastMarkerAt.set(input.deviceId, input.timestamp);
59134
+ const suppressed = this.suppressed.get(input.deviceId) ?? 0;
59135
+ this.suppressed.delete(input.deviceId);
59136
+ if (decision.reason === "quiet-marker") this.deps.logger.info("motion marker still: detection drought", {
59137
+ tags: { deviceId: input.deviceId },
59138
+ meta: {
59139
+ eventId: input.eventId,
59140
+ quietMs: input.policy.quietMs,
59141
+ suppressedSinceLastMarker: suppressed,
59142
+ lastDetectionAtMs: input.policy.lastDetectionAtMs ?? null
59143
+ }
59144
+ });
57396
59145
  let snap;
57397
59146
  try {
57398
59147
  snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
57399
59148
  } catch (err) {
57400
59149
  this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
57401
- return;
59150
+ return {
59151
+ captured: false,
59152
+ reason: decision.reason
59153
+ };
57402
59154
  }
57403
- if (snap === null) return;
59155
+ if (snap === null) return {
59156
+ captured: false,
59157
+ reason: decision.reason
59158
+ };
57404
59159
  const raw = Buffer.from(snap.base64, "base64");
57405
59160
  let data = raw;
57406
59161
  try {
@@ -57419,7 +59174,21 @@ var MotionEventSnapshotCapturer = class {
57419
59174
  });
57420
59175
  } catch (err) {
57421
59176
  this.deps.onError?.("snapshotMedia", err, input.deviceId);
59177
+ return {
59178
+ captured: false,
59179
+ reason: decision.reason
59180
+ };
57422
59181
  }
59182
+ return {
59183
+ captured: true,
59184
+ reason: decision.reason
59185
+ };
59186
+ }
59187
+ /** Drop a device's clocks when it unbinds — same lifetime as every other
59188
+ * per-device mirror on the addon. */
59189
+ forgetDevice(deviceId) {
59190
+ this.lastMarkerAt.delete(deviceId);
59191
+ this.suppressed.delete(deviceId);
57423
59192
  }
57424
59193
  };
57425
59194
  /**
@@ -57486,6 +59255,78 @@ async function projectSensorMarkers(deps, data, timestamp) {
57486
59255
  }
57487
59256
  return landed;
57488
59257
  }
59258
+ var SensorTransitionGate = class {
59259
+ /** Last observed boolean, keyed by `${deviceId}:${capName}`. */
59260
+ lastValues = /* @__PURE__ */ new Map();
59261
+ /** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
59262
+ drops = /* @__PURE__ */ new Map();
59263
+ windowStartedAt;
59264
+ startedAtMs;
59265
+ now;
59266
+ freshnessMs;
59267
+ summaryWindowMs;
59268
+ constructor(options) {
59269
+ this.startedAtMs = options.startedAtMs;
59270
+ this.now = options.now;
59271
+ this.freshnessMs = options.firstSightingFreshnessMs ?? 3e4;
59272
+ this.summaryWindowMs = options.summaryWindowMs ?? 6e5;
59273
+ this.windowStartedAt = options.now();
59274
+ }
59275
+ /**
59276
+ * Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
59277
+ *
59278
+ * A cap with no boolean level passes through unconditionally. A cap that HAS
59279
+ * one passes only when its boolean moved — in either direction.
59280
+ */
59281
+ observe(deviceId, capName, slice) {
59282
+ if (!isSourceCap(capName)) return { changed: true };
59283
+ const key = `${deviceId}:${capName}`;
59284
+ const verdict = evaluateSensorEdge({
59285
+ capName,
59286
+ slice,
59287
+ prior: this.lastValues.get(key),
59288
+ startedAtMs: this.startedAtMs,
59289
+ nowMs: this.now(),
59290
+ firstSightingFreshnessMs: this.freshnessMs
59291
+ });
59292
+ if (verdict.edge === "rising") {
59293
+ this.lastValues.set(key, true);
59294
+ return { changed: true };
59295
+ }
59296
+ if (verdict.value !== null) this.lastValues.set(key, verdict.value);
59297
+ if (verdict.reason === "falling-edge") return { changed: true };
59298
+ this.noteDrop(deviceId, capName, verdict.reason);
59299
+ return {
59300
+ changed: false,
59301
+ reason: verdict.reason
59302
+ };
59303
+ }
59304
+ /**
59305
+ * The window's drops, or `[]` while the window is still open. Draining opens
59306
+ * the next window.
59307
+ *
59308
+ * Sorted by `(deviceId, capName, reason)` so a line is comparable with the
59309
+ * previous window's without the reader re-sorting it.
59310
+ */
59311
+ takeDropSummary(nowMs) {
59312
+ if (nowMs - this.windowStartedAt < this.summaryWindowMs) return [];
59313
+ this.windowStartedAt = nowMs;
59314
+ if (this.drops.size === 0) return [];
59315
+ const out = [...this.drops.values()].sort((a, b) => a.deviceId - b.deviceId || a.capName.localeCompare(b.capName) || a.reason.localeCompare(b.reason));
59316
+ this.drops.clear();
59317
+ return out;
59318
+ }
59319
+ noteDrop(deviceId, capName, reason) {
59320
+ const key = `${deviceId}:${capName}:${reason}`;
59321
+ const prior = this.drops.get(key);
59322
+ this.drops.set(key, {
59323
+ deviceId,
59324
+ capName,
59325
+ reason,
59326
+ count: (prior?.count ?? 0) + 1
59327
+ });
59328
+ }
59329
+ };
57489
59330
  //#endregion
57490
59331
  //#region src/pipeline-analytics/services/synthetic-track.ts
57491
59332
  /**
@@ -58669,10 +60510,23 @@ function buildGlobalSettingsSchema(cap) {
58669
60510
  fields: [
58670
60511
  {
58671
60512
  type: "boolean",
58672
- key: "saveThumbnails",
58673
- label: "Save track thumbnails",
58674
- description: "Periodic snapshot crops attached to tracks for timeline review.",
58675
- default: true
60513
+ key: "debugMediaEnabled",
60514
+ label: "Debug media",
60515
+ 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.",
60516
+ default: false
60517
+ },
60518
+ {
60519
+ type: "slider",
60520
+ key: "motionMarkerQuietMs",
60521
+ label: "Motion marker after",
60522
+ 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.",
60523
+ min: 6e4,
60524
+ max: 216e5,
60525
+ step: 6e4,
60526
+ default: 9e5,
60527
+ showValue: true,
60528
+ unit: "min",
60529
+ displayScale: 6e4
58676
60530
  },
58677
60531
  {
58678
60532
  type: "slider",
@@ -59181,7 +61035,7 @@ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
59181
61035
  }
59182
61036
  /**
59183
61037
  * Decide whether the periodic `snapshot` should be captured for a track THIS
59184
- * frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
61038
+ * frame. Pure: no side effects. The caller keeps the `debugMediaEnabled` master
59185
61039
  * switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
59186
61040
  * actually lands — so a skipped (stationary) frame leaves the clock untouched,
59187
61041
  * which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
@@ -59347,6 +61201,129 @@ async function runAudioTrackClassMigration(deps, collection) {
59347
61201
  deps.logger.info("audio track class migration", { meta: { ...stats } });
59348
61202
  return stats;
59349
61203
  }
61204
+ /** Rows one pass will touch. A ceiling, not a target. */
61205
+ var CONVERSION_MAX_ROWS = 2e4;
61206
+ /** Ids named in the "could not convert" line before it stops. */
61207
+ var REFUSED_IDS_LOGGED = 10;
61208
+ /** A stored `embedding` column, narrowed. */
61209
+ function floatsOf(raw) {
61210
+ if (!Array.isArray(raw)) return null;
61211
+ const out = [];
61212
+ for (const v of raw) {
61213
+ if (typeof v !== "number" || !Number.isFinite(v)) return null;
61214
+ out.push(v);
61215
+ }
61216
+ return out;
61217
+ }
61218
+ /**
61219
+ * Convert one collection. Never throws — a failed page ends the pass with what
61220
+ * it has, and the next boot picks the rest up.
61221
+ */
61222
+ async function convertJsonEmbeddings(spec, deps) {
61223
+ let examined = 0;
61224
+ let alreadyConverted = 0;
61225
+ let converted = 0;
61226
+ let refused = 0;
61227
+ let unusable = 0;
61228
+ let offset = 0;
61229
+ let stoppedBy = "exhausted";
61230
+ const refusedIds = [];
61231
+ while (examined < CONVERSION_MAX_ROWS) {
61232
+ let rows;
61233
+ try {
61234
+ rows = await deps.store.query.query({
61235
+ collection: spec.collection,
61236
+ filter: {
61237
+ orderBy: {
61238
+ field: "id",
61239
+ direction: "asc"
61240
+ },
61241
+ limit: 200,
61242
+ offset
61243
+ }
61244
+ });
61245
+ } catch (err) {
61246
+ deps.logger.warn("embedding conversion: page failed — pass abandoned", { meta: {
61247
+ collection: spec.collection,
61248
+ offset,
61249
+ error: String(err)
61250
+ } });
61251
+ stoppedBy = "page-failed";
61252
+ break;
61253
+ }
61254
+ if (rows.length === 0) break;
61255
+ for (const row of rows) {
61256
+ examined += 1;
61257
+ const data = row.data;
61258
+ const raw = data["embedding"];
61259
+ if (Array.isArray(raw) && raw.length === 0) {
61260
+ alreadyConverted += 1;
61261
+ continue;
61262
+ }
61263
+ const embedding = floatsOf(raw);
61264
+ if (embedding === null || embedding.length === 0) {
61265
+ unusable += 1;
61266
+ continue;
61267
+ }
61268
+ const write = spec.write(row.id, embedding, data);
61269
+ if (write === null || !await spec.vectors.put(write)) {
61270
+ refused += 1;
61271
+ if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
61272
+ continue;
61273
+ }
61274
+ if (((await spec.vectors.load([row.id])).get(row.id)?.length ?? 0) !== embedding.length) {
61275
+ refused += 1;
61276
+ if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
61277
+ continue;
61278
+ }
61279
+ try {
61280
+ await deps.store.update.mutate({
61281
+ collection: spec.collection,
61282
+ id: row.id,
61283
+ data: { embedding: [] }
61284
+ });
61285
+ converted += 1;
61286
+ } catch (err) {
61287
+ refused += 1;
61288
+ deps.logger.warn("embedding conversion: row kept its JSON after a successful index write", { meta: {
61289
+ collection: spec.collection,
61290
+ id: row.id,
61291
+ error: String(err)
61292
+ } });
61293
+ }
61294
+ }
61295
+ if (deps.breathe) await deps.breathe();
61296
+ offset += rows.length;
61297
+ if (rows.length < 200) break;
61298
+ }
61299
+ if (examined >= 2e4) stoppedBy = "row-ceiling";
61300
+ if (refused > 0) deps.logger.warn("embedding conversion: rows could not be moved into the index", { meta: {
61301
+ collection: spec.collection,
61302
+ index: spec.vectors.name,
61303
+ refused,
61304
+ ids: refusedIds,
61305
+ 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"
61306
+ } });
61307
+ deps.logger.info("embedding conversion", { meta: {
61308
+ collection: spec.collection,
61309
+ index: spec.vectors.name,
61310
+ examined,
61311
+ alreadyConverted,
61312
+ converted,
61313
+ refused,
61314
+ unusable,
61315
+ stoppedBy
61316
+ } });
61317
+ return {
61318
+ collection: spec.collection,
61319
+ examined,
61320
+ alreadyConverted,
61321
+ converted,
61322
+ refused,
61323
+ unusable,
61324
+ stoppedBy
61325
+ };
61326
+ }
59350
61327
  //#endregion
59351
61328
  //#region src/pipeline-analytics/store/object-embedding-store.ts
59352
61329
  /**
@@ -59598,10 +61575,19 @@ var ObjectEmbeddingStore = class {
59598
61575
  score: match.score
59599
61576
  });
59600
61577
  }
61578
+ if (result.effectiveTopK < input.limit) this.logger.warn("vector search ran with a smaller topK than requested", {
61579
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
61580
+ meta: {
61581
+ index: OBJECT_CLIP_INDEX,
61582
+ requested: input.limit,
61583
+ effectiveTopK: result.effectiveTopK
61584
+ }
61585
+ });
59601
61586
  return {
59602
61587
  hits,
59603
61588
  scanned: result.scanned,
59604
- truncated: result.truncated
61589
+ truncated: result.truncated,
61590
+ effectiveTopK: result.effectiveTopK
59605
61591
  };
59606
61592
  } catch (err) {
59607
61593
  this.logger.warn("ObjectEmbeddingStore.searchByVector failed", {
@@ -59611,7 +61597,109 @@ var ObjectEmbeddingStore = class {
59611
61597
  return {
59612
61598
  hits: [],
59613
61599
  scanned: 0,
59614
- truncated: false
61600
+ truncated: false,
61601
+ effectiveTopK: 0
61602
+ };
61603
+ }
61604
+ }
61605
+ /**
61606
+ * ENUMERATE one page of the index, unranked.
61607
+ *
61608
+ * The orphan scan used to enumerate by asking for a KNN of 20,000 against a
61609
+ * ZERO probe vector. Both halves were wrong. sqlite-vec clamps `k` to 4,096,
61610
+ * so the scan saw 18.5% of the index and logged `examined: 4096` as though it
61611
+ * had walked it; and a zero vector has no direction, so whatever the 4,096
61612
+ * were, they were not a meaningful sample of anything. This asks the question
61613
+ * the reconcile actually has — *what is in here* — through `vectorStore.scan`,
61614
+ * which has no `k` ceiling because it computes no distance.
61615
+ *
61616
+ * An empty page is NOT the end of the index; `nextCursor === null` is.
61617
+ */
61618
+ async scanRefs(cursor, limit) {
61619
+ const page = await this.vectorStore.scan.query({
61620
+ index: OBJECT_CLIP_INDEX,
61621
+ cursor,
61622
+ limit
61623
+ });
61624
+ const rows = [];
61625
+ for (const item of page.items) {
61626
+ const trackId = item.metadata["trackId"];
61627
+ const deviceId = item.metadata["deviceId"];
61628
+ const timestamp = item.metadata["timestamp"];
61629
+ rows.push({
61630
+ id: item.id,
61631
+ trackId: typeof trackId === "string" && trackId.length > 0 ? trackId : item.id.length > 0 ? item.id : null,
61632
+ deviceId: typeof deviceId === "number" ? deviceId : null,
61633
+ timestamp: typeof timestamp === "number" ? timestamp : null
61634
+ });
61635
+ }
61636
+ return {
61637
+ rows,
61638
+ nextCursor: page.nextCursor
61639
+ };
61640
+ }
61641
+ /**
61642
+ * Delete named vectors and CONFIRM they are gone.
61643
+ *
61644
+ * `deleteByIds` reports a row count the `vec0` backend cannot always produce,
61645
+ * so "the call returned" has never been the same statement as "the row left"
61646
+ * — and this store has already shipped one counter that could not tell those
61647
+ * apart (`rebuilt: 704` against an index that moved by 0). The read-back is
61648
+ * `getByIds`, which returns metadata and no vectors, so confirming a page of
61649
+ * deletions costs one indexed lookup and not one byte of float.
61650
+ *
61651
+ * Returns the ids that are VERIFIABLY gone. Anything still present is
61652
+ * returned as `stillPresent` for the caller to log — a delete that silently
61653
+ * did nothing is how an orphan becomes permanent.
61654
+ */
61655
+ async deleteVerified(ids) {
61656
+ if (ids.length === 0) return {
61657
+ deleted: [],
61658
+ stillPresent: []
61659
+ };
61660
+ try {
61661
+ await this.vectorStore.deleteByIds.mutate({
61662
+ index: OBJECT_CLIP_INDEX,
61663
+ ids: [...ids]
61664
+ });
61665
+ } catch (err) {
61666
+ this.declaredDim = null;
61667
+ this.logger.warn("object embedding delete failed — vectors kept", { meta: {
61668
+ index: OBJECT_CLIP_INDEX,
61669
+ ids: ids.length,
61670
+ error: String(err)
61671
+ } });
61672
+ return {
61673
+ deleted: [],
61674
+ stillPresent: [...ids]
61675
+ };
61676
+ }
61677
+ try {
61678
+ const readBack = await this.vectorStore.getByIds.query({
61679
+ index: OBJECT_CLIP_INDEX,
61680
+ ids: [...ids]
61681
+ });
61682
+ const survivors = new Set(readBack.items.map((i) => i.id));
61683
+ const deleted = ids.filter((id) => !survivors.has(id));
61684
+ const stillPresent = ids.filter((id) => survivors.has(id));
61685
+ if (stillPresent.length > 0) this.logger.warn("object embedding delete did not land — rows survived the delete", { meta: {
61686
+ index: OBJECT_CLIP_INDEX,
61687
+ asked: ids.length,
61688
+ survived: stillPresent.length
61689
+ } });
61690
+ return {
61691
+ deleted,
61692
+ stillPresent
61693
+ };
61694
+ } catch (err) {
61695
+ this.logger.warn("object embedding delete could not be verified", { meta: {
61696
+ index: OBJECT_CLIP_INDEX,
61697
+ ids: ids.length,
61698
+ error: String(err)
61699
+ } });
61700
+ return {
61701
+ deleted: [],
61702
+ stillPresent: [...ids]
59615
61703
  };
59616
61704
  }
59617
61705
  }
@@ -59658,13 +61746,15 @@ var ObjectEmbeddingStore = class {
59658
61746
  deleted: 0,
59659
61747
  refused: false
59660
61748
  };
59661
- await this.deleteByTracks(orphans);
61749
+ const { deleted, stillPresent } = await this.deleteVerified(orphans);
59662
61750
  this.logger.info("object embedding orphans pruned", { meta: {
59663
61751
  orphans: orphans.length,
61752
+ deleted: deleted.length,
61753
+ kept: stillPresent.length,
59664
61754
  candidates: input.candidates.length
59665
61755
  } });
59666
61756
  return {
59667
- deleted: orphans.length,
61757
+ deleted: deleted.length,
59668
61758
  refused: false
59669
61759
  };
59670
61760
  }
@@ -59701,25 +61791,15 @@ var ObjectEmbeddingStore = class {
59701
61791
  rows: 0,
59702
61792
  bytes: 0
59703
61793
  }];
59704
- try {
59705
- await this.vectorStore.deleteByIds.mutate({
59706
- index: OBJECT_CLIP_INDEX,
59707
- ids: [...trackIds]
59708
- });
59709
- } catch (err) {
59710
- this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
59711
- count: trackIds.length,
59712
- error: String(err)
59713
- } });
59714
- return [{
59715
- family: "embeddings",
59716
- rows: 0,
59717
- bytes: 0
59718
- }];
59719
- }
61794
+ const { deleted, stillPresent } = await this.deleteVerified(trackIds);
61795
+ if (stillPresent.length > 0) this.logger.warn("cascade left CLIP vectors behind — they are now unreachable by ownership", { meta: {
61796
+ asked: trackIds.length,
61797
+ kept: stillPresent.length,
61798
+ why: "the track rows are deleted immediately after this leaf, so these vectors can only be reclaimed by the orphan audit"
61799
+ } });
59720
61800
  return [{
59721
61801
  family: "embeddings",
59722
- rows: trackIds.length,
61802
+ rows: deleted.length,
59723
61803
  bytes: 0
59724
61804
  }];
59725
61805
  }
@@ -60665,15 +62745,19 @@ function buildStationarySnapshot(input) {
60665
62745
  */
60666
62746
  var ENCODER_INFO_TTL_MS = 6e4;
60667
62747
  /**
60668
- * Rows the orphan sweep considers per pass, on both sides of the comparison.
62748
+ * Rows of the vector index one orphan pass may examine.
62749
+ *
62750
+ * Bounded so a retention tick cannot turn into a full-index scan; the rest is
62751
+ * left for the next tick, resumed from a cursor. Orphans are not urgent, they
62752
+ * are dead weight the search has to skip.
60669
62753
  *
60670
- * Bounded so a retention tick cannot turn into a full-index scan. A pass that
60671
- * does not reach everything simply leaves the rest for the next tick — orphans
60672
- * are not urgent, they are just dead weight the search has to skip.
62754
+ * **This number is now real.** It used to be handed to a KNN as `topK`, where
62755
+ * sqlite-vec silently clamped it to 4,096 — so the audit saw 18.5% of a 22,128-
62756
+ * row index and reported `examined: 4096` as if it had walked the whole thing.
62757
+ * The pass enumerates through `vectorStore.scan` instead, which has no `k`
62758
+ * ceiling because it computes no distance. See `embedding-orphan-scan.ts`.
60673
62759
  */
60674
62760
  var EMBEDDING_ORPHAN_SCAN_LIMIT = 2e4;
60675
- /** Dimension of the zero probe vector used to enumerate the index. */
60676
- var EMBEDDING_ORPHAN_PROBE_DIM = 512;
60677
62761
  /**
60678
62762
  * Tracks fetched per page while rebuilding.
60679
62763
  *
@@ -60741,6 +62825,7 @@ var customActions = {
60741
62825
  ...embeddingActions,
60742
62826
  ...debugActions,
60743
62827
  ...orphanAuditActions,
62828
+ ...mediaReclaimActions,
60744
62829
  ...viewerSettingsActions,
60745
62830
  ...photoEnrollActions,
60746
62831
  ...faceRescoreActions,
@@ -60781,11 +62866,38 @@ var FACE_REEMBED_AUTO_CHECK_MS = 6e4;
60781
62866
  /** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
60782
62867
  * scheduled detail call's frameHandle lease is already gone. */
60783
62868
  var DETAIL_FALLBACK_CROP_PADDING = .15;
60784
- /** Max |keyFrame − plate-read| skew (ms) accepted for the DURABLE keyFrame-
60785
- * derived plate-crop last resort. A readable plate implies a slow/stopping
60786
- * vehicle, so within this window the subject barely moves off its bbox;
60787
- * beyond it the crop would show empty scene, so it is rejected (no image). */
62869
+ /** Max |keyFrame − measured| skew (ms) accepted for a DURABLE keyFrame-derived
62870
+ * crop when the track's position trail carries NO motion evidence about the
62871
+ * two instants. With evidence the motion guard decides instead — time was
62872
+ * never the quantity that separated a right crop from a wrong one. */
60788
62873
  var KEYFRAME_CROP_MAX_SKEW_MS = 1500;
62874
+ /**
62875
+ * Subject-box IoU across the two instants at or above which a keyFrame-derived
62876
+ * crop is accepted whatever the skew.
62877
+ *
62878
+ * Chosen from the 2026-08-30 debug-marked tracks, where each crop's verdict
62879
+ * came from LOOKING at the stored image (`docs/design/
62880
+ * 2026-08-30-debug-marked-tracks-media-triage.md`):
62881
+ *
62882
+ * `e9c024bb` windshield, wrong skew 1179 ms IoU 0.063
62883
+ * `4126f977` gravel, wrong skew 531 ms IoU 0.318
62884
+ * `7613c0fa` plate framed, RIGHT skew 2579 ms IoU 1.000
62885
+ *
62886
+ * Note the ordering: the only correct crop of the three has the LARGEST skew.
62887
+ * The healthy population sits far above the wrong pair too — subject IoU across
62888
+ * a 1500 ms lag is 0.83–1.00 on the eight debug tracks whose tiles frame their
62889
+ * subject. 0.5 is the midpoint of the empirical gap (0.318 → 1.000) and stays
62890
+ * well under that 0.83 floor. A refusal is not a lost tile: the close path
62891
+ * falls through to the retained detection-raster crop, which is right-moment
62892
+ * pixels by construction, so a false refusal costs resolution, not the image.
62893
+ */
62894
+ var KEYFRAME_CROP_MIN_SUBJECT_IOU = .5;
62895
+ /** How far a position sample may sit from an instant and still be evidence
62896
+ * about it. The trail is one sample per emitted frame and is downsampled only
62897
+ * on long tracks (≈280–385 ms spacing at 60–95 s of life), so a full second
62898
+ * covers a healthy trail many times over; past it the honest answer is "no
62899
+ * evidence" and the temporal bound takes over. */
62900
+ var KEYFRAME_CROP_MAX_SAMPLE_GAP_MS = 1e3;
60789
62901
  /** How long the active CLIP model id (from the embedding-encoder) is cached
60790
62902
  * before re-reading. */
60791
62903
  var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
@@ -60797,7 +62909,9 @@ var CONFIRMATION_DETECTOR_CACHE_TTL_MS = 6e4;
60797
62909
  * around the subject improves the confirmation detector's recall. */
60798
62910
  var CONFIRMATION_CROP_PADDING = .15;
60799
62911
  /** Cap the confirmation crop width so a 4K native surface never floods the
60800
- * transport just to confirm one small box. */
62912
+ * transport just to confirm one small box. EXPORTED so a test can recognise a
62913
+ * confirmation round trip among the runner's requests by the real constant
62914
+ * rather than by a copy of the number that would silently stop matching. */
60801
62915
  var CONFIRMATION_CROP_MAX_WIDTH = 320;
60802
62916
  /** Staleness bound for QUEUED enrichment captures (face/plate gallery crops,
60803
62917
  * object-embedding best crops, embedding full-frame fetches — S4). These are
@@ -60980,6 +63094,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60980
63094
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
60981
63095
  * so the DeviceStateChanged handler stays cheap. */
60982
63096
  linkedCamerasCache = null;
63097
+ /**
63098
+ * Whether a mapped sensor cap's slice carried a real TRANSITION.
63099
+ *
63100
+ * `DeviceStateChanged` is emitted on a slice diff, and a contact slice
63101
+ * carries `lastChangedAt` beside `entryOpen` — so an availability flap or a
63102
+ * provider-side device rebuild re-emits with the boolean unmoved. Every one
63103
+ * of those became a `SensorEvent` row and a synthetic track until this gate
63104
+ * existed. Built in the field initializer: `startedAtMs` is the moment this
63105
+ * process began watching, which is the floor for a first sighting that is
63106
+ * already active. See `services/sensor-transition-gate.ts`.
63107
+ */
63108
+ sensorTransitions = new SensorTransitionGate({
63109
+ startedAtMs: Date.now(),
63110
+ now: () => Date.now()
63111
+ });
60983
63112
  /** The ONE projector of non-spatial events into synthetic snapshot-carrying
60984
63113
  * tracks — linked sensor/control state changes AND audio markers. */
60985
63114
  syntheticTracks = null;
@@ -61661,6 +63790,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61661
63790
  decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
61662
63791
  cropRegionToJpeg: (bytes, w, h, norm) => cropWideCentralSquareFromRgb(bytes, w, h, norm),
61663
63792
  maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
63793
+ minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
63794
+ maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
61664
63795
  logger: this.ctx.logger.child("CloseKeyFrameThumbnail")
61665
63796
  });
61666
63797
  this._keyFrameThumbnailDerive = async (input) => {
@@ -61672,7 +63803,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61672
63803
  return {
61673
63804
  thumbnail: derived.jpeg,
61674
63805
  thumbnailSmall: await deriveThumbnailSmall(derived.jpeg),
61675
- skewMs: derived.skewMs
63806
+ skewMs: derived.skewMs,
63807
+ motion: derived.motion,
63808
+ ...derived.subjectIou !== void 0 ? { subjectIou: derived.subjectIou } : {}
61676
63809
  };
61677
63810
  };
61678
63811
  }
@@ -61759,6 +63892,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61759
63892
  * page rather than a full rescan. In RAM: losing them costs a rescan, and
61760
63893
  * persisting them would be a durable record of a purely advisory position. */
61761
63894
  orphanAuditCursors = /* @__PURE__ */ new Map();
63895
+ /** Resume points for the debug-media reclaim, keyed `<scope>:<deviceId>`.
63896
+ * In RAM like the audit's: the pass is resumable from zero at no cost
63897
+ * beyond re-walking a head that is already empty. */
63898
+ mediaReclaimCursors = /* @__PURE__ */ new Map();
63899
+ /** Single-flight latch — two concurrent passes would double the disk
63900
+ * pressure the pacing exists to avoid. */
63901
+ mediaReclaimRunning = false;
63902
+ /**
63903
+ * The vector-index leg's resume cursor, kept in its OWN field.
63904
+ *
63905
+ * Not a key in {@link orphanAuditCursors}: `runOrphanAudit` returns a map
63906
+ * built from ITS scopes and the caller assigns it wholesale, so a key added
63907
+ * afterwards is dropped on the next run — the walk would silently restart at
63908
+ * row 0 every six hours and never reach the tail of the index, which is the
63909
+ * same failure the `k` clamp already caused once.
63910
+ */
63911
+ orphanEmbeddingCursor = 0;
61762
63912
  /** Re-entrancy guard — two whole-database scans must never overlap on the
61763
63913
  * thread that also serves frames. */
61764
63914
  orphanAuditRunning = false;
@@ -61845,6 +63995,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61845
63995
  designatedNode: designated
61846
63996
  } });
61847
63997
  }
63998
+ if (this.isPostProcessingNode) this.convertLegacyEmbeddingColumns(api);
61848
63999
  const transport = createNativeFrameTransport({
61849
64000
  api,
61850
64001
  ownNodeId,
@@ -61896,6 +64047,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61896
64047
  ...embeddingActions,
61897
64048
  ...debugActions,
61898
64049
  ...orphanAuditActions,
64050
+ ...mediaReclaimActions,
61899
64051
  ...viewerSettingsActions,
61900
64052
  ...photoEnrollActions,
61901
64053
  ...faceRescoreActions,
@@ -62091,6 +64243,37 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62091
64243
  });
62092
64244
  this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
62093
64245
  }
64246
+ /**
64247
+ * Move `faces.embedding` and `identity-samples.embedding` out of JSON text
64248
+ * and into their vector indexes — once, in place, idempotently.
64249
+ *
64250
+ * Not awaited by the boot path and not a schema migration: see
64251
+ * `embedding-json-conversion.ts` for why "converted" is a property of a row
64252
+ * rather than a version number. Every failure mode here is safe — a row whose
64253
+ * index write did not land keeps its array, and both readers fall back to it.
64254
+ */
64255
+ async convertLegacyEmbeddingColumns(api) {
64256
+ const faceStore = this.faceStore;
64257
+ const identityStore = this.identityStore;
64258
+ if (!faceStore || !identityStore) {
64259
+ this.ctx.logger.warn("embedding conversion skipped — the gallery stores are not up", { meta: {
64260
+ faceStore: faceStore !== null,
64261
+ identityStore: identityStore !== null
64262
+ } });
64263
+ return;
64264
+ }
64265
+ const deps = {
64266
+ store: api.settingsStore,
64267
+ logger: this.ctx.logger.child("EmbeddingConversion"),
64268
+ breathe: () => new Promise((resolve) => setImmediate(resolve))
64269
+ };
64270
+ try {
64271
+ await convertJsonEmbeddings(faceStore.conversionSpec(), deps);
64272
+ await convertJsonEmbeddings(identityStore.conversionSpec(), deps);
64273
+ } catch (err) {
64274
+ this.ctx.logger.warn("embedding conversion pass failed", { meta: { error: String(err) } });
64275
+ }
64276
+ }
62094
64277
  async declareCollections(api) {
62095
64278
  this.settingsStore = api.settingsStore;
62096
64279
  await Promise.all([
@@ -62423,6 +64606,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62423
64606
  this.motionEventSnapshots = new MotionEventSnapshotCapturer({
62424
64607
  snapshot: { getSnapshot: (i) => api.snapshot?.getSnapshot.query(i) ?? Promise.resolve(null) },
62425
64608
  media: mediaStore,
64609
+ logger: logger.child("MotionMarkerStill"),
62426
64610
  onError: (scope, err, deviceId) => logger.warn("motion event snapshot failed", {
62427
64611
  ...deviceId !== void 0 ? { tags: { deviceId } } : {},
62428
64612
  meta: {
@@ -62440,13 +64624,25 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62440
64624
  error: errMsg(err)
62441
64625
  } })
62442
64626
  });
64627
+ const galleryVectorStore = api.vectorStore;
64628
+ 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");
62443
64629
  const identityStore = new IdentityStore({
62444
64630
  store: api.settingsStore,
64631
+ vectors: new EmbeddingVectorIndex({
64632
+ index: IDENTITY_ARCFACE_INDEX,
64633
+ client: galleryVectorStore,
64634
+ logger: logger.child("IdentityVectors")
64635
+ }),
62445
64636
  logger: logger.child("IdentityStore")
62446
64637
  });
62447
64638
  this.identityStore = identityStore;
62448
64639
  const faceStore = new FaceStore({
62449
64640
  store: api.settingsStore,
64641
+ vectors: new EmbeddingVectorIndex({
64642
+ index: FACE_ARCFACE_INDEX,
64643
+ client: galleryVectorStore,
64644
+ logger: logger.child("FaceVectors")
64645
+ }),
62450
64646
  logger: logger.child("FaceStore")
62451
64647
  });
62452
64648
  this.faceStore = faceStore;
@@ -62568,6 +64764,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62568
64764
  decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
62569
64765
  cropRegionToJpeg: async (bytes, w, h, norm) => (await extractCrop(bytes, w, h, norm)).crop,
62570
64766
  maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
64767
+ minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
64768
+ maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
62571
64769
  logger: logger.child("KeyFrameCrop")
62572
64770
  });
62573
64771
  this.plateRecognizer = new PlateRecognizer({
@@ -63395,10 +65593,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63395
65593
  const limit = input.candidateLimit ?? 25;
63396
65594
  const asked = input.deviceIds;
63397
65595
  const known = new Set([...registry.deviceIds(), ...evidence?.deviceIdsWithCandidates() ?? []]);
63398
- return { devices: (asked === void 0 || asked.length === 0 ? [...known].sort((a, b) => a - b) : asked.filter((id) => known.has(id))).map((deviceId) => {
65596
+ return { devices: (asked === void 0 || asked.length === 0 ? [...known].toSorted((a, b) => a - b) : asked.filter((id) => known.has(id))).map((deviceId) => {
63399
65597
  const observedAt = registry.observedAt(deviceId);
63400
65598
  const sessionOpenedAt = registry.sessionOpenedAt(deviceId);
63401
- const ordered = [...evidence?.candidatesFor(deviceId) ?? []].sort((a, b) => b.agreed - a.agreed);
65599
+ const ordered = [...evidence?.candidatesFor(deviceId) ?? []].toSorted((a, b) => b.agreed - a.agreed);
63402
65600
  return {
63403
65601
  deviceId,
63404
65602
  ...sessionOpenedAt !== void 0 ? { sessionOpenedAt } : {},
@@ -63687,6 +65885,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63687
65885
  this.forgetDeviceProcessors(deviceId);
63688
65886
  this.levelStateByDevice.delete(deviceId);
63689
65887
  this.lastTrackActivityMs.delete(deviceId);
65888
+ this.motionEventSnapshots?.forgetDevice(deviceId);
63690
65889
  this.audioEpisodeByDevice.delete(deviceId);
63691
65890
  for (const [episodeId, track] of this.audioEpisodeTracks) if (track.deviceId === deviceId) {
63692
65891
  this.audioEpisodeTracks.delete(episodeId);
@@ -65099,12 +67298,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65099
67298
  deviceId,
65100
67299
  trackId: candidate.trackId,
65101
67300
  kind: "confirm",
67301
+ staleAfterMs: config.timeoutMs,
65102
67302
  exec: () => captureCrop(frameHandle, {
65103
67303
  x: candidate.bbox.x,
65104
67304
  y: candidate.bbox.y,
65105
67305
  w: candidate.bbox.w,
65106
67306
  h: candidate.bbox.h
65107
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, CONFIRMATION_CROP_MAX_WIDTH, deviceId)
67307
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
65108
67308
  }),
65109
67309
  fetchFallbackCrop: (candidate) => {
65110
67310
  const displayCrop = this.captureDisplayCropFn;
@@ -65114,7 +67314,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65114
67314
  y: candidate.bbox.y,
65115
67315
  w: candidate.bbox.w,
65116
67316
  h: candidate.bbox.h
65117
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, CONFIRMATION_CROP_MAX_WIDTH);
67317
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
65118
67318
  },
65119
67319
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
65120
67320
  minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
@@ -65477,12 +67677,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65477
67677
  /**
65478
67678
  * §5 — decide which active tracks need periodic media THIS frame. Pure over
65479
67679
  * TrackStore.lastSnapshotAt + the per-track best-confidence map:
65480
- * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
65481
- * the snapshotIntervalMs cadence, gated by `saveThumbnails`;
67680
+ * • `snapshot` (append) fires on the snapshotIntervalMs cadence, gated by
67681
+ * `debugMediaEnabled`; the rolling `lastFrame` fires on its own cadence
67682
+ * and is NOT gated (it is in the keep-set);
65482
67683
  * • `thumbnail` (best) fires when confidence beats the held best per
65483
67684
  * `BEST_FRAME_POLICY` (hysteresis margin, rate-limited to one per
65484
67685
  * `minGapMs` — see `pipeline/best-selection-policies.ts`), and is
65485
- * NOT gated by saveThumbnails (a single best still-frame is always useful).
67686
+ * never gated (a single best still-frame is always useful).
65486
67687
  * The per-track best-confidence map is updated here as a side effect.
65487
67688
  */
65488
67689
  /**
@@ -65894,9 +68095,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65894
68095
  });
65895
68096
  continue;
65896
68097
  }
65897
- const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
65898
- const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
65899
- lastSnapshotAt: lastSnap,
68098
+ const dueSnapshot = evaluatePeriodicSnapshot({
68099
+ lastSnapshotAt: this.trackStore.lastSnapshotAt(t.trackId),
65900
68100
  lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
65901
68101
  currentBbox: t.bbox,
65902
68102
  now: timestamp,
@@ -65921,7 +68121,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65921
68121
  const isNewBest = this.residents.observeBestFrame(deviceId, t.trackId, t.confidence, timestamp, edgeClear, centerScore, containment, sizeScore, squareFraction, false);
65922
68122
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
65923
68123
  const plan = planPeriodicMedia({
65924
- saveThumbnails: media.saveThumbnails,
68124
+ debugMediaEnabled: media.debugMediaEnabled,
65925
68125
  dueSnapshot,
65926
68126
  isNewBest,
65927
68127
  thumbnailLanded: this.residents.isThumbnailLanded(t.trackId),
@@ -66350,11 +68550,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66350
68550
  timestamp: ev.timestamp
66351
68551
  }
66352
68552
  });
66353
- if (transitionedOn) await this.motionEventSnapshots?.capture({
66354
- deviceId,
66355
- eventId: ev.id,
66356
- timestamp: ev.timestamp
66357
- });
68553
+ if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
66358
68554
  }
66359
68555
  /**
66360
68556
  * Persist firmware-driven (onboard) motion events. Mirrors
@@ -66429,10 +68625,38 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66429
68625
  timestamp: ev.timestamp
66430
68626
  }
66431
68627
  });
66432
- if (transitionedOn) await this.motionEventSnapshots?.capture({
68628
+ if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
68629
+ }
68630
+ /**
68631
+ * The gate in front of the motion still. Both motion handlers reach it, so
68632
+ * there is exactly one place that decides.
68633
+ *
68634
+ * With debug media OFF (the default) the still is the operator's fallback
68635
+ * marker: produced only during a DETECTION DROUGHT, once per
68636
+ * `motionMarkerQuietMs` window. See `services/motion-marker-policy.ts` for
68637
+ * why the raw edge is not a scene fact — it is the provider's ~1 Hz
68638
+ * keepalive beating against the analyzer's per-frame sample through one
68639
+ * shared edge map.
68640
+ *
68641
+ * A dropped write is REPORTED, but as a per-window summary and never per
68642
+ * event: the suppressed count since the last marker rides on the marker's
68643
+ * own line. At 92 400 suppressions/day a line each would be the flood the
68644
+ * logging rules exist to prevent, and a silent branch reads as "never
68645
+ * happened".
68646
+ */
68647
+ async captureMotionMarkerStill(deviceId, eventId, timestamp) {
68648
+ const capturer = this.motionEventSnapshots;
68649
+ if (!capturer) return;
68650
+ const media = await this.resolveDeviceMediaSettings(deviceId);
68651
+ await capturer.capture({
66433
68652
  deviceId,
66434
- eventId: ev.id,
66435
- timestamp: ev.timestamp
68653
+ eventId,
68654
+ timestamp,
68655
+ policy: {
68656
+ debugMediaEnabled: media.debugMediaEnabled,
68657
+ quietMs: media.motionMarkerQuietMs,
68658
+ lastDetectionAtMs: this.lastTrackActivityMs.get(deviceId)
68659
+ }
66436
68660
  });
66437
68661
  }
66438
68662
  async sweepExpiredTracks() {
@@ -67171,7 +69395,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67171
69395
  };
67172
69396
  if (faceEnd) faceEnd.finally(dropKeyFrame);
67173
69397
  else dropKeyFrame();
67174
- this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
69398
+ const promotedPath = this.trackStore?.getActiveByTrack(track.trackId)?.positions;
69399
+ this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
67175
69400
  this.residents.close(track.trackId, "stationary");
67176
69401
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
67177
69402
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -67509,11 +69734,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67509
69734
  * press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
67510
69735
  * event is a missing history row and a missing notification — never wrong
67511
69736
  * durable state.
69737
+ *
69738
+ * The FIRST question is whether the sensor changed at all. This event is
69739
+ * emitted on a slice diff, and a level cap's slice carries its transition
69740
+ * timestamp beside its boolean — so an availability flap or a device rebuild
69741
+ * re-emits with the boolean unmoved. Both the row and the marker are gated on
69742
+ * the same verdict, in that one place: two derivations of "did this happen"
69743
+ * is one too many, and it is the mistake this handler already made once with
69744
+ * "which cameras" (`resolveSensorEventCameras`).
67512
69745
  */
67513
69746
  async handleSensorStateChanged(data, timestamp) {
67514
69747
  const store = this.sensorEventStore;
67515
69748
  const cache = this.linkedCamerasCache;
67516
69749
  if (store === null || cache === null) return;
69750
+ if (!this.sensorSliceCarriedATransition(data)) return;
67517
69751
  const persisted = [];
67518
69752
  try {
67519
69753
  await ingestSensorStateChange({
@@ -67577,6 +69811,55 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67577
69811
  for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
67578
69812
  }
67579
69813
  /**
69814
+ * True when this slice carried a real transition of its sensor.
69815
+ *
69816
+ * A cap with no boolean level (`doorbell`, `button`, `lock-control`,
69817
+ * `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
69818
+ * the event, and gating it would re-ship the silence of 2026-08-08. A cap
69819
+ * that HAS one passes only when the boolean moved, in either direction.
69820
+ *
69821
+ * A slice that is not an object at all is refused rather than defaulted to
69822
+ * `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
69823
+ * object, which looks like a sensor reporting nonsense when what happened is
69824
+ * that there was nothing to read. Same discipline as the recorder's
69825
+ * `parseSensorStateChanged`.
69826
+ *
69827
+ * The drops are SUMMARISED, never logged one by one: this path carries every
69828
+ * camera's native `motion` slice at motion rate.
69829
+ */
69830
+ sensorSliceCarriedATransition(data) {
69831
+ const slice = data.slice;
69832
+ if (typeof slice !== "object" || slice === null || Array.isArray(slice)) {
69833
+ this.ctx.logger.debug("sensor state change carried no slice — dropped", {
69834
+ tags: { deviceId: data.deviceId },
69835
+ meta: { capName: data.capName }
69836
+ });
69837
+ return false;
69838
+ }
69839
+ const verdict = this.sensorTransitions.observe(data.deviceId, data.capName, { ...slice });
69840
+ this.flushSensorTransitionDrops();
69841
+ return verdict.changed;
69842
+ }
69843
+ /**
69844
+ * One `info` line per (device, cap, reason) per window, or none.
69845
+ *
69846
+ * A branch that drops work says so — but this one drops ~0.6 events/s
69847
+ * fleet-wide in its steady state (every camera's native `motion`), so what it
69848
+ * says is a COUNT. The line an operator greps when a real contact produced no
69849
+ * marker is here, with the numeric `deviceId` of the SENSOR, and it says
69850
+ * which verdict swallowed it.
69851
+ */
69852
+ flushSensorTransitionDrops() {
69853
+ for (const drop of this.sensorTransitions.takeDropSummary(Date.now())) this.ctx.logger.info("sensor state changes dropped — no transition", {
69854
+ tags: { deviceId: drop.deviceId },
69855
+ meta: {
69856
+ capName: drop.capName,
69857
+ reason: drop.reason,
69858
+ count: drop.count
69859
+ }
69860
+ });
69861
+ }
69862
+ /**
67580
69863
  * Say out loud that a MAPPED sensor cap ingested nothing, and why.
67581
69864
  *
67582
69865
  * BOTH branches are `debug`, deliberately. After the self-camera fix neither
@@ -67755,25 +70038,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67755
70038
  return runner.runStatelessStep.mutate(input, nodePin(nodeId));
67756
70039
  }
67757
70040
  /**
67758
- * Every track id the track store still holds, across the given devices.
67759
- *
67760
- * Returned as a Set for the orphan sweep, which REFUSES an empty one — a
67761
- * store that failed to answer must not be read as "no tracks exist".
67762
- */
67763
- async collectLiveTrackIds(deviceIds) {
67764
- const live = /* @__PURE__ */ new Set();
67765
- const trackStore = this.trackStore;
67766
- if (!trackStore) return live;
67767
- for (const deviceId of deviceIds) {
67768
- const page = await trackStore.queryRecentIds({
67769
- deviceIds: [deviceId],
67770
- limit: EMBEDDING_ORPHAN_SCAN_LIMIT
67771
- });
67772
- for (const trackId of page.trackIds) live.add(trackId);
67773
- }
67774
- return live;
67775
- }
67776
- /**
67777
70041
  * The periodic ORPHAN AUDIT — ownership-keyed, wide interval, report-first.
67778
70042
  *
67779
70043
  * This is NOT a fifth age sweep and must never be mistaken for one. It takes
@@ -67874,7 +70138,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67874
70138
  }
67875
70139
  });
67876
70140
  }
67877
- await this.auditEmbeddingOrphans(limits);
70141
+ await this.auditEmbeddingOrphans(limits, overrides?.restart === true);
67878
70142
  if (report.totalOrphans > 0) this.eventsOpsLog?.append({
67879
70143
  op: "orphan-audit",
67880
70144
  reason: "maintenance",
@@ -67904,43 +70168,53 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67904
70168
  * that failed to answer must never be read as "no tracks exist", which would
67905
70169
  * wipe the whole index. In report mode nothing is deleted at all.
67906
70170
  */
67907
- async auditEmbeddingOrphans(limits) {
70171
+ async auditEmbeddingOrphans(limits, restart) {
67908
70172
  const embeddingStore = this.objectEmbeddingStore;
67909
- if (!embeddingStore) return;
67910
- try {
67911
- const deviceIds = await this.retentionDeviceIds();
67912
- const live = await this.collectLiveTrackIds(deviceIds);
67913
- const candidates = (await embeddingStore.searchByVector({
67914
- vector: new Float32Array(EMBEDDING_ORPHAN_PROBE_DIM),
67915
- limit: EMBEDDING_ORPHAN_SCAN_LIMIT
67916
- })).hits.map((h) => h.row.trackId);
67917
- const orphans = candidates.filter((id) => !live.has(id));
67918
- if (limits.mode !== "reclaim") {
67919
- this.ctx.logger.info("analytics orphan audit", { meta: {
67920
- mode: limits.mode,
67921
- collection: "vector-index:object-clip",
67922
- examined: candidates.length,
67923
- orphans: live.size === 0 ? 0 : orphans.length,
67924
- reclaimed: 0,
67925
- refused: live.size === 0
67926
- } });
67927
- return;
67928
- }
67929
- const result = await embeddingStore.pruneOrphans({
67930
- liveTrackIds: live,
67931
- candidates
67932
- });
67933
- this.ctx.logger.info("analytics orphan audit", { meta: {
67934
- mode: limits.mode,
67935
- collection: "vector-index:object-clip",
67936
- examined: candidates.length,
67937
- orphans: result.deleted,
67938
- reclaimed: result.deleted,
67939
- refused: result.refused
70173
+ const trackStore = this.trackStore;
70174
+ if (!embeddingStore || !trackStore) {
70175
+ this.ctx.logger.warn("analytics orphan audit: vector-index leg skipped — stores not up", { meta: {
70176
+ collection: EMBEDDING_ORPHAN_SCOPE,
70177
+ embeddingStore: embeddingStore !== null,
70178
+ trackStore: trackStore !== null
67940
70179
  } });
67941
- } catch (err) {
67942
- this.ctx.logger.debug("analytics orphan audit (vector index) failed", { meta: { error: String(err) } });
70180
+ return;
67943
70181
  }
70182
+ const report = await scanEmbeddingOrphans({
70183
+ scanPage: (cursor, limit) => embeddingStore.scanRefs(cursor, limit),
70184
+ liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
70185
+ reclaim: (ids) => embeddingStore.deleteVerified(ids),
70186
+ now: () => Date.now(),
70187
+ logger: this.ctx.logger,
70188
+ breathe: () => new Promise((resolve) => setImmediate(resolve))
70189
+ }, {
70190
+ mode: limits.mode,
70191
+ graceMs: limits.graceMs,
70192
+ pageSize: limits.pageSize,
70193
+ maxRows: Math.max(limits.maxRowsPerScope, EMBEDDING_ORPHAN_SCAN_LIMIT),
70194
+ maxReclaim: limits.maxReclaimPerScope
70195
+ }, restart ? 0 : this.orphanEmbeddingCursor);
70196
+ this.orphanEmbeddingCursor = report.nextCursor;
70197
+ this.ctx.logger.info("analytics orphan audit", { meta: {
70198
+ mode: limits.mode,
70199
+ collection: report.scope,
70200
+ examined: report.examined,
70201
+ orphans: report.orphans,
70202
+ reclaimed: report.reclaimed,
70203
+ kept: report.kept,
70204
+ withinGrace: report.withinGrace,
70205
+ unresolvable: report.unresolvable,
70206
+ notReclaimed: report.notReclaimed,
70207
+ stoppedBy: report.stoppedBy,
70208
+ nextCursor: report.nextCursor
70209
+ } });
70210
+ for (const deviceId of report.deviceIds) this.ctx.logger.info("analytics orphan audit (device)", {
70211
+ tags: { deviceId },
70212
+ meta: {
70213
+ deviceId,
70214
+ collection: report.scope,
70215
+ mode: limits.mode
70216
+ }
70217
+ });
67944
70218
  }
67945
70219
  /**
67946
70220
  * Operator-tunable audit limits. `mode` and `pageSize` are SETTINGS because
@@ -68418,11 +70692,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68418
70692
  }, Buffer.from(file.base64, "base64"));
68419
70693
  if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
68420
70694
  if (outcome.kind === "failed") return refuse("failed", outcome.reason);
68421
- await faceStore.update(face.id, {
70695
+ if (!await faceStore.restampEmbedding({
70696
+ faceId: face.id,
70697
+ deviceId: face.deviceId,
70698
+ trackId: face.trackId,
70699
+ timestamp: face.timestamp,
68422
70700
  embedding: outcome.embedding,
68423
- embeddingModelId: clusterModelId
68424
- });
68425
- if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
70701
+ modelId: clusterModelId
70702
+ })) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
68426
70703
  this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
68427
70704
  tags: { deviceId: face.deviceId },
68428
70705
  meta: {
@@ -68483,7 +70760,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68483
70760
  const identityStore = this.identityStore;
68484
70761
  if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
68485
70762
  const faceId = `face-${input.trackId}`;
68486
- const face = await faceStore.get(faceId);
70763
+ const face = await faceStore.getWithEmbedding(faceId);
68487
70764
  if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
68488
70765
  if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
68489
70766
  const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
@@ -68695,9 +70972,95 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68695
70972
  "embedding.wipe": async () => this.wipeObjectEmbeddings(),
68696
70973
  "embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
68697
70974
  "embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
68698
- "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
70975
+ "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
70976
+ "retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
68699
70977
  };
68700
70978
  }
70979
+ /**
70980
+ * One operator-driven debug-media reclaim pass — the entry point behind
70981
+ * `retention.mediaReclaim`.
70982
+ *
70983
+ * DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
70984
+ * `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
70985
+ * from the per-device cursors this method keeps, and a run that is cut short
70986
+ * has still only forgotten rows whose blobs it proved gone.
70987
+ *
70988
+ * Single-flight: a second call while a pass is in flight returns
70989
+ * `ran: false` rather than doubling the disk pressure the pacing exists to
70990
+ * avoid.
70991
+ */
70992
+ async runMediaReclaimNow(rawInput) {
70993
+ const input = MediaReclaimActionInputSchema.parse(rawInput);
70994
+ const mediaStore = this.mediaStore;
70995
+ const eventStore = this.eventStore;
70996
+ const trackStore = this.trackStore;
70997
+ const empty = {
70998
+ ran: false,
70999
+ mode: input.mode,
71000
+ devices: [],
71001
+ totalExamined: 0,
71002
+ totalEligible: 0,
71003
+ totalReclaimed: 0,
71004
+ totalBytesReclaimed: 0,
71005
+ totalRefused: 0,
71006
+ durationMs: 0,
71007
+ complete: false
71008
+ };
71009
+ if (!mediaStore || !eventStore || !trackStore) return empty;
71010
+ if (this.mediaReclaimRunning) return empty;
71011
+ this.mediaReclaimRunning = true;
71012
+ try {
71013
+ const limits = {
71014
+ mode: input.mode,
71015
+ pageSize: input.pageSize ?? DEFAULT_MEDIA_RECLAIM_LIMITS.pageSize,
71016
+ maxRowsPerDevice: input.maxRowsPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxRowsPerDevice,
71017
+ maxReclaimPerDevice: input.maxReclaimPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxReclaimPerDevice,
71018
+ maxBytesPerRun: input.maxBytesPerRun ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxBytesPerRun,
71019
+ budgetMs: input.budgetMinutes !== void 0 ? input.budgetMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.budgetMs,
71020
+ graceMs: input.graceMinutes !== void 0 ? input.graceMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.graceMs,
71021
+ throttleBytesPerSec: input.throttleBytesPerSec ?? DEFAULT_MEDIA_RECLAIM_LIMITS.throttleBytesPerSec
71022
+ };
71023
+ const scopes = input.scopes ?? MEDIA_RECLAIM_SCOPES;
71024
+ const { report, cursors } = await runMediaReclaim({
71025
+ deviceIds: input.deviceIds ?? await this.retentionDeviceIds(),
71026
+ scopes,
71027
+ limits,
71028
+ cursors: input.restart === true ? /* @__PURE__ */ new Map() : this.mediaReclaimCursors,
71029
+ deps: {
71030
+ page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
71031
+ deviceId,
71032
+ ownerKind: scope === "motion-still" ? "event" : "track",
71033
+ kind: "snapshot",
71034
+ afterMs,
71035
+ limit
71036
+ }),
71037
+ motionEventIds: (ids) => eventStore.existingMotionEventIds(ids),
71038
+ pinnedTrackIds: (ids) => trackStore.pinnedTrackIds(ids),
71039
+ reclaim: (key) => mediaStore.deleteConfirmed(key),
71040
+ now: () => Date.now(),
71041
+ logger: this.ctx.logger,
71042
+ breathe: () => new Promise((resolve) => setImmediate(resolve)),
71043
+ pace: (ms) => ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms))
71044
+ }
71045
+ });
71046
+ this.mediaReclaimCursors = cursors;
71047
+ if (limits.mode === "reclaim" && report.totalReclaimed > 0) await this.eventsOpsLog?.append({
71048
+ op: "prune",
71049
+ reason: "operator",
71050
+ deviceId: null,
71051
+ itemsAffected: report.totalReclaimed,
71052
+ bytesReclaimed: report.totalBytesReclaimed,
71053
+ detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
71054
+ });
71055
+ return {
71056
+ ran: true,
71057
+ ...report,
71058
+ devices: [...report.devices]
71059
+ };
71060
+ } finally {
71061
+ this.mediaReclaimRunning = false;
71062
+ }
71063
+ }
68701
71064
  /** Progress of the detached rebuild — the only way to observe a long pass. */
68702
71065
  async getObjectEmbeddingRebuildStatus() {
68703
71066
  const state = this.rebuildState;
@@ -68749,7 +71112,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68749
71112
  ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {}
68750
71113
  });
68751
71114
  const ranked = search.hits;
68752
- if (ranked.length === 0) return [];
71115
+ if (ranked.length === 0) {
71116
+ if (input.classFilter !== void 0) this.ctx.logger.warn("searchObjectEvents: classFilter matched nothing", {
71117
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
71118
+ meta: {
71119
+ classFilter: input.classFilter,
71120
+ why: "the index's className column carries the CLIP step's output class, not the track's class — only that value can match"
71121
+ }
71122
+ });
71123
+ return [];
71124
+ }
68753
71125
  const embeddingMediaKeyByTrackId = /* @__PURE__ */ new Map();
68754
71126
  const keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
68755
71127
  for (const { row } of ranked) {
@@ -68815,15 +71187,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68815
71187
  const mediaStore = this.mediaStore;
68816
71188
  const trackStore = this.trackStore;
68817
71189
  if (!eventStore || !mediaStore || !trackStore) return null;
71190
+ const stores = {
71191
+ eventStore,
71192
+ mediaStore,
71193
+ faceStore: this.faceStore,
71194
+ plateStore: this.plateStore,
71195
+ objectEmbeddingStore: this.objectEmbeddingStore,
71196
+ groupStore: this.groupStore
71197
+ };
71198
+ const absent = absentCascadeLegs(stores);
71199
+ if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {
71200
+ absent,
71201
+ 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"
71202
+ } });
68818
71203
  return {
68819
- registry: buildAnalyticsCascadeRegistry({
68820
- eventStore,
68821
- mediaStore,
68822
- faceStore: this.faceStore,
68823
- plateStore: this.plateStore,
68824
- objectEmbeddingStore: this.objectEmbeddingStore,
68825
- groupStore: this.groupStore
68826
- }),
71204
+ registry: buildAnalyticsCascadeRegistry(stores),
68827
71205
  trackStore
68828
71206
  };
68829
71207
  }
@@ -68978,6 +71356,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68978
71356
  if (!this.mediaRelocate) throw new Error("media relocation unavailable");
68979
71357
  return this.mediaRelocate.countUnstamped();
68980
71358
  }
71359
+ /**
71360
+ * How many rows a pass would still act on against `toLocationId`.
71361
+ *
71362
+ * The media lane's denominator AND its residue, from the one predicate the
71363
+ * pass itself uses. `null` means the count could not be taken — never `0`,
71364
+ * which would read as "there is nothing left to move".
71365
+ */
71366
+ async countRelocatableMedia(input) {
71367
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
71368
+ return this.mediaRelocate.countSelectable(input);
71369
+ }
68981
71370
  /** Every relocate job this addon knows about, newest first (in RAM: the
68982
71371
  * move is resumable, so a lost list costs nothing but the display). */
68983
71372
  async listRelocateMediaJobs() {
@@ -69386,6 +71775,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69386
71775
  this.sensitivitySettingsCache.delete(input.deviceId);
69387
71776
  this.trackingSettingsCache.delete(input.deviceId);
69388
71777
  this.confirmationGateSettingsCache.delete(input.deviceId);
71778
+ this.mediaSettingsCache.delete(input.deviceId);
69389
71779
  this.forgetDeviceProcessors(input.deviceId);
69390
71780
  return { success: true };
69391
71781
  }
@@ -69419,4 +71809,4 @@ function withMean(row) {
69419
71809
  };
69420
71810
  }
69421
71811
  //#endregion
69422
- export { DETECTION_PIPELINE_SECTION_IDS, customActions, PipelineAnalyticsAddon as default, ncActions, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };
71812
+ export { CONFIRMATION_CROP_MAX_WIDTH, DETECTION_PIPELINE_SECTION_IDS, customActions, PipelineAnalyticsAddon as default, ncActions, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };