@camstack/addon-post-analysis 1.2.209 → 1.2.211

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 audioModeOf, $t as EventCategory, A as NcSnoozeSchema, At as vectorDimFromBase64, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as nodePin, C as NcConditionDescriptorSchema, Ct as readTimelapseGeneratedAt, D as NcRuleTargetSchema, Dt as storageOccupancyCapability, E as NcRuleSchema, Et as sceneMonitorCapability, Ft as CamProfileSchema, G as TimelapseRulePatchSchema, Gt as discriminatedUnion, H as SceneMonitorSchema, Ht as _enum, I as RECORDING_EXPORT_MAX_READ_BYTES, It as DeviceType, J as VISIT_MERGE_GAP_MS, Jt as object, K as TimelapseRuleSchema, Kt as literal, L as RetrainStatusSchema, Lt as createEvent, M as NcSystemEventKindSchema, Mt as zoneAnalyticsCapability, N as NcTaxonomySchema, Nt as errMsg$1, O as NcScheduleSchema, Ot as subKindsOf, P as OpsLogEntrySchema, Pt as BaseAddon, Q as audioMetricsCapability, Qt as unknown, Rt as hydrateSchema, S as NC_TAXONOMY, St as readDeviceStateFrom, T as NcRulePatchSchema, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as array, V as SCENE_DIVERGED, Vt as sleep$1, W as TimelapseRuleInputSchema, Wt as boolean, X as alarmPanelCapability, Xt as record, Y as addonWidgetsSourceCapability, Yt as partialRecord, Z as assertTimelapseCadences, Zt as string, _ as MediaFileKindEnum, _t as notificationRulesCapability, a as DEFAULT_EVENT_COLOR, b as NC_DEFAULT_SNOOZE_MINUTES, bt as pipelineAnalyticsCapability, c as DETECTION_MACRO_CLASSES, ct as evictionPolicyOfLocation, d as EVENT_KIND_BY_CAP, et as buildEventKindDescriptor, f as EVENT_PAD_MS, ft as isDetectionMacroClass, g as MACRO_LABELS, gt as mayWriteToLocation, h as LabelAttributionSchema, ht as kebabToCamel, i as COCO_TO_MACRO, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as videoclipsCapability, k as NcSnoozeInputSchema, kt as systemEventFilterApplies, lt as faceGalleryCapability, m as FailureCounters, mt as isSourceCap, n as BaseDevice, nt as customAction, ot as encodeVectorBase64, p as FULL_IMAGE_BBOX, pt as isScheduleActive, q as TrackSourceSchema, qt as number, r as CLUSTER_MODEL_SCOPED_STEPS, rt as defineCustomActions, st as evaluateSensorEdge, t as AUDIO_MACRO_LABELS, tt as cosineSimilarity$1, u as DeclaredDevices, ut as failureContributionCapability, v as NC_ALARM_SYSTEM_EVENT_KINDS, w as NcRuleInputSchema, wt as resolveLocationMode, xt as plateGalleryCapability, y as NC_CONDITION_CATALOG, yt as pickClusterStepModels, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as isDeviceScopedCap } from "../dist-BV15Z8DR.mjs";
1
+ import { $ as assertTimelapseCadences, $t as string, A as NcScheduleSchema, At as subKindsOf, Bt as hydrateSchema, Ct as plateGalleryCapability, D as NcRulePatchSchema, E as NcRuleInputSchema, Et as resolveLocationMode, F as NcTaxonomySchema, Ft as errMsg$1, G as TIMELAPSE_DENSE_FLOOR_SEC, Gt as array, H as SCENE_DEFAULT_UNCOVERED_POLICY, Ht as nodePin, I as OpsLogEntrySchema, It as BaseAddon, J as TimelapseRuleSchema, Jt as literal, K as TimelapseRuleInputSchema, Kt as boolean, Lt as CamProfileSchema, M as NcSnoozeSchema, Mt as vectorDimFromBase64, N as NcSnoozeSuppressedSchema, Nt as videoclipsCapability, O as NcRuleSchema, Ot as sceneMonitorCapability, P as NcSystemEventKindSchema, Pt as zoneAnalyticsCapability, Q as alarmPanelCapability, Qt as record, R as RECORDING_EXPORT_MAX_READ_BYTES, Rt as DeviceType, S as NC_DEFAULT_SNOOZE_MINUTES, St as pipelineAnalyticsCapability, T as NcConditionDescriptorSchema, Tt as readTimelapseGeneratedAt, U as SCENE_DIVERGED, Ut as sleep$1, V as SCENE_DEFAULT_ANCHOR_THRESHOLD, Vt as isDeviceScopedCap, W as SceneMonitorSchema, Wt as _enum, X as VISIT_MERGE_GAP_MS, Xt as object, Y as TrackSourceSchema, Yt as number, Z as addonWidgetsSourceCapability, Zt as partialRecord, _ as MACRO_LABELS, _t as kebabToCamel, a as COCO_TO_MACRO, at as defineCustomActions, b as NC_ALARM_SYSTEM_EVENT_KINDS, ct as encodeVectorBase64, d as DeclaredDevices, dt as faceGalleryCapability, en as unknown, et as audioMetricsCapability, f as EVENT_KIND_BY_CAP, ft as failureContributionCapability, g as LabelAttributionSchema, gt as isSourceCap, h as FailureCounters, ht as isScheduleActive, i as CLUSTER_MODEL_SCOPED_STEPS, it as customAction, j as NcSnoozeInputSchema, jt as systemEventFilterApplies, k as NcRuleTargetSchema, kt as storageOccupancyCapability, l as DETECTION_MACRO_CLASSES, lt as evaluateSensorEdge, m as FULL_IMAGE_BBOX, mt as isDetectionMacroClass, n as ArchivedDebugNoteSchema, nt as buildEventKindDescriptor, o as DEFAULT_EVENT_COLOR, ot as deriveRecordingMode, p as EVENT_PAD_MS, q as TimelapseRulePatchSchema, qt as discriminatedUnion, r as BaseDevice, rt as cosineSimilarity$1, t as AUDIO_MACRO_LABELS, tn as EventCategory, tt as audioModeOf, ut as evictionPolicyOfLocation, v as MAX_ARCHIVED_DEBUG_NOTES, vt as mayWriteToLocation, w as NC_TAXONOMY, wt as readDeviceStateFrom, x as NC_CONDITION_CATALOG, xt as pickClusterStepModels, y as MediaFileKindEnum, yt as notificationRulesCapability, z as RetrainStatusSchema, zt as createEvent } from "../dist-D2rFaMex.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";
@@ -3445,7 +3445,7 @@ var SceneConfirmGate = class {
3445
3445
  };
3446
3446
  }
3447
3447
  };
