@camstack/addon-post-analysis 1.2.270 → 1.2.271

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 TimelapseRuleSchema, $t as array, A as NcConditionDescriptorSchema, At as readDeviceStateFrom, B as NcTaxonomySchema, Bt as zoneAnalyticsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as mayWriteToLocation, D as NC_DEFAULT_SNOOZE_MINUTES, Dt as pickClusterStepModels, E as NC_CONDITION_CATALOG, F as NcScheduleSchema, Ft as storageOccupancyCapability, Gt as createEvent, Ht as BaseAddon, I as NcSnoozeInputSchema, It as subKindsOf, J as SCENE_DIVERGED, Jt as hydrateSchema, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as customAction, L as NcSnoozeSchema, Lt as systemEventFilterApplies, M as NcRulePatchSchema, Mt as resolveLocationMode, N as NcRuleSchema, Ot as pipelineAnalyticsCapability, P as NcRuleTargetSchema, Pt as sceneMonitorCapability, Q as TimelapseRulePatchSchema, Qt as _enum, R as NcSnoozeSuppressedSchema, Rt as vectorDimFromBase64, St as kebabToCamel, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as occupancyScope, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as CamProfileSchema, V as OpsLogEntrySchema, Vt as errMsg$1, W as RetrainStatusSchema, Wt as DeviceType, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as nodePin, Y as SceneMonitorSchema, Yt as isDeviceScopedCap, Z as TimelapseRuleInputSchema, Zt as sleep$1, _ as FULL_IMAGE_BBOX, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, an as partialRecord, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isScheduleActive, cn as unknown, ct as cosineSimilarity$1, dt as encodeVectorBase64, en as boolean, et as TrackSourceSchema, f as DeclaredDevices, ft as evaluateSensorEdge, g as FIRST_LEVEL_MACRO_CLASSES, h as EVENT_PAD_MS, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as object, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readTimelapseGeneratedAt, k as NC_TAXONOMY, kt as plateGalleryCapability, ln as EventCategory, lt as deriveRecordingMode, m as EVENT_OWNER_TYPES, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as literal, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as record, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as defineCustomActions, r as BaseDevice, rn as number, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as string, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as discriminatedUnion, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, v as FailureCounters, vt as isEventOwnerType, w as MediaFileKindEnum, wt as notificationRulesCapability, x as MAX_BIRTH_DECISION_RECORDS, xt as isSourceCap, y as LabelAttributionSchema, yt as isMediaOwnerType, z as NcSystemEventKindSchema, zt as videoclipsCapability } from "../dist-DsMoBsZn.mjs";
1
+ import { $ as TimelapseRuleSchema, $t as array, A as NcConditionDescriptorSchema, At as readDeviceStateFrom, B as NcTaxonomySchema, Bt as zoneAnalyticsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as mayWriteToLocation, D as NC_DEFAULT_SNOOZE_MINUTES, Dt as pickClusterStepModels, E as NC_CONDITION_CATALOG, F as NcScheduleSchema, Ft as storageOccupancyCapability, Gt as createEvent, Ht as BaseAddon, I as NcSnoozeInputSchema, It as subKindsOf, J as SCENE_DIVERGED, Jt as hydrateSchema, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as customAction, L as NcSnoozeSchema, Lt as systemEventFilterApplies, M as NcRulePatchSchema, Mt as resolveLocationMode, N as NcRuleSchema, Ot as pipelineAnalyticsCapability, P as NcRuleTargetSchema, Pt as sceneMonitorCapability, Q as TimelapseRulePatchSchema, Qt as _enum, R as NcSnoozeSuppressedSchema, Rt as vectorDimFromBase64, St as kebabToCamel, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as occupancyScope, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as CamProfileSchema, V as OpsLogEntrySchema, Vt as errMsg$1, W as RetrainStatusSchema, Wt as DeviceType, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as nodePin, Y as SceneMonitorSchema, Yt as isDeviceScopedCap, Z as TimelapseRuleInputSchema, Zt as sleep$1, _ as FULL_IMAGE_BBOX, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, an as partialRecord, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isScheduleActive, cn as unknown, ct as cosineSimilarity$1, dt as encodeVectorBase64, en as boolean, et as TrackSourceSchema, f as DeclaredDevices, ft as evaluateSensorEdge, g as FIRST_LEVEL_MACRO_CLASSES, h as EVENT_PAD_MS, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as object, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readTimelapseGeneratedAt, k as NC_TAXONOMY, kt as plateGalleryCapability, ln as EventCategory, lt as deriveRecordingMode, m as EVENT_OWNER_TYPES, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as literal, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as record, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as defineCustomActions, r as BaseDevice, rn as number, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as string, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as discriminatedUnion, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, v as FailureCounters, vt as isEventOwnerType, w as MediaFileKindEnum, wt as notificationRulesCapability, x as MAX_BIRTH_DECISION_RECORDS, xt as isSourceCap, y as LabelAttributionSchema, yt as isMediaOwnerType, z as NcSystemEventKindSchema, zt as videoclipsCapability } from "../dist-K6zDULiD.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";
@@ -30033,10 +30033,31 @@ var MediaStore = class {
30033
30033
  * was simply never returned, which is why every retention ops-log row this
30034
30034
  * addon has ever written says `bytesReclaimed: 0` (D161).
30035
30035
  */
30036
- async deleteForOwnerCounted(ownerKind, ownerIds) {
30037
- return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
30036
+ async deleteForOwnerCounted(ownerKind, ownerIds, spare) {
30037
+ return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds, spare));
30038
30038
  }
