@camstack/addon-post-analysis 1.2.211 → 1.2.213

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 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";
1
+ import { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as LabelAttributionSchema, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FailureCounters, h as FULL_IMAGE_BBOX, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as MACRO_LABELS, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-CT2SouNJ.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";
@@ -34836,6 +34836,9 @@ var DEFAULT_TRACKER_CONFIG = {
34836
34836
  var PERSON_CLASS = "person";
34837
34837
  var ANIMAL_CLASS = "animal";
34838
34838
  var MAX_PATH_LENGTH = 300;
34839
+ /** No stationary-held detections on this frame — the default for every caller
34840
+ * that has no gate (replay, tests, the non-pipeline planes). */
34841
+ var EMPTY_INDEX_SET = /* @__PURE__ */ new Set();
34839
34842
  function clamp$1(value, min, max) {
34840
34843
  return Math.max(min, Math.min(max, value));
34841
34844
  }
@@ -35455,13 +35458,15 @@ var SortTracker = class SortTracker {
35455
35458
  * gates) so the two-tier ByteTrack association stays a single implementation.
35456
35459
  * Mutates `used` / `matchedTracks` / `matched` in place.
35457
35460
  */
35458
- greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched) {
35461
+ greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35459
35462
  const pairs = [];
35460
35463
  for (const track of this.tracks) {
35461
35464
  if (matchedTracks.has(track)) continue;
35462
35465
  const pbox = this.predicted(track, timestamp);
35466
+ const confirmed = this.isConfirmedForEmit(track);
35463
35467
  for (const di of candidateIdxs) {
35464
35468
  if (used.has(di)) continue;
35469
+ if (!confirmed && continuationOnly.has(di)) continue;
35465
35470
  const det = detections[di];
35466
35471
  if (!this.associable(track.class, det)) continue;
35467
35472
  const score = iou$3(pbox, det.bbox);
@@ -35482,6 +35487,7 @@ var SortTracker = class SortTracker {
35482
35487
  }
35483
35488
  update(detections, timestamp, frameContext) {
35484
35489
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
35490
+ const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
35485
35491
  const resurrectedScene = /* @__PURE__ */ new Map();
35486
35492
  if (this.config.maxTrackLifetimeMs > 0) {
35487
35493
  const alive = [];
@@ -35518,12 +35524,12 @@ var SortTracker = class SortTracker {
35518
35524
  for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
35519
35525
  else highIdxs.push(di);
35520
35526
  const lowSet = new Set(lowIdxs);
35521
- this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched);
35527
+ this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35522
35528
  const rescuePairs = [];
35523
35529
  for (const track of this.tracks) {
35524
35530
  if (matchedTracks.has(track)) continue;
35525
35531
  for (let di = 0; di < detections.length; di++) {
35526
- if (used.has(di) || lowSet.has(di)) continue;
35532
+ if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35527
35533
  const det = detections[di];
35528
35534
  const gate = this.looseMatch(track, det, timestamp);
35529
35535
  if (gate === null) continue;
@@ -35545,7 +35551,7 @@ var SortTracker = class SortTracker {
35545
35551
  used.add(pair.detIdx);
35546
35552
  this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35547
35553
  }
35548
- if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched);
35554
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35549
35555
  for (const [track, det] of matched) {
35550
35556
  if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, { against: track });
35551
35557
  const prevCenter = bboxCentroid({
@@ -35601,7 +35607,7 @@ var SortTracker = class SortTracker {
35601
35607
  } else surviving.push(track);
35602
35608
  }
35603
35609
  for (let di = 0; di < detections.length; di++) {
35604
- if (used.has(di)) continue;
35610
+ if (used.has(di) || continuationOnly.has(di)) continue;
35605
35611
  const det = detections[di];
35606
35612
  let best;
35607
35613
  let bestScore = -1;
@@ -35669,6 +35675,11 @@ var SortTracker = class SortTracker {
35669
35675
  });
35670
35676
  for (const di of unmatchedIdx) {
35671
35677
  const det = detections[di];
35678
+ if (continuationOnly.has(di)) {
35679
+ this.reportRefusal("stationary-hold", det);
35680
+ used.add(di);
35681
+ continue;
35682
+ }
35672
35683
  if (!this.meetsClassScoreFloor(det)) {
35673
35684
  this.reportRefusal("score-floor", det, {
35674
35685
  threshold: this.config.classMinScores[det.class] ?? 0,
@@ -36407,14 +36418,28 @@ var FrameProcessor = class {
36407
36418
  timestamp
36408
36419
  }) : {
36409
36420
  suppressedIndices: /* @__PURE__ */ new Set(),
36421
+ continuableIndices: /* @__PURE__ */ new Set(),
36410
36422
  confirmed: [],
36411
36423
  woken: []
36412
36424
  };
36413
- const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
36425
+ const trackerInput = [];
36426
+ const continuationOnly = /* @__PURE__ */ new Set();
36427
+ const continuableBoxes = /* @__PURE__ */ new Set();
36428
+ for (let i = 0; i < filteredDetections.length; i++) {
36429
+ const det = filteredDetections[i];
36430
+ if (gate.suppressedIndices.has(i)) {
36431
+ if (!gate.continuableIndices.has(i)) continue;
36432
+ continuationOnly.add(trackerInput.length);
36433
+ continuableBoxes.add(det.bbox);
36434
+ }
36435
+ trackerInput.push(det);
36436
+ }
36414
36437
  const trackedDetections = this.tracker.update(trackerInput, timestamp, {
36415
36438
  frameWidth,
36416
- frameHeight
36439
+ frameHeight,
36440
+ continuationOnly
36417
36441
  });
36442
+ const stationaryContinued = continuableBoxes.size === 0 ? 0 : trackedDetections.filter((t) => t.matchedThisFrame !== false && continuableBoxes.has(t.bbox)).length;
36418
36443
  const riderFolds = foldByVehicleBbox.size === 0 ? EMPTY_RIDER_FOLDS : [...foldByVehicleBbox].map(([bbox, fold]) => {
36419
36444
  const td = trackedDetections.find((t) => t.bbox === bbox && t.matchedThisFrame !== false);
36420
36445
  return td !== void 0 ? {
@@ -36618,6 +36643,7 @@ var FrameProcessor = class {
36618
36643
  rawTrackedDetections: trackedDetections,
36619
36644
  stationaryConfirmed: gate.confirmed,
36620
36645
  stationaryWoken: gate.woken,
36646
+ stationaryContinued,
36621
36647
  riderFolds
36622
36648
  };
36623
36649
  }
@@ -53358,6 +53384,95 @@ var BirthEvidenceStore = class {
53358
53384
  }
53359
53385
  };
53360
53386
  //#endregion
53387
+ //#region src/pipeline-analytics/pipeline/birth-decision-record.ts
53388
+ /**
53389
+ * birth-decision-record — turn one resolved birth verdict into the row that is
53390
+ * kept (D409).
53391
+ *
53392
+ * PURE, and separated from the store for the usual reason: the two rules worth
53393
+ * getting right — what counts as the fail-open, and when the latency proxy is
53394
+ * allowed to report a number — are decisions, not I/O, and a decision that only
53395
+ * exists inside a SQLite call cannot be tested or argued with.
53396
+ *
53397
+ * MEASUREMENT ONLY. Nothing here gates a birth, changes a verdict, or feeds
53398
+ * back into the frame path. The caller writes the row after the verdict is
53399
+ * already taken.
53400
+ */
53401
+ /**
53402
+ * The verdict as the ledger reports it.
53403
+ *
53404
+ * `exhausted-fallback` is a CONFIRMATION and is counted apart from one, because
53405
+ * a birth nobody ever looked at and a birth measured and passed are the same
53406
+ * row in every existing aggregate — which is what made "the gate stopped
53407
+ * failing open" and "the gate now suppresses everything it cannot measure"
53408
+ * indistinguishable. Exhaustion attached to a SUPPRESSION is meaningless
53409
+ * (exhaustion only ever waves a birth through) and is discarded rather than
53410
+ * inventing a fourth member.
53411
+ */
53412
+ function verdictFor(input) {
53413
+ if (!input.confirmed) return "suppressed";
53414
+ return input.exhaustedFallback ? "exhausted-fallback" : "confirmed";
53415
+ }
53416
+ /**
53417
+ * The latency proxy, and the three ways it declines to produce a number.
53418
+ *
53419
+ * It reports `null` — never a fabricated or clamped value — when there is no
53420
+ * track (a suppressed birth), no motion reference on this runner, or a
53421
+ * reference older than {@link MAX_BIRTH_LATENCY_PROXY_MS}. That last case is
53422
+ * the one that matters: on a busy scene the burst never closes and the onset is
53423
+ * minutes old, and a minutes-long "birth latency" would be read as the
53424
+ * detector's fault when it is the proxy's. When the reference is refused, the
53425
+ * onset and the burst index go with it — reporting an index against a reference
53426
+ * this row does not carry would be an invitation to join them back up.
53427
+ *
53428
+ * A NEGATIVE result is returned as measured. The analyzer's pixel-count floor
53429
+ * can be crossed after the detector's confidence floor for a subject entering
53430
+ * slowly at the frame edge, and clamping that away would delete precisely the
53431
+ * evidence that the proxy is unreliable. See `BirthDecisionRecordSchema` for
53432
+ * the full error bars.
53433
+ */
53434
+ function latencyFor(input, firstSeen) {
53435
+ const onset = input.motionOnsetAtMs;
53436
+ if (firstSeen === null || onset === null) return {
53437
+ motionOnsetAt: null,
53438
+ birthLatencyMs: null,
53439
+ birthIndexInBurst: null
53440
+ };
53441
+ if (firstSeen - onset > 6e4) return {
53442
+ motionOnsetAt: null,
53443
+ birthLatencyMs: null,
53444
+ birthIndexInBurst: null
53445
+ };
53446
+ return {
53447
+ motionOnsetAt: onset,
53448
+ birthLatencyMs: firstSeen - onset,
53449
+ birthIndexInBurst: input.birthIndexInBurst
53450
+ };
53451
+ }
53452
+ /** Build the durable row for one resolved birth decision. */
53453
+ function buildBirthDecisionRecord(input) {
53454
+ const verdict = verdictFor(input);
53455
+ const firstSeen = verdict === "suppressed" ? null : input.firstSeen;
53456
+ const latency = latencyFor(input, firstSeen);
53457
+ return {
53458
+ at: input.decidedAtMs,
53459
+ deviceId: input.deviceId,
53460
+ sourceTrackId: input.trackId,
53461
+ className: input.className,
53462
+ verdict,
53463
+ reason: input.reason ?? null,
53464
+ attempts: input.attempts,
53465
+ deferredForMs: input.deferredForMs,
53466
+ decidedOnCoastedFrame: input.matchedThisFrame === false,
53467
+ birthEvidenceAvailable: input.birthEvidenceAvailable,
53468
+ decidedByBirthEvidence: input.decidedByBirthEvidence,
53469
+ bestScore: input.bestScore ?? null,
53470
+ appliedMinConfidence: input.appliedMinConfidence ?? null,
53471
+ firstSeen,
53472
+ ...latency
53473
+ };
53474
+ }
53475
+ //#endregion
53361
53476
  //#region src/pipeline-analytics/pipeline/deferred-births.ts
53362
53477
  var DeferredBirthRegistry = class {
53363
53478
  byDevice = /* @__PURE__ */ new Map();
@@ -53432,6 +53547,44 @@ var DeferredBirthRegistry = class {
53432
53547
  }
53433
53548
  };
53434
53549
  //#endregion
53550
+ //#region src/pipeline-analytics/pipeline/motion-onset-registry.ts
53551
+ var MotionOnsetRegistry = class {
53552
+ byDevice = /* @__PURE__ */ new Map();
53553
+ /**
53554
+ * Motion went off→on at `atMs`. Opens a new burst, discarding the previous
53555
+ * one's birth count — a new burst is a new subject population.
53556
+ */
53557
+ noteRisingEdge(deviceId, atMs) {
53558
+ this.byDevice.set(deviceId, {
53559
+ onsetAtMs: atMs,
53560
+ births: 0
53561
+ });
53562
+ }
53563
+ /** The open burst's onset, or `null` for a device with no reference. */
53564
+ onsetFor(deviceId) {
53565
+ return this.byDevice.get(deviceId)?.onsetAtMs ?? null;
53566
+ }
53567
+ /**
53568
+ * Count one birth decision against this device's burst and return its 0-based
53569
+ * index — `null` when there is no burst to count it against.
53570
+ *
53571
+ * Index 0 is the only index at which the burst plausibly belongs to the same
53572
+ * subject as the birth; the ledger records the index rather than filtering
53573
+ * here, so the reader can see how often it is not 0.
53574
+ */
53575
+ noteBirth(deviceId) {
53576
+ const burst = this.byDevice.get(deviceId);
53577
+ if (burst === void 0) return null;
53578
+ const index = burst.births;
53579
+ burst.births += 1;
53580
+ return index;
53581
+ }
53582
+ /** Drop a device's reference (device removed / pipeline reset). */
53583
+ forgetDevice(deviceId) {
53584
+ this.byDevice.delete(deviceId);
53585
+ }
53586
+ };
53587
+ //#endregion
53435
53588
  //#region src/pipeline-analytics/pipeline/detail-semantic-gate.ts
53436
53589
  /** The detail `className` whose result IS the recognition. */
53437
53590
  var PLATE_CLASS = "plate";
@@ -54788,6 +54941,31 @@ function thumbnailWouldShowParkedNotSubject(input) {
54788
54941
  * dropped, against every entry that did NOT wake this frame. Woken entries are
54789
54942
  * excluded so a departure still releases its object — a car that drives away is
54790
54943
  * noticed on the frame its second read lands, exactly as before.
54944
+ *
54945
+ * ## A weak hold may CONTINUE a track; only a strong read is the object (D410)
54946
+ *
54947
+ * D275 predicted that a moving vehicle overlapping a parked one "loses a frame
54948
+ * or two while it passes" and coasts through on `maxAge`. Measured on device
54949
+ * 617 on 2026-09-08 it lost the whole pass: the parked cars sit at the frame
54950
+ * edge, so a car driving across their front is held on every frame from the
54951
+ * first overlap to the moment it leaves the frame, and the tracker — which had
54952
+ * followed it for 5–10 frames — coasts `maxMissedMs` on nothing and drops it.
54953
+ * Four operator-marked tracks, 1.7–3.5 s old, died on exactly that geometry;
54954
+ * the sweep for the window reported `reach: 0`, so the hold was pass 1's: the
54955
+ * mover occludes the parked car, the detector reports one vehicle box, and it
54956
+ * is the entry's best match.
54957
+ *
54958
+ * The hold is right — that box covers the parked spot and must not OPEN a
54959
+ * track, which is the whole of the operator's requirement — but it is not the
54960
+ * parked car: a strong read (IoU ≥ `suppressIou`) is the parked object unmoved,
54961
+ * and everything below it is a box that merely overlaps the spot (swallow,
54962
+ * jitter, departing). So `suppressedIndices` still names every box that may
54963
+ * not spawn, and `continuableIndices` names the weak subset the tracker may use
54964
+ * to CONTINUE a track it has already confirmed — through its strict
54965
+ * predicted-box association only, never a rescue or a resurrection. The reach
54966
+ * pass reports strength the same way. Nothing about confirmation, anchoring or
54967
+ * departure changes: a weak hold confirms and never re-anchors, exactly as
54968
+ * before.
54791
54969
  */
54792
54970
  function partitionDetectionsAgainstRegistry(input) {
54793
54971
  const { entries, detections, config, now } = input;
@@ -54795,6 +54973,7 @@ function partitionDetectionsAgainstRegistry(input) {
54795
54973
  const departure = input.departure ?? DEFAULT_DEPARTURE_CONFIG;
54796
54974
  const warmUp = input.warmUp ?? false;
54797
54975
  const suppressed = /* @__PURE__ */ new Set();
54976
+ const continuable = /* @__PURE__ */ new Set();
54798
54977
  const confirmed = [];
54799
54978
  const woken = [];
54800
54979
  const strikeChanges = [];
@@ -54834,6 +55013,7 @@ function partitionDetectionsAgainstRegistry(input) {
54834
55013
  }
54835
55014
  if (holdIdx >= 0) {
54836
55015
  suppressed.add(holdIdx);
55016
+ if (!strong) continuable.add(holdIdx);
54837
55017
  confirmed.push({
54838
55018
  entryId: entry.id,
54839
55019
  className: entry.className,
@@ -54873,6 +55053,7 @@ function partitionDetectionsAgainstRegistry(input) {
54873
55053
  if (warmUp) {
54874
55054
  warmUpDepartureReads += 1;
54875
55055
  suppressed.add(bestIdx);
55056
+ continuable.add(bestIdx);
54876
55057
  continue;
54877
55058
  }
54878
55059
  if (strike === void 0) {
@@ -54885,6 +55066,7 @@ function partitionDetectionsAgainstRegistry(input) {
54885
55066
  reason: "armed"
54886
55067
  });
54887
55068
  suppressed.add(bestIdx);
55069
+ continuable.add(bestIdx);
54888
55070
  continue;
54889
55071
  }
54890
55072
  if (strikeAgeMs >= departure.confirmMinGapMs) {
@@ -54905,6 +55087,7 @@ function partitionDetectionsAgainstRegistry(input) {
54905
55087
  reason: "held"
54906
55088
  });
54907
55089
  suppressed.add(bestIdx);
55090
+ continuable.add(bestIdx);
54908
55091
  }
54909
55092
  const wokenIds = woken.length === 0 ? EMPTY_IDS : new Set(woken.map((w) => w.entryId));
54910
55093
  let reachSuppressedCount = 0;
@@ -54915,14 +55098,17 @@ function partitionDetectionsAgainstRegistry(input) {
54915
55098
  for (let ei = 0; ei < entries.length; ei++) {
54916
55099
  const entry = entries[ei];
54917
55100
  if (entryClasses[ei] !== detClass || wokenIds.has(entry.id)) continue;
54918
- if (!parkedEntryHoldsDetection(entry, det.bbox, config).holds) continue;
55101
+ const held = parkedEntryHoldsDetection(entry, det.bbox, config);
55102
+ if (!held.holds) continue;
54919
55103
  suppressed.add(di);
55104
+ if (!held.strong) continuable.add(di);
54920
55105
  reachSuppressedCount += 1;
54921
55106
  break;
54922
55107
  }
54923
55108
  }
54924
55109
  return {
54925
55110
  suppressedIndices: suppressed,
55111
+ continuableIndices: continuable,
54926
55112
  confirmed,
54927
55113
  woken,
54928
55114
  strikeChanges,
@@ -56546,6 +56732,10 @@ var StationaryObjectRegistry = class {
56546
56732
  */
56547
56733
  suppressedSinceSweep = /* @__PURE__ */ new Map();
56548
56734
  reachSuppressedSinceSweep = /* @__PURE__ */ new Map();
56735
+ /** Weak holds handed to the tracker as continuable (D410), per device. */
56736
+ continuableSinceSweep = /* @__PURE__ */ new Map();
56737
+ /** Of those, the ones the tracker reports actually continued a track. */
56738
+ continuedSinceSweep = /* @__PURE__ */ new Map();
56549
56739
  constructor(deps) {
56550
56740
  this.logger = deps.logger;
56551
56741
  this.ledger = new DurableLedger({
@@ -56629,6 +56819,7 @@ var StationaryObjectRegistry = class {
56629
56819
  const entries = this.list(deviceId);
56630
56820
  if (entries.length === 0) return {
56631
56821
  suppressedIndices: /* @__PURE__ */ new Set(),
56822
+ continuableIndices: /* @__PURE__ */ new Set(),
56632
56823
  confirmed: [],
56633
56824
  woken: [],
56634
56825
  reachSuppressedCount: 0
@@ -56649,14 +56840,23 @@ var StationaryObjectRegistry = class {
56649
56840
  if (result.suppressedIndices.size > 0) {
56650
56841
  this.suppressedSinceSweep.set(deviceId, (this.suppressedSinceSweep.get(deviceId) ?? 0) + result.suppressedIndices.size);
56651
56842
  if (result.reachSuppressedCount > 0) this.reachSuppressedSinceSweep.set(deviceId, (this.reachSuppressedSinceSweep.get(deviceId) ?? 0) + result.reachSuppressedCount);
56843
+ if (result.continuableIndices.size > 0) this.continuableSinceSweep.set(deviceId, (this.continuableSinceSweep.get(deviceId) ?? 0) + result.continuableIndices.size);
56652
56844
  }
56653
56845
  return {
56654
56846
  suppressedIndices: result.suppressedIndices,
56847
+ continuableIndices: result.continuableIndices,
56655
56848
  confirmed: result.confirmed,
56656
56849
  woken: result.woken,
56657
56850
  reachSuppressedCount: result.reachSuppressedCount
56658
56851
  };
56659
56852
  }
56853
+ /** The tracker's answer to {@link StationaryFrameFilterResult.continuableIndices}:
56854
+ * how many of this frame's weak holds continued a confirmed track (D410).
56855
+ * Counted here so the sweep line can report it beside `continuable`. */
56856
+ noteContinued(deviceId, count) {
56857
+ if (count <= 0) return;
56858
+ this.continuedSinceSweep.set(deviceId, (this.continuedSinceSweep.get(deviceId) ?? 0) + count);
56859
+ }
56660
56860
  /** Pending departure strikes for a device — the read-only diagnostic's whole
56661
56861
  * reason to exist. Ordered oldest first so "what has been waiting" reads
56662
56862
  * straight off the top. */
@@ -56795,6 +56995,8 @@ var StationaryObjectRegistry = class {
56795
56995
  absorbed,
56796
56996
  suppressed,
56797
56997
  reach: this.reachSuppressedSinceSweep.get(deviceId) ?? 0,
56998
+ continuable: this.continuableSinceSweep.get(deviceId) ?? 0,
56999
+ continued: this.continuedSinceSweep.get(deviceId) ?? 0,
56798
57000
  entries: this.count(deviceId)
56799
57001
  }
56800
57002
  });
@@ -56802,6 +57004,8 @@ var StationaryObjectRegistry = class {
56802
57004
  this.absorbedSinceSweep.clear();
56803
57005
  this.suppressedSinceSweep.clear();
56804
57006
  this.reachSuppressedSinceSweep.clear();
57007
+ this.continuableSinceSweep.clear();
57008
+ this.continuedSinceSweep.clear();
56805
57009
  for (const [deviceId, counters] of this.strikeCountersByDevice) this.logger.info("stationary departure strikes", {
56806
57010
  tags: { deviceId },
56807
57011
  meta: {
@@ -59960,6 +60164,332 @@ var IdentityStore = class {
59960
60164
  }
59961
60165
  };
59962
60166
  //#endregion
60167
+ //#region src/pipeline-analytics/store/birth-decision-ledger-store.ts
60168
+ /**
60169
+ * BirthDecisionLedgerStore — the durable record of every track-birth verdict
60170
+ * (D409).
60171
+ *
60172
+ * A declared SQL-backed collection, like the events ops-log and the debug-note
60173
+ * archive next door, and for the same reason: pipeline-analytics already owns
60174
+ * SQLite collections, and an undeclared collection crash-loops the runner. MUST
60175
+ * be declared in `onInitialize` before the first write.
60176
+ *
60177
+ * Collection name: pipeline-analytics:birth-decision-ledger
60178
+ *
60179
+ * ── Why a table rather than a log line ─────────────────────────────────────
60180
+ * The gate ALREADY logs every decision. `logs.query` is an in-memory ring that
60181
+ * measured 40 000 entries covering 38 MINUTES of fleet traffic on 2026-09-08 —
60182
+ * so a fourteenth field on that line answers nothing the next morning, and the
60183
+ * questions this data exists for ("what fraction of births on 617 were decided
60184
+ * on a coasted frame, over the week the operator complained about") are all
60185
+ * multi-day. A previous investigation in this repo was blocked for exactly
60186
+ * this.
60187
+ *
60188
+ * ── Why it is safe on the write path ──────────────────────────────────────
60189
+ * ONE INSERT PER BIRTH DECISION. Not per frame, not periodic, and never an
60190
+ * update of an existing row. Measured fleet-wide on the live hub, 2026-09-08:
60191
+ * 31 decided births in 38 minutes = ~49/hour = 0.014 writes/s. The write
60192
+ * amplification the runtime-state guard exists to prevent
60193
+ * (`scripts/check-runtime-state-durability.ts`: 12-19 writes/s rewriting
60194
+ * ~2.6-5 GB/day to maintain 161 KB) is three orders of magnitude above this and
60195
+ * is a different shape entirely — that one is a periodic REWRITE of a slice
60196
+ * whose values do not change. Nothing here advances a clock under an unchanged
60197
+ * value, because every row is new.
60198
+ *
60199
+ * ── Two properties, stated once ───────────────────────────────────────────
60200
+ * - **It is not track-owned.** A SUPPRESSED birth never had a track; a
60201
+ * confirmed one is expected to age out long before this row. The column is
60202
+ * `sourceTrackId` and not `trackId` so the ownership derivation in
60203
+ * `collection-classification.ts` cannot see it and cascade it away.
60204
+ * - **It is bounded by COUNT, not by age.** A miss rate needs a denominator
60205
+ * over whatever window the table holds; a quiet fleet should keep more of
60206
+ * that window, not less. See {@link MAX_BIRTH_DECISION_RECORDS}.
60207
+ *
60208
+ * MEASUREMENT ONLY. Nothing reads this on the frame path; nothing gates on it.
60209
+ */
60210
+ /**
60211
+ * @durable class=ledger owner=pipeline-analytics
60212
+ * write="one row per track-birth VERDICT — appended by the birth loop in
60213
+ * `processFrame` the instant the confirmation gate's verdict resolves
60214
+ * (confirmed / suppressed / exhausted fail-open). Per BIRTH, never per
60215
+ * frame: measured at ~49 rows/hour fleet-wide on 2026-09-08. Append-only,
60216
+ * never updated. BEST-EFFORT — it logs and swallows, because a failed
60217
+ * measurement must not fail the birth it measures."
60218
+ * retention="NOT track retention and NOT an age clock — a miss rate needs
60219
+ * whatever window the table holds, and an age sweep would shorten it on a
60220
+ * quiet fleet exactly when more history is affordable. Bounded by ROW
60221
+ * COUNT (MAX_BIRTH_DECISION_RECORDS, 30000, fleet-wide), oldest `at`
60222
+ * first, trimmed on append. ~6 MB at the ceiling."
60223
+ */
60224
+ var BIRTH_DECISION_LEDGER_COLLECTION = "pipeline-analytics:birth-decision-ledger";
60225
+ var BIRTH_DECISION_LEDGER_COLUMNS = [
60226
+ {
60227
+ name: "id",
60228
+ type: "TEXT",
60229
+ primaryKey: true,
60230
+ notNull: true
60231
+ },
60232
+ {
60233
+ name: "at",
60234
+ type: "INTEGER",
60235
+ notNull: true
60236
+ },
60237
+ {
60238
+ name: "deviceId",
60239
+ type: "INTEGER",
60240
+ notNull: true
60241
+ },
60242
+ {
60243
+ name: "sourceTrackId",
60244
+ type: "TEXT",
60245
+ notNull: true
60246
+ },
60247
+ {
60248
+ name: "className",
60249
+ type: "TEXT",
60250
+ notNull: true
60251
+ },
60252
+ {
60253
+ name: "verdict",
60254
+ type: "TEXT",
60255
+ notNull: true
60256
+ },
60257
+ {
60258
+ name: "reason",
60259
+ type: "TEXT"
60260
+ },
60261
+ {
60262
+ name: "attempts",
60263
+ type: "INTEGER",
60264
+ notNull: true
60265
+ },
60266
+ {
60267
+ name: "deferredForMs",
60268
+ type: "INTEGER",
60269
+ notNull: true
60270
+ },
60271
+ {
60272
+ name: "decidedOnCoastedFrame",
60273
+ type: "BOOLEAN",
60274
+ notNull: true
60275
+ },
60276
+ {
60277
+ name: "birthEvidenceAvailable",
60278
+ type: "BOOLEAN",
60279
+ notNull: true
60280
+ },
60281
+ {
60282
+ name: "decidedByBirthEvidence",
60283
+ type: "BOOLEAN",
60284
+ notNull: true
60285
+ },
60286
+ {
60287
+ name: "bestScore",
60288
+ type: "REAL"
60289
+ },
60290
+ {
60291
+ name: "appliedMinConfidence",
60292
+ type: "REAL"
60293
+ },
60294
+ {
60295
+ name: "firstSeen",
60296
+ type: "INTEGER"
60297
+ },
60298
+ {
60299
+ name: "motionOnsetAt",
60300
+ type: "INTEGER"
60301
+ },
60302
+ {
60303
+ name: "birthLatencyMs",
60304
+ type: "INTEGER"
60305
+ },
60306
+ {
60307
+ name: "birthIndexInBurst",
60308
+ type: "INTEGER"
60309
+ }
60310
+ ];
60311
+ var BIRTH_DECISION_LEDGER_INDEXES = [{
60312
+ name: "idx_birthdecision_at",
60313
+ columns: ["at"]
60314
+ }, {
60315
+ name: "idx_birthdecision_device_at",
60316
+ columns: ["deviceId", "at"]
60317
+ }];
60318
+ var BirthDecisionLedgerStore = class {
60319
+ store;
60320
+ logger;
60321
+ newId;
60322
+ maxRows;
60323
+ trimEvery;
60324
+ /** Rows appended since the last trim — see {@link trim}. */
60325
+ sinceTrim = 0;
60326
+ constructor(deps) {
60327
+ this.store = deps.store;
60328
+ this.logger = deps.logger;
60329
+ this.newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
60330
+ this.maxRows = deps.maxRows ?? 3e4;
60331
+ this.trimEvery = deps.trimEvery ?? DEFAULT_TRIM_EVERY;
60332
+ }
60333
+ /** The ceiling a store built without an override enforces. */
60334
+ static defaultMaxRows() {
60335
+ return MAX_BIRTH_DECISION_RECORDS;
60336
+ }
60337
+ static async declare(store) {
60338
+ await store.declareCollection.mutate({
60339
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60340
+ columns: [...BIRTH_DECISION_LEDGER_COLUMNS],
60341
+ indexes: [...BIRTH_DECISION_LEDGER_INDEXES]
60342
+ });
60343
+ }
60344
+ /**
60345
+ * Append one birth decision. NEVER THROWS.
60346
+ *
60347
+ * Best-effort, unlike the debug-note archive and deliberately so: that table
60348
+ * holds the only copy of something a PERSON typed, and this one holds a
60349
+ * measurement the system takes ~49 times an hour. A birth must not fail — nor
60350
+ * be delayed by a retry — because bookkeeping could not be written. The
60351
+ * failure is logged with `tags: { deviceId }` so a camera whose rows are
60352
+ * silently missing is attributable rather than an unexplained hole in the
60353
+ * denominator.
60354
+ */
60355
+ async append(draft) {
60356
+ try {
60357
+ const row = BirthDecisionRecordSchema.parse({
60358
+ id: this.newId(),
60359
+ ...draft
60360
+ });
60361
+ await this.store.insert.mutate({
60362
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60363
+ record: {
60364
+ id: row.id,
60365
+ data: {
60366
+ at: row.at,
60367
+ deviceId: row.deviceId,
60368
+ sourceTrackId: row.sourceTrackId,
60369
+ className: row.className,
60370
+ verdict: row.verdict,
60371
+ reason: row.reason,
60372
+ attempts: row.attempts,
60373
+ deferredForMs: row.deferredForMs,
60374
+ decidedOnCoastedFrame: row.decidedOnCoastedFrame,
60375
+ birthEvidenceAvailable: row.birthEvidenceAvailable,
60376
+ decidedByBirthEvidence: row.decidedByBirthEvidence,
60377
+ bestScore: row.bestScore,
60378
+ appliedMinConfidence: row.appliedMinConfidence,
60379
+ firstSeen: row.firstSeen,
60380
+ motionOnsetAt: row.motionOnsetAt,
60381
+ birthLatencyMs: row.birthLatencyMs,
60382
+ birthIndexInBurst: row.birthIndexInBurst
60383
+ }
60384
+ }
60385
+ });
60386
+ this.sinceTrim += 1;
60387
+ await this.trim();
60388
+ } catch (err) {
60389
+ this.logger.warn("birth-decision ledger append failed — this decision is not counted", {
60390
+ tags: { deviceId: draft.deviceId },
60391
+ meta: {
60392
+ trackId: draft.sourceTrackId,
60393
+ verdict: draft.verdict,
60394
+ error: err instanceof Error ? err.message : String(err)
60395
+ }
60396
+ });
60397
+ }
60398
+ }
60399
+ /**
60400
+ * Hold the table at its ceiling, oldest first.
60401
+ *
60402
+ * NOT an age-keyed sweep and must never become one: the boundary is computed
60403
+ * from the POSITION of the excess rows, never from a clock. Rows sharing the
60404
+ * boundary timestamp go together, which at this write rate is a rounding
60405
+ * error against a 30 000-row ceiling.
60406
+ *
60407
+ * AMORTISED. The debug-note archive counts on every append because it writes
60408
+ * a few rows a week; this one writes ~49 an hour, and a `count` round trip per
60409
+ * birth would triple the ledger's own store traffic to answer a question whose
60410
+ * answer only changes on the row that crosses the ceiling. Checking once per
60411
+ * {@link DEFAULT_TRIM_EVERY} appends bounds the overshoot at that many rows —
60412
+ * 0.3% of the ceiling — for two hours' delay in enforcing it.
60413
+ */
60414
+ async trim() {
60415
+ if (this.sinceTrim < this.trimEvery) return;
60416
+ this.sinceTrim = 0;
60417
+ try {
60418
+ const held = await this.store.count.query({ collection: BIRTH_DECISION_LEDGER_COLLECTION });
60419
+ const excess = held - this.maxRows;
60420
+ if (excess <= 0) return;
60421
+ const boundary = (await this.store.query.query({
60422
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60423
+ filter: {
60424
+ orderBy: {
60425
+ field: "at",
60426
+ direction: "asc"
60427
+ },
60428
+ limit: excess
60429
+ },
60430
+ columns: ["at"]
60431
+ })).reduce((acc, r) => {
60432
+ const at = r.data["at"];
60433
+ return typeof at === "number" && at > acc ? at : acc;
60434
+ }, 0);
60435
+ if (boundary === 0) return;
60436
+ const { deleted } = await this.store.deleteWhere.mutate({
60437
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60438
+ filter: { whereBetween: { at: [0, boundary] } }
60439
+ });
60440
+ this.logger.info("birth-decision ledger trimmed to its ceiling", { meta: {
60441
+ held,
60442
+ ceiling: this.maxRows,
60443
+ deleted
60444
+ } });
60445
+ } catch (err) {
60446
+ this.logger.warn("birth-decision ledger trim failed — the ledger will keep growing", { meta: {
60447
+ ceiling: this.maxRows,
60448
+ error: err instanceof Error ? err.message : String(err)
60449
+ } });
60450
+ }
60451
+ }
60452
+ /** Birth decisions, newest first, optionally scoped by camera / verdict / age. */
60453
+ async list(query) {
60454
+ const where = {};
60455
+ if (query.deviceId !== void 0) where["deviceId"] = query.deviceId;
60456
+ if (query.verdict !== void 0) where["verdict"] = query.verdict;
60457
+ const filter = {
60458
+ orderBy: {
60459
+ field: "at",
60460
+ direction: "desc"
60461
+ },
60462
+ limit: query.limit ?? 500
60463
+ };
60464
+ if (Object.keys(where).length > 0) filter["where"] = where;
60465
+ if (query.since !== void 0) filter["whereBetween"] = { at: [query.since, Number.MAX_SAFE_INTEGER] };
60466
+ const rows = await this.store.query.query({
60467
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60468
+ filter
60469
+ });
60470
+ const out = [];
60471
+ for (const r of rows) {
60472
+ const parsed = BirthDecisionRecordSchema.safeParse({
60473
+ id: r.id,
60474
+ ...r.data
60475
+ });
60476
+ if (parsed.success) out.push(parsed.data);
60477
+ else this.logger.debug("birth-decision ledger: skipped a malformed row", { meta: { id: r.id } });
60478
+ }
60479
+ return out;
60480
+ }
60481
+ };
60482
+ /**
60483
+ * How many appends between ceiling checks, by default. See
60484
+ * {@link BirthDecisionLedgerStore.trim}.
60485
+ *
60486
+ * 100 rows is two hours of measured fleet traffic and 0.3% of the ceiling —
60487
+ * small enough that the table's real size never meaningfully exceeds its stated
60488
+ * bound, large enough that the ledger costs one `count` per two hours instead of
60489
+ * one per birth.
60490
+ */
60491
+ var DEFAULT_TRIM_EVERY = 100;
60492
+ //#endregion
59963
60493
  //#region src/pipeline-analytics/store/debug-note-archive-store.ts
59964
60494
  /**
59965
60495
  * DebugNoteArchiveStore — the durable corpus of what operators asked to be
@@ -61585,6 +62115,13 @@ var ANALYTICS_COLLECTIONS = [
61585
62115
  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
62116
  }
61587
62117
  },
62118
+ {
62119
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
62120
+ classification: {
62121
+ kind: "never-orphaned",
62122
+ why: "the birth-decision ledger (D409) — a measurement of what the confirmation gate decided, not analytics data about a track. Most of its rows describe births that were SUPPRESSED and therefore never had a track at all, and the confirmed ones are expected to outlive theirs: a miss rate needs its denominator for as long as the table holds, and cascading rows away with their tracks would silently shorten the window while leaving the numerator behind. 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."
62123
+ }
62124
+ },
61588
62125
  {
61589
62126
  collection: STATIONARY_COLLECTION,
61590
62127
  classification: {
@@ -69361,6 +69898,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69361
69898
  * operator asked for never depends on this addon's own bookkeeping.
69362
69899
  */
69363
69900
  debugNoteArchive = null;
69901
+ /** The durable birth-decision ledger (D409) — measurement, never a gate. */
69902
+ birthDecisionLedger = null;
69903
+ /** Has the "no ledger on this runner" warning already been said? Once per
69904
+ * process: the condition is a property of the runner, not of the birth. */
69905
+ birthLedgerAbsenceLogged = false;
69364
69906
  /** Event-media relocation engine (entity-routing Phase 4). */
69365
69907
  mediaRelocate = null;
69366
69908
  mediaLocationStorage = null;
@@ -69799,6 +70341,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69799
70341
  /** Births the gate could not MEASURE, awaiting another look on a later frame. */
69800
70342
  deferredBirths = new DeferredBirthRegistry();
69801
70343
  /**
70344
+ * The last motion RISING EDGE per device — the reference instant the
70345
+ * birth-latency proxy is measured from (D409). Measurement only; nothing
70346
+ * gates on it. Fed by the two motion handlers, which are subscribed on the
70347
+ * SAME designated post-processing node as the frame path, so a camera whose
70348
+ * births land here always has its motion mirror here too.
70349
+ */
70350
+ motionOnsets = new MotionOnsetRegistry();
70351
+ /**
69802
70352
  * The pixels each deferred birth arrived with — a RESERVE per camera, evicted
69803
70353
  * by age inside that camera's own quota (D379).
69804
70354
  *
@@ -70353,6 +70903,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70353
70903
  store: api.settingsStore,
70354
70904
  logger: logger.child("debug-note-archive")
70355
70905
  });
70906
+ this.birthDecisionLedger = new BirthDecisionLedgerStore({
70907
+ store: api.settingsStore,
70908
+ logger: logger.child("birth-decision-ledger")
70909
+ });
70356
70910
  {
70357
70911
  const designated = await step("postProcessingNodeState", () => this.postProcessingNodeState.get());
70358
70912
  this.isPostProcessingNode = ownNodeId === designated;
@@ -70673,6 +71227,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70673
71227
  RetrainAnnotationStore.declare(api.settingsStore),
70674
71228
  OpsLogStore.declare(api.settingsStore),
70675
71229
  DebugNoteArchiveStore.declare(api.settingsStore),
71230
+ BirthDecisionLedgerStore.declare(api.settingsStore),
70676
71231
  AnalyticsLts.declare(api.settingsStore),
70677
71232
  SceneStore.declare(api.settingsStore),
70678
71233
  NotificationCenter.declare(api.settingsStore),
@@ -72337,6 +72892,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72337
72892
  this.overlaySynthesisWarnAt.delete(deviceId);
72338
72893
  this.forgetDeviceProcessors(deviceId);
72339
72894
  this.levelStateByDevice.delete(deviceId);
72895
+ this.motionOnsets.forgetDevice(deviceId);
72340
72896
  this.audioConfirmByDevice.delete(deviceId);
72341
72897
  this.lastTrackActivityMs.delete(deviceId);
72342
72898
  this.motionEventSnapshots?.forgetDevice(deviceId);
@@ -73020,6 +73576,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73020
73576
  woken: result.stationaryWoken,
73021
73577
  timestamp: result.timestamp
73022
73578
  });
73579
+ if (this.stationaryRegistry && result.stationaryContinued > 0) this.stationaryRegistry.noteContinued(deviceId, result.stationaryContinued);
73023
73580
  const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
73024
73581
  const stationaryAsTracked = stationaryViews.map((v) => ({
73025
73582
  trackId: `stationary:${v.id}`,
@@ -73123,8 +73680,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73123
73680
  if (t.birthEvidence === void 0) continue;
73124
73681
  this.birthEvidence.remember(key, id, Buffer.from(t.birthEvidence.jpegBase64, "base64"), exhaustionNowMs);
73125
73682
  }
73126
- const outcome = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => {
73683
+ const withBirthEvidence = /* @__PURE__ */ new Set();
73684
+ const run = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => {
73127
73685
  const evidenceCrop = this.birthEvidence.get(key, id, exhaustionNowMs);
73686
+ if (evidenceCrop !== null) withBirthEvidence.add(id);
73128
73687
  return {
73129
73688
  trackId: id,
73130
73689
  className: t.className,
@@ -73139,6 +73698,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73139
73698
  frameWidth: result.frameWidth,
73140
73699
  frameHeight: result.frameHeight
73141
73700
  }, exhaustedIds);
73701
+ const outcome = run.outcome;
73142
73702
  const gateNowMs = Date.now();
73143
73703
  for (const { id, t } of bornCandidates) {
73144
73704
  if (outcome.undecided.has(id)) {
@@ -73167,6 +73727,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73167
73727
  } });
73168
73728
  continue;
73169
73729
  }
73730
+ this.recordBirthDecision({
73731
+ deviceId,
73732
+ trackId: id,
73733
+ className: t.className,
73734
+ confirmed: outcome.confirmed.has(id),
73735
+ exhaustedFallback: wasDeferred && exhaustedIds.has(id),
73736
+ attempts: deferredAttempts,
73737
+ deferredForMs,
73738
+ matchedThisFrame: t.matchedThisFrame,
73739
+ birthEvidenceAvailable: withBirthEvidence.has(id),
73740
+ decision: run.decisions.get(id),
73741
+ decidedAtMs: gateNowMs,
73742
+ firstSeen: this.trackStore?.peekActive(id)?.firstSeen ?? null
73743
+ });
73170
73744
  if (!outcome.confirmed.has(id)) {
73171
73745
  this.suppressedBirths.reject(key, id);
73172
73746
  this.trackStore?.dropActive(id);
@@ -73865,10 +74439,64 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73865
74439
  * candidate) and each failure path fails OPEN, so the synchronous frame path
73866
74440
  * is never blocked or reordered by a slow/failed re-detection.
73867
74441
  */
74442
+ /**
74443
+ * Record ONE resolved birth verdict in the durable ledger (D409).
74444
+ *
74445
+ * Synchronous up to the append, which is fired and forgotten: the frame path
74446
+ * awaits `confirmTrackBirths` already, and a measurement must never add a
74447
+ * store round trip to the latency of a birth. `BirthDecisionLedgerStore.append`
74448
+ * never throws, so the un-awaited promise cannot reject.
74449
+ *
74450
+ * Cost, per BIRTH and not per frame: one `Map.get`, one `Set.has`, a counter
74451
+ * bump and one INSERT. Measured fleet-wide on the live hub 2026-09-08 at ~49
74452
+ * births/hour, i.e. 0.014 writes/s — three orders of magnitude below the
74453
+ * 12-19/s the runtime-state guard was written for, and a different shape
74454
+ * (append-only, never a periodic rewrite of an unchanged slice).
74455
+ *
74456
+ * A runner with no ledger (construction failed) records nothing and says so
74457
+ * exactly once per process, rather than per birth: a measurement that cannot
74458
+ * be taken must be visible, but it must not become the noisiest line in Loki.
74459
+ */
74460
+ recordBirthDecision(input) {
74461
+ const ledger = this.birthDecisionLedger;
74462
+ if (ledger === null) {
74463
+ if (!this.birthLedgerAbsenceLogged) {
74464
+ this.birthLedgerAbsenceLogged = true;
74465
+ this.ctx.logger.warn("birth decisions are NOT being measured — no ledger on this runner", { tags: { deviceId: input.deviceId } });
74466
+ }
74467
+ return;
74468
+ }
74469
+ const motionOnsetAtMs = this.motionOnsets.onsetFor(input.deviceId);
74470
+ const birthIndexInBurst = this.motionOnsets.noteBirth(input.deviceId);
74471
+ const decision = input.decision;
74472
+ ledger.append(buildBirthDecisionRecord({
74473
+ deviceId: input.deviceId,
74474
+ trackId: input.trackId,
74475
+ className: input.className,
74476
+ confirmed: input.confirmed,
74477
+ exhaustedFallback: input.exhaustedFallback,
74478
+ attempts: input.attempts,
74479
+ deferredForMs: input.deferredForMs,
74480
+ ...input.matchedThisFrame !== void 0 ? { matchedThisFrame: input.matchedThisFrame } : {},
74481
+ birthEvidenceAvailable: input.birthEvidenceAvailable,
74482
+ decidedByBirthEvidence: decision?.cropSource === "carried",
74483
+ reason: decision?.reason ?? null,
74484
+ bestScore: decision?.bestScore ?? null,
74485
+ appliedMinConfidence: decision?.appliedMinConfidence ?? null,
74486
+ decidedAtMs: input.decidedAtMs,
74487
+ firstSeen: input.firstSeen,
74488
+ motionOnsetAtMs,
74489
+ birthIndexInBurst
74490
+ }));
74491
+ }
73868
74492
  async confirmTrackBirths(candidates, params, exhaustedIds = /* @__PURE__ */ new Set()) {
74493
+ const decisions = /* @__PURE__ */ new Map();
73869
74494
  const allConfirmed = () => ({
73870
- confirmed: new Set(candidates.map((c) => c.trackId)),
73871
- undecided: /* @__PURE__ */ new Set()
74495
+ outcome: {
74496
+ confirmed: new Set(candidates.map((c) => c.trackId)),
74497
+ undecided: /* @__PURE__ */ new Set()
74498
+ },
74499
+ decisions
73872
74500
  });
73873
74501
  if (candidates.length === 0) return allConfirmed();
73874
74502
  const config = await this.resolveDeviceConfirmationGateSettings(params.deviceId);
@@ -73891,101 +74519,105 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73891
74519
  const nodeId = frameHandle.nodeId;
73892
74520
  const candidateNativeRecovery = /* @__PURE__ */ new Map();
73893
74521
  for (const c of candidates) if (c.nativeRecovery !== void 0) candidateNativeRecovery.set(c.trackId, c.nativeRecovery);
73894
- return confirmBirths(candidates, config, {
73895
- fetchCrop: (candidate) => this.captureScheduler.request({
73896
- deviceId,
73897
- trackId: candidate.trackId,
73898
- kind: "confirm",
73899
- staleAfterMs: config.timeoutMs,
73900
- exec: () => captureCrop(frameHandle, {
74522
+ return {
74523
+ outcome: await confirmBirths(candidates, config, {
74524
+ fetchCrop: (candidate) => this.captureScheduler.request({
74525
+ deviceId,
74526
+ trackId: candidate.trackId,
74527
+ kind: "confirm",
74528
+ staleAfterMs: config.timeoutMs,
74529
+ exec: () => captureCrop(frameHandle, {
74530
+ x: candidate.bbox.x,
74531
+ y: candidate.bbox.y,
74532
+ w: candidate.bbox.w,
74533
+ h: candidate.bbox.h
74534
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
74535
+ }),
74536
+ fetchFallbackCrop: (candidate) => {
74537
+ const displayCrop = this.captureDisplayCropFn;
74538
+ if (!displayCrop) return Promise.resolve(null);
74539
+ return displayCrop(frameHandle, {
74540
+ x: candidate.bbox.x,
74541
+ y: candidate.bbox.y,
74542
+ w: candidate.bbox.w,
74543
+ h: candidate.bbox.h
74544
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
74545
+ },
74546
+ redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
74547
+ cropRectFor: (candidate) => captureCropFrameRect({
73901
74548
  x: candidate.bbox.x,
73902
74549
  y: candidate.bbox.y,
73903
74550
  w: candidate.bbox.w,
73904
74551
  h: candidate.bbox.h
73905
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
73906
- }),
73907
- fetchFallbackCrop: (candidate) => {
73908
- const displayCrop = this.captureDisplayCropFn;
73909
- if (!displayCrop) return Promise.resolve(null);
73910
- return displayCrop(frameHandle, {
74552
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING),
74553
+ minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
74554
+ deviceId,
74555
+ className: candidate.className,
73911
74556
  x: candidate.bbox.x,
73912
74557
  y: candidate.bbox.y,
73913
- w: candidate.bbox.w,
73914
- h: candidate.bbox.h
73915
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
73916
- },
73917
- redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
73918
- cropRectFor: (candidate) => captureCropFrameRect({
73919
- x: candidate.bbox.x,
73920
- y: candidate.bbox.y,
73921
- w: candidate.bbox.w,
73922
- h: candidate.bbox.h
73923
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING),
73924
- minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
73925
- deviceId,
73926
- className: candidate.className,
73927
- x: candidate.bbox.x,
73928
- y: candidate.bbox.y,
73929
- base: config.minConfidence,
73930
- now: Date.now()
73931
- }).bar ?? config.minConfidence,
73932
- onDecision: (decision) => {
73933
- const meta = {
73934
- trackId: decision.trackId,
73935
- reason: decision.reason,
73936
- className: decision.className,
73937
- ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
73938
- ...decision.bestIncompatibleClass !== void 0 ? {
73939
- bestIncompatibleClass: decision.bestIncompatibleClass,
73940
- bestIncompatibleScore: decision.bestIncompatibleScore
73941
- } : {},
73942
- minConfidence: config.minConfidence,
73943
- ...decision.cropSource !== void 0 ? { cropSource: decision.cropSource } : {},
73944
- ...decision.reason === "native-pass" ? {
73945
- nativeRecoveryCropSidePx: candidateNativeRecovery.get(decision.trackId)?.cropSidePx,
73946
- nativeRecoveryViewPx: candidateNativeRecovery.get(decision.trackId)?.viewPx
73947
- } : {},
73948
- ...decision.appliedMinConfidence !== void 0 && decision.appliedMinConfidence > config.minConfidence ? {
73949
- appliedMinConfidence: decision.appliedMinConfidence,
73950
- barRaisedByPhantomCell: true
73951
- } : {}
73952
- };
73953
- switch (decision.verdict) {
73954
- case "suppressed":
73955
- this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
73956
- tags: { deviceId },
73957
- meta
73958
- });
73959
- break;
73960
- case "undecided":
73961
- this.ctx.logger.info("confirmation gate: birth undecided", {
73962
- tags: { deviceId },
73963
- meta
73964
- });
73965
- break;
73966
- case "confirmed":
73967
- this.ctx.logger.info("confirmation gate: birth confirmed", {
74558
+ base: config.minConfidence,
74559
+ now: Date.now()
74560
+ }).bar ?? config.minConfidence,
74561
+ onDecision: (decision) => {
74562
+ decisions.set(decision.trackId, decision);
74563
+ const meta = {
74564
+ trackId: decision.trackId,
74565
+ reason: decision.reason,
74566
+ className: decision.className,
74567
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
74568
+ ...decision.bestIncompatibleClass !== void 0 ? {
74569
+ bestIncompatibleClass: decision.bestIncompatibleClass,
74570
+ bestIncompatibleScore: decision.bestIncompatibleScore
74571
+ } : {},
74572
+ minConfidence: config.minConfidence,
74573
+ ...decision.cropSource !== void 0 ? { cropSource: decision.cropSource } : {},
74574
+ ...decision.reason === "native-pass" ? {
74575
+ nativeRecoveryCropSidePx: candidateNativeRecovery.get(decision.trackId)?.cropSidePx,
74576
+ nativeRecoveryViewPx: candidateNativeRecovery.get(decision.trackId)?.viewPx
74577
+ } : {},
74578
+ ...decision.appliedMinConfidence !== void 0 && decision.appliedMinConfidence > config.minConfidence ? {
74579
+ appliedMinConfidence: decision.appliedMinConfidence,
74580
+ barRaisedByPhantomCell: true
74581
+ } : {}
74582
+ };
74583
+ switch (decision.verdict) {
74584
+ case "suppressed":
74585
+ this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
74586
+ tags: { deviceId },
74587
+ meta
74588
+ });
74589
+ break;
74590
+ case "undecided":
74591
+ this.ctx.logger.info("confirmation gate: birth undecided", {
74592
+ tags: { deviceId },
74593
+ meta
74594
+ });
74595
+ break;
74596
+ case "confirmed":
74597
+ this.ctx.logger.info("confirmation gate: birth confirmed", {
74598
+ tags: { deviceId },
74599
+ meta
74600
+ });
74601
+ break;
74602
+ }
74603
+ if (decision.secondary !== void 0) {
74604
+ this.secondarySubjects.add(this.procKey(deviceId, source), decision.secondary, decision.trackId, Date.now());
74605
+ this.ctx.logger.info("confirmation gate: secondary subject promoted from the crop", {
73968
74606
  tags: { deviceId },
73969
- meta
74607
+ meta: {
74608
+ trackId: decision.trackId,
74609
+ className: decision.className,
74610
+ secondaryClass: decision.secondary.className,
74611
+ secondaryScore: decision.secondary.score,
74612
+ secondaryBbox: decision.secondary.bbox,
74613
+ verdict: decision.verdict
74614
+ }
73970
74615
  });
73971
- break;
73972
- }
73973
- if (decision.secondary !== void 0) {
73974
- this.secondarySubjects.add(this.procKey(deviceId, source), decision.secondary, decision.trackId, Date.now());
73975
- this.ctx.logger.info("confirmation gate: secondary subject promoted from the crop", {
73976
- tags: { deviceId },
73977
- meta: {
73978
- trackId: decision.trackId,
73979
- className: decision.className,
73980
- secondaryClass: decision.secondary.className,
73981
- secondaryScore: decision.secondary.score,
73982
- secondaryBbox: decision.secondary.bbox,
73983
- verdict: decision.verdict
73984
- }
73985
- });
74616
+ }
73986
74617
  }
73987
- }
73988
- }, exhaustedIds);
74618
+ }, exhaustedIds),
74619
+ decisions
74620
+ };
73989
74621
  }
73990
74622
  async resolveGlobalFaceEnabled() {
73991
74623
  return this.faceGlobalEnabledCache.get(() => this.faceGlobalEnabledState.get());
@@ -75317,6 +75949,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75317
75949
  detected,
75318
75950
  atMs: timestamp
75319
75951
  });
75952
+ if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
75320
75953
  this.sceneEngine?.noteMotion(deviceId, timestamp);
75321
75954
  await this.eventStore.insertMotion(ev);
75322
75955
  this.ctx.eventBus.emit({
@@ -75392,6 +76025,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75392
76025
  detected,
75393
76026
  atMs: timestamp
75394
76027
  });
76028
+ if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
75395
76029
  this.sceneEngine?.noteMotion(deviceId, timestamp);
75396
76030
  await this.eventStore.insertMotion(ev);
75397
76031
  this.ctx.eventBus.emit({
@@ -76313,6 +76947,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76313
76947
  const s = this.stationarySettingsFromCache(deviceId);
76314
76948
  if (!s.enabled) return {
76315
76949
  suppressedIndices: /* @__PURE__ */ new Set(),
76950
+ continuableIndices: /* @__PURE__ */ new Set(),
76316
76951
  confirmed: [],
76317
76952
  woken: []
76318
76953
  };
@@ -78351,6 +78986,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78351
78986
  return await this.debugNoteArchive?.list(input) ?? [];
78352
78987
  }
78353
78988
  /**
78989
+ * The birth-decision ledger, newest first (D409).
78990
+ *
78991
+ * A READ of a measurement table and nothing else — no join back to the
78992
+ * tracks, deliberately: a SUPPRESSED birth never had a track row and a
78993
+ * confirmed one is expected to have aged out, so a join would return exactly
78994
+ * the rows the ledger exists to preserve. A runner with no ledger answers
78995
+ * EMPTY, which is true: nothing has been measured here.
78996
+ */
78997
+ async listBirthDecisions(input) {
78998
+ return await this.birthDecisionLedger?.list(input) ?? [];
78999
+ }
79000
+ /**
78354
79001
  * Track-centric time-based retention (design §5.1). Drains every persisted
78355
79002
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
78356
79003
  * widened cascade — enrolled faces/plates + identity media are exempt (design