3448
- function clamp$1(v, lo, hi) {
3448
+ function clamp$2(v, lo, hi) {
3449
3449
  return Math.max(lo, Math.min(v, hi));
3450
3450
  }
3451
3451
  /**
@@ -3460,10 +3460,10 @@ async function cropSnapshotRoi(encoded, roi) {
3460
3460
  const frameW = meta.width ?? 0;
3461
3461
  const frameH = meta.height ?? 0;
3462
3462
  if (frameW <= 0 || frameH <= 0) throw new Error("snapshot has no decodable dimensions");
3463
- const left = clamp$1(Math.round(roi.x * frameW), 0, frameW - 1);
3464
- const top = clamp$1(Math.round(roi.y * frameH), 0, frameH - 1);
3465
- const width = clamp$1(Math.round(roi.width * frameW), 1, frameW - left);
3466
- const height = clamp$1(Math.round(roi.height * frameH), 1, frameH - top);
3463
+ const left = clamp$2(Math.round(roi.x * frameW), 0, frameW - 1);
3464
+ const top = clamp$2(Math.round(roi.y * frameH), 0, frameH - 1);
3465
+ const width = clamp$2(Math.round(roi.width * frameW), 1, frameW - left);
3466
+ const height = clamp$2(Math.round(roi.height * frameH), 1, frameH - top);
3467
3467
  return {
3468
3468
  crop: await sharp(encoded).extract({
3469
3469
  left,
@@ -28611,6 +28611,37 @@ var MediaStore = class {
28611
28611
  return newKey;
28612
28612
  }
28613
28613
  /**
28614
+ * Exchange the bytes+instant held in the `firstFrame` and `lastFrame` slots
28615
+ * (close-time ordering repair, see `pipeline/first-last-frame-order.ts`).
28616
+ *
28617
+ * Both are single-instance kinds, so each `putReplacing` overwrites the slot
28618
+ * it names — the two rows end up holding each other's blob and timestamp.
28619
+ * Nothing is deleted and nothing is re-encoded: both blobs are already the
28620
+ * track's own stored pixels and the only thing wrong with them is which slot
28621
+ * they sit in. The `lastFrame` write lands first so the "ultimo" tile is
28622
+ * never momentarily the older view a viewer just refuted.
28623
+ */
28624
+ async swapFirstAndLastFrame(input) {
28625
+ await this.gated(async () => {
28626
+ await this.putReplacingUnchecked({
28627
+ deviceId: input.deviceId,
28628
+ ownerKind: "track",
28629
+ ownerId: input.trackId,
28630
+ kind: "lastFrame",
28631
+ timestamp: input.firstFrame.timestamp,
28632
+ data: Buffer.from(input.firstFrame.base64, "base64")
28633
+ });
28634
+ await this.putReplacingUnchecked({
28635
+ deviceId: input.deviceId,
28636
+ ownerKind: "track",
28637
+ ownerId: input.trackId,
28638
+ kind: "firstFrame",
28639
+ timestamp: input.lastFrame.timestamp,
28640
+ data: Buffer.from(input.lastFrame.base64, "base64")
28641
+ });
28642
+ });
28643
+ }
28644
+ /**
28614
28645
  * Fetch one media entry by its key (id). Returns null if the key is not
28615
28646
  * found in the index or if the blob is missing from storage.
28616
28647
  */
@@ -30482,6 +30513,17 @@ function trackDurationMs(facts) {
30482
30513
  function hasKind(media, kind) {
30483
30514
  return media.some((m) => m.kind === kind);
30484
30515
  }
30516
+ /** Newest instant stamped on a row of `kind`, or `null` when the track owns
30517
+ * none. Single-instance kinds hold at most one row; the max guards a legacy
30518
+ * duplicate, mirroring `decideFirstLastFrameOrder`. */
30519
+ function newestOfKind(media, kind) {
30520
+ let newest = null;
30521
+ for (const m of media) {
30522
+ if (m.kind !== kind) continue;
30523
+ if (newest === null || m.timestamp > newest) newest = m.timestamp;
30524
+ }
30525
+ return newest;
30526
+ }
30485
30527
  function latestMediaAt(media) {
30486
30528
  let latest = null;
30487
30529
  for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
@@ -30529,6 +30571,12 @@ function analyseDebugTrack(inventory) {
30529
30571
  const lag = facts.lastSeen - lastFrameAt;
30530
30572
  if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
30531
30573
  }
30574
+ const firstFrameAt = newestOfKind(media, "firstFrame");
30575
+ if (firstFrameAt !== null) {
30576
+ const lastFrameAt = newestOfKind(media, "lastFrame");
30577
+ if (lastFrameAt !== null && firstFrameAt > lastFrameAt) out.push(finding("FIRSTFRAME_AFTER_LASTFRAME", "error", `firstFrame is ${String(firstFrameAt - lastFrameAt)} ms NEWER than lastFrame — the "primo" tile post-dates the "ultimo" one. The first view is captured at the CONFIRMED birth, which the confirmation gate can defer by up to its maxDeferralMs, while keyFrame/thumbnail/lastFrame are stamped at the earlier frame they were cut from.`));
30578
+ if (closed && firstFrameAt > facts.lastSeen) out.push(finding("FIRSTFRAME_AFTER_TRACK_END", "error", `firstFrame was captured ${String(firstFrameAt - facts.lastSeen)} ms AFTER the track's last observation — the tile shows a scene the subject had already left (a birth confirmed on a coasted frame).`));
30579
+ }
30532
30580
  if (closed && durationMs >= 1e4 && media.length > 0) {
30533
30581
  const latest = latestMediaAt(media);
30534
30582
  if (latest !== null) {
@@ -34126,6 +34174,29 @@ function firstFrameRetryBbox(current, birth) {
34126
34174
  return { ...current };
34127
34175
  }
34128
34176
  //#endregion
34177
+ //#region src/pipeline-analytics/pipeline/birth-first-frame-gate.ts
34178
+ /**
34179
+ * Decide whether the confirmed-birth frame may be dispatched as this track's
34180
+ * `firstFrame` capture. Pure; the caller logs the refusal and keeps the
34181
+ * `firstFramePending` mark so a later frame retries.
34182
+ *
34183
+ * `coasted-frame` is reported ahead of `no-frame-handle` when both hold: a
34184
+ * handle-less coasted frame would have been refused for BEING coasted whatever
34185
+ * the decode plane did, and naming the decode is what would send the next
34186
+ * investigation at the wrong subsystem.
34187
+ */
34188
+ function decideBirthFirstFrameCapture(input) {
34189
+ if (input.matchedThisFrame === false) return {
34190
+ capture: false,
34191
+ refusedBecause: "coasted-frame"
34192
+ };
34193
+ if (!input.hasFrameHandle) return {
34194
+ capture: false,
34195
+ refusedBecause: "no-frame-handle"
34196
+ };
34197
+ return { capture: true };
34198
+ }
34199
+ //#endregion
34129
34200
  //#region src/pipeline-analytics/pipeline/events/event-filter.ts
34130
34201
  var DEFAULT_EVENT_EMITTER_CONFIG = {
34131
34202
  minTrackAge: 3,
@@ -34473,6 +34544,37 @@ var TWO_WHEELERS = new Set([
34473
34544
  function intersection(a, b) {
34474
34545
  return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
34475
34546
  }
34547
+ /**
34548
+ * The FOLDED subject's box: the union of the rider and the machine.
34549
+ *
34550
+ * The fold's whole claim is that the two halves are ONE subject. Until
34551
+ * 2026-09-08 the box that survived the fold was the MACHINE's alone, so every
34552
+ * consumer that cuts pixels from `TrackedDetectionOut.bbox` — the best-shot
34553
+ * thumbnail, `wideCentralSquareLayout`'s central square, the close-time
34554
+ * keyFrame→thumbnail derive — framed a motorcycle with its rider's head cut
34555
+ * off. Operator, device 617, tracks `7f50622b` and `9cbc4791`, three minutes
34556
+ * apart: "motocicli e veicoli a due ruote devono includere il conducente nel
34557
+ * keyframe" (D407).
34558
+ *
34559
+ * This is the ONE place the folded subject's rectangle is formed, and it is
34560
+ * formed BEFORE the tracker — so no crop path derives a second rectangle
34561
+ * (D52 stays intact: `deriveDetailCropRect` is still the only detail-crop
34562
+ * derivation, and it now receives a subject box that already holds the rider).
34563
+ * Pure geometry: a strict union, never a pad — the fold widens the subject, it
34564
+ * never moves it.
34565
+ */
34566
+ function riderSubjectBox(person, vehicle) {
34567
+ const x = Math.min(person.x, vehicle.x);
34568
+ const y = Math.min(person.y, vehicle.y);
34569
+ const right = Math.max(person.x + person.w, vehicle.x + vehicle.w);
34570
+ const bottom = Math.max(person.y + person.h, vehicle.y + vehicle.h);
34571
+ return {
34572
+ x,
34573
+ y,
34574
+ w: right - x,
34575
+ h: bottom - y
34576
+ };
34577
+ }
34476
34578
  /** True when `vehicle` is a two-wheeler that `person` is riding. */
34477
34579
  function isRiderPair(person, vehicle) {
34478
34580
  if (person.macroClass !== "person") return false;
@@ -34734,7 +34836,7 @@ var DEFAULT_TRACKER_CONFIG = {
34734
34836
  var PERSON_CLASS = "person";
34735
34837
  var ANIMAL_CLASS = "animal";
34736
34838
  var MAX_PATH_LENGTH = 300;
34737
- function clamp(value, min, max) {
34839
+ function clamp$1(value, min, max) {
34738
34840
  return Math.max(min, Math.min(max, value));
34739
34841
  }
34740
34842
  function iou$3(a, b) {
@@ -35174,8 +35276,8 @@ var SortTracker = class SortTracker {
35174
35276
  const perFrameX = t.velocity.dx * t.emitIntervalMs;
35175
35277
  const perFrameY = t.velocity.dy * t.emitIntervalMs;
35176
35278
  return {
35177
- x: t.bbox.x + clamp(perFrameX * f, -t.bbox.w, t.bbox.w),
35178
- y: t.bbox.y + clamp(perFrameY * f, -t.bbox.h, t.bbox.h),
35279
+ x: t.bbox.x + clamp$1(perFrameX * f, -t.bbox.w, t.bbox.w),
35280
+ y: t.bbox.y + clamp$1(perFrameY * f, -t.bbox.h, t.bbox.h),
35179
35281
  w: t.bbox.w,
35180
35282
  h: t.bbox.h
35181
35283
  };
@@ -35184,8 +35286,8 @@ var SortTracker = class SortTracker {
35184
35286
  const allowX = driftAllowancePx(t.bbox.w, elapsedMs);
35185
35287
  const allowY = driftAllowancePx(t.bbox.h, elapsedMs);
35186
35288
  return {
35187
- x: t.bbox.x + clamp(t.velocity.dx * elapsedMs, -allowX, allowX),
35188
- y: t.bbox.y + clamp(t.velocity.dy * elapsedMs, -allowY, allowY),
35289
+ x: t.bbox.x + clamp$1(t.velocity.dx * elapsedMs, -allowX, allowX),
35290
+ y: t.bbox.y + clamp$1(t.velocity.dy * elapsedMs, -allowY, allowY),
35189
35291
  w: t.bbox.w,
35190
35292
  h: t.bbox.h
35191
35293
  };
@@ -36239,17 +36341,64 @@ var FrameProcessor = class {
36239
36341
  const foldByVehicleBbox = /* @__PURE__ */ new Map();
36240
36342
  if (riderPairs.length > 0) {
36241
36343
  const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
36344
+ /**
36345
+ * Move every bbox-OBJECT-keyed side table from the machine's box to the
36346
+ * folded subject's box (D407).
36347
+ *
36348
+ * This file carries frame-local facts across the tracker by bbox-object
36349
+ * REFERENCE (`labelsByBbox`, `embeddingByBbox`, `maskByBbox`, …); the
36350
+ * tracker then assigns `track.bbox = det.bbox`, and the emit block below
36351
+ * reads every one of those maps with `td.bbox`. Widening the subject
36352
+ * therefore means the widened box must inherit the machine's entries —
36353
+ * otherwise a folded rider silently loses its vehicle-classifier label
36354
+ * ("Motorcycle"), its mask, its plate read and its source id. One place,
36355
+ * exhaustive, next to the maps it moves.
36356
+ */
36357
+ const adoptSubjectBox = (from, to) => {
36358
+ const labels = labelsByBbox.get(from);
36359
+ if (labels !== void 0) labelsByBbox.set(to, labels);
36360
+ const tier = originalClassTierByBbox.get(from);
36361
+ if (tier !== void 0) originalClassTierByBbox.set(to, tier);
36362
+ const embedding = embeddingByBbox.get(from);
36363
+ if (embedding !== void 0) embeddingByBbox.set(to, embedding);
36364
+ const mask = maskByBbox.get(from);
36365
+ if (mask !== void 0) maskByBbox.set(to, mask);
36366
+ const plate = plateByBbox.get(from);
36367
+ if (plate !== void 0) plateByBbox.set(to, plate);
36368
+ const faceBbox = faceBboxByBbox.get(from);
36369
+ if (faceBbox !== void 0) faceBboxByBbox.set(to, faceBbox);
36370
+ const faceSize = nativeFaceSizeByBbox.get(from);
36371
+ if (faceSize !== void 0) nativeFaceSizeByBbox.set(to, faceSize);
36372
+ const alignedCrop = faceAlignedCropByBbox.get(from);
36373
+ if (alignedCrop !== void 0) faceAlignedCropByBbox.set(to, alignedCrop);
36374
+ const sourceId = sourceIdByBbox.get(from);
36375
+ if (sourceId !== void 0) {
36376
+ sourceIdByBbox.set(to, sourceId);
36377
+ firstLevelBboxById.set(sourceId, to);
36378
+ }
36379
+ };
36380
+ /** Folded vehicles, by their index in `filteredDetections`. */
36381
+ const foldedVehicles = /* @__PURE__ */ new Map();
36242
36382
  for (const p of riderPairs) {
36243
- const vehicle = filteredDetections[Number(p.vehicleId)];
36244
- if (vehicle === void 0) continue;
36245
- foldByVehicleBbox.set(vehicle.bbox, {
36383
+ const vehicleIdx = Number(p.vehicleId);
36384
+ const vehicle = filteredDetections[vehicleIdx];
36385
+ const person = filteredDetections[Number(p.personId)];
36386
+ if (vehicle === void 0 || person === void 0) continue;
36387
+ const subjectBox = riderSubjectBox(person.bbox, vehicle.bbox);
36388
+ adoptSubjectBox(vehicle.bbox, subjectBox);
36389
+ const folded = {
36390
+ ...vehicle,
36391
+ bbox: subjectBox
36392
+ };
36393
+ foldedVehicles.set(vehicleIdx, folded);
36394
+ foldByVehicleBbox.set(subjectBox, {
36246
36395
  overlap: p.overlap,
36247
- personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
36396
+ personScore: person.score,
36248
36397
  vehicleScore: vehicle.score,
36249
36398
  vehicleClass: vehicle.originalClass ?? "two-wheeler"
36250
36399
  });
36251
36400
  }
36252
- filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
36401
+ filteredDetections = filteredDetections.map((d, i) => foldedVehicles.get(i) ?? d).filter((_, i) => !riderIdx.has(i));
36253
36402
  }
36254
36403
  const gate = this.stationaryGate ? this.stationaryGate.filter({
36255
36404
  detections: filteredDetections,
@@ -37262,6 +37411,44 @@ function decideLastFramePromotion(media) {
37262
37411
  };
37263
37412
  }
37264
37413
  //#endregion
37414
+ //#region src/pipeline-analytics/pipeline/first-last-frame-order.ts
37415
+ /**
37416
+ * Decide whether a closing track's `firstFrame` and `lastFrame` slots hold each
37417
+ * other's bytes. Pure — see the module header for the contract.
37418
+ *
37419
+ * Swap when BOTH slots are filled and the `firstFrame` is STRICTLY newer than
37420
+ * the `lastFrame`. Equal timestamps are not an inversion.
37421
+ */
37422
+ function decideFirstLastFrameOrder(media) {
37423
+ let firstFrame;
37424
+ let lastFrame;
37425
+ for (const m of media) if (m.kind === "firstFrame") {
37426
+ if (firstFrame === void 0 || m.timestamp > firstFrame.timestamp) firstFrame = m;
37427
+ } else if (m.kind === "lastFrame") {
37428
+ if (lastFrame === void 0 || m.timestamp > lastFrame.timestamp) lastFrame = m;
37429
+ }
37430
+ if (firstFrame === void 0) return {
37431
+ swap: false,
37432
+ skippedBecause: "no-first-frame"
37433
+ };
37434
+ if (lastFrame === void 0) return {
37435
+ swap: false,
37436
+ skippedBecause: "no-last-frame"
37437
+ };
37438
+ if (firstFrame.timestamp <= lastFrame.timestamp) return {
37439
+ swap: false,
37440
+ skippedBecause: "ordered"
37441
+ };
37442
+ return {
37443
+ swap: true,
37444
+ firstFrameKey: firstFrame.key,
37445
+ lastFrameKey: lastFrame.key,
37446
+ firstFrameTimestamp: firstFrame.timestamp,
37447
+ lastFrameTimestamp: lastFrame.timestamp,
37448
+ inversionMs: firstFrame.timestamp - lastFrame.timestamp
37449
+ };
37450
+ }
37451
+ //#endregion
37265
37452
  //#region src/pipeline-analytics/pipeline/static-track-gate.ts
37266
37453
  /**
37267
37454
  * Net displacement + path span for a track's centroid path, normalized to
@@ -37404,6 +37591,7 @@ var TrackCloser = class {
37404
37591
  tile = "raster";
37405
37592
  }
37406
37593
  await this.maybePromoteLastFrame(t, ownedMedia);
37594
+ await this.repairFirstLastFrameOrder(t);
37407
37595
  const hasRasterFallback = closure?.rasterFallback !== void 0;
37408
37596
  let deriveMiss;
37409
37597
  if (!thumbnailLanded) {
@@ -37747,6 +37935,56 @@ var TrackCloser = class {
37747
37935
  }
37748
37936
  }
37749
37937
  /**
37938
+ * A track's "primo" tile is never newer than its "ultimo" tile
37939
+ * (`first-last-frame-order.ts`). Filed by hand three times on device 3829,
37940
+ * 2026-09-08 — `d035f251`, `448678bb`, `7628b19d`.
37941
+ *
37942
+ * The media list is RE-READ rather than reusing the caller's `ownedMedia`:
37943
+ * `maybePromoteLastFrame` has just run and is precisely what may have put the
37944
+ * older bytes in the `lastFrame` slot, so the caller's copy predates the pair
37945
+ * being ordered here. Best-effort — a failure never blocks the close.
37946
+ */
37947
+ async repairFirstLastFrameOrder(t) {
37948
+ const store = this.deps.mediaStore();
37949
+ if (!store) return;
37950
+ const media = await store.listByOwner("track", t.trackId);
37951
+ const decision = decideFirstLastFrameOrder(media);
37952
+ if (!decision.swap) return;
37953
+ const firstFrame = media.find((m) => m.key === decision.firstFrameKey);
37954
+ const lastFrame = media.find((m) => m.key === decision.lastFrameKey);
37955
+ if (!firstFrame || !lastFrame) return;
37956
+ try {
37957
+ await store.swapFirstAndLastFrame({
37958
+ deviceId: t.deviceId,
37959
+ trackId: t.trackId,
37960
+ firstFrame,
37961
+ lastFrame
37962
+ });
37963
+ this.deps.logger.warn("firstFrame/lastFrame were out of order — slots swapped", {
37964
+ tags: { deviceId: t.deviceId },
37965
+ meta: {
37966
+ trackId: t.trackId,
37967
+ className: t.className,
37968
+ inversionMs: decision.inversionMs,
37969
+ firstFrameAt: decision.firstFrameTimestamp,
37970
+ lastFrameAt: decision.lastFrameTimestamp,
37971
+ /** How far the late capture ran past the track itself. Positive means
37972
+ * it was cut from a frame the subject was no longer observed in. */
37973
+ pastTrackEndMs: decision.firstFrameTimestamp - t.lastSeen,
37974
+ durationMs: t.lastSeen - t.firstSeen
37975
+ }
37976
+ });
37977
+ } catch (err) {
37978
+ this.deps.logger.warn("firstFrame/lastFrame order repair failed", {
37979
+ tags: { deviceId: t.deviceId },
37980
+ meta: {
37981
+ trackId: t.trackId,
37982
+ error: String(err)
37983
+ }
37984
+ });
37985
+ }
37986
+ }
37987
+ /**
37750
37988
  * Key-event importance: score the just-expired track and persist. Peak
37751
37989
  * confidence comes from the shared best-detection tracker; peak bbox area
37752
37990
  * + the winning object-event id from one indexed queryObject(trackId) —
@@ -41562,7 +41800,10 @@ var TrackStore = class {
41562
41800
  retrainStatus: active.retrainStatus ?? "none",
41563
41801
  debug: active.debug === true,
41564
41802
  debugNote: active.debugNote ?? "",
41565
- favourited: active.favourited === true
41803
+ favourited: active.favourited === true,
41804
+ startedAt: active.firstSeen,
41805
+ className: active.className,
41806
+ ...displayLabel(active) !== void 0 ? { label: displayLabel(active) } : {}
41566
41807
  };
41567
41808
  const persisted = await this.getPersistedByTrackId(trackId);
41568
41809
  if (!persisted) return null;
@@ -41570,7 +41811,10 @@ var TrackStore = class {
41570
41811
  retrainStatus: persisted.retrainStatus ?? "none",
41571
41812
  debug: persisted.debug === true,
41572
41813
  debugNote: persisted.debugNote ?? "",
41573
- favourited: persisted.favourited === true
41814
+ favourited: persisted.favourited === true,
41815
+ startedAt: persisted.firstSeen,
41816
+ className: persisted.className,
41817
+ ...displayLabel(persisted) !== void 0 ? { label: displayLabel(persisted) } : {}
41574
41818
  };
41575
41819
  }
41576
41820
  /**
@@ -48828,10 +49072,9 @@ var FaceRecognizer = class {
48828
49072
  margin: settings.margin,
48829
49073
  minIdentitySamples: settings.minIdentitySamples
48830
49074
  }) : /* @__PURE__ */ new Map();
48831
- if (this.deps.onSemanticMatch !== void 0) for (const c of matchCandidates) {
49075
+ if (this.deps.onSemanticFace !== void 0) for (const c of matchCandidates) {
48832
49076
  const match = matches.get(c.trackId);
48833
- if (match === void 0) continue;
48834
- this.deps.onSemanticMatch({
49077
+ this.deps.onSemanticFace({
48835
49078
  deviceId: input.deviceId,
48836
49079
  trackId: c.trackId,
48837
49080
  timestamp: input.timestamp,
@@ -48839,8 +49082,10 @@ var FaceRecognizer = class {
48839
49082
  frameHeight: input.frameHeight,
48840
49083
  bbox: c.bbox,
48841
49084
  confidence: c.confidence,
48842
- matchScore: match.score,
48843
- identityId: match.identityId,
49085
+ ...match !== void 0 ? {
49086
+ matchScore: match.score,
49087
+ identityId: match.identityId
49088
+ } : {},
48844
49089
  ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
48845
49090
  });
48846
49091
  }
@@ -53187,6 +53432,19 @@ var DeferredBirthRegistry = class {
53187
53432
  }
53188
53433
  };
53189
53434
  //#endregion
53435
+ //#region src/pipeline-analytics/pipeline/detail-semantic-gate.ts
53436
+ /** The detail `className` whose result IS the recognition. */
53437
+ var PLATE_CLASS = "plate";
53438
+ /**
53439
+ * The moment `className`'s detail result earns the semantic tier.
53440
+ *
53441
+ * `recognition` — promote as soon as the result exists (the plate read).
53442
+ * `label-write` — promote only once the track ACCEPTED the label.
53443
+ */
53444
+ function detailSemanticTrigger(className) {
53445
+ return className === PLATE_CLASS ? "recognition" : "label-write";
53446
+ }
53447
+ //#endregion
53190
53448
  //#region src/pipeline-analytics/pipeline/dropout-skip.ts
53191
53449
  function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
53192
53450
  if (!cfg.enabled) return false;
@@ -54949,7 +55207,7 @@ var PackageDropDetector = class {
54949
55207
  async onStationaryChange(change) {
54950
55208
  try {
54951
55209
  if (change.phase === "appeared") await this.onAppeared(change.entry, change.timestamp);
54952
- else await this.onDeparted(change.entry, change.timestamp);
55210
+ else await this.onDeparted(change);
54953
55211
  } catch (err) {
54954
55212
  this.deps.onError?.("onStationaryChange", err);
54955
55213
  this.deps.logger.warn("package-drop detector failed on change", {
@@ -55127,7 +55385,9 @@ var PackageDropDetector = class {
55127
55385
  }
55128
55386
  });
55129
55387
  }
55130
- async onDeparted(entry, timestamp) {
55388
+ async onDeparted(change) {
55389
+ const entry = change.entry;
55390
+ const timestamp = change.timestamp;
55131
55391
  if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) {
55132
55392
  this.deps.logger.debug("package pick-up skipped — packageDropPickupEnabled is false", {
55133
55393
  tags: { deviceId: entry.deviceId },
@@ -55162,13 +55422,25 @@ var PackageDropDetector = class {
55162
55422
  });
55163
55423
  return;
55164
55424
  }
55425
+ const collector = change.collector;
55426
+ const packageTrackId = entry.sourceTrackId ?? entry.id;
55427
+ const mediaTrackId = collector?.trackId ?? packageTrackId;
55428
+ if (collector === void 0) this.deps.logger.info("package pick-up has no collector track — the notification carries the parcel frame", {
55429
+ tags: { deviceId: entry.deviceId },
55430
+ meta: {
55431
+ entryId: entry.id,
55432
+ eventId: pickedUpId,
55433
+ packageTrackId,
55434
+ pickupAt: entry.lastConfirmedAt
55435
+ }
55436
+ });
55165
55437
  const ev = {
55166
55438
  id: pickedUpId,
55167
55439
  kind: "object",
55168
55440
  deviceId: entry.deviceId,
55169
55441
  timestamp,
55170
55442
  source: "pipeline",
55171
- trackId: entry.sourceTrackId ?? entry.id,
55443
+ trackId: mediaTrackId,
55172
55444
  className: PACKAGE_EVENT_CLASS,
55173
55445
  ...entry.label !== void 0 ? { label: entry.label } : {},
55174
55446
  confidence: PACKAGE_IMPORTANCE,
@@ -55182,6 +55454,7 @@ var PackageDropDetector = class {
55182
55454
  state: PICKED_UP_STATE,
55183
55455
  frameWidth: entry.frameWidth,
55184
55456
  frameHeight: entry.frameHeight,
55457
+ ...collector?.keyFrameMediaKey !== void 0 ? { mediaKey: collector.keyFrameMediaKey } : {},
55185
55458
  importance: PACKAGE_IMPORTANCE
55186
55459
  };
55187
55460
  await this.deps.events.insertObject(ev);
@@ -55191,6 +55464,7 @@ var PackageDropDetector = class {
55191
55464
  entryId: entry.id,
55192
55465
  deliveredEventId: deliveredId,
55193
55466
  className: entry.className,
55467
+ ...collector !== void 0 ? { collectorTrackId: collector.trackId } : {},
55194
55468
  timestamp
55195
55469
  });
55196
55470
  this.deps.logger.info("package picked up", {
@@ -55198,11 +55472,139 @@ var PackageDropDetector = class {
55198
55472
  meta: {
55199
55473
  entryId: entry.id,
55200
55474
  eventId: pickedUpId,
55201
- deliveredEventId: deliveredId
55475
+ deliveredEventId: deliveredId,
55476
+ mediaTrackId,
55477
+ ...collector !== void 0 ? {
55478
+ collectorTrackId: collector.trackId,
55479
+ collectorClassName: collector.className,
55480
+ collectorSkewMs: collector.skewMs
55481
+ } : { collectorTrackId: null }
55202
55482
  }
55203
55483
  });
55204
55484
  }
55205
55485
  };
55486
+ /**
55487
+ * How long a presence survives in the mirror, in FRAME time.
55488
+ *
55489
+ * It must outlive the whole detection latency: a pick-up at T is concluded no
55490
+ * earlier than T + `PACKAGE_STILLNESS_TTL_MS` (5 min of observed time), and the
55491
+ * pick is made at conclusion time. 10 minutes is double that, so a collector
55492
+ * noted at the true instant is still there to be named — and a mirror that
55493
+ * expires the answer before the question is asked would be the same silent
55494
+ * hole in a different place.
55495
+ */
55496
+ var PACKAGE_COLLECTOR_RETENTION_MS = 10 * 6e4;
55497
+ /** The pick-up instant a stationary entry implies. One derivation, named. */
55498
+ function pickupInstantOf(entry) {
55499
+ return entry.lastConfirmedAt;
55500
+ }
55501
+ var PackageCollectorMirror = class {
55502
+ /** Per device, per track. Every write replaces the record (immutable). */
55503
+ byDevice = /* @__PURE__ */ new Map();
55504
+ /**
55505
+ * Record one sighting. Extends the track's presence span and refreshes its
55506
+ * key-frame handle (a re-shot key frame is a better one, and the newest is
55507
+ * also the nearest to the pick-up — the same trade as above).
55508
+ */
55509
+ note(obs) {
55510
+ const current = this.byDevice.get(obs.deviceId);
55511
+ const next = /* @__PURE__ */ new Map();
55512
+ const horizon = obs.at - PACKAGE_COLLECTOR_RETENTION_MS;
55513
+ if (current !== void 0) for (const [id, p] of current) {
55514
+ if (p.lastAt < horizon) continue;
55515
+ next.set(id, p);
55516
+ }
55517
+ const prev = next.get(obs.trackId);
55518
+ next.set(obs.trackId, {
55519
+ trackId: obs.trackId,
55520
+ className: obs.className,
55521
+ firstAt: prev === void 0 ? obs.at : Math.min(prev.firstAt, obs.at),
55522
+ lastAt: prev === void 0 ? obs.at : Math.max(prev.lastAt, obs.at),
55523
+ ...obs.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: obs.keyFrameMediaKey } : prev?.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: prev.keyFrameMediaKey } : {}
55524
+ });
55525
+ this.byDevice.set(obs.deviceId, evictOldest(next));
55526
+ }
55527
+ /**
55528
+ * The track nearest the pick-up instant, or `null` when nobody qualifies.
55529
+ *
55530
+ * `null` is a real answer and not an error: a parcel can stop being seen
55531
+ * because the light changed or the detector lost it, and naming a person who
55532
+ * was not there would be worse than naming nobody. The caller degrades to the
55533
+ * parcel's own owner and logs it.
55534
+ */
55535
+ pickCollector(input) {
55536
+ const presences = this.byDevice.get(input.deviceId);
55537
+ if (presences === void 0 || presences.size === 0) return null;
55538
+ const maxSkewMs = input.maxSkewMs ?? 3e4;
55539
+ let best = null;
55540
+ let bestLastAt = Number.NEGATIVE_INFINITY;
55541
+ for (const p of presences.values()) {
55542
+ const observedAt = clamp(input.pickupAt, p.firstAt, p.lastAt);
55543
+ const skewMs = Math.abs(observedAt - input.pickupAt);
55544
+ if (skewMs > maxSkewMs) continue;
55545
+ if (best !== null && (skewMs > best.skewMs || skewMs === best.skewMs && p.lastAt <= bestLastAt)) continue;
55546
+ best = {
55547
+ trackId: p.trackId,
55548
+ className: p.className,
55549
+ observedAt,
55550
+ skewMs,
55551
+ ...p.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: p.keyFrameMediaKey } : {}
55552
+ };
55553
+ bestLastAt = p.lastAt;
55554
+ }
55555
+ return best;
55556
+ }
55557
+ /**
55558
+ * Fold one pipeline frame in. Returns how many presences it touched, so the
55559
+ * caller can say "nobody was ever in the drop-off zone" with a number.
55560
+ *
55561
+ * This is the ONLY place the frame-shaped input is interpreted, so the
55562
+ * production path and the test path read the same rules: a non-package class,
55563
+ * inside a configured drop-off zone, on this frame.
55564
+ */
55565
+ noteFrame(input) {
55566
+ if (input.packageZoneIds.size === 0) return 0;
55567
+ let noted = 0;
55568
+ for (const t of input.tracked) {
55569
+ if (input.packageClasses.has(t.className)) continue;
55570
+ if (!isDelivererInPackageZone(t.zones, input.packageZoneIds)) continue;
55571
+ const keyFrameMediaKey = input.keyFrameMediaKeyFor(t.trackId);
55572
+ this.note({
55573
+ deviceId: input.deviceId,
55574
+ trackId: t.trackId,
55575
+ className: t.className,
55576
+ at: input.timestamp,
55577
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
55578
+ });
55579
+ noted += 1;
55580
+ }
55581
+ return noted;
55582
+ }
55583
+ /** Drop a camera's memory (unbind / step disabled / device removal). */
55584
+ forgetDevice(deviceId) {
55585
+ this.byDevice.delete(deviceId);
55586
+ }
55587
+ reset() {
55588
+ this.byDevice.clear();
55589
+ }
55590
+ /** Retained presences for a camera — diagnostics and tests only. */
55591
+ debugPresenceCount(deviceId) {
55592
+ return this.byDevice.get(deviceId)?.size ?? 0;
55593
+ }
55594
+ };
55595
+ function clamp(value, low, high) {
55596
+ if (value < low) return low;
55597
+ if (value > high) return high;
55598
+ return value;
55599
+ }
55600
+ /** Keep the newest {@link PACKAGE_COLLECTOR_MAX_PRESENCES} by `lastAt`. */
55601
+ function evictOldest(presences) {
55602
+ if (presences.size <= 32) return presences;
55603
+ const ordered = [...presences.values()].sort((a, b) => b.lastAt - a.lastAt);
55604
+ const kept = /* @__PURE__ */ new Map();
55605
+ for (const p of ordered.slice(0, 32)) kept.set(p.trackId, p);
55606
+ return kept;
55607
+ }
55206
55608
  //#endregion
55207
55609
  //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
55208
55610
  /**
@@ -56678,6 +57080,61 @@ function classifyTrackAppearance(input) {
56678
57080
  if (input.inPrevActive) return "continuing";
56679
57081
  return input.positionsCount > 1 ? "resurrection" : "birth";
56680
57082
  }
57083
+ //#endregion
57084
+ //#region src/pipeline-analytics/pipeline/archive-debug-note.ts
57085
+ /**
57086
+ * Archive the note a clear is about to destroy. Never throws.
57087
+ *
57088
+ * **Which way this fails, stated once.** The operator pressed a flag button;
57089
+ * the archive is this system's own bookkeeping. So a failing archive does NOT
57090
+ * block the flag write — the button must do what it says even when SQLite is
57091
+ * busy — and it does NOT retry, because the caller is a synchronous cap method
57092
+ * an operator is waiting on.
57093
+ *
57094
+ * What it must never do is lose the sentence quietly. The failure line carries
57095
+ * the NOTE TEXT verbatim, plus `tags: { deviceId }` and the trackId, so the
57096
+ * words are recoverable from the log by hand and the loss is attributable to
57097
+ * one camera. A branch that drops work silently reads as "never happened", and
57098
+ * the 3-hour media blackout produced not one line.
57099
+ *
57100
+ * An EMPTY note archives nothing: there is nothing to keep, and a row of `''`
57101
+ * would be a permanent entry in a corpus meant to be mined for sentences.
57102
+ */
57103
+ async function archiveNoteBeforeClear(input) {
57104
+ const note = input.current.debugNote;
57105
+ if (note.trim() === "") return;
57106
+ if (input.archive === void 0) {
57107
+ input.logger.warn("debug note dropped — no archive is wired on this runner", {
57108
+ tags: { deviceId: input.deviceId },
57109
+ meta: {
57110
+ deviceId: input.deviceId,
57111
+ trackId: input.trackId,
57112
+ note
57113
+ }
57114
+ });
57115
+ return;
57116
+ }
57117
+ try {
57118
+ await input.archive.append({
57119
+ deviceId: input.deviceId,
57120
+ sourceTrackId: input.trackId,
57121
+ note,
57122
+ ...input.current.startedAt !== void 0 ? { trackStartedAt: input.current.startedAt } : {},
57123
+ ...input.current.className !== void 0 ? { trackClass: input.current.className } : {},
57124
+ ...input.current.label !== void 0 ? { trackLabel: input.current.label } : {}
57125
+ });
57126
+ } catch (err) {
57127
+ input.logger.error("failed to archive a debug note — the text is in this line", {
57128
+ tags: { deviceId: input.deviceId },
57129
+ meta: {
57130
+ deviceId: input.deviceId,
57131
+ trackId: input.trackId,
57132
+ note,
57133
+ error: err instanceof Error ? err.message : String(err)
57134
+ }
57135
+ });
57136
+ }
57137
+ }
56681
57138
  /** Thrown when the device is already holding its full staging budget. Distinct
56682
57139
  * type so a surface can tell "you are out of room" from "that track is gone". */
56683
57140
  var StagingBudgetExceededError = class extends Error {
@@ -56718,7 +57175,10 @@ var TrackAlreadyTrainedError = class extends Error {
56718
57175
  * caller sent with it. A note surviving the flag is a leftover that reads
56719
57176
  * as a live request, and the invariant belongs here rather than in the
56720
57177
  * three surfaces that write this patch — one of which is a browser grid
56721
- * that has no note UI at all and would never think to clear it.
57178
+ * that has no note UI at all and would never think to clear it. Since
57179
+ * D405 the clear is not a DELETE: `applyTrackFlags` ARCHIVES the note
57180
+ * first, into a table that outlives the track. The invariant above is
57181
+ * unchanged — only where the words go.
56722
57182
  * 2. **No note in the patch → nothing is written.** This is what makes a
56723
57183
  * cancelled prompt free: the app turns debug on, the operator declines to
56724
57184
  * type, and the flag still lands. Refusing to write a note must never cost
@@ -56812,6 +57272,13 @@ async function applyTrackFlags(deps, input) {
56812
57272
  ...input.flags.favourited !== void 0 ? { favourited: input.flags.favourited } : {},
56813
57273
  ...resolveNotePatch(deps, input, current)
56814
57274
  };
57275
+ if (patch.debugNote === "" && current.debugNote !== "") await archiveNoteBeforeClear({
57276
+ deviceId: input.deviceId,
57277
+ trackId: input.trackId,
57278
+ logger,
57279
+ archive: deps.noteArchive,
57280
+ current
57281
+ });
56815
57282
  if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
56816
57283
  const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
56817
57284
  return {
@@ -59493,6 +59960,261 @@ var IdentityStore = class {
59493
59960
  }
59494
59961
  };
59495
59962
  //#endregion
59963
+ //#region src/pipeline-analytics/store/debug-note-archive-store.ts
59964
+ /**
59965
+ * DebugNoteArchiveStore — the durable corpus of what operators asked to be
59966
+ * checked (D405).
59967
+ *
59968
+ * A declared SQL-backed collection, like the events ops-log next door, and for
59969
+ * the same reason: pipeline-analytics already owns SQLite collections, and an
59970
+ * undeclared collection crash-loops the runner. MUST be declared in
59971
+ * `onInitialize` before the first write.
59972
+ *
59973
+ * Collection name: pipeline-analytics:debug-note-archive
59974
+ *
59975
+ * Two properties separate it from every other table in this addon:
59976
+ *
59977
+ * - **It is not track-owned.** It names a track in `sourceTrackId` and that is
59978
+ * PROVENANCE, never ownership — the whole point is that the row outlives the
59979
+ * track, which `debug` deliberately never pinned (D353). It is classified
59980
+ * `never-orphaned` by hand in `collection-classification.ts` so the
59981
+ * ownership derivation cannot see the column and cascade it away.
59982
+ * - **It is bounded by COUNT, not by age.** An age sweep would delete the
59983
+ * corpus for being old, which is the failure this table exists to fix: a
59984
+ * six-month-old note is the most valuable row in it, because the pattern it
59985
+ * describes has had time to repeat. See {@link MAX_ARCHIVED_DEBUG_NOTES}.
59986
+ */
59987
+ /**
59988
+ * @durable class=ledger owner=pipeline-analytics
59989
+ * write="one row per debug note RETIRED by a review — appended by
59990
+ * `applyTrackFlags` immediately before `debug: false` clears the note off
59991
+ * the track row (D405). Idempotent per (sourceTrackId, note): the row id is
59992
+ * derived from the pair, so reviewing the same track twice cannot double
59993
+ * the corpus. An empty note writes nothing."
59994
+ * retention="NOT track retention and NOT an age clock — deleting a note for
59995
+ * being old is the failure this table fixes. Bounded by ROW COUNT
59996
+ * (MAX_ARCHIVED_DEBUG_NOTES, 5000, fleet-wide), oldest `archivedAt` first,
59997
+ * trimmed on append. ~3 MB of text at the ceiling."
59998
+ */
59999
+ var DEBUG_NOTE_ARCHIVE_COLLECTION = "pipeline-analytics:debug-note-archive";
60000
+ var DEBUG_NOTE_ARCHIVE_COLUMNS = [
60001
+ {
60002
+ name: "id",
60003
+ type: "TEXT",
60004
+ primaryKey: true,
60005
+ notNull: true
60006
+ },
60007
+ {
60008
+ name: "archivedAt",
60009
+ type: "INTEGER",
60010
+ notNull: true
60011
+ },
60012
+ {
60013
+ name: "deviceId",
60014
+ type: "INTEGER",
60015
+ notNull: true
60016
+ },
60017
+ {
60018
+ name: "sourceTrackId",
60019
+ type: "TEXT",
60020
+ notNull: true
60021
+ },
60022
+ {
60023
+ name: "note",
60024
+ type: "TEXT",
60025
+ notNull: true
60026
+ },
60027
+ {
60028
+ name: "trackStartedAt",
60029
+ type: "INTEGER"
60030
+ },
60031
+ {
60032
+ name: "trackClass",
60033
+ type: "TEXT"
60034
+ },
60035
+ {
60036
+ name: "trackLabel",
60037
+ type: "TEXT"
60038
+ }
60039
+ ];
60040
+ var DEBUG_NOTE_ARCHIVE_INDEXES = [{
60041
+ name: "idx_debugnote_archived_at",
60042
+ columns: ["archivedAt"]
60043
+ }, {
60044
+ name: "idx_debugnote_device_at",
60045
+ columns: ["deviceId", "archivedAt"]
60046
+ }];
60047
+ /**
60048
+ * The row id for one `(track, note)` pair.
60049
+ *
60050
+ * Deterministic on purpose: the idempotency the operator needs is "reviewing
60051
+ * twice does not double the row", and a derived primary key gets it from the
60052
+ * database rather than from a read-modify-write that two concurrent reviews
60053
+ * could interleave through. Truncated to 32 hex characters — 128 bits over a
60054
+ * table capped at 5 000 rows.
60055
+ */
60056
+ function archiveRowId(sourceTrackId, note) {
60057
+ return createHash("sha256").update(`${sourceTrackId}${note}`).digest("hex").slice(0, 32);
60058
+ }
60059
+ var DebugNoteArchiveStore = class {
60060
+ store;
60061
+ logger;
60062
+ now;
60063
+ maxRows;
60064
+ constructor(deps) {
60065
+ this.store = deps.store;
60066
+ this.logger = deps.logger;
60067
+ this.now = deps.now ?? (() => Date.now());
60068
+ this.maxRows = deps.maxRows ?? 5e3;
60069
+ }
60070
+ /** The ceiling a store built without an override enforces. */
60071
+ static defaultMaxRows() {
60072
+ return MAX_ARCHIVED_DEBUG_NOTES;
60073
+ }
60074
+ static async declare(store) {
60075
+ await store.declareCollection.mutate({
60076
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60077
+ columns: [...DEBUG_NOTE_ARCHIVE_COLUMNS],
60078
+ indexes: [...DEBUG_NOTE_ARCHIVE_INDEXES]
60079
+ });
60080
+ }
60081
+ /**
60082
+ * Append one retired note. THROWS if the row cannot be written.
60083
+ *
60084
+ * Deliberately not best-effort, unlike the ops-log's `append`: this is the
60085
+ * only copy of something a person typed, and the caller
60086
+ * (`archiveNoteBeforeClear`) has both the text and the camera and is the
60087
+ * right place to decide what a failure costs — it logs the note verbatim and
60088
+ * lets the operator's flag write proceed. Swallowing here would hide the
60089
+ * failure from the one caller able to preserve the words.
60090
+ *
60091
+ * The TRIM is the exception and is best-effort: the row has already landed,
60092
+ * and failing the append because housekeeping could not run would make the
60093
+ * caller report a note lost that is sitting in the table.
60094
+ */
60095
+ async append(input) {
60096
+ const note = input.note.trim() === "" ? "" : input.note;
60097
+ if (note === "") return {
60098
+ archived: false,
60099
+ id: null
60100
+ };
60101
+ const id = archiveRowId(input.sourceTrackId, note);
60102
+ if ((await this.store.query.query({
60103
+ collection: "pipeline-analytics:debug-note-archive",
60104
+ filter: {
60105
+ where: { id },
60106
+ limit: 1
60107
+ }
60108
+ })).length > 0) return {
60109
+ archived: false,
60110
+ id
60111
+ };
60112
+ const row = ArchivedDebugNoteSchema.parse({
60113
+ id,
60114
+ note,
60115
+ deviceId: input.deviceId,
60116
+ sourceTrackId: input.sourceTrackId,
60117
+ archivedAt: this.now(),
60118
+ trackStartedAt: input.trackStartedAt ?? null,
60119
+ trackClass: input.trackClass ?? null,
60120
+ trackLabel: input.trackLabel ?? null
60121
+ });
60122
+ await this.store.insert.mutate({
60123
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60124
+ record: {
60125
+ id: row.id,
60126
+ data: {
60127
+ note: row.note,
60128
+ deviceId: row.deviceId,
60129
+ sourceTrackId: row.sourceTrackId,
60130
+ archivedAt: row.archivedAt,
60131
+ trackStartedAt: row.trackStartedAt,
60132
+ trackClass: row.trackClass,
60133
+ trackLabel: row.trackLabel
60134
+ }
60135
+ }
60136
+ });
60137
+ await this.trim();
60138
+ return {
60139
+ archived: true,
60140
+ id: row.id
60141
+ };
60142
+ }
60143
+ /**
60144
+ * Hold the table at its ceiling, oldest first. Best-effort by design — see
60145
+ * {@link append}.
60146
+ *
60147
+ * NOT an age-keyed sweep and must never become one: the boundary below is
60148
+ * computed from the POSITION of the excess rows, never from a clock, so a
60149
+ * quiet fleet keeps its whole corpus for ever and a busy one drops its oldest
60150
+ * questions. Rows sharing the boundary timestamp go together; at a
60151
+ * one-per-review write rate that is a rounding error, and the alternative —
60152
+ * deleting by id, one round trip per row — is the N+1 drain this store layer
60153
+ * spent a release removing.
60154
+ */
60155
+ async trim() {
60156
+ try {
60157
+ const held = await this.store.count.query({ collection: DEBUG_NOTE_ARCHIVE_COLLECTION });
60158
+ const excess = held - this.maxRows;
60159
+ if (excess <= 0) return;
60160
+ const boundary = (await this.store.query.query({
60161
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60162
+ filter: {
60163
+ orderBy: {
60164
+ field: "archivedAt",
60165
+ direction: "asc"
60166
+ },
60167
+ limit: excess
60168
+ },
60169
+ columns: ["archivedAt"]
60170
+ })).reduce((acc, r) => {
60171
+ const at = r.data["archivedAt"];
60172
+ return typeof at === "number" && at > acc ? at : acc;
60173
+ }, 0);
60174
+ if (boundary === 0) return;
60175
+ const { deleted } = await this.store.deleteWhere.mutate({
60176
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60177
+ filter: { whereBetween: { archivedAt: [0, boundary] } }
60178
+ });
60179
+ this.logger.info("debug-note archive trimmed to its ceiling", { meta: {
60180
+ held,
60181
+ ceiling: this.maxRows,
60182
+ deleted
60183
+ } });
60184
+ } catch (err) {
60185
+ this.logger.warn("debug-note archive trim failed — the archive will keep growing", { meta: {
60186
+ ceiling: this.maxRows,
60187
+ error: err instanceof Error ? err.message : String(err)
60188
+ } });
60189
+ }
60190
+ }
60191
+ /** Archived notes, newest first, optionally scoped to one camera. */
60192
+ async list(query) {
60193
+ const filter = {
60194
+ orderBy: {
60195
+ field: "archivedAt",
60196
+ direction: "desc"
60197
+ },
60198
+ limit: query.limit ?? 200
60199
+ };
60200
+ if (query.deviceId !== void 0) filter["where"] = { deviceId: query.deviceId };
60201
+ const rows = await this.store.query.query({
60202
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
60203
+ filter
60204
+ });
60205
+ const out = [];
60206
+ for (const r of rows) {
60207
+ const parsed = ArchivedDebugNoteSchema.safeParse({
60208
+ id: r.id,
60209
+ ...r.data
60210
+ });
60211
+ if (parsed.success) out.push(parsed.data);
60212
+ else this.logger.debug("debug-note archive: skipped a malformed row", { meta: { id: r.id } });
60213
+ }
60214
+ return out;
60215
+ }
60216
+ };
60217
+ //#endregion
59496
60218
  //#region src/pipeline-analytics/store/ops-log-store.ts
59497
60219
  /**
59498
60220
  * OpsLogStore — the EVENTS-domain operations audit for pipeline-analytics.
@@ -60856,6 +61578,13 @@ var ANALYTICS_COLLECTIONS = [
60856
61578
  why: "the drained predecessor of the row above, declared only so its rows can be read once and moved (`viewer-settings-legacy-adoption.ts`). Retention must never touch it: its rows go one way only, to the successor, gated on a re-read that confirms they landed. Delete this entry together with that module."
60857
61579
  }
60858
61580
  },
61581
+ {
61582
+ collection: DEBUG_NOTE_ARCHIVE_COLLECTION,
61583
+ classification: {
61584
+ kind: "never-orphaned",
61585
+ why: "the archived operator notes (D405) — the ONE table here whose whole purpose is to outlive the track it names. `debug` never pinned a track against retention and D405 does not change that, so the track a note was written on IS expected to be evicted; deleting the note with it is the failure the archive was built to fix (25–27 notes from one sweep unreadable the same afternoon). Its column is called `sourceTrackId` and not `trackId` precisely so the ownership derivation cannot reach it, and its bound is a fleet-wide ROW COUNT trimmed on append — never an age sweep, which would delete the corpus for the one property that makes it valuable."
61586
+ }
61587
+ },
60859
61588
  {
60860
61589
  collection: STATIONARY_COLLECTION,
60861
61590
  classification: {
@@ -68626,6 +69355,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68626
69355
  eventStore = null;
68627
69356
  /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
68628
69357
  eventsOpsLog = null;
69358
+ /**
69359
+ * The archived operator debug notes (D405). Null until onInitialize — and
69360
+ * `applyTrackFlags` treats null as "no archive wired": the flag write the
69361
+ * operator asked for never depends on this addon's own bookkeeping.
69362
+ */
69363
+ debugNoteArchive = null;
68629
69364
  /** Event-media relocation engine (entity-routing Phase 4). */
68630
69365
  mediaRelocate = null;
68631
69366
  mediaLocationStorage = null;
@@ -69183,6 +69918,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69183
69918
  * which would let the streetlight wedge vouch for its own delivery.
69184
69919
  */
69185
69920
  lastNonPackageActivityAt = /* @__PURE__ */ new Map();
69921
+ /**
69922
+ * WHO was in the drop-off zone, and when (D404). The pick-up is CONCLUDED
69923
+ * minutes after the fact, so the collector must be remembered while it is
69924
+ * still true — handles only, never pixels.
69925
+ */
69926
+ packageCollectors = new PackageCollectorMirror();
69186
69927
  /** Last time the oversize-sighting drop was logged per device — one line
69187
69928
  * per 5 minutes, not one per governor round (the wedge re-sights all
69188
69929
  * night; ~300 identical lines would bury the log it exists to serve). */
@@ -69608,6 +70349,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69608
70349
  logger: logger.child("ops-log"),
69609
70350
  nodeId: ownNodeId
69610
70351
  });
70352
+ this.debugNoteArchive = new DebugNoteArchiveStore({
70353
+ store: api.settingsStore,
70354
+ logger: logger.child("debug-note-archive")
70355
+ });
69611
70356
  {
69612
70357
  const designated = await step("postProcessingNodeState", () => this.postProcessingNodeState.get());
69613
70358
  this.isPostProcessingNode = ownNodeId === designated;
@@ -69927,6 +70672,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69927
70672
  RetrainFrameStore.declare(api.settingsStore),
69928
70673
  RetrainAnnotationStore.declare(api.settingsStore),
69929
70674
  OpsLogStore.declare(api.settingsStore),
70675
+ DebugNoteArchiveStore.declare(api.settingsStore),
69930
70676
  AnalyticsLts.declare(api.settingsStore),
69931
70677
  SceneStore.declare(api.settingsStore),
69932
70678
  NotificationCenter.declare(api.settingsStore),
@@ -70398,18 +71144,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70398
71144
  },
70399
71145
  getKeyFrameMediaKey: (trackId) => this.residents.keyFrameKey(trackId),
70400
71146
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
70401
- onSemanticMatch: (match) => this.promoteSemanticRecognition({
70402
- deviceId: match.deviceId,
70403
- trackId: match.trackId,
70404
- timestamp: match.timestamp,
70405
- bbox: match.bbox,
70406
- frameWidth: match.frameWidth,
70407
- frameHeight: match.frameHeight,
70408
- kind: "face",
70409
- score: match.matchScore,
70410
- confidence: match.confidence,
70411
- ...match.frameHandle !== void 0 ? { frameHandle: match.frameHandle } : {}
70412
- }),
71147
+ onSemanticFace: (face) => this.promoteSemanticRecognition({
71148
+ deviceId: face.deviceId,
71149
+ trackId: face.trackId,
71150
+ timestamp: face.timestamp,
71151
+ bbox: face.bbox,
71152
+ frameWidth: face.frameWidth,
71153
+ frameHeight: face.frameHeight,
71154
+ kind: face.identityId !== void 0 ? "face" : "face-visible",
71155
+ score: face.matchScore ?? face.confidence,
71156
+ confidence: face.confidence,
71157
+ ...face.frameHandle !== void 0 ? { frameHandle: face.frameHandle } : {}
71158
+ }, face.identityId !== void 0 ? "identified" : "evidence"),
70413
71159
  onLiveHierarchy: (input) => this.requestTrackHierarchy(input),
70414
71160
  logger: logger.child("FaceRecognizer")
70415
71161
  });
@@ -71565,6 +72311,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71565
72311
  this.packageStillness.forgetDevice(data.deviceId);
71566
72312
  this.packageDropDetector?.forgetDevice(data.deviceId);
71567
72313
  this.lastNonPackageActivityAt.delete(data.deviceId);
72314
+ this.packageCollectors.forgetDevice(data.deviceId);
71568
72315
  this.packageOversizeLogAt.delete(data.deviceId);
71569
72316
  this.overlayState.clearDevice(data.deviceId);
71570
72317
  this.overlaySynthesisWarnAt.delete(data.deviceId);
@@ -71584,6 +72331,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71584
72331
  this.packageStillness.forgetDevice(deviceId);
71585
72332
  this.packageDropDetector?.forgetDevice(deviceId);
71586
72333
  this.lastNonPackageActivityAt.delete(deviceId);
72334
+ this.packageCollectors.forgetDevice(deviceId);
71587
72335
  this.packageOversizeLogAt.delete(deviceId);
71588
72336
  this.overlayState.clearDevice(deviceId);
71589
72337
  this.overlaySynthesisWarnAt.delete(deviceId);
@@ -72467,12 +73215,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72467
73215
  this.residents.markFirstFramePending(deviceId, id);
72468
73216
  this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
72469
73217
  this.residents.setLastFrameAt(deviceId, id, result.timestamp);
72470
- if (frameHandle) firstFrameTargets.push({
73218
+ const birthCapture = decideBirthFirstFrameCapture({
73219
+ hasFrameHandle: frameHandle !== void 0,
73220
+ matchedThisFrame: t.matchedThisFrame
73221
+ });
73222
+ if (birthCapture.capture) firstFrameTargets.push({
72471
73223
  trackId: id,
72472
73224
  timestamp: result.timestamp,
72473
73225
  bbox: { ...t.bbox },
72474
73226
  ...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
72475
73227
  });
73228
+ else log.info("birth firstFrame not captured — retry armed", {
73229
+ tags: { deviceId },
73230
+ meta: {
73231
+ trackId: id,
73232
+ className: t.className,
73233
+ source,
73234
+ refusedBecause: birthCapture.refusedBecause,
73235
+ deferredMs: result.timestamp - (this.trackStore?.peekActive(id)?.firstSeen ?? result.timestamp)
73236
+ }
73237
+ });
72476
73238
  }
72477
73239
  this.ctx.eventBus.emit({
72478
73240
  id: `pa-${randomUUID()}`,
@@ -73409,10 +74171,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73409
74171
  } } : {}
73410
74172
  });
73411
74173
  }
74174
+ if (detailSemanticTrigger(d.className) === "recognition") this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame), "identified");
73412
74175
  }
73413
74176
  const resolved = d.className === "plate" ? this.plateRecognizer?.resolveLabelWithVehicle(d.label, d.score) ?? null : { text: d.label };
73414
74177
  if (resolved !== null && resolved.text !== void 0) {
73415
- if ((await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId)).trackWritten) this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame));
74178
+ const applied = await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId);
74179
+ if (detailSemanticTrigger(d.className) === "label-write" && applied.trackWritten) this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame), "identified");
73416
74180
  }
73417
74181
  } else if (d.className === "plate") this.failureReport.notePlateRead(deviceId, d.bbox === void 0 ? REASON_NO_BBOX : REASON_EMPTY_READ);
73418
74182
  } catch (err) {
@@ -73853,8 +74617,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73853
74617
  * when it wins and the frame's pixels are still reachable. Callers: the
73854
74618
  * FaceRecognizer's per-frame auto-match, and `routeDetailResults`' plate /
73855
74619
  * fine-classification attribution. */
73856
- promoteSemanticRecognition(input) {
73857
- this.recognizedTrackIds.add(input.trackId);
74620
+ promoteSemanticRecognition(input, origin) {
74621
+ if (origin === "identified") this.recognizedTrackIds.add(input.trackId);
73858
74622
  const ctx = this.ctxIfReady;
73859
74623
  if (ctx === null) return;
73860
74624
  promoteSemanticBestFrame({
@@ -75182,6 +75946,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75182
75946
  this.lastNonPackageActivityAt.set(input.deviceId, input.timestamp);
75183
75947
  break;
75184
75948
  }
75949
+ this.packageCollectors.noteFrame({
75950
+ deviceId: input.deviceId,
75951
+ tracked: input.tracked,
75952
+ packageClasses: input.packageClasses,
75953
+ packageZoneIds: input.packageZoneIds,
75954
+ timestamp: input.timestamp,
75955
+ keyFrameMediaKeyFor: (trackId) => this.residents.keyFrameKey(trackId)
75956
+ });
75185
75957
  const sightings = [];
75186
75958
  for (const t of input.tracked) {
75187
75959
  if (!input.packageClasses.has(t.className)) continue;
@@ -75293,10 +76065,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75293
76065
  meta: { entryId: entry.id }
75294
76066
  });
75295
76067
  }).catch(() => void 0);
76068
+ const collector = this.packageCollectors.pickCollector({
76069
+ deviceId: input.deviceId,
76070
+ pickupAt: pickupInstantOf(entry)
76071
+ });
76072
+ if (collector === null) this.ctx.logger.info("package pick-up has no collector in the drop-off zone", {
76073
+ tags,
76074
+ meta: {
76075
+ entryId: entry.id,
76076
+ pickupAt: entry.lastConfirmedAt,
76077
+ departedAt: input.timestamp,
76078
+ presencesKnown: this.packageCollectors.debugPresenceCount(input.deviceId)
76079
+ }
76080
+ });
75296
76081
  this.packageDropDetector.onStationaryChange({
75297
76082
  phase: "departed",
75298
76083
  entry,
75299
- timestamp: input.timestamp
76084
+ timestamp: input.timestamp,
76085
+ ...collector !== null ? { collector } : {}
75300
76086
  });
75301
76087
  }
75302
76088
  for (const entry of outcome.departedWithoutActivity) {
@@ -77421,7 +78207,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77421
78207
  setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch),
77422
78208
  countStaging: (query) => trackStore.countStaging(query)
77423
78209
  },
77424
- logger: this.ctx.logger
78210
+ logger: this.ctx.logger,
78211
+ ...this.debugNoteArchive !== null ? { noteArchive: this.debugNoteArchive } : {}
77425
78212
  }, input);
77426
78213
  this.ctx.logger.info("track operator flags set", {
77427
78214
  tags: { deviceId: input.deviceId },
@@ -77552,6 +78339,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77552
78339
  return this.queryFacade.listOpsLog(input);
77553
78340
  }
77554
78341
  /**
78342
+ * The archived debug notes, newest first (D405).
78343
+ *
78344
+ * Answers from the archive alone — never from the track rows. The point of
78345
+ * the table is that the tracks these notes were written on are gone, so a
78346
+ * read that joined them would return exactly the rows the archive exists to
78347
+ * replace. An archive that has not been built yet answers EMPTY, which is
78348
+ * true: nothing has been archived on this runner.
78349
+ */
78350
+ async listArchivedDebugNotes(input) {
78351
+ return await this.debugNoteArchive?.list(input) ?? [];
78352
+ }
78353
+ /**
77555
78354
  * Track-centric time-based retention (design §5.1). Drains every persisted
77556
78355
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
77557
78356
  * widened cascade — enrolled faces/plates + identity media are exempt (design