30039
- async deleteForOwnerUnchecked(ownerKind, ownerIds) {
30039
+ /**
30040
+ * Who owns a media row — kind and id — or `null` for an unknown id. The
30041
+ * stationary registry asks this before releasing an entry's key frame
30042
+ * (D547): the row is still `track`-owned by a track whose row is gone, and
30043
+ * only when no track row owns it is the entry the last thing vouching for it.
30044
+ */
30045
+ async ownerOf(mediaId) {
30046
+ const row = await this.store.get.query({
30047
+ collection: MEDIA_COLLECTION,
30048
+ key: String(mediaId)
30049
+ });
30050
+ if (!row) return null;
30051
+ const data = row;
30052
+ const ownerKind = String(data["ownerKind"] ?? "");
30053
+ const ownerId = String(data["ownerId"] ?? "");
30054
+ if (!isMediaOwnerKind(ownerKind) || ownerId === "") return null;
30055
+ return {
30056
+ ownerKind,
30057
+ ownerId
30058
+ };
30059
+ }
30060
+ async deleteForOwnerUnchecked(ownerKind, ownerIds, spare) {
30040
30061
  let rows = 0;
30041
30062
  let bytes = 0;
30042
30063
  for (const ownerId of ownerIds) {
@@ -30048,6 +30069,7 @@ var MediaStore = class {
30048
30069
  } }
30049
30070
  });
