@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-FHSaZkf_.js");
5
+ const require_dist = require("../dist-DA1sJKXM.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -34861,6 +34861,9 @@ var DEFAULT_TRACKER_CONFIG = {
34861
34861
  var PERSON_CLASS = "person";
34862
34862
  var ANIMAL_CLASS = "animal";
34863
34863
  var MAX_PATH_LENGTH = 300;
34864
+ /** No stationary-held detections on this frame — the default for every caller
34865
+ * that has no gate (replay, tests, the non-pipeline planes). */
34866
+ var EMPTY_INDEX_SET = /* @__PURE__ */ new Set();
34864
34867
  function clamp$1(value, min, max) {
34865
34868
  return Math.max(min, Math.min(max, value));
34866
34869
  }
@@ -35480,13 +35483,15 @@ var SortTracker = class SortTracker {
35480
35483
  * gates) so the two-tier ByteTrack association stays a single implementation.
35481
35484
  * Mutates `used` / `matchedTracks` / `matched` in place.
35482
35485
  */
35483
- greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched) {
35486
+ greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35484
35487
  const pairs = [];
35485
35488
  for (const track of this.tracks) {
35486
35489
  if (matchedTracks.has(track)) continue;
35487
35490
  const pbox = this.predicted(track, timestamp);
35491
+ const confirmed = this.isConfirmedForEmit(track);
35488
35492
  for (const di of candidateIdxs) {
35489
35493
  if (used.has(di)) continue;
35494
+ if (!confirmed && continuationOnly.has(di)) continue;
35490
35495
  const det = detections[di];
35491
35496
  if (!this.associable(track.class, det)) continue;
35492
35497
  const score = iou$3(pbox, det.bbox);
@@ -35507,6 +35512,7 @@ var SortTracker = class SortTracker {
35507
35512
  }
35508
35513
  update(detections, timestamp, frameContext) {
35509
35514
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
35515
+ const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
35510
35516
  const resurrectedScene = /* @__PURE__ */ new Map();
35511
35517
  if (this.config.maxTrackLifetimeMs > 0) {
35512
35518
  const alive = [];
@@ -35543,12 +35549,12 @@ var SortTracker = class SortTracker {
35543
35549
  for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
35544
35550
  else highIdxs.push(di);
35545
35551
  const lowSet = new Set(lowIdxs);
35546
- this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched);
35552
+ this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35547
35553
  const rescuePairs = [];
35548
35554
  for (const track of this.tracks) {
35549
35555
  if (matchedTracks.has(track)) continue;
35550
35556
  for (let di = 0; di < detections.length; di++) {
35551
- if (used.has(di) || lowSet.has(di)) continue;
35557
+ if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35552
35558
  const det = detections[di];
35553
35559
  const gate = this.looseMatch(track, det, timestamp);
35554
35560
  if (gate === null) continue;
@@ -35570,7 +35576,7 @@ var SortTracker = class SortTracker {
35570
35576
  used.add(pair.detIdx);
35571
35577
  this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35572
35578
  }
35573
- if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched);
35579
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35574
35580
  for (const [track, det] of matched) {
35575
35581
  if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, { against: track });
35576
35582
  const prevCenter = bboxCentroid({
@@ -35626,7 +35632,7 @@ var SortTracker = class SortTracker {
35626
35632
  } else surviving.push(track);
35627
35633
  }
35628
35634
  for (let di = 0; di < detections.length; di++) {
35629
- if (used.has(di)) continue;
35635
+ if (used.has(di) || continuationOnly.has(di)) continue;
35630
35636
  const det = detections[di];
35631
35637
  let best;
35632
35638
  let bestScore = -1;
@@ -35694,6 +35700,11 @@ var SortTracker = class SortTracker {
35694
35700
  });
35695
35701
  for (const di of unmatchedIdx) {
35696
35702
  const det = detections[di];
35703
+ if (continuationOnly.has(di)) {
35704
+ this.reportRefusal("stationary-hold", det);
35705
+ used.add(di);
35706
+ continue;
35707
+ }
35697
35708
  if (!this.meetsClassScoreFloor(det)) {
35698
35709
  this.reportRefusal("score-floor", det, {
35699
35710
  threshold: this.config.classMinScores[det.class] ?? 0,
@@ -36432,14 +36443,28 @@ var FrameProcessor = class {
36432
36443
  timestamp
36433
36444
  }) : {
36434
36445
  suppressedIndices: /* @__PURE__ */ new Set(),
36446
+ continuableIndices: /* @__PURE__ */ new Set(),
36435
36447
  confirmed: [],
36436
36448
  woken: []
36437
36449
  };
36438
- const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
36450
+ const trackerInput = [];
36451
+ const continuationOnly = /* @__PURE__ */ new Set();
36452
+ const continuableBoxes = /* @__PURE__ */ new Set();
36453
+ for (let i = 0; i < filteredDetections.length; i++) {
36454
+ const det = filteredDetections[i];
36455
+ if (gate.suppressedIndices.has(i)) {
36456
+ if (!gate.continuableIndices.has(i)) continue;
36457
+ continuationOnly.add(trackerInput.length);
36458
+ continuableBoxes.add(det.bbox);
36459
+ }
36460
+ trackerInput.push(det);
36461
+ }
36439
36462
  const trackedDetections = this.tracker.update(trackerInput, timestamp, {
36440
36463
  frameWidth,
36441
- frameHeight
36464
+ frameHeight,
36465
+ continuationOnly
36442
36466
  });
36467
+ const stationaryContinued = continuableBoxes.size === 0 ? 0 : trackedDetections.filter((t) => t.matchedThisFrame !== false && continuableBoxes.has(t.bbox)).length;
36443
36468
  const riderFolds = foldByVehicleBbox.size === 0 ? EMPTY_RIDER_FOLDS : [...foldByVehicleBbox].map(([bbox, fold]) => {
36444
36469
  const td = trackedDetections.find((t) => t.bbox === bbox && t.matchedThisFrame !== false);
36445
36470
  return td !== void 0 ? {
@@ -36643,6 +36668,7 @@ var FrameProcessor = class {
36643
36668
  rawTrackedDetections: trackedDetections,
36644
36669
  stationaryConfirmed: gate.confirmed,
36645
36670
  stationaryWoken: gate.woken,
36671
+ stationaryContinued,
36646
36672
  riderFolds
36647
36673
  };
36648
36674
  }
@@ -53432,6 +53458,95 @@ var BirthEvidenceStore = class {
53432
53458
  }
53433
53459
  };
53434
53460
  //#endregion
53461
+ //#region src/pipeline-analytics/pipeline/birth-decision-record.ts
53462
+ /**
53463
+ * birth-decision-record — turn one resolved birth verdict into the row that is
53464
+ * kept (D409).
53465
+ *
53466
+ * PURE, and separated from the store for the usual reason: the two rules worth
53467
+ * getting right — what counts as the fail-open, and when the latency proxy is
53468
+ * allowed to report a number — are decisions, not I/O, and a decision that only
53469
+ * exists inside a SQLite call cannot be tested or argued with.
53470
+ *
53471
+ * MEASUREMENT ONLY. Nothing here gates a birth, changes a verdict, or feeds
53472
+ * back into the frame path. The caller writes the row after the verdict is
53473
+ * already taken.
53474
+ */
53475
+ /**
53476
+ * The verdict as the ledger reports it.
53477
+ *
53478
+ * `exhausted-fallback` is a CONFIRMATION and is counted apart from one, because
53479
+ * a birth nobody ever looked at and a birth measured and passed are the same
53480
+ * row in every existing aggregate — which is what made "the gate stopped
53481
+ * failing open" and "the gate now suppresses everything it cannot measure"
53482
+ * indistinguishable. Exhaustion attached to a SUPPRESSION is meaningless
53483
+ * (exhaustion only ever waves a birth through) and is discarded rather than
53484
+ * inventing a fourth member.
53485
+ */
53486
+ function verdictFor(input) {
53487
+ if (!input.confirmed) return "suppressed";
53488
+ return input.exhaustedFallback ? "exhausted-fallback" : "confirmed";
53489
+ }
53490
+ /**
53491
+ * The latency proxy, and the three ways it declines to produce a number.
53492
+ *
53493
+ * It reports `null` — never a fabricated or clamped value — when there is no
53494
+ * track (a suppressed birth), no motion reference on this runner, or a
53495
+ * reference older than {@link MAX_BIRTH_LATENCY_PROXY_MS}. That last case is
53496
+ * the one that matters: on a busy scene the burst never closes and the onset is
53497
+ * minutes old, and a minutes-long "birth latency" would be read as the
53498
+ * detector's fault when it is the proxy's. When the reference is refused, the
53499
+ * onset and the burst index go with it — reporting an index against a reference
53500
+ * this row does not carry would be an invitation to join them back up.
53501
+ *
53502
+ * A NEGATIVE result is returned as measured. The analyzer's pixel-count floor
53503
+ * can be crossed after the detector's confidence floor for a subject entering
53504
+ * slowly at the frame edge, and clamping that away would delete precisely the
53505
+ * evidence that the proxy is unreliable. See `BirthDecisionRecordSchema` for
53506
+ * the full error bars.
53507
+ */
53508
+ function latencyFor(input, firstSeen) {
53509
+ const onset = input.motionOnsetAtMs;
53510
+ if (firstSeen === null || onset === null) return {
53511
+ motionOnsetAt: null,
53512
+ birthLatencyMs: null,
53513
+ birthIndexInBurst: null
53514
+ };
53515
+ if (firstSeen - onset > 6e4) return {
53516
+ motionOnsetAt: null,
53517
+ birthLatencyMs: null,
53518
+ birthIndexInBurst: null
53519
+ };
53520
+ return {
53521
+ motionOnsetAt: onset,
53522
+ birthLatencyMs: firstSeen - onset,
53523
+ birthIndexInBurst: input.birthIndexInBurst
53524
+ };
53525
+ }
53526
+ /** Build the durable row for one resolved birth decision. */
53527
+ function buildBirthDecisionRecord(input) {
53528
+ const verdict = verdictFor(input);
53529
+ const firstSeen = verdict === "suppressed" ? null : input.firstSeen;
53530
+ const latency = latencyFor(input, firstSeen);
53531
+ return {
53532
+ at: input.decidedAtMs,
53533
+ deviceId: input.deviceId,
53534
+ sourceTrackId: input.trackId,
53535
+ className: input.className,
53536
+ verdict,
53537
+ reason: input.reason ?? null,
53538
+ attempts: input.attempts,
53539
+ deferredForMs: input.deferredForMs,
53540
+ decidedOnCoastedFrame: input.matchedThisFrame === false,
53541
+ birthEvidenceAvailable: input.birthEvidenceAvailable,
53542
+ decidedByBirthEvidence: input.decidedByBirthEvidence,
53543
+ bestScore: input.bestScore ?? null,
53544
+ appliedMinConfidence: input.appliedMinConfidence ?? null,
53545
+ firstSeen,
53546
+ ...latency
53547
+ };
53548
+ }
53549
+ //#endregion
53435
53550
  //#region src/pipeline-analytics/pipeline/deferred-births.ts
53436
53551
  var DeferredBirthRegistry = class {
53437
53552
  byDevice = /* @__PURE__ */ new Map();
@@ -53506,6 +53621,44 @@ var DeferredBirthRegistry = class {
53506
53621
  }
53507
53622
  };
53508
53623
  //#endregion
53624
+ //#region src/pipeline-analytics/pipeline/motion-onset-registry.ts
53625
+ var MotionOnsetRegistry = class {
53626
+ byDevice = /* @__PURE__ */ new Map();
53627
+ /**
53628
+ * Motion went off→on at `atMs`. Opens a new burst, discarding the previous
53629
+ * one's birth count — a new burst is a new subject population.
53630
+ */
53631
+ noteRisingEdge(deviceId, atMs) {
53632
+ this.byDevice.set(deviceId, {
53633
+ onsetAtMs: atMs,
53634
+ births: 0
53635
+ });
53636
+ }
53637
+ /** The open burst's onset, or `null` for a device with no reference. */
53638
+ onsetFor(deviceId) {
53639
+ return this.byDevice.get(deviceId)?.onsetAtMs ?? null;
53640
+ }
53641
+ /**
53642
+ * Count one birth decision against this device's burst and return its 0-based
53643
+ * index — `null` when there is no burst to count it against.
53644
+ *
53645
+ * Index 0 is the only index at which the burst plausibly belongs to the same
53646
+ * subject as the birth; the ledger records the index rather than filtering
53647
+ * here, so the reader can see how often it is not 0.
53648
+ */
53649
+ noteBirth(deviceId) {
53650
+ const burst = this.byDevice.get(deviceId);
53651
+ if (burst === void 0) return null;
53652
+ const index = burst.births;
53653
+ burst.births += 1;
53654
+ return index;
53655
+ }
53656
+ /** Drop a device's reference (device removed / pipeline reset). */
53657
+ forgetDevice(deviceId) {
53658
+ this.byDevice.delete(deviceId);
53659
+ }
53660
+ };
53661
+ //#endregion
53509
53662
  //#region src/pipeline-analytics/pipeline/detail-semantic-gate.ts
53510
53663
  /** The detail `className` whose result IS the recognition. */
53511
53664
  var PLATE_CLASS = "plate";
@@ -54862,6 +55015,31 @@ function thumbnailWouldShowParkedNotSubject(input) {
54862
55015
  * dropped, against every entry that did NOT wake this frame. Woken entries are
54863
55016
  * excluded so a departure still releases its object — a car that drives away is
54864
55017
  * noticed on the frame its second read lands, exactly as before.
55018
+ *
55019
+ * ## A weak hold may CONTINUE a track; only a strong read is the object (D410)
55020
+ *
55021
+ * D275 predicted that a moving vehicle overlapping a parked one "loses a frame
55022
+ * or two while it passes" and coasts through on `maxAge`. Measured on device
55023
+ * 617 on 2026-09-08 it lost the whole pass: the parked cars sit at the frame
55024
+ * edge, so a car driving across their front is held on every frame from the
55025
+ * first overlap to the moment it leaves the frame, and the tracker — which had
55026
+ * followed it for 5–10 frames — coasts `maxMissedMs` on nothing and drops it.
55027
+ * Four operator-marked tracks, 1.7–3.5 s old, died on exactly that geometry;
55028
+ * the sweep for the window reported `reach: 0`, so the hold was pass 1's: the
55029
+ * mover occludes the parked car, the detector reports one vehicle box, and it
55030
+ * is the entry's best match.
55031
+ *
55032
+ * The hold is right — that box covers the parked spot and must not OPEN a
55033
+ * track, which is the whole of the operator's requirement — but it is not the
55034
+ * parked car: a strong read (IoU ≥ `suppressIou`) is the parked object unmoved,
55035
+ * and everything below it is a box that merely overlaps the spot (swallow,
55036
+ * jitter, departing). So `suppressedIndices` still names every box that may
55037
+ * not spawn, and `continuableIndices` names the weak subset the tracker may use
55038
+ * to CONTINUE a track it has already confirmed — through its strict
55039
+ * predicted-box association only, never a rescue or a resurrection. The reach
55040
+ * pass reports strength the same way. Nothing about confirmation, anchoring or
55041
+ * departure changes: a weak hold confirms and never re-anchors, exactly as
55042
+ * before.
54865
55043
  */
54866
55044
  function partitionDetectionsAgainstRegistry(input) {
54867
55045
  const { entries, detections, config, now } = input;
@@ -54869,6 +55047,7 @@ function partitionDetectionsAgainstRegistry(input) {
54869
55047
  const departure = input.departure ?? DEFAULT_DEPARTURE_CONFIG;
54870
55048
  const warmUp = input.warmUp ?? false;
54871
55049
  const suppressed = /* @__PURE__ */ new Set();
55050
+ const continuable = /* @__PURE__ */ new Set();
54872
55051
  const confirmed = [];
54873
55052
  const woken = [];
54874
55053
  const strikeChanges = [];
@@ -54908,6 +55087,7 @@ function partitionDetectionsAgainstRegistry(input) {
54908
55087
  }
54909
55088
  if (holdIdx >= 0) {
54910
55089
  suppressed.add(holdIdx);
55090
+ if (!strong) continuable.add(holdIdx);
54911
55091
  confirmed.push({
54912
55092
  entryId: entry.id,
54913
55093
  className: entry.className,
@@ -54947,6 +55127,7 @@ function partitionDetectionsAgainstRegistry(input) {
54947
55127
  if (warmUp) {
54948
55128
  warmUpDepartureReads += 1;
54949
55129
  suppressed.add(bestIdx);
55130
+ continuable.add(bestIdx);
54950
55131
  continue;
54951
55132
  }
54952
55133
  if (strike === void 0) {
@@ -54959,6 +55140,7 @@ function partitionDetectionsAgainstRegistry(input) {
54959
55140
  reason: "armed"
54960
55141
  });
54961
55142
  suppressed.add(bestIdx);
55143
+ continuable.add(bestIdx);
54962
55144
  continue;
54963
55145
  }
54964
55146
  if (strikeAgeMs >= departure.confirmMinGapMs) {
@@ -54979,6 +55161,7 @@ function partitionDetectionsAgainstRegistry(input) {
54979
55161
  reason: "held"
54980
55162
  });
54981
55163
  suppressed.add(bestIdx);
55164
+ continuable.add(bestIdx);
54982
55165
  }
54983
55166
  const wokenIds = woken.length === 0 ? EMPTY_IDS : new Set(woken.map((w) => w.entryId));
54984
55167
  let reachSuppressedCount = 0;
@@ -54989,14 +55172,17 @@ function partitionDetectionsAgainstRegistry(input) {
54989
55172
  for (let ei = 0; ei < entries.length; ei++) {
54990
55173
  const entry = entries[ei];
54991
55174
  if (entryClasses[ei] !== detClass || wokenIds.has(entry.id)) continue;
54992
- if (!parkedEntryHoldsDetection(entry, det.bbox, config).holds) continue;
55175
+ const held = parkedEntryHoldsDetection(entry, det.bbox, config);
55176
+ if (!held.holds) continue;
54993
55177
  suppressed.add(di);
55178
+ if (!held.strong) continuable.add(di);
54994
55179
  reachSuppressedCount += 1;
54995
55180
  break;
54996
55181
  }
54997
55182
  }
54998
55183
  return {
54999
55184
  suppressedIndices: suppressed,
55185
+ continuableIndices: continuable,
55000
55186
  confirmed,
55001
55187
  woken,
55002
55188
  strikeChanges,
@@ -56620,6 +56806,10 @@ var StationaryObjectRegistry = class {
56620
56806
  */
56621
56807
  suppressedSinceSweep = /* @__PURE__ */ new Map();
56622
56808
  reachSuppressedSinceSweep = /* @__PURE__ */ new Map();
56809
+ /** Weak holds handed to the tracker as continuable (D410), per device. */
56810
+ continuableSinceSweep = /* @__PURE__ */ new Map();
56811
+ /** Of those, the ones the tracker reports actually continued a track. */
56812
+ continuedSinceSweep = /* @__PURE__ */ new Map();
56623
56813
  constructor(deps) {
56624
56814
  this.logger = deps.logger;
56625
56815
  this.ledger = new DurableLedger({
@@ -56703,6 +56893,7 @@ var StationaryObjectRegistry = class {
56703
56893
  const entries = this.list(deviceId);
56704
56894
  if (entries.length === 0) return {
56705
56895
  suppressedIndices: /* @__PURE__ */ new Set(),
56896
+ continuableIndices: /* @__PURE__ */ new Set(),
56706
56897
  confirmed: [],
56707
56898
  woken: [],
56708
56899
  reachSuppressedCount: 0
@@ -56723,14 +56914,23 @@ var StationaryObjectRegistry = class {
56723
56914
  if (result.suppressedIndices.size > 0) {
56724
56915
  this.suppressedSinceSweep.set(deviceId, (this.suppressedSinceSweep.get(deviceId) ?? 0) + result.suppressedIndices.size);
56725
56916
  if (result.reachSuppressedCount > 0) this.reachSuppressedSinceSweep.set(deviceId, (this.reachSuppressedSinceSweep.get(deviceId) ?? 0) + result.reachSuppressedCount);
56917
+ if (result.continuableIndices.size > 0) this.continuableSinceSweep.set(deviceId, (this.continuableSinceSweep.get(deviceId) ?? 0) + result.continuableIndices.size);
56726
56918
  }
56727
56919
  return {
56728
56920
  suppressedIndices: result.suppressedIndices,
56921
+ continuableIndices: result.continuableIndices,
56729
56922
  confirmed: result.confirmed,
56730
56923
  woken: result.woken,
56731
56924
  reachSuppressedCount: result.reachSuppressedCount
56732
56925
  };
56733
56926
  }
56927
+ /** The tracker's answer to {@link StationaryFrameFilterResult.continuableIndices}:
56928
+ * how many of this frame's weak holds continued a confirmed track (D410).
56929
+ * Counted here so the sweep line can report it beside `continuable`. */
56930
+ noteContinued(deviceId, count) {
56931
+ if (count <= 0) return;
56932
+ this.continuedSinceSweep.set(deviceId, (this.continuedSinceSweep.get(deviceId) ?? 0) + count);
56933
+ }
56734
56934
  /** Pending departure strikes for a device — the read-only diagnostic's whole
56735
56935
  * reason to exist. Ordered oldest first so "what has been waiting" reads
56736
56936
  * straight off the top. */
@@ -56869,6 +57069,8 @@ var StationaryObjectRegistry = class {
56869
57069
  absorbed,
56870
57070
  suppressed,
56871
57071
  reach: this.reachSuppressedSinceSweep.get(deviceId) ?? 0,
57072
+ continuable: this.continuableSinceSweep.get(deviceId) ?? 0,
57073
+ continued: this.continuedSinceSweep.get(deviceId) ?? 0,
56872
57074
  entries: this.count(deviceId)
56873
57075
  }
56874
57076
  });
@@ -56876,6 +57078,8 @@ var StationaryObjectRegistry = class {
56876
57078
  this.absorbedSinceSweep.clear();
56877
57079
  this.suppressedSinceSweep.clear();
56878
57080
  this.reachSuppressedSinceSweep.clear();
57081
+ this.continuableSinceSweep.clear();
57082
+ this.continuedSinceSweep.clear();
56879
57083
  for (const [deviceId, counters] of this.strikeCountersByDevice) this.logger.info("stationary departure strikes", {
56880
57084
  tags: { deviceId },
56881
57085
  meta: {
@@ -60034,6 +60238,332 @@ var IdentityStore = class {
60034
60238
  }
60035
60239
  };
60036
60240
  //#endregion
60241
+ //#region src/pipeline-analytics/store/birth-decision-ledger-store.ts
60242
+ /**
60243
+ * BirthDecisionLedgerStore — the durable record of every track-birth verdict
60244
+ * (D409).
60245
+ *
60246
+ * A declared SQL-backed collection, like the events ops-log and the debug-note
60247
+ * archive next door, and for the same reason: pipeline-analytics already owns
60248
+ * SQLite collections, and an undeclared collection crash-loops the runner. MUST
60249
+ * be declared in `onInitialize` before the first write.
60250
+ *
60251
+ * Collection name: pipeline-analytics:birth-decision-ledger
60252
+ *
60253
+ * ── Why a table rather than a log line ─────────────────────────────────────
60254
+ * The gate ALREADY logs every decision. `logs.query` is an in-memory ring that
60255
+ * measured 40 000 entries covering 38 MINUTES of fleet traffic on 2026-09-08 —
60256
+ * so a fourteenth field on that line answers nothing the next morning, and the
60257
+ * questions this data exists for ("what fraction of births on 617 were decided
60258
+ * on a coasted frame, over the week the operator complained about") are all
60259
+ * multi-day. A previous investigation in this repo was blocked for exactly
60260
+ * this.
60261
+ *
60262
+ * ── Why it is safe on the write path ──────────────────────────────────────
60263
+ * ONE INSERT PER BIRTH DECISION. Not per frame, not periodic, and never an
60264
+ * update of an existing row. Measured fleet-wide on the live hub, 2026-09-08:
60265
+ * 31 decided births in 38 minutes = ~49/hour = 0.014 writes/s. The write
60266
+ * amplification the runtime-state guard exists to prevent
60267
+ * (`scripts/check-runtime-state-durability.ts`: 12-19 writes/s rewriting
60268
+ * ~2.6-5 GB/day to maintain 161 KB) is three orders of magnitude above this and
60269
+ * is a different shape entirely — that one is a periodic REWRITE of a slice
60270
+ * whose values do not change. Nothing here advances a clock under an unchanged
60271
+ * value, because every row is new.
60272
+ *
60273
+ * ── Two properties, stated once ───────────────────────────────────────────
60274
+ * - **It is not track-owned.** A SUPPRESSED birth never had a track; a
60275
+ * confirmed one is expected to age out long before this row. The column is
60276
+ * `sourceTrackId` and not `trackId` so the ownership derivation in
60277
+ * `collection-classification.ts` cannot see it and cascade it away.
60278
+ * - **It is bounded by COUNT, not by age.** A miss rate needs a denominator
60279
+ * over whatever window the table holds; a quiet fleet should keep more of
60280
+ * that window, not less. See {@link MAX_BIRTH_DECISION_RECORDS}.
60281
+ *
60282
+ * MEASUREMENT ONLY. Nothing reads this on the frame path; nothing gates on it.
60283
+ */
60284
+ /**
60285
+ * @durable class=ledger owner=pipeline-analytics
60286
+ * write="one row per track-birth VERDICT — appended by the birth loop in
60287
+ * `processFrame` the instant the confirmation gate's verdict resolves
60288
+ * (confirmed / suppressed / exhausted fail-open). Per BIRTH, never per
60289
+ * frame: measured at ~49 rows/hour fleet-wide on 2026-09-08. Append-only,
60290
+ * never updated. BEST-EFFORT — it logs and swallows, because a failed
60291
+ * measurement must not fail the birth it measures."
60292
+ * retention="NOT track retention and NOT an age clock — a miss rate needs
60293
+ * whatever window the table holds, and an age sweep would shorten it on a
60294
+ * quiet fleet exactly when more history is affordable. Bounded by ROW
60295
+ * COUNT (MAX_BIRTH_DECISION_RECORDS, 30000, fleet-wide), oldest `at`
60296
+ * first, trimmed on append. ~6 MB at the ceiling."
60297
+ */
60298
+ var BIRTH_DECISION_LEDGER_COLLECTION = "pipeline-analytics:birth-decision-ledger";
60299
+ var BIRTH_DECISION_LEDGER_COLUMNS = [
60300
+ {
60301
+ name: "id",
60302
+ type: "TEXT",
60303
+ primaryKey: true,
60304
+ notNull: true
60305
+ },
60306
+ {
60307
+ name: "at",
60308
+ type: "INTEGER",
60309
+ notNull: true
60310
+ },
60311
+ {
60312
+ name: "deviceId",
60313
+ type: "INTEGER",
60314
+ notNull: true
60315
+ },
60316
+ {
60317
+ name: "sourceTrackId",
60318
+ type: "TEXT",
60319
+ notNull: true
60320
+ },
60321
+ {
60322
+ name: "className",
60323
+ type: "TEXT",
60324
+ notNull: true
60325
+ },
60326
+ {
60327
+ name: "verdict",
60328
+ type: "TEXT",
60329
+ notNull: true
60330
+ },
60331
+ {
60332
+ name: "reason",
60333
+ type: "TEXT"
60334
+ },
60335
+ {
60336
+ name: "attempts",
60337
+ type: "INTEGER",
60338
+ notNull: true
60339
+ },
60340
+ {
60341
+ name: "deferredForMs",
60342
+ type: "INTEGER",
60343
+ notNull: true
60344
+ },
60345
+ {
60346
+ name: "decidedOnCoastedFrame",
60347
+ type: "BOOLEAN",
60348
+ notNull: true
60349
+ },
60350
+ {
60351
+ name: "birthEvidenceAvailable",
60352
+ type: "BOOLEAN",
60353
+ notNull: true
60354
+ },
60355
+ {
60356
+ name: "decidedByBirthEvidence",
60357
+ type: "BOOLEAN",
60358
+ notNull: true
60359
+ },
60360
+ {
60361
+ name: "bestScore",
60362
+ type: "REAL"
60363
+ },
60364
+ {
60365
+ name: "appliedMinConfidence",
60366
+ type: "REAL"
60367
+ },
60368
+ {
60369
+ name: "firstSeen",
60370
+ type: "INTEGER"
60371
+ },
60372
+ {
60373
+ name: "motionOnsetAt",
60374
+ type: "INTEGER"
60375
+ },
60376
+ {
60377
+ name: "birthLatencyMs",
60378
+ type: "INTEGER"
60379
+ },
60380
+ {
60381
+ name: "birthIndexInBurst",
60382
+ type: "INTEGER"
60383
+ }
60384
+ ];
60385
+ var BIRTH_DECISION_LEDGER_INDEXES = [{
60386
+ name: "idx_birthdecision_at",
60387
+ columns: ["at"]
60388
+ }, {
60389
+ name: "idx_birthdecision_device_at",
60390
+ columns: ["deviceId", "at"]
60391
+ }];
60392
+ var BirthDecisionLedgerStore = class {
60393
+ store;
60394
+ logger;
60395
+ newId;
60396
+ maxRows;
60397
+ trimEvery;
60398
+ /** Rows appended since the last trim — see {@link trim}. */
60399
+ sinceTrim = 0;
60400
+ constructor(deps) {
60401
+ this.store = deps.store;
60402
+ this.logger = deps.logger;
60403
+ this.newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
60404
+ this.maxRows = deps.maxRows ?? 3e4;
60405
+ this.trimEvery = deps.trimEvery ?? DEFAULT_TRIM_EVERY;
60406
+ }
60407
+ /** The ceiling a store built without an override enforces. */
60408
+ static defaultMaxRows() {
60409
+ return require_dist.MAX_BIRTH_DECISION_RECORDS;
60410
+ }
60411
+ static async declare(store) {
60412
+ await store.declareCollection.mutate({
60413
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60414
+ columns: [...BIRTH_DECISION_LEDGER_COLUMNS],
60415
+ indexes: [...BIRTH_DECISION_LEDGER_INDEXES]
60416
+ });
60417
+ }
60418
+ /**
60419
+ * Append one birth decision. NEVER THROWS.
60420
+ *
60421
+ * Best-effort, unlike the debug-note archive and deliberately so: that table
60422
+ * holds the only copy of something a PERSON typed, and this one holds a
60423
+ * measurement the system takes ~49 times an hour. A birth must not fail — nor
60424
+ * be delayed by a retry — because bookkeeping could not be written. The
60425
+ * failure is logged with `tags: { deviceId }` so a camera whose rows are
60426
+ * silently missing is attributable rather than an unexplained hole in the
60427
+ * denominator.
60428
+ */
60429
+ async append(draft) {
60430
+ try {
60431
+ const row = require_dist.BirthDecisionRecordSchema.parse({
60432
+ id: this.newId(),
60433
+ ...draft
60434
+ });
60435
+ await this.store.insert.mutate({
60436
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60437
+ record: {
60438
+ id: row.id,
60439
+ data: {
60440
+ at: row.at,
60441
+ deviceId: row.deviceId,
60442
+ sourceTrackId: row.sourceTrackId,
60443
+ className: row.className,
60444
+ verdict: row.verdict,
60445
+ reason: row.reason,
60446
+ attempts: row.attempts,
60447
+ deferredForMs: row.deferredForMs,
60448
+ decidedOnCoastedFrame: row.decidedOnCoastedFrame,
60449
+ birthEvidenceAvailable: row.birthEvidenceAvailable,
60450
+ decidedByBirthEvidence: row.decidedByBirthEvidence,
60451
+ bestScore: row.bestScore,
60452
+ appliedMinConfidence: row.appliedMinConfidence,
60453
+ firstSeen: row.firstSeen,
60454
+ motionOnsetAt: row.motionOnsetAt,
60455
+ birthLatencyMs: row.birthLatencyMs,
60456
+ birthIndexInBurst: row.birthIndexInBurst
60457
+ }
60458
+ }
60459
+ });
60460
+ this.sinceTrim += 1;
60461
+ await this.trim();
60462
+ } catch (err) {
60463
+ this.logger.warn("birth-decision ledger append failed — this decision is not counted", {
60464
+ tags: { deviceId: draft.deviceId },
60465
+ meta: {
60466
+ trackId: draft.sourceTrackId,
60467
+ verdict: draft.verdict,
60468
+ error: err instanceof Error ? err.message : String(err)
60469
+ }
60470
+ });
60471
+ }
60472
+ }
60473
+ /**
60474
+ * Hold the table at its ceiling, oldest first.
60475
+ *
60476
+ * NOT an age-keyed sweep and must never become one: the boundary is computed
60477
+ * from the POSITION of the excess rows, never from a clock. Rows sharing the
60478
+ * boundary timestamp go together, which at this write rate is a rounding
60479
+ * error against a 30 000-row ceiling.
60480
+ *
60481
+ * AMORTISED. The debug-note archive counts on every append because it writes
60482
+ * a few rows a week; this one writes ~49 an hour, and a `count` round trip per
60483
+ * birth would triple the ledger's own store traffic to answer a question whose
60484
+ * answer only changes on the row that crosses the ceiling. Checking once per
60485
+ * {@link DEFAULT_TRIM_EVERY} appends bounds the overshoot at that many rows —
60486
+ * 0.3% of the ceiling — for two hours' delay in enforcing it.
60487
+ */
60488
+ async trim() {
60489
+ if (this.sinceTrim < this.trimEvery) return;
60490
+ this.sinceTrim = 0;
60491
+ try {
60492
+ const held = await this.store.count.query({ collection: BIRTH_DECISION_LEDGER_COLLECTION });
60493
+ const excess = held - this.maxRows;
60494
+ if (excess <= 0) return;
60495
+ const boundary = (await this.store.query.query({
60496
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60497
+ filter: {
60498
+ orderBy: {
60499
+ field: "at",
60500
+ direction: "asc"
60501
+ },
60502
+ limit: excess
60503
+ },
60504
+ columns: ["at"]
60505
+ })).reduce((acc, r) => {
60506
+ const at = r.data["at"];
60507
+ return typeof at === "number" && at > acc ? at : acc;
60508
+ }, 0);
60509
+ if (boundary === 0) return;
60510
+ const { deleted } = await this.store.deleteWhere.mutate({
60511
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60512
+ filter: { whereBetween: { at: [0, boundary] } }
60513
+ });
60514
+ this.logger.info("birth-decision ledger trimmed to its ceiling", { meta: {
60515
+ held,
60516
+ ceiling: this.maxRows,
60517
+ deleted
60518
+ } });
60519
+ } catch (err) {
60520
+ this.logger.warn("birth-decision ledger trim failed — the ledger will keep growing", { meta: {
60521
+ ceiling: this.maxRows,
60522
+ error: err instanceof Error ? err.message : String(err)
60523
+ } });
60524
+ }
60525
+ }
60526
+ /** Birth decisions, newest first, optionally scoped by camera / verdict / age. */
60527
+ async list(query) {
60528
+ const where = {};
60529
+ if (query.deviceId !== void 0) where["deviceId"] = query.deviceId;
60530
+ if (query.verdict !== void 0) where["verdict"] = query.verdict;
60531
+ const filter = {
60532
+ orderBy: {
60533
+ field: "at",
60534
+ direction: "desc"
60535
+ },
60536
+ limit: query.limit ?? 500
60537
+ };
60538
+ if (Object.keys(where).length > 0) filter["where"] = where;
60539
+ if (query.since !== void 0) filter["whereBetween"] = { at: [query.since, Number.MAX_SAFE_INTEGER] };
60540
+ const rows = await this.store.query.query({
60541
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
60542
+ filter
60543
+ });
60544
+ const out = [];
60545
+ for (const r of rows) {
60546
+ const parsed = require_dist.BirthDecisionRecordSchema.safeParse({
60547
+ id: r.id,
60548
+ ...r.data
60549
+ });
60550
+ if (parsed.success) out.push(parsed.data);
60551
+ else this.logger.debug("birth-decision ledger: skipped a malformed row", { meta: { id: r.id } });
60552
+ }
60553
+ return out;
60554
+ }
60555
+ };
60556
+ /**
60557
+ * How many appends between ceiling checks, by default. See
60558
+ * {@link BirthDecisionLedgerStore.trim}.
60559
+ *
60560
+ * 100 rows is two hours of measured fleet traffic and 0.3% of the ceiling —
60561
+ * small enough that the table's real size never meaningfully exceeds its stated
60562
+ * bound, large enough that the ledger costs one `count` per two hours instead of
60563
+ * one per birth.
60564
+ */
60565
+ var DEFAULT_TRIM_EVERY = 100;
60566
+ //#endregion
60037
60567
  //#region src/pipeline-analytics/store/debug-note-archive-store.ts
60038
60568
  /**
60039
60569
  * DebugNoteArchiveStore — the durable corpus of what operators asked to be
@@ -61659,6 +62189,13 @@ var ANALYTICS_COLLECTIONS = [
61659
62189
  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."
61660
62190
  }
61661
62191
  },
62192
+ {
62193
+ collection: BIRTH_DECISION_LEDGER_COLLECTION,
62194
+ classification: {
62195
+ kind: "never-orphaned",
62196
+ 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."
62197
+ }
62198
+ },
61662
62199
  {
61663
62200
  collection: STATIONARY_COLLECTION,
61664
62201
  classification: {
@@ -69435,6 +69972,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
69435
69972
  * operator asked for never depends on this addon's own bookkeeping.
69436
69973
  */
69437
69974
  debugNoteArchive = null;
69975
+ /** The durable birth-decision ledger (D409) — measurement, never a gate. */
69976
+ birthDecisionLedger = null;
69977
+ /** Has the "no ledger on this runner" warning already been said? Once per
69978
+ * process: the condition is a property of the runner, not of the birth. */
69979
+ birthLedgerAbsenceLogged = false;
69438
69980
  /** Event-media relocation engine (entity-routing Phase 4). */
69439
69981
  mediaRelocate = null;
69440
69982
  mediaLocationStorage = null;
@@ -69873,6 +70415,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
69873
70415
  /** Births the gate could not MEASURE, awaiting another look on a later frame. */
69874
70416
  deferredBirths = new DeferredBirthRegistry();
69875
70417
  /**
70418
+ * The last motion RISING EDGE per device — the reference instant the
70419
+ * birth-latency proxy is measured from (D409). Measurement only; nothing
70420
+ * gates on it. Fed by the two motion handlers, which are subscribed on the
70421
+ * SAME designated post-processing node as the frame path, so a camera whose
70422
+ * births land here always has its motion mirror here too.
70423
+ */
70424
+ motionOnsets = new MotionOnsetRegistry();
70425
+ /**
69876
70426
  * The pixels each deferred birth arrived with — a RESERVE per camera, evicted
69877
70427
  * by age inside that camera's own quota (D379).
69878
70428
  *
@@ -70427,6 +70977,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70427
70977
  store: api.settingsStore,
70428
70978
  logger: logger.child("debug-note-archive")
70429
70979
  });
70980
+ this.birthDecisionLedger = new BirthDecisionLedgerStore({
70981
+ store: api.settingsStore,
70982
+ logger: logger.child("birth-decision-ledger")
70983
+ });
70430
70984
  {
70431
70985
  const designated = await step("postProcessingNodeState", () => this.postProcessingNodeState.get());
70432
70986
  this.isPostProcessingNode = ownNodeId === designated;
@@ -70747,6 +71301,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70747
71301
  RetrainAnnotationStore.declare(api.settingsStore),
70748
71302
  OpsLogStore.declare(api.settingsStore),
70749
71303
  DebugNoteArchiveStore.declare(api.settingsStore),
71304
+ BirthDecisionLedgerStore.declare(api.settingsStore),
70750
71305
  AnalyticsLts.declare(api.settingsStore),
70751
71306
  SceneStore.declare(api.settingsStore),
70752
71307
  NotificationCenter.declare(api.settingsStore),
@@ -72411,6 +72966,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
72411
72966
  this.overlaySynthesisWarnAt.delete(deviceId);
72412
72967
  this.forgetDeviceProcessors(deviceId);
72413
72968
  this.levelStateByDevice.delete(deviceId);
72969
+ this.motionOnsets.forgetDevice(deviceId);
72414
72970
  this.audioConfirmByDevice.delete(deviceId);
72415
72971
  this.lastTrackActivityMs.delete(deviceId);
72416
72972
  this.motionEventSnapshots?.forgetDevice(deviceId);
@@ -73094,6 +73650,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73094
73650
  woken: result.stationaryWoken,
73095
73651
  timestamp: result.timestamp
73096
73652
  });
73653
+ if (this.stationaryRegistry && result.stationaryContinued > 0) this.stationaryRegistry.noteContinued(deviceId, result.stationaryContinued);
73097
73654
  const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
73098
73655
  const stationaryAsTracked = stationaryViews.map((v) => ({
73099
73656
  trackId: `stationary:${v.id}`,
@@ -73197,8 +73754,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73197
73754
  if (t.birthEvidence === void 0) continue;
73198
73755
  this.birthEvidence.remember(key, id, Buffer.from(t.birthEvidence.jpegBase64, "base64"), exhaustionNowMs);
73199
73756
  }
73200
- const outcome = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => {
73757
+ const withBirthEvidence = /* @__PURE__ */ new Set();
73758
+ const run = await this.confirmTrackBirths(bornCandidates.map(({ id, t }) => {
73201
73759
  const evidenceCrop = this.birthEvidence.get(key, id, exhaustionNowMs);
73760
+ if (evidenceCrop !== null) withBirthEvidence.add(id);
73202
73761
  return {
73203
73762
  trackId: id,
73204
73763
  className: t.className,
@@ -73213,6 +73772,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73213
73772
  frameWidth: result.frameWidth,
73214
73773
  frameHeight: result.frameHeight
73215
73774
  }, exhaustedIds);
73775
+ const outcome = run.outcome;
73216
73776
  const gateNowMs = Date.now();
73217
73777
  for (const { id, t } of bornCandidates) {
73218
73778
  if (outcome.undecided.has(id)) {
@@ -73241,6 +73801,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73241
73801
  } });
73242
73802
  continue;
73243
73803
  }
73804
+ this.recordBirthDecision({
73805
+ deviceId,
73806
+ trackId: id,
73807
+ className: t.className,
73808
+ confirmed: outcome.confirmed.has(id),
73809
+ exhaustedFallback: wasDeferred && exhaustedIds.has(id),
73810
+ attempts: deferredAttempts,
73811
+ deferredForMs,
73812
+ matchedThisFrame: t.matchedThisFrame,
73813
+ birthEvidenceAvailable: withBirthEvidence.has(id),
73814
+ decision: run.decisions.get(id),
73815
+ decidedAtMs: gateNowMs,
73816
+ firstSeen: this.trackStore?.peekActive(id)?.firstSeen ?? null
73817
+ });
73244
73818
  if (!outcome.confirmed.has(id)) {
73245
73819
  this.suppressedBirths.reject(key, id);
73246
73820
  this.trackStore?.dropActive(id);
@@ -73939,10 +74513,64 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73939
74513
  * candidate) and each failure path fails OPEN, so the synchronous frame path
73940
74514
  * is never blocked or reordered by a slow/failed re-detection.
73941
74515
  */
74516
+ /**
74517
+ * Record ONE resolved birth verdict in the durable ledger (D409).
74518
+ *
74519
+ * Synchronous up to the append, which is fired and forgotten: the frame path
74520
+ * awaits `confirmTrackBirths` already, and a measurement must never add a
74521
+ * store round trip to the latency of a birth. `BirthDecisionLedgerStore.append`
74522
+ * never throws, so the un-awaited promise cannot reject.
74523
+ *
74524
+ * Cost, per BIRTH and not per frame: one `Map.get`, one `Set.has`, a counter
74525
+ * bump and one INSERT. Measured fleet-wide on the live hub 2026-09-08 at ~49
74526
+ * births/hour, i.e. 0.014 writes/s — three orders of magnitude below the
74527
+ * 12-19/s the runtime-state guard was written for, and a different shape
74528
+ * (append-only, never a periodic rewrite of an unchanged slice).
74529
+ *
74530
+ * A runner with no ledger (construction failed) records nothing and says so
74531
+ * exactly once per process, rather than per birth: a measurement that cannot
74532
+ * be taken must be visible, but it must not become the noisiest line in Loki.
74533
+ */
74534
+ recordBirthDecision(input) {
74535
+ const ledger = this.birthDecisionLedger;
74536
+ if (ledger === null) {
74537
+ if (!this.birthLedgerAbsenceLogged) {
74538
+ this.birthLedgerAbsenceLogged = true;
74539
+ this.ctx.logger.warn("birth decisions are NOT being measured — no ledger on this runner", { tags: { deviceId: input.deviceId } });
74540
+ }
74541
+ return;
74542
+ }
74543
+ const motionOnsetAtMs = this.motionOnsets.onsetFor(input.deviceId);
74544
+ const birthIndexInBurst = this.motionOnsets.noteBirth(input.deviceId);
74545
+ const decision = input.decision;
74546
+ ledger.append(buildBirthDecisionRecord({
74547
+ deviceId: input.deviceId,
74548
+ trackId: input.trackId,
74549
+ className: input.className,
74550
+ confirmed: input.confirmed,
74551
+ exhaustedFallback: input.exhaustedFallback,
74552
+ attempts: input.attempts,
74553
+ deferredForMs: input.deferredForMs,
74554
+ ...input.matchedThisFrame !== void 0 ? { matchedThisFrame: input.matchedThisFrame } : {},
74555
+ birthEvidenceAvailable: input.birthEvidenceAvailable,
74556
+ decidedByBirthEvidence: decision?.cropSource === "carried",
74557
+ reason: decision?.reason ?? null,
74558
+ bestScore: decision?.bestScore ?? null,
74559
+ appliedMinConfidence: decision?.appliedMinConfidence ?? null,
74560
+ decidedAtMs: input.decidedAtMs,
74561
+ firstSeen: input.firstSeen,
74562
+ motionOnsetAtMs,
74563
+ birthIndexInBurst
74564
+ }));
74565
+ }
73942
74566
  async confirmTrackBirths(candidates, params, exhaustedIds = /* @__PURE__ */ new Set()) {
74567
+ const decisions = /* @__PURE__ */ new Map();
73943
74568
  const allConfirmed = () => ({
73944
- confirmed: new Set(candidates.map((c) => c.trackId)),
73945
- undecided: /* @__PURE__ */ new Set()
74569
+ outcome: {
74570
+ confirmed: new Set(candidates.map((c) => c.trackId)),
74571
+ undecided: /* @__PURE__ */ new Set()
74572
+ },
74573
+ decisions
73946
74574
  });
73947
74575
  if (candidates.length === 0) return allConfirmed();
73948
74576
  const config = await this.resolveDeviceConfirmationGateSettings(params.deviceId);
@@ -73965,101 +74593,105 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73965
74593
  const nodeId = frameHandle.nodeId;
73966
74594
  const candidateNativeRecovery = /* @__PURE__ */ new Map();
73967
74595
  for (const c of candidates) if (c.nativeRecovery !== void 0) candidateNativeRecovery.set(c.trackId, c.nativeRecovery);
73968
- return confirmBirths(candidates, config, {
73969
- fetchCrop: (candidate) => this.captureScheduler.request({
73970
- deviceId,
73971
- trackId: candidate.trackId,
73972
- kind: "confirm",
73973
- staleAfterMs: config.timeoutMs,
73974
- exec: () => captureCrop(frameHandle, {
74596
+ return {
74597
+ outcome: await confirmBirths(candidates, config, {
74598
+ fetchCrop: (candidate) => this.captureScheduler.request({
74599
+ deviceId,
74600
+ trackId: candidate.trackId,
74601
+ kind: "confirm",
74602
+ staleAfterMs: config.timeoutMs,
74603
+ exec: () => captureCrop(frameHandle, {
74604
+ x: candidate.bbox.x,
74605
+ y: candidate.bbox.y,
74606
+ w: candidate.bbox.w,
74607
+ h: candidate.bbox.h
74608
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
74609
+ }),
74610
+ fetchFallbackCrop: (candidate) => {
74611
+ const displayCrop = this.captureDisplayCropFn;
74612
+ if (!displayCrop) return Promise.resolve(null);
74613
+ return displayCrop(frameHandle, {
74614
+ x: candidate.bbox.x,
74615
+ y: candidate.bbox.y,
74616
+ w: candidate.bbox.w,
74617
+ h: candidate.bbox.h
74618
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
74619
+ },
74620
+ redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
74621
+ cropRectFor: (candidate) => captureCropFrameRect({
73975
74622
  x: candidate.bbox.x,
73976
74623
  y: candidate.bbox.y,
73977
74624
  w: candidate.bbox.w,
73978
74625
  h: candidate.bbox.h
73979
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
73980
- }),
73981
- fetchFallbackCrop: (candidate) => {
73982
- const displayCrop = this.captureDisplayCropFn;
73983
- if (!displayCrop) return Promise.resolve(null);
73984
- return displayCrop(frameHandle, {
74626
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING),
74627
+ minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
74628
+ deviceId,
74629
+ className: candidate.className,
73985
74630
  x: candidate.bbox.x,
73986
74631
  y: candidate.bbox.y,
73987
- w: candidate.bbox.w,
73988
- h: candidate.bbox.h
73989
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
73990
- },
73991
- redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
73992
- cropRectFor: (candidate) => captureCropFrameRect({
73993
- x: candidate.bbox.x,
73994
- y: candidate.bbox.y,
73995
- w: candidate.bbox.w,
73996
- h: candidate.bbox.h
73997
- }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING),
73998
- minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
73999
- deviceId,
74000
- className: candidate.className,
74001
- x: candidate.bbox.x,
74002
- y: candidate.bbox.y,
74003
- base: config.minConfidence,
74004
- now: Date.now()
74005
- }).bar ?? config.minConfidence,
74006
- onDecision: (decision) => {
74007
- const meta = {
74008
- trackId: decision.trackId,
74009
- reason: decision.reason,
74010
- className: decision.className,
74011
- ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
74012
- ...decision.bestIncompatibleClass !== void 0 ? {
74013
- bestIncompatibleClass: decision.bestIncompatibleClass,
74014
- bestIncompatibleScore: decision.bestIncompatibleScore
74015
- } : {},
74016
- minConfidence: config.minConfidence,
74017
- ...decision.cropSource !== void 0 ? { cropSource: decision.cropSource } : {},
74018
- ...decision.reason === "native-pass" ? {
74019
- nativeRecoveryCropSidePx: candidateNativeRecovery.get(decision.trackId)?.cropSidePx,
74020
- nativeRecoveryViewPx: candidateNativeRecovery.get(decision.trackId)?.viewPx
74021
- } : {},
74022
- ...decision.appliedMinConfidence !== void 0 && decision.appliedMinConfidence > config.minConfidence ? {
74023
- appliedMinConfidence: decision.appliedMinConfidence,
74024
- barRaisedByPhantomCell: true
74025
- } : {}
74026
- };
74027
- switch (decision.verdict) {
74028
- case "suppressed":
74029
- this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
74030
- tags: { deviceId },
74031
- meta
74032
- });
74033
- break;
74034
- case "undecided":
74035
- this.ctx.logger.info("confirmation gate: birth undecided", {
74036
- tags: { deviceId },
74037
- meta
74038
- });
74039
- break;
74040
- case "confirmed":
74041
- this.ctx.logger.info("confirmation gate: birth confirmed", {
74632
+ base: config.minConfidence,
74633
+ now: Date.now()
74634
+ }).bar ?? config.minConfidence,
74635
+ onDecision: (decision) => {
74636
+ decisions.set(decision.trackId, decision);
74637
+ const meta = {
74638
+ trackId: decision.trackId,
74639
+ reason: decision.reason,
74640
+ className: decision.className,
74641
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
74642
+ ...decision.bestIncompatibleClass !== void 0 ? {
74643
+ bestIncompatibleClass: decision.bestIncompatibleClass,
74644
+ bestIncompatibleScore: decision.bestIncompatibleScore
74645
+ } : {},
74646
+ minConfidence: config.minConfidence,
74647
+ ...decision.cropSource !== void 0 ? { cropSource: decision.cropSource } : {},
74648
+ ...decision.reason === "native-pass" ? {
74649
+ nativeRecoveryCropSidePx: candidateNativeRecovery.get(decision.trackId)?.cropSidePx,
74650
+ nativeRecoveryViewPx: candidateNativeRecovery.get(decision.trackId)?.viewPx
74651
+ } : {},
74652
+ ...decision.appliedMinConfidence !== void 0 && decision.appliedMinConfidence > config.minConfidence ? {
74653
+ appliedMinConfidence: decision.appliedMinConfidence,
74654
+ barRaisedByPhantomCell: true
74655
+ } : {}
74656
+ };
74657
+ switch (decision.verdict) {
74658
+ case "suppressed":
74659
+ this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
74660
+ tags: { deviceId },
74661
+ meta
74662
+ });
74663
+ break;
74664
+ case "undecided":
74665
+ this.ctx.logger.info("confirmation gate: birth undecided", {
74666
+ tags: { deviceId },
74667
+ meta
74668
+ });
74669
+ break;
74670
+ case "confirmed":
74671
+ this.ctx.logger.info("confirmation gate: birth confirmed", {
74672
+ tags: { deviceId },
74673
+ meta
74674
+ });
74675
+ break;
74676
+ }
74677
+ if (decision.secondary !== void 0) {
74678
+ this.secondarySubjects.add(this.procKey(deviceId, source), decision.secondary, decision.trackId, Date.now());
74679
+ this.ctx.logger.info("confirmation gate: secondary subject promoted from the crop", {
74042
74680
  tags: { deviceId },
74043
- meta
74681
+ meta: {
74682
+ trackId: decision.trackId,
74683
+ className: decision.className,
74684
+ secondaryClass: decision.secondary.className,
74685
+ secondaryScore: decision.secondary.score,
74686
+ secondaryBbox: decision.secondary.bbox,
74687
+ verdict: decision.verdict
74688
+ }
74044
74689
  });
74045
- break;
74046
- }
74047
- if (decision.secondary !== void 0) {
74048
- this.secondarySubjects.add(this.procKey(deviceId, source), decision.secondary, decision.trackId, Date.now());
74049
- this.ctx.logger.info("confirmation gate: secondary subject promoted from the crop", {
74050
- tags: { deviceId },
74051
- meta: {
74052
- trackId: decision.trackId,
74053
- className: decision.className,
74054
- secondaryClass: decision.secondary.className,
74055
- secondaryScore: decision.secondary.score,
74056
- secondaryBbox: decision.secondary.bbox,
74057
- verdict: decision.verdict
74058
- }
74059
- });
74690
+ }
74060
74691
  }
74061
- }
74062
- }, exhaustedIds);
74692
+ }, exhaustedIds),
74693
+ decisions
74694
+ };
74063
74695
  }
74064
74696
  async resolveGlobalFaceEnabled() {
74065
74697
  return this.faceGlobalEnabledCache.get(() => this.faceGlobalEnabledState.get());
@@ -75391,6 +76023,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75391
76023
  detected,
75392
76024
  atMs: timestamp
75393
76025
  });
76026
+ if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
75394
76027
  this.sceneEngine?.noteMotion(deviceId, timestamp);
75395
76028
  await this.eventStore.insertMotion(ev);
75396
76029
  this.ctx.eventBus.emit({
@@ -75466,6 +76099,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75466
76099
  detected,
75467
76100
  atMs: timestamp
75468
76101
  });
76102
+ if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
75469
76103
  this.sceneEngine?.noteMotion(deviceId, timestamp);
75470
76104
  await this.eventStore.insertMotion(ev);
75471
76105
  this.ctx.eventBus.emit({
@@ -76387,6 +77021,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76387
77021
  const s = this.stationarySettingsFromCache(deviceId);
76388
77022
  if (!s.enabled) return {
76389
77023
  suppressedIndices: /* @__PURE__ */ new Set(),
77024
+ continuableIndices: /* @__PURE__ */ new Set(),
76390
77025
  confirmed: [],
76391
77026
  woken: []
76392
77027
  };
@@ -78425,6 +79060,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78425
79060
  return await this.debugNoteArchive?.list(input) ?? [];
78426
79061
  }
78427
79062
  /**
79063
+ * The birth-decision ledger, newest first (D409).
79064
+ *
79065
+ * A READ of a measurement table and nothing else — no join back to the
79066
+ * tracks, deliberately: a SUPPRESSED birth never had a track row and a
79067
+ * confirmed one is expected to have aged out, so a join would return exactly
79068
+ * the rows the ledger exists to preserve. A runner with no ledger answers
79069
+ * EMPTY, which is true: nothing has been measured here.
79070
+ */
79071
+ async listBirthDecisions(input) {
79072
+ return await this.birthDecisionLedger?.list(input) ?? [];
79073
+ }
79074
+ /**
78428
79075
  * Track-centric time-based retention (design §5.1). Drains every persisted
78429
79076
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
78430
79077
  * widened cascade — enrolled faces/plates + identity media are exempt (design