30050
30071
  for (const row of found) {
30072
+ if (spare !== void 0 && spare.has(Number(row.id))) continue;
30051
30073
  const rowData = row.data;
30052
30074
  const path = String(rowData["path"] ?? "");
30053
30075
  const sizeBytes = Number(rowData["sizeBytes"]);
@@ -30366,16 +30388,17 @@ var MediaStore = class {
30366
30388
  * `deleteForOwner` is best-effort per row, so one bad row can't abort the
30367
30389
  * batch.
30368
30390
  */
30369
- async deleteByTracks(trackIds) {
30391
+ async deleteByTracks(trackIds, opts) {
30392
+ const spare = opts?.spare;
30370
30393
  const own = await this.gated(async () => {
30371
- const tracks = await this.deleteForOwnerUnchecked("track", trackIds);
30372
- const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
30394
+ const tracks = await this.deleteForOwnerUnchecked("track", trackIds, spare);
30395
+ const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id), spare);
30373
30396
  return {
30374
30397
  rows: tracks.rows + faces.rows,
30375
30398
  bytes: tracks.bytes + faces.bytes
30376
30399
  };
30377
30400
  });
30378
- const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
30401
+ const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id), spare);
30379
30402
  return [{
30380
30403
  family: "media",
30381
30404
  rows: own.rows + plates.rows,
@@ -39361,7 +39384,9 @@ var FrameProcessor = class {
39361
39384
  const refusals = [];
39362
39385
  const labelPatchByTrack = /* @__PURE__ */ new Map();
39363
39386
  const tracked = trackedDetections.map((td) => {
39364
- const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
39387
+ const objectState = objectStates.find((o) => o.trackId === td.trackId);
39388
+ const state = mapObjectStateToTrackState(objectState?.state);
39389
+ const settling = objectState?.stationarySince !== void 0 && state !== "idle";
39365
39390
  const candidate = resolveDetectionLabelCandidate({
39366
39391
  className: td.class,
39367
39392
  originalClass: td.originalClass,
@@ -39399,6 +39424,7 @@ var FrameProcessor = class {
39399
39424
  zones: zonesByTrack.get(td.trackId) ?? [],
39400
39425
  state,
39401
39426
  matchedThisFrame: td.matchedThisFrame !== false,
39427
+ settling,
39402
39428
  ...td.resurrectionScene !== void 0 ? { resurrectionScene: td.resurrectionScene } : {},
39403
39429
  ...labelPatch !== void 0 ? { labelPatch } : {},
39404
39430
  ...emb !== void 0 ? {
@@ -40394,8 +40420,16 @@ var TrackCloser = class {
40394
40420
  try {
40395
40421
  const trackStore = this.deps.trackStore();
40396
40422
  if (!trackStore) return;
40397
- const expired = await trackStore.expireStale(this.now(), this.deps.isKnownParked !== void 0 ? { isKnownParked: this.deps.isKnownParked } : void 0);
40423
+ const discarded = [];
40424
+ const discardTracks = this.deps.discardTracks;
40425
+ const expired = await trackStore.expireStale(this.now(), {
40426
+ ...this.deps.isKnownParked !== void 0 ? { isKnownParked: this.deps.isKnownParked } : {},
40427
+ ...discardTracks !== void 0 ? { onDiscard: (t) => {
40428
+ discarded.push(t);
40429
+ } } : {}
40430
+ });
40398
40431
  for (const t of expired) await this.closeExpired(t);
40432
+ if (discardTracks !== void 0 && discarded.length > 0) await discardTracks(discarded);
40399
40433
  } catch (err) {
40400
40434
  if (this.deps.isShuttingDown()) return;
40401
40435
  this.deps.logger.debug("sweepExpiredTracks failed", { meta: { error: String(err) } });
@@ -44093,6 +44127,19 @@ var VEHICLE_CLASSES$1 = new Set([
44093
44127
  "truck",
44094
44128
  "bus"
44095
44129
  ]);
44130
+ /**
44131
+ * A short motionless PERSON is stamped and persisted, never discarded (D547).
44132
+ *
44133
+ * The discard exists for re-sightings of an OBJECT an entity represents — a
44134
+ * parked car the registry censuses in two sessions, a plate flash on it. No
44135
+ * entity ever represents a person who stood still for ten seconds (the person
44136
+ * policy needs thirty minutes of agreement, on purpose), and the face read
44137
+ * such a track holds is the only record of who was there. Deleting the track
44138
+ * would delete that; hiding the row from the feed was always the whole rule.
44139
+ */
44140
+ function isDiscardExemptClass(className) {
44141
+ return className.toLowerCase() === "person";
44142
+ }
44096
44143
  function motionlessMaxPxForClass(className) {
44097
44144
  return VEHICLE_CLASSES$1.has(className.toLowerCase()) ? 8 : 40;
44098
44145
  }
@@ -44850,7 +44897,30 @@ var TrackStore = class {
44850
44897
  for (const [trackId, t] of this.active) {
44851
44898
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
44852
44899
  t.active = false;
44853
- if (t.positions.length > 0 && (opts?.isKnownParked?.(t) === true || isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions), t.className))) t.closedReason = "stationary-promotion";
44900
+ const knownParked = t.positions.length > 0 && opts?.isKnownParked?.(t) === true;
44901
+ const shortMotionless = t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions), t.className);
44902
+ const stillObject = knownParked || shortMotionless;
44903
+ const discardable = knownParked || shortMotionless && !isDiscardExemptClass(t.className);
44904
+ const onDiscard = opts?.onDiscard;
44905
+ if (discardable && onDiscard !== void 0) {
44906
+ const record = cloneTrack(t);
44907
+ this.active.delete(trackId);
44908
+ this.noteRowClosed(trackId, nowMs);
44909
+ this.logger.debug("track discarded on expiry — a still object is an entity, not a track", {
44910
+ tags: { deviceId: t.deviceId },
44911
+ meta: {
44912
+ deviceId: t.deviceId,
44913
+ trackId,
44914
+ className: t.className,
44915
+ lifeMs: t.lastSeen - t.firstSeen,
44916
+ netDisplacementPx: Math.round(netDisplacementPx(t.positions)),
44917
+ knownParked
44918
+ }
44919
+ });
44920
+ onDiscard(record);
44921
+ continue;
44922
+ }
44923
+ if (stillObject) t.closedReason = "stationary-promotion";
44854
44924
  const record = cloneTrack(t);
44855
44925
  try {
44856
44926
  await this.persistCompleted(record);
@@ -53411,6 +53481,7 @@ var FaceRecognizer = class {
53411
53481
  zones: [],
53412
53482
  state: "moving",
53413
53483
  matchedThisFrame: true,
53484
+ settling: false,
53414
53485
  embedding: input.embedding,
53415
53486
  embeddingModelId: modelId,
53416
53487
  ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
@@ -60805,64 +60876,53 @@ function stillnessVerdictFor(source, trackState) {
60805
60876
  return source === "burst" ? void 0 : trackState;
60806
60877
  }
60807
60878
  /**
60808
- * Does this observation count as EVIDENCE that the object is parked?
60809
- *
60810
- * Two questions, deliberately kept apart:
60879
+ * What one observation is worth to a candidate: `agree` (N and M), `disagree`
60880
+ * (M only) or `undecided` (neither).
60811
60881
  *
60812
- * - IDENTITY — is this the same object in the same place? That is the IoU,
60813
- * and it is the only notion of sameness in this module.
60814
- * - STILLNESS — is it going anywhere? On a session frame that is the
60815
- * TRACKER's verdict, and nothing here re-derives it. On a burst frame it is
60816
- * the OCCASION: the runner rechecks a still scene and nothing else (see
60817
- * {@link stillnessVerdictFor}).
60882
+ * ## Why a third answer exists (D547)
60818
60883
  *
60819
- * ## The tracker's verdict is `idle`, and `idle` costs the dwell
60884
+ * The ratio N/M exists to dilute a car that merely drove through the spot.
60885
+ * It was also diluting a car that had not yet had the CHANCE to be called
60886
+ * still: the five `entered` frames every track begins with, and every frame
60887
+ * on which the analyzer's settled test already passed but
60888
+ * `stationaryThresholdSec` had not elapsed — `moving` in name only. On a
60889
+ * session-dispatched camera those frames are a third of every 36 s track, and
60890
+ * a session cut short by a passer-by is made of nothing else. Measured on
60891
+ * device 617 (DNRG309, 2026-09-19 20:32 → 09-20 07:53): 13 of the 21 tracks
60892
+ * bound to the candidate never reached `idle`, the ledger's ratio was 0.493
60893
+ * against 0.6, and a parked car took 11.3 h and 19 tracks to be censused.
60820
60894
  *
60821
- * `idle` used to be reachable in five frames: the analyzer's never-moved
60822
- * shortcut called a track stationary the moment its net travel was ~0 after
60823
- * the entering window, without waiting `stationaryThresholdSec`. That is how
60824
- * the 617 phantom voted — tiny vehicle detections at the top of the frame
60825
- * living 1 to 6 seconds, identical boxes under different ids, which tipped the
60826
- * ledger into an entry on 10 agreements (the real cars carried 773 and 1565).
60827
- * The first answer was a second clock in this module (`MIN_STILL_FOR_EVIDENCE_MS`,
60828
- * 10 s) — a copy of the tracker's own dwell, kept in step by hand. The
60829
- * shortcut is gone instead: `idle` now means the per-device
60830
- * `stationaryThresholdSec` has elapsed, on every camera, and this module has
60831
- * no number of its own.
60832
- *
60833
- * ## A verdict, not the absence of one
60834
- *
60835
- * `entered` — the five-frame warm-up every track begins with — does not agree
60836
- * on a session frame. Measured on 617 over 12 h (2026-09-15): a car merely
60837
- * passing the edge is gone in ~8 frames, five of them `entered`, and 5/8 = 0.62
60838
- * cleared the 0.6 ratio unaided — an entry born on an empty spot, retired 24
60839
- * absent readings later, one "parked" and one "departed" notification for a
60840
- * passage. A parked car's re-detection loses five observations of ~280.
60841
- *
60842
- * A session observation with NO verdict at all does not agree either. There
60843
- * is no trackless producer any more (the hydrated baseline sampler is gone),
60844
- * so an absent verdict on a session frame is a producer that forgot the
60845
- * field — the one way D494 said this could rot — and it must read as a miss,
60846
- * never as the old geometry-only rule.
60895
+ * "Not yet decided" is not evidence either way, so it counts as neither. A
60896
+ * subject that is TRAVELLING — `moving` with the settled test failing — still
60897
+ * dilutes, so the drive-through case D508 measured (5 `entered` of 8 frames)
60898
+ * now scores 0/3 instead of the 5/8 that once minted an entry on an empty spot.
60847
60899
  */
60848
- function observationAgrees(overlap, agreeIou, source, movementState) {
60849
- if (overlap < agreeIou) return false;
60850
- if (source === "burst") return true;
60851
- return movementState === "idle";
60900
+ function observationVote(overlap, agreeIou, source, movementState, settling) {
60901
+ if (overlap < agreeIou) return "disagree";
60902
+ if (source === "burst") return "agree";
60903
+ if (movementState === "idle") return "agree";
60904
+ if (movementState === "entered") return "undecided";
60905
+ if (movementState === "moving" && settling) return "undecided";
60906
+ return "disagree";
60852
60907
  }
60853
60908
  /**
60854
60909
  * Fold one observation into a candidate.
60855
60910
  *
60856
- * `agrees` is decided by the CALLER, because M is what makes the ratio mean
60911
+ * The `vote` is decided by the CALLER, because M is what makes the ratio mean
60857
60912
  * anything: once a track has bound to a candidate, every later observation of
60858
60913
  * that track counts toward M whether it agrees or not. That is what dilutes a
60859
60914
  * car that merely drove through the spot, and it is why this function cannot
60860
60915
  * re-derive agreement from the box alone.
60861
60916
  */
60862
- function foldObservation(row, observation, agrees, overlap) {
60863
- const seen = row.seen + 1;
60917
+ function foldObservation(row, observation, vote, overlap) {
60864
60918
  const rotated = rotateWindow(row, observation.timestamp);
60865
- if (!agrees) return {
60919
+ if (vote === "undecided") return {
60920
+ ...rotated,
60921
+ undecided: row.undecided + 1,
60922
+ updatedAt: observation.timestamp
60923
+ };
60924
+ const seen = row.seen + 1;
60925
+ if (vote === "disagree") return {
60866
60926
  ...rotated,
60867
60927
  seen,
60868
60928
  windowSeen: rotated.windowSeen + 1,
@@ -60910,6 +60970,7 @@ function newEvidenceRow(observation, anchorId) {
60910
60970
  ...observation.label !== void 0 ? { label: observation.label } : {},
60911
60971
  sessionAgreed: observation.source === "session" ? 1 : 0,
60912
60972
  burstAgreed: observation.source === "burst" ? 1 : 0,
60973
+ undecided: 0,
60913
60974
  updatedAt: observation.timestamp
60914
60975
  };
60915
60976
  }
@@ -60982,6 +61043,10 @@ var STATIONARY_EVIDENCE_COLUMNS = [
60982
61043
  name: "burstAgreed",
60983
61044
  type: "INTEGER"
60984
61045
  },
61046
+ {
61047
+ name: "undecided",
61048
+ type: "INTEGER"
61049
+ },
60985
61050
  {
60986
61051
  name: "updatedAt",
60987
61052
  type: "INTEGER"
@@ -61029,6 +61094,9 @@ var StationaryEvidenceLedger = class {
61029
61094
  * every observation of a box that never changes (3836's jacket is seen five
61030
61095
  * times a second in a session), so the line is said once per candidate. */
61031
61096
  implausibleReported = /* @__PURE__ */ new Set();
61097
+ /** `<deviceId>:<trackId>` of tracks whose first undecided fold was logged
61098
+ * (D547). Pruned with the bindings in {@link retainTracks}. */
61099
+ undecidedReported = /* @__PURE__ */ new Set();
61032
61100
  anchorSeq = 0;
61033
61101
  constructor(deps) {
61034
61102
  this.logger = deps.logger;
@@ -61057,6 +61125,7 @@ var StationaryEvidenceLedger = class {
61057
61125
  ...row.label !== void 0 ? { label: row.label } : {},
61058
61126
  sessionAgreed: row.sessionAgreed,
61059
61127
  burstAgreed: row.burstAgreed,
61128
+ undecided: row.undecided,
61060
61129
  updatedAt: row.updatedAt
61061
61130
  }),
61062
61131
  fromRecord: (key, data) => {
@@ -61090,6 +61159,7 @@ var StationaryEvidenceLedger = class {
61090
61159
  ...label !== void 0 ? { label } : {},
61091
61160
  sessionAgreed: numberOr(data.sessionAgreed, 0),
61092
61161
  burstAgreed: numberOr(data.burstAgreed, 0),
61162
+ undecided: numberOr(data.undecided, 0),
61093
61163
  updatedAt
61094
61164
  };
61095
61165
  },
@@ -61224,8 +61294,10 @@ var StationaryEvidenceLedger = class {
61224
61294
  }
61225
61295
  if (bindKey !== void 0) this.binding.set(bindKey, row.key);
61226
61296
  }
61227
- const agrees = observationAgrees(overlap, policy.agreeIou, observation.source, observation.movementState);
61228
- const next = foldObservation(row, observation, agrees, overlap);
61297
+ const vote = observationVote(overlap, policy.agreeIou, observation.source, observation.movementState, observation.settling === true);
61298
+ const agrees = vote === "agree";
61299
+ if (vote === "undecided") this.noteUndecided(row, observation);
61300
+ const next = foldObservation(row, observation, vote, overlap);
61229
61301
  this.ledger.stage(next);
61230
61302
  this.dirty.add(next.key);
61231
61303
  if (created) this.capDevice(observation.deviceId);
@@ -61275,6 +61347,10 @@ var StationaryEvidenceLedger = class {
61275
61347
  if (!bindKey.startsWith(prefix)) continue;
61276
61348
  if (!liveTrackIds.has(bindKey.slice(prefix.length))) this.binding.delete(bindKey);
61277
61349
  }
61350
+ for (const key of [...this.undecidedReported]) {
61351
+ if (!key.startsWith(prefix)) continue;
61352
+ if (!liveTrackIds.has(key.slice(prefix.length))) this.undecidedReported.delete(key);
61353
+ }
61278
61354
  }
61279
61355
  forgetKey(key) {
61280
61356
  this.forgetKeyDurable(key);
@@ -61287,6 +61363,32 @@ var StationaryEvidenceLedger = class {
61287
61363
  * work is never silent (D391), and one that refuses it five times a second
61288
61364
  * is never loud.
61289
61365
  */
61366
+ /**
61367
+ * An undecided observation is dropped from the tally on purpose (D547), and
61368
+ * a branch that drops work says so — once per track, at debug: the same
61369
+ * track offers one every frame until it decides, and a line per frame would
61370
+ * bury what it reports. The lifetime count rides the row (`undecided`).
61371
+ */
61372
+ noteUndecided(row, observation) {
61373
+ const trackId = observation.trackId;
61374
+ if (trackId === void 0) return;
61375
+ const key = `${observation.deviceId}:${trackId}`;
61376
+ if (this.undecidedReported.has(key)) return;
61377
+ this.undecidedReported.add(key);
61378
+ this.logger.debug("stationary evidence: undecided observation — counted as neither N nor M", {
61379
+ tags: { deviceId: observation.deviceId },
61380
+ meta: {
61381
+ deviceId: observation.deviceId,
61382
+ trackId,
61383
+ candidateKey: row.key,
61384
+ movementState: observation.movementState ?? null,
61385
+ settling: observation.settling === true,
61386
+ agreed: row.agreed,
61387
+ seen: row.seen,
61388
+ undecided: row.undecided
61389
+ }
61390
+ });
61391
+ }
61290
61392
  noteImplausible(row, frame) {
61291
61393
  if (this.implausibleReported.has(row.key)) return;
61292
61394
  this.implausibleReported.add(row.key);
@@ -61359,6 +61461,65 @@ var StationaryEvidenceLedger = class {
61359
61461
  for (const row of excess) this.forgetKey(row.key);
61360
61462
  }
61361
61463
  };
61464
+ //#endregion
61465
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
61466
+ function planStationaryTrackDisposal(input) {
61467
+ const trackIds = [input.liveTrackId];
61468
+ if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
61469
+ const keepMediaIds = /* @__PURE__ */ new Set();
61470
+ if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
61471
+ return {
61472
+ trackIds,
61473
+ keepMediaIds
61474
+ };
61475
+ }
61476
+ /**
61477
+ * Run the plan. The live track is dropped from RAM FIRST so it can never be
61478
+ * persisted by a sweep racing this call; the cascade then removes what every
61479
+ * track owned, sparing the entry's key frame, and deletes the row (a no-op for
61480
+ * the never-persisted live track).
61481
+ *
61482
+ * Never silent: the line names the entity, what went and what was kept — a
61483
+ * deleted track is dropped work, and the operator asked for it by name.
61484
+ */
61485
+ async function disposeStationaryTracks(deps, plan) {
61486
+ const [liveTrackId] = plan.trackIds;
61487
+ if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
61488
+ const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
61489
+ const result = await runTrackCascadeBatch({
61490
+ registry: [...deps.leaves, sparingMedia],
61491
+ trackStore: deps.trackStore,
61492
+ deviceId: deps.deviceId,
61493
+ onTrackCleanup: deps.onTrackCleanup,
61494
+ onFailure: (trackId, err) => {
61495
+ deps.logger.warn("stationary promotion: a source track could not be disposed of", {
61496
+ tags: { deviceId: deps.deviceId },
61497
+ meta: {
61498
+ deviceId: deps.deviceId,
61499
+ entryId: deps.entryId,
61500
+ trackId,
61501
+ error: String(err)
61502
+ }
61503
+ });
61504
+ }
61505
+ }, plan.trackIds);
61506
+ deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
61507
+ tags: { deviceId: deps.deviceId },
61508
+ meta: {
61509
+ deviceId: deps.deviceId,
61510
+ entryId: deps.entryId,
61511
+ trackIds: plan.trackIds,
61512
+ keptMediaIds: [...plan.keepMediaIds],
61513
+ deleted: result.deleted,
61514
+ failed: result.failed,
61515
+ mediaRows: result.tally.media
61516
+ }
61517
+ });
61518
+ return {
61519
+ deleted: result.deleted,
61520
+ failed: result.failed
61521
+ };
61522
+ }
61362
61523
  /**
61363
61524
  * How long the same weak box must keep being seen before it may re-anchor.
61364
61525
  *
@@ -61538,6 +61699,7 @@ var StationaryObjectRegistry = class {
61538
61699
  sessionGapMs;
61539
61700
  warmUpMs;
61540
61701
  ttlForDevice;
61702
+ onEntryDropped;
61541
61703
  onChange;
61542
61704
  requestConfirmationLook;
61543
61705
  /**
@@ -61631,6 +61793,7 @@ var StationaryObjectRegistry = class {
61631
61793
  this.sessionGapMs = deps.sessionGapMs ?? 5e3;
61632
61794
  this.warmUpMs = deps.warmUpMs ?? 2e3;
61633
61795
  this.ttlForDevice = deps.ttlForDevice;
61796
+ this.onEntryDropped = deps.onEntryDropped;
61634
61797
  this.onChange = deps.onChange;
61635
61798
  this.requestConfirmationLook = deps.requestConfirmationLook;
61636
61799
  }
@@ -62240,6 +62403,7 @@ var StationaryObjectRegistry = class {
62240
62403
  return at !== void 0 && at > entry.lastConfirmedAt;
62241
62404
  }
62242
62405
  dropEntry(deviceId, id) {
62406
+ const dropped = this.ledger.get(id);
62243
62407
  this.idsByDevice.get(deviceId)?.delete(id);
62244
62408
  this.dirty.delete(id);
62245
62409
  this.weakRuns.delete(id);
@@ -62249,6 +62413,7 @@ var StationaryObjectRegistry = class {
62249
62413
  if (armed.size === 0) this.departureStrikes.delete(deviceId);
62250
62414
  }
62251
62415
  this.ledger.forget(id);
62416
+ if (dropped !== void 0) this.onEntryDropped?.(dropped);
62252
62417
  }
62253
62418
  };
62254
62419
  /** The persisted column map for an entry (the `id` PK is passed separately). */
@@ -68181,8 +68346,10 @@ async function auditScope(scope, deps, limits, cursor) {
68181
68346
  let liveTracks;
68182
68347
  const liveEvents = /* @__PURE__ */ new Map();
68183
68348
  let liveSummaries;
68349
+ let keptMedia = /* @__PURE__ */ new Set();
68184
68350
  try {
68185
68351
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
68352
+ if (deps.keptMediaKeys !== void 0 && scope.collection === "pipeline-analytics:media") keptMedia = await deps.keptMediaKeys(rows.map((row) => row.key));
68186
68353
  for (const [table, ids] of eventIdsByTable) {
68187
68354
  if (ids.length === 0) continue;
68188
68355
  liveEvents.set(table, await deps.liveEventIds(table, ids));
@@ -68215,7 +68382,7 @@ async function auditScope(scope, deps, limits, cursor) {
68215
68382
  tally.unresolvable += 1;
68216
68383
  continue;
68217
68384
  }
68218
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false : ref.kind === "summary" ? liveSummaries.has(ref.summaryId) : false) continue;
68385
+ if (ref.kind === "track" ? liveTracks.has(ref.trackId) || keptMedia.has(row.key) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false : ref.kind === "summary" ? liveSummaries.has(ref.summaryId) : false) continue;
68219
68386
  const ts = num(row.data, scope.timestampField);
68220
68387
  if (ts !== null && ts > graceFloor) {
68221
68388
  tally.withinGrace += 1;
@@ -77949,10 +78116,149 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77949
78116
  const last = track.positions.at(-1)?.bbox;
77950
78117
  if (!last) return false;
77951
78118
  return bboxMatchesParkedEntry(last, track.className, this.stationaryRegistry?.listViews(track.deviceId) ?? []);
77952
- }
78119
+ },
78120
+ discardTracks: (tracks) => this.cascadeDiscardedTracks(tracks)
77953
78121
  });
77954
78122
  return this._trackCloser;
77955
78123
  }
78124
+ /**
78125
+ * Cascade what `expireStale` refused to persist (D547): scenery re-fires and
78126
+ * re-sightings of a censused object. Per device, per track, through the
78127
+ * shipped registry — never a raw delete — so the vector, the crops and the
78128
+ * events go with the media. A track whose cascade fails is named.
78129
+ */
78130
+ async cascadeDiscardedTracks(tracks) {
78131
+ const cascade = this.buildTrackCascadeRegistry();
78132
+ if (!cascade) {
78133
+ this.ctx.logger.warn("discarded tracks could not be cascaded — stores not ready", { meta: { trackIds: tracks.map((t) => t.trackId) } });
78134
+ return;
78135
+ }
78136
+ const byDevice = /* @__PURE__ */ new Map();
78137
+ for (const t of tracks) {
78138
+ const ids = byDevice.get(t.deviceId) ?? [];
78139
+ ids.push(t.trackId);
78140
+ byDevice.set(t.deviceId, ids);
78141
+ }
78142
+ for (const [deviceId, trackIds] of byDevice) {
78143
+ const result = await runTrackCascadeBatch({
78144
+ registry: cascade.registry,
78145
+ trackStore: cascade.trackStore,
78146
+ deviceId,
78147
+ onTrackCleanup: (id, trackId) => this.clearLiveTrackState(id, trackId),
78148
+ onFailure: (trackId, err) => {
78149
+ this.ctx.logger.warn("discarded track could not be cascaded", {
78150
+ tags: { deviceId },
78151
+ meta: {
78152
+ deviceId,
78153
+ trackId,
78154
+ error: String(err)
78155
+ }
78156
+ });
78157
+ }
78158
+ }, trackIds);
78159
+ this.ctx.logger.info("tracks discarded on expiry — still objects are entities, not tracks", {
78160
+ tags: { deviceId },
78161
+ meta: {
78162
+ deviceId,
78163
+ trackIds,
78164
+ deleted: result.deleted,
78165
+ failed: result.failed,
78166
+ mediaRows: result.tally.media
78167
+ }
78168
+ });
78169
+ }
78170
+ }
78171
+ /**
78172
+ * Dispose of the tracks that carried a promoted candidate's evidence (D547):
78173
+ * the one alive on the tipping frame and the persisted source of the entry's
78174
+ * best box. The entry's key frame is spared — it is the one row the entity
78175
+ * vouches for, and `keptStationaryMediaKeys` keeps the orphan audit off it.
78176
+ */
78177
+ async disposePromotedTracks(deviceId, entry, liveTrackId, sourceTrackId) {
78178
+ const cascade = this.buildTrackCascadeRegistry();
78179
+ const mediaStore = this.mediaStore;
78180
+ if (!cascade || !mediaStore) {
78181
+ this.ctx.logger.warn("stationary promotion: source tracks could not be disposed of — stores not ready", {
78182
+ tags: { deviceId },
78183
+ meta: {
78184
+ deviceId,
78185
+ entryId: entry.id,
78186
+ liveTrackId,
78187
+ sourceTrackId: sourceTrackId ?? null
78188
+ }
78189
+ });
78190
+ return;
78191
+ }
78192
+ const plan = planStationaryTrackDisposal({
78193
+ liveTrackId,
78194
+ ...sourceTrackId !== void 0 ? { sourceTrackId } : {},
78195
+ ...entry.keyFrameMediaId !== void 0 ? { entryKeyFrameMediaId: entry.keyFrameMediaId } : {}
78196
+ });
78197
+ await disposeStationaryTracks({
78198
+ deviceId,
78199
+ entryId: entry.id,
78200
+ leaves: cascade.registry.filter((leaf) => leaf !== mediaStore),
78201
+ media: mediaStore,
78202
+ trackStore: cascade.trackStore,
78203
+ onTrackCleanup: (id, trackId) => this.clearLiveTrackState(id, trackId),
78204
+ logger: this.ctx.logger
78205
+ }, plan);
78206
+ }
78207
+ /**
78208
+ * Media row keys a live stationary entry vouches for (D547) — the orphan
78209
+ * audit's probe. Answered from the registry's RAM index, which is the
78210
+ * authority on which entries are alive.
78211
+ */
78212
+ keptStationaryMediaKeys(keys) {
78213
+ const registry = this.stationaryRegistry;
78214
+ if (!registry) return /* @__PURE__ */ new Set();
78215
+ const held = /* @__PURE__ */ new Set();
78216
+ for (const deviceId of registry.deviceIds()) for (const entry of registry.list(deviceId)) if (entry.keyFrameMediaId !== void 0) held.add(String(entry.keyFrameMediaId));
78217
+ return new Set(keys.filter((key) => held.has(key)));
78218
+ }
78219
+ /**
78220
+ * An entry left the registry: free the key frame it vouched for (D547),
78221
+ * unless another live entry still holds it or a track row still owns it
78222
+ * (then retention's cascade will). Never silent either way.
78223
+ */
78224
+ async releaseStationaryKeyFrame(entry) {
78225
+ const mediaId = entry.keyFrameMediaId;
78226
+ if (mediaId === void 0) return;
78227
+ const deviceId = entry.deviceId;
78228
+ const registry = this.stationaryRegistry;
78229
+ const mediaStore = this.mediaStore;
78230
+ const trackStore = this.trackStore;
78231
+ if (!registry || !mediaStore || !trackStore) return;
78232
+ if (registry.list(deviceId).some((e) => e.keyFrameMediaId === mediaId)) return;
78233
+ try {
78234
+ const owner = await mediaStore.ownerOf(mediaId);
78235
+ if (owner === null) return;
78236
+ if (owner.ownerKind === "track") {
78237
+ if ((await trackStore.existingTrackIds([owner.ownerId])).has(owner.ownerId)) return;
78238
+ }
78239
+ await mediaStore.deleteById(mediaId);
78240
+ this.ctx.logger.info("stationary entry released its key frame", {
78241
+ tags: { deviceId },
78242
+ meta: {
78243
+ deviceId,
78244
+ entryId: entry.id,
78245
+ mediaId,
78246
+ ownerKind: owner.ownerKind,
78247
+ ownerId: owner.ownerId
78248
+ }
78249
+ });
78250
+ } catch (err) {
78251
+ this.ctx.logger.warn("stationary entry key frame could not be released", {
78252
+ tags: { deviceId },
78253
+ meta: {
78254
+ deviceId,
78255
+ entryId: entry.id,
78256
+ mediaId,
78257
+ error: String(err)
78258
+ }
78259
+ });
78260
+ }
78261
+ }
77956
78262
  /** Master toggle for the migrated object/face EmbeddingDispatcher. Read at
77957
78263
  * init; a change takes effect on addon restart (same convention as
77958
78264
  * `postProcessingNodeId`).
@@ -78678,6 +78984,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78678
78984
  store: api.settingsStore,
78679
78985
  logger: logger.child("StationaryRegistry"),
78680
78986
  ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
78987
+ onEntryDropped: (entry) => {
78988
+ this.releaseStationaryKeyFrame(entry);
78989
+ },
78681
78990
  requestConfirmationLook: (ask) => this.requestConfirmationLook(ask),
78682
78991
  onChange: ({ phase, entry, timestamp, observedAt }) => {
78683
78992
  this.ctx.eventBus.emit({
@@ -80066,6 +80375,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
80066
80375
  anchor: { ...row.anchor },
80067
80376
  agreed: row.agreed,
80068
80377
  seen: row.seen,
80378
+ undecided: row.undecided,
80069
80379
  ratio: row.seen > 0 ? row.agreed / row.seen : 0,
80070
80380
  spanMs: row.lastAgreedAt - row.firstAgreedAt,
80071
80381
  firstAgreedAt: row.firstAgreedAt,
@@ -81466,7 +81776,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
81466
81776
  frameHeight: result.frameHeight,
81467
81777
  ...observedKeyFrame !== void 0 ? { keyFrameMediaId: observedKeyFrame } : {},
81468
81778
  ...observedLabel !== void 0 ? { label: observedLabel } : {},
81469
- ...movementState !== void 0 ? { movementState } : {}
81779
+ ...movementState !== void 0 ? { movementState } : {},
81780
+ settling: t.settling
81470
81781
  };
81471
81782
  const outcome = evidence.observe(observation);
81472
81783
  if (outcome === void 0 || !outcome.promoted) continue;
@@ -84616,17 +84927,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
84616
84927
  wakeAssocIou: gate.wakeAssocIou
84617
84928
  });
84618
84929
  processor.dropTrack(track.trackId);
84619
- this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
84620
- this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
84621
- tags: { deviceId },
84622
- meta: {
84623
- deviceId,
84624
- trackId: track.trackId,
84625
- entryId: entry.id,
84626
- error: String(err)
84627
- }
84628
- });
84629
- });
84630
84930
  const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
84631
84931
  const dropKeyFrame = () => {
84632
84932
  this.residents.dropKeyFrameMediaId(track.trackId);
@@ -84634,7 +84934,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
84634
84934
  if (faceEnd) faceEnd.finally(dropKeyFrame);
84635
84935
  else dropKeyFrame();
84636
84936
  const promotedPath = this.trackStore?.getActiveByTrack(track.trackId)?.positions;
84637
- this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
84937
+ const plateEnd = this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
84938
+ Promise.allSettled([faceEnd, plateEnd]).then(() => this.disposePromotedTracks(deviceId, entry, track.trackId, row.sourceTrackId));
84638
84939
  this.residents.close(track.trackId, "stationary");
84639
84940
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
84640
84941
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -85577,6 +85878,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85577
85878
  if (!summaries) throw new Error("summary store not built — probe unavailable");
85578
85879
  return summaries.existingIds(ids);
85579
85880
  },
85881
+ keptMediaKeys: async (keys) => this.keptStationaryMediaKeys(keys),
85580
85882
  reclaim: buildOrphanReclaimer({
85581
85883
  deleteFace: async (faceId) => {
85582
85884
  await this.faceStore?.delete(faceId);