@camstack/addon-post-analysis 1.2.135 → 1.2.136

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.
@@ -26978,8 +26978,12 @@ function rankAndCap(faces, order, limit) {
26978
26978
  * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
26979
26979
  * exempt from that and from every sweep — an assignment is a curation act.
26980
26980
  * The only other bound is capacity: pruneCapOverflow holds each camera's
26981
- * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
26982
- * of JSON embedding per row, that cap is what keeps the table small."
26981
+ * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50), plus a
26982
+ * SEPARATE, larger budget of deriveSuggestedBufferCap(bufferMaxPerDevice)
26983
+ * rows (default 200) for rows carrying a suggestedIdentityId — a question
26984
+ * the operator has not answered outlives a row nobody asked about, and
26985
+ * evicting one is logged with its deviceId. At 10.8 KB of JSON embedding
26986
+ * per row, those caps are what keep the table small."
26983
26987
  */
26984
26988
  var FACES_COLLECTION = "pipeline-analytics:faces";
26985
26989
  /**
@@ -27007,6 +27011,17 @@ var FACES_COLLECTION = "pipeline-analytics:faces";
27007
27011
  * cap, so the page is always the caller's bound and never the engine's.
27008
27012
  */
27009
27013
  var FACE_SWEEP_PAGE = 1e3;
27014
+ /** How many evicted face ids one per-camera eviction line names before it
27015
+ * stops — enough to find the rows, not enough to turn a log line into a page. */
27016
+ var SUGGESTED_EVICTION_IDS_LOGGED = 10;
27017
+ /**
27018
+ * A stored suggestion is present. The relational backend surfaces an unset
27019
+ * column as SQL NULL and the fake store as `undefined`; an empty string is
27020
+ * neither an identity nor a question, so it is not a suggestion either.
27021
+ */
27022
+ function hasSuggestion(raw) {
27023
+ return typeof raw === "string" && raw.length > 0;
27024
+ }
27010
27025
  /**
27011
27026
  * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
27012
27027
  * million faces — far past anything real, and reached only by a bug.
@@ -27213,12 +27228,21 @@ var FaceStore = class {
27213
27228
  * property the recognizer actually depends on: the gallery of recent
27214
27229
  * unassigned faces stays small.
27215
27230
  *
27216
- * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
27217
- * slots. Returns all deleted ids (the caller removes their crops).
27231
+ * **A row the system asked a question about outlives one it said nothing
27232
+ * about.** A single cap evicted both alike, so on 15 of 24 live cameras
27233
+ * (measured 2026-08-25) the buffer sat at exactly the cap and an unanswered
27234
+ * suggestion was deleted within about a day. Rows carrying a
27235
+ * `suggestedIdentityId` are ranked in their OWN, larger budget
27236
+ * (`maxSuggestedPerDevice`) — never immortality, because nothing clears a
27237
+ * suggestion the operator merely declines to answer. Size and trade-off:
27238
+ * `deriveSuggestedBufferCap` in `face-settings.ts:263`.
27239
+ *
27240
+ * ENROLLED (`assigned`) rows are skipped entirely and consume NO slot in
27241
+ * either tier. Returns all deleted ids (the caller removes their crops).
27218
27242
  */
27219
27243
  async pruneCapOverflow(input) {
27220
27244
  /**
27221
- * Paged, not one statement. Only three scalars per row survive the page —
27245
+ * Paged, not one statement. Only four scalars per row survive the page —
27222
27246
  * the `embedding` string that dominates the row is dropped as soon as the
27223
27247
  * page goes out of scope, so peak heap is one page rather than the whole
27224
27248
  * face table. The prune still considers EVERY row, which is what makes it a
@@ -27240,38 +27264,79 @@ var FaceStore = class {
27240
27264
  if (rows.length === 0) break;
27241
27265
  for (const r of rows) {
27242
27266
  const data = r.data;
27243
- if (data.assigned) continue;
27244
- const deviceId = Number(data.deviceId ?? -1);
27267
+ if (data["assigned"]) continue;
27268
+ const deviceId = Number(data["deviceId"] ?? -1);
27245
27269
  const list = byDevice.get(deviceId) ?? [];
27246
27270
  list.push({
27247
27271
  id: r.id,
27248
- timestamp: Number(data.timestamp ?? 0)
27272
+ timestamp: Number(data["timestamp"] ?? 0),
27273
+ suggested: hasSuggestion(data["suggestedIdentityId"])
27249
27274
  });
27250
27275
  byDevice.set(deviceId, list);
27251
27276
  }
27252
27277
  if (rows.length < 1e3) break;
27253
27278
  }
27254
27279
  const toDelete = /* @__PURE__ */ new Set();
27255
- for (const list of byDevice.values()) {
27280
+ const suggestedVictims = /* @__PURE__ */ new Map();
27281
+ for (const [deviceId, list] of byDevice) {
27256
27282
  list.sort((a, b) => b.timestamp - a.timestamp);
27257
- list.forEach((row, idx) => {
27258
- if (idx >= input.maxPerDevice) toDelete.add(row.id);
27259
- });
27283
+ let ordinaryRank = 0;
27284
+ let suggestedRank = 0;
27285
+ for (const row of list) {
27286
+ if (row.suggested) {
27287
+ if (suggestedRank++ < input.maxSuggestedPerDevice) continue;
27288
+ toDelete.add(row.id);
27289
+ const victims = suggestedVictims.get(deviceId) ?? [];
27290
+ victims.push(row.id);
27291
+ suggestedVictims.set(deviceId, victims);
27292
+ continue;
27293
+ }
27294
+ if (ordinaryRank++ < input.maxPerDevice) continue;
27295
+ toDelete.add(row.id);
27296
+ }
27260
27297
  }
27261
- const deleted = [];
27298
+ const deleted = /* @__PURE__ */ new Set();
27262
27299
  for (const id of toDelete) try {
27263
27300
  await this.store.delete.mutate({
27264
27301
  collection: FACES_COLLECTION,
27265
27302
  key: id
27266
27303
  });
27267
- deleted.push(id);
27304
+ deleted.add(id);
27268
27305
  } catch (err) {
27269
27306
  this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
27270
27307
  faceId: id,
27271
27308
  error: String(err)
27272
27309
  } });
27273
27310
  }
27274
- return deleted;
27311
+ this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
27312
+ return [...deleted];
27313
+ }
27314
+ /**
27315
+ * Name every camera that lost an UNANSWERED suggestion, and how many.
27316
+ *
27317
+ * This exists because the loss it reports ran for weeks without a single log
27318
+ * line: the prune only ever logged an aggregate count with no `deviceId`, so
27319
+ * "why did camera 618 forget the person it asked me about" could not be asked
27320
+ * of the logs at all. One line per camera (not per row) keeps the volume sane
27321
+ * while still answering the per-camera question.
27322
+ *
27323
+ * Reports rows that were ACTUALLY deleted — a row whose delete threw is still
27324
+ * in the buffer, and is reported by the failure line above instead.
27325
+ */
27326
+ announceSuggestedEvictions(victimsByDevice, deleted, cap) {
27327
+ for (const [deviceId, victims] of victimsByDevice) {
27328
+ const gone = victims.filter((id) => deleted.has(id));
27329
+ if (gone.length === 0) continue;
27330
+ this.logger.warn("face buffer evicted UNANSWERED suggestions — the operator never saw them", {
27331
+ tags: { deviceId },
27332
+ meta: {
27333
+ deleted: gone.length,
27334
+ cap,
27335
+ faceIds: gone.slice(0, SUGGESTED_EVICTION_IDS_LOGGED),
27336
+ why: "the suggested tier is full on this camera: suggestions arrive faster than they are answered, and the OLDEST unanswered ones are dropped first."
27337
+ }
27338
+ });
27339
+ }
27275
27340
  }
27276
27341
  /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
27277
27342
  async markAssigned(faceId, identityId, sampleId) {
@@ -37782,13 +37847,20 @@ var FaceSettingsSchema = object({
37782
37847
  * 0.175, best accuracy 98.3% at threshold 0.28.
37783
37848
  *
37784
37849
  * Recalibration plan, in this order:
37785
- * 1. land the landmark-precision gate (the alignment fix),
37850
+ * 1. DONE (2026-08-19, commit b6641329f): the landmark-precision gate —
37851
+ * `landmark-precision-gate.ts` in `addon-pipeline`, called from
37852
+ * `detection-pipeline/pipeline/executor.ts:1164` BEFORE the native-size
37853
+ * substitution, floor `minLandmarkFaceSize` (default 24 detection px).
37854
+ * Do not re-implement it; the steps below are what is still open.
37786
37855
  * 2. re-enrol the gallery from crops produced AFTER it,
37787
37856
  * 3. re-measure genuine/impostor over a week of post-fix traffic,
37788
37857
  * 4. expect to lower this toward ~0.30, and `margin` with it.
37789
- * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
37790
- * scores ~0.9 against everything, so the degenerate population sits ABOVE
37791
- * any threshold you could pick — the knob cannot reach it.
37858
+ * Lowering it before step 2 is still actively harmful: the gate stops NEW
37859
+ * degenerate crops, but every vector enrolled before it is still in the
37860
+ * gallery, and a misaligned crop scores ~0.9 against everything — so the
37861
+ * degenerate population sits ABOVE any threshold you could pick and the knob
37862
+ * cannot reach it. Steps 3 and 4 need data that only exists once step 2 has
37863
+ * run and post-fix traffic has accumulated.
37792
37864
  */
37793
37865
  similarityThreshold: number().min(0).max(1).default(.55),
37794
37866
  /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
@@ -37924,6 +37996,18 @@ function deriveSuggestionMinCosine(similarityThreshold) {
37924
37996
  return rounded < similarityThreshold ? rounded : exact;
37925
37997
  }
37926
37998
  /**
37999
+ * The suggested tier's capacity for a given ordinary capacity.
38000
+ *
38001
+ * INVARIANT, total over every finite input: the result is a non-negative
38002
+ * integer, is never BELOW `bufferMaxPerDevice` (a suggested row is never worse
38003
+ * off than an unsuggested one), and is `0` exactly when the ordinary cap is `0`
38004
+ * (one switch turns the buffer off, not two).
38005
+ */
38006
+ function deriveSuggestedBufferCap(bufferMaxPerDevice) {
38007
+ if (!Number.isFinite(bufferMaxPerDevice) || bufferMaxPerDevice <= 0) return 0;
38008
+ return Math.round(bufferMaxPerDevice * 4);
38009
+ }
38010
+ /**
37927
38011
  * The settings key that used to hold the suggestion floor. Named once, here,
37928
38012
  * because the ONLY code allowed to mention it now is the migration that removes
37929
38013
  * it from a store.
@@ -44497,13 +44581,27 @@ var SUPPRESS_IOU = .6;
44497
44581
  */
44498
44582
  var WAKE_ASSOC_IOU = .1;
44499
44583
  /**
44500
- * Look-back window over which a track must have stayed put to be PROMOTED. A
44501
- * car that drives in then parks has a large whole-life displacement but a tiny
44502
- * last-`windowMs` displacement — so promotion is judged on the recent window,
44503
- * not the full path. Also the minimum age (the track must have EXISTED this
44504
- * long) so a freshly-spawned static blob isn't promoted instantly.
44505
- */
44506
- var PROMOTION_WINDOW_MS = 3e4;
44584
+ * Look-back window over which a track must have stayed put to stop counting as
44585
+ * PRESENT for the orchestrator's session hold.
44586
+ *
44587
+ * This used to be the promotion criterion — "a track that stayed still this
44588
+ * long IS a parked object" — and it was measuring the wrong thing: it rewarded
44589
+ * an object seen well and often, while the parked cars that actually need
44590
+ * suppressing are re-discovered as dozens of short tracks that never live this
44591
+ * long (device 617: 140 tracks for one car, median life 6.8 s). Birth moved to
44592
+ * repetition — see `stationary-evidence.ts`.
44593
+ *
44594
+ * The stillness test survives because it was doing TWO jobs and only one of
44595
+ * them was destructive. Creating an entry BLINDS the pipeline to an object, so
44596
+ * it needs repeated evidence. Deciding that a motionless subject should stop
44597
+ * holding a motion session open destroys nothing — if it is wrong the session
44598
+ * closes early and the next motion re-opens it — so one recent stillness read
44599
+ * is the right amount of proof. Without this valve, removing promotion would
44600
+ * leave every session containing a parked car pinned to `maxSessionHoldMs`
44601
+ * (120 s) forever: 617 measured 504 sessions in 24 h, which is 70% detection
44602
+ * uptime on one camera, and three cameras are already above 50%.
44603
+ */
44604
+ var SESSION_STILLNESS_WINDOW_MS = 3e4;
44507
44605
  /** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
44508
44606
  * long (object removed while unobserved, or a long occlusion) → retire it. */
44509
44607
  var ENTRY_TTL_MS = 5 * 6e4;
@@ -44524,15 +44622,20 @@ var DEFAULT_MATCH_CONFIG = {
44524
44622
  * which entries are confirmed present, and which entries WOKE (their object
44525
44623
  * moved → retire the entry and let the detection spawn a normal track).
44526
44624
  *
44527
- * 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
44528
- * decides whether it has stayed put long enough to become a stationary
44529
- * entry. Reuses the static-track-gate metrics (net displacement + path span
44530
- * normalised to the frame diagonal) over the recent look-back window.
44625
+ * 2. `trackIsStill` — given a track's recent centroid path, decides whether it
44626
+ * has stopped moving. Reuses the static-track-gate metrics (net
44627
+ * displacement + path span normalised to the frame diagonal) over the
44628
+ * recent look-back window.
44629
+ *
44630
+ * This does NOT create a stationary entry, and used to. Birth is decided by
44631
+ * repetition (`stationary-evidence.ts`); this predicate now answers the one
44632
+ * question whose wrong answer is harmless — "should this subject still hold
44633
+ * a motion session open?"
44531
44634
  */
44532
- /** Default promotion tunables — static thresholds shared with the key-event
44533
- * static gate so "stationary" means the same thing in both places. */
44534
- var DEFAULT_PROMOTION_CONFIG = {
44535
- windowMs: PROMOTION_WINDOW_MS,
44635
+ /** Default stillness tunables — static thresholds shared with the key-event
44636
+ * static gate so "still" means the same thing in both places. */
44637
+ var DEFAULT_SESSION_STILLNESS_CONFIG = {
44638
+ windowMs: SESSION_STILLNESS_WINDOW_MS,
44536
44639
  netFracMax: STATIC_DISPLACEMENT_FRAC,
44537
44640
  spanFracMax: STATIC_SPAN_FRAC,
44538
44641
  minPoints: 4
@@ -44654,26 +44757,29 @@ function partitionDetectionsAgainstRegistry(input) {
44654
44757
  }
44655
44758
  var EMPTY_ARMED = /* @__PURE__ */ new Set();
44656
44759
  /**
44657
- * A track is promoted when, over the recent `windowMs`, its centroid barely
44658
- * moved (both net displacement and path span below the static thresholds) AND
44659
- * the track has actually EXISTED for at least `windowMs` (so a car that just
44660
- * arrived isn't parked yet). Judging on the recent window — not the whole life
44661
- * — is what lets a car that drove in then parked be recognised as stationary.
44760
+ * A track is STILL when, over the recent `windowMs`, its centroid barely moved
44761
+ * (both net displacement and path span below the static thresholds) AND the
44762
+ * track has actually EXISTED for at least `windowMs` (so a car that just
44763
+ * arrived has not stopped yet). Judging on the recent window — not the whole
44764
+ * life — is what lets a car that drove in then stopped be recognised.
44765
+ *
44766
+ * The one consumer is the orchestrator's presence signal: a still subject stops
44767
+ * holding its motion session open. It creates nothing and suppresses nothing.
44662
44768
  */
44663
- function evaluateStationaryPromotion(input) {
44769
+ function trackIsStill(input) {
44664
44770
  const { positions, referenceDiagonalPx, now, config } = input;
44665
- if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
44666
- if (now - positions[0].timestamp < config.windowMs) return { promote: false };
44771
+ if (!(referenceDiagonalPx > 0) || positions.length === 0) return { still: false };
44772
+ if (now - positions[0].timestamp < config.windowMs) return { still: false };
44667
44773
  const cutoff = now - config.windowMs;
44668
44774
  const window = positions.filter((p) => p.timestamp >= cutoff);
44669
- if (window.length < config.minPoints) return { promote: false };
44775
+ if (window.length < config.minPoints) return { still: false };
44670
44776
  const metrics = computeStaticTrackMetrics(window.map((p) => ({
44671
44777
  x: p.x,
44672
44778
  y: p.y
44673
44779
  })), referenceDiagonalPx);
44674
- if (metrics === void 0) return { promote: false };
44780
+ if (metrics === void 0) return { still: false };
44675
44781
  return {
44676
- promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
44782
+ still: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
44677
44783
  netFrac: metrics.netDisplacementFrac,
44678
44784
  spanFrac: metrics.pathSpanFrac
44679
44785
  };
@@ -45180,6 +45286,602 @@ var PackageDropDetector = class {
45180
45286
  }
45181
45287
  };
45182
45288
  //#endregion
45289
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
45290
+ /**
45291
+ * stationary-evidence — how a parked object EARNS a registry entry.
45292
+ *
45293
+ * ## The criterion this replaces, and why it was measuring the wrong thing
45294
+ *
45295
+ * Until 2026-08-28 an object became stationary by TRACK CONTINUITY: one track
45296
+ * had to stay alive and barely move for `PROMOTION_WINDOW_MS` (30 s). That
45297
+ * rewards an object that is *seen well and often* — precisely the opposite of
45298
+ * what the operator needs. Measured on device 617 (the parking lot), 24 h:
45299
+ *
45300
+ * - three cars are parked in view; the registry held exactly ONE entry;
45301
+ * - the two it missed were re-discovered as **140 and 112 separate tracks**,
45302
+ * median life **6.8 s**, p90 29 s. Not one of them could ever reach 30 s.
45303
+ * - those two objects alone produced **13 494 of 18 402** vehicle
45304
+ * observations (73%) — the whole re-spawn flood the registry exists to stop.
45305
+ *
45306
+ * The track dies; the OBJECT does not. So the evidence must outlive the track.
45307
+ *
45308
+ * ## The new criterion: a RATIO, not a count
45309
+ *
45310
+ * "The same thing, in the same place, most of the times it was seen."
45311
+ *
45312
+ * N — observations that AGREED with the candidate's anchor box (IoU ≥
45313
+ * {@link StationaryEvidencePolicy.agreeIou}).
45314
+ * M — observations OFFERED by a track once it bound to this candidate,
45315
+ * agreeing or not.
45316
+ *
45317
+ * A count would rescue nothing: a badly-lit car at night is seen rarely, and
45318
+ * any floor high enough to exclude a passer-by would exclude it too. A RATIO
45319
+ * asks a question a rarely-seen object can still answer well — 8 sightings out
45320
+ * of 10 in the same spot is stationary even though 10 is a small number.
45321
+ *
45322
+ * The dilution is the whole point, and it is why M counts a bound track's
45323
+ * observations even after it walks away: a car that drives THROUGH a spot binds
45324
+ * for a few frames and then contributes dozens of disagreeing ones. Measured on
45325
+ * 617 the two populations do not overlap:
45326
+ *
45327
+ * | candidate | N / M | ratio |
45328
+ * | ---------------------------- | ----------- | ----- |
45329
+ * | parked car A (missed today) | 6405 / 6467 | 0.99 |
45330
+ * | parked car B (missed today) | 343 / 343 | 1.00 |
45331
+ * | car crossing the lot | 5 / 70 | 0.07 |
45332
+ * | car turning through the lot | 14 / 91 | 0.15 |
45333
+ * | car manoeuvring | 12 / 41 | 0.29 |
45334
+ *
45335
+ * The threshold sits in the empty band between 0.29 and 0.99.
45336
+ *
45337
+ * ## The span is what a ratio cannot say
45338
+ *
45339
+ * A ratio alone promotes a car that paused for four seconds — its handful of
45340
+ * observations all agree. So evidence must also STRETCH: the agreeing
45341
+ * observations must span {@link StationaryEvidencePolicy.minSpanMs}. Measured
45342
+ * on 617, real parked cars span 2 min to 24 h; the two momentary stops in the
45343
+ * same day spanned 4.2 s and 8.0 s. Setting the span to
45344
+ * `DEFAULT_MAX_SESSION_HOLD_MS` also buys a structural guarantee: no single
45345
+ * motion session, however long it is held open, can promote on its own.
45346
+ *
45347
+ * ## ONE ledger, two producers
45348
+ *
45349
+ * Evidence is anchored to a BOX, not to a track and not to a session, so an
45350
+ * object confirmed half by an active motion session and half by a periodic
45351
+ * burst still promotes. Occasion A (the session) is implemented and is
45352
+ * sufficient on its own — it is free, because the pipeline is already decoding
45353
+ * and already inferring. Occasion B (the burst) is a precaution for the cases
45354
+ * where A could not have fired at all (hub restarted, camera offline, motion
45355
+ * detection failed, object already parked at boot); it is NOT implemented yet,
45356
+ * and {@link StationaryEvidenceSource} is the seam it will arrive through.
45357
+ *
45358
+ * ## Identity is IoU. There is no grid.
45359
+ *
45360
+ * The predecessor mechanism (`phantom-cells.ts`) quantises a point to a 32 px
45361
+ * cell, and its write path passes a CENTROID while its read path passes a
45362
+ * top-left CORNER — at 32 px every box bigger than 32 px lands in a different
45363
+ * cell on read, and its unit test never crosses that boundary because it calls
45364
+ * both halves with the same literal coordinates. This module has no such
45365
+ * boundary to get wrong: a candidate is found by overlap against the boxes a
45366
+ * device already holds, bounded by {@link EVIDENCE_MAX_CANDIDATES_PER_DEVICE}.
45367
+ * Device 617 — the busiest lot in the fleet — holds 8.
45368
+ */
45369
+ /**
45370
+ * `vehicle` and its kin. Every number below is read off device 617 over 24 h
45371
+ * (18 402 vehicle observations, 290 tracks) with the three parked cars the
45372
+ * operator can see out of the window as the bench.
45373
+ *
45374
+ * - `agreeIou` **0.5** — between the registry's own `WAKE_ASSOC_IOU` (0.1) and
45375
+ * `SUPPRESS_IOU` (0.6). At 0.7 the two missed cars lose 11% of their evidence
45376
+ * to night-time box wobble for no reduction in promotions; at 0.3 the day's
45377
+ * promotions double (10 vs 5) without catching anything new.
45378
+ * - `minAgreementRatio` **0.6** — the measured gap is [0.29, 0.90]; 0.6 is the
45379
+ * middle of it. Raising it to 0.95 starts dropping real parked cars.
45380
+ * - `minAgreed` **8** — a floor, not a discriminator: sweeping it 3→60 changes
45381
+ * nothing on 617 (the smallest true positive promotes with N=15). Set well
45382
+ * below that so a rarely-seen object is never excluded for being rare, which
45383
+ * is the entire point of using a ratio.
45384
+ * - `minSpanMs` **120 000** — the real discriminator. True positives span
45385
+ * 2 min–24 h; the day's two momentary stops spanned 4.2 s and 8.0 s.
45386
+ */
45387
+ var VEHICLE_EVIDENCE_POLICY = {
45388
+ agreeIou: .5,
45389
+ minAgreed: 8,
45390
+ minAgreementRatio: .6,
45391
+ minSpanMs: 12e4,
45392
+ promotesByDefault: true
45393
+ };
45394
+ /**
45395
+ * `person`, and it ships OFF.
45396
+ *
45397
+ * This is not caution, it is a measurement. Replaying 24 h of person
45398
+ * observations from devices 617, 1443, 590 and 592 through this exact ledger:
45399
+ *
45400
+ * | ratio | minAgreed | minSpanMs | promotions / 24 h across 4 cameras |
45401
+ * | ----- | --------- | --------- | ---------------------------------- |
45402
+ * | 0.85 | 20 | 600 s | 22 |
45403
+ * | 0.95 | 40 | 1800 s | 10 |
45404
+ * | 0.99 | 80 | 1800 s | **6** |
45405
+ *
45406
+ * There is no setting that reaches zero. Device 590 holds a person-shaped thing
45407
+ * that agrees with itself 1292 times out of 1654 (0.78) across 26 tracks over
45408
+ * ten hours, and device 592 produced eleven candidates at ratio 1.00 between
45409
+ * 02:00 and 05:00 — a statue, a poster, an IR artifact; the ledger cannot tell
45410
+ * those from a person standing still, because they look identical to it.
45411
+ *
45412
+ * Promoting a person is a BLINDING decision (D49 read in the direction that
45413
+ * destroys work: for a car the destructive read is the RETIREMENT — the flood
45414
+ * comes back; for a person it is the BIRTH — we stop seeing them). So the
45415
+ * class is available, tuned as strictly as the data allows, and switched off
45416
+ * until an operator turns it on for a camera where they know what is standing
45417
+ * there.
45418
+ */
45419
+ var PERSON_EVIDENCE_POLICY = {
45420
+ agreeIou: .5,
45421
+ minAgreed: 40,
45422
+ minAgreementRatio: .95,
45423
+ minSpanMs: 18e5,
45424
+ promotesByDefault: false
45425
+ };
45426
+ /** Classes with no policy of their own fall back to the vehicle numbers —
45427
+ * `animal`, `package` and friends are all "an object that ended up somewhere",
45428
+ * and none of them makes the camera blind to a person. */
45429
+ var DEFAULT_EVIDENCE_POLICY = VEHICLE_EVIDENCE_POLICY;
45430
+ var POLICY_BY_CLASS = new Map([["vehicle", VEHICLE_EVIDENCE_POLICY], ["person", PERSON_EVIDENCE_POLICY]]);
45431
+ function evidencePolicyFor(className) {
45432
+ return POLICY_BY_CLASS.get(className) ?? DEFAULT_EVIDENCE_POLICY;
45433
+ }
45434
+ function stationaryEvidenceKey(deviceId, className, anchorId) {
45435
+ return `${deviceId}:${className}:${anchorId}`;
45436
+ }
45437
+ /**
45438
+ * Does this candidate's evidence justify a registry entry?
45439
+ *
45440
+ * Pure, and ordered cheapest-question-first so the reason a candidate was
45441
+ * refused is the FIRST thing it failed, not the last.
45442
+ */
45443
+ function evaluateStationaryEvidence(row, policy, classEnabled) {
45444
+ const ratio = row.seen > 0 ? row.agreed / row.seen : 0;
45445
+ const spanMs = row.lastAgreedAt - row.firstAgreedAt;
45446
+ const base = {
45447
+ agreed: row.agreed,
45448
+ seen: row.seen,
45449
+ ratio,
45450
+ spanMs
45451
+ };
45452
+ if (!classEnabled) return {
45453
+ kind: "accumulating",
45454
+ shortfall: "class-not-promoted",
45455
+ ...base
45456
+ };
45457
+ if (row.agreed < policy.minAgreed) return {
45458
+ kind: "accumulating",
45459
+ shortfall: "too-few-agreements",
45460
+ ...base
45461
+ };
45462
+ if (ratio < policy.minAgreementRatio) return {
45463
+ kind: "accumulating",
45464
+ shortfall: "inconsistent-position",
45465
+ ...base
45466
+ };
45467
+ if (spanMs < policy.minSpanMs) return {
45468
+ kind: "accumulating",
45469
+ shortfall: "span-too-short",
45470
+ ...base
45471
+ };
45472
+ return {
45473
+ kind: "promote",
45474
+ ...base
45475
+ };
45476
+ }
45477
+ /**
45478
+ * The candidate this observation belongs to: the same-class row whose anchor it
45479
+ * overlaps most, provided the overlap reaches `agreeIou`. Overlap is the ONLY
45480
+ * notion of sameness here, and it is the same {@link iou} the suppression gate
45481
+ * uses — a second notion of "same place" is a second source of truth.
45482
+ */
45483
+ function matchEvidenceCandidate(rows, className, bbox, agreeIou) {
45484
+ let best;
45485
+ let bestOverlap = 0;
45486
+ for (const row of rows) {
45487
+ if (row.className !== className) continue;
45488
+ const overlap = iou(row.anchor, bbox);
45489
+ if (overlap > bestOverlap) {
45490
+ bestOverlap = overlap;
45491
+ best = row;
45492
+ }
45493
+ }
45494
+ if (best === void 0 || bestOverlap < agreeIou) return void 0;
45495
+ return {
45496
+ row: best,
45497
+ overlap: bestOverlap
45498
+ };
45499
+ }
45500
+ /**
45501
+ * Fold one observation into a candidate.
45502
+ *
45503
+ * `agrees` is decided by the CALLER, because M is what makes the ratio mean
45504
+ * anything: once a track has bound to a candidate, every later observation of
45505
+ * that track counts toward M whether it agrees or not. That is what dilutes a
45506
+ * car that merely drove through the spot, and it is why this function cannot
45507
+ * re-derive agreement from the box alone.
45508
+ */
45509
+ function foldObservation(row, observation, agrees, overlap) {
45510
+ const seen = row.seen + 1;
45511
+ if (!agrees) return {
45512
+ ...row,
45513
+ seen,
45514
+ updatedAt: observation.timestamp
45515
+ };
45516
+ const improves = overlap >= iou(row.anchor, row.bestBbox);
45517
+ return {
45518
+ ...row,
45519
+ seen,
45520
+ agreed: row.agreed + 1,
45521
+ firstAgreedAt: Math.min(row.firstAgreedAt, observation.timestamp),
45522
+ lastAgreedAt: Math.max(row.lastAgreedAt, observation.timestamp),
45523
+ ...improves ? {
45524
+ bestBbox: { ...observation.bbox },
45525
+ ...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
45526
+ ...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
45527
+ ...observation.label !== void 0 ? { label: observation.label } : {}
45528
+ } : {},
45529
+ sessionAgreed: row.sessionAgreed + (observation.source === "session" ? 1 : 0),
45530
+ burstAgreed: row.burstAgreed + (observation.source === "burst" ? 1 : 0),
45531
+ updatedAt: observation.timestamp
45532
+ };
45533
+ }
45534
+ /** A brand-new candidate anchored on the observation that had nowhere to go. */
45535
+ function newEvidenceRow(observation, anchorId) {
45536
+ return {
45537
+ key: stationaryEvidenceKey(observation.deviceId, observation.className, anchorId),
45538
+ deviceId: observation.deviceId,
45539
+ className: observation.className,
45540
+ anchor: { ...observation.bbox },
45541
+ agreed: 1,
45542
+ seen: 1,
45543
+ firstAgreedAt: observation.timestamp,
45544
+ lastAgreedAt: observation.timestamp,
45545
+ bestBbox: { ...observation.bbox },
45546
+ ...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
45547
+ ...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
45548
+ ...observation.label !== void 0 ? { label: observation.label } : {},
45549
+ sessionAgreed: observation.source === "session" ? 1 : 0,
45550
+ burstAgreed: observation.source === "burst" ? 1 : 0,
45551
+ updatedAt: observation.timestamp
45552
+ };
45553
+ }
45554
+ //#endregion
45555
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence-ledger.ts
45556
+ /**
45557
+ * @durable class=ledger owner=pipeline-analytics
45558
+ * write="a candidate's BIRTH and its PROMOTION are written immediately; the per-frame agreement tallies are STAGED in RAM and flushed on the 5-minute sweep (a parked car is observed 5-30 times a second and one commit per observation would offer ~10^6 writes a day to maintain a handful of rows)"
45559
+ * retention="a candidate untouched for 6h is deleted, and a device is capped at 512 candidates (least-recently-touched evicted). A promoted candidate is deleted the moment its entry is born. Never orphaned by track retention — evidence describes an OBJECT that outlives every track that ever saw it, which is the entire reason this collection exists."
45560
+ */
45561
+ var STATIONARY_EVIDENCE_COLLECTION = "pipeline-analytics:stationary-evidence";
45562
+ var STATIONARY_EVIDENCE_COLUMNS = [
45563
+ {
45564
+ name: "id",
45565
+ type: "TEXT",
45566
+ primaryKey: true,
45567
+ notNull: true
45568
+ },
45569
+ {
45570
+ name: "deviceId",
45571
+ type: "INTEGER",
45572
+ notNull: true
45573
+ },
45574
+ {
45575
+ name: "className",
45576
+ type: "TEXT",
45577
+ notNull: true
45578
+ },
45579
+ {
45580
+ name: "anchor",
45581
+ type: "JSON"
45582
+ },
45583
+ {
45584
+ name: "agreed",
45585
+ type: "INTEGER"
45586
+ },
45587
+ {
45588
+ name: "seen",
45589
+ type: "INTEGER"
45590
+ },
45591
+ {
45592
+ name: "firstAgreedAt",
45593
+ type: "INTEGER"
45594
+ },
45595
+ {
45596
+ name: "lastAgreedAt",
45597
+ type: "INTEGER"
45598
+ },
45599
+ {
45600
+ name: "bestBbox",
45601
+ type: "JSON"
45602
+ },
45603
+ {
45604
+ name: "sourceTrackId",
45605
+ type: "TEXT"
45606
+ },
45607
+ {
45608
+ name: "keyFrameMediaKey",
45609
+ type: "TEXT"
45610
+ },
45611
+ {
45612
+ name: "label",
45613
+ type: "TEXT"
45614
+ },
45615
+ {
45616
+ name: "sessionAgreed",
45617
+ type: "INTEGER"
45618
+ },
45619
+ {
45620
+ name: "burstAgreed",
45621
+ type: "INTEGER"
45622
+ },
45623
+ {
45624
+ name: "updatedAt",
45625
+ type: "INTEGER"
45626
+ }
45627
+ ];
45628
+ var STATIONARY_EVIDENCE_INDEXES = [{
45629
+ name: "idx_stationary_evidence_device",
45630
+ columns: ["deviceId"]
45631
+ }];
45632
+ function parseBbox(value) {
45633
+ if (typeof value !== "object" || value === null) return null;
45634
+ const { x, y, w, h } = { ...value };
45635
+ if (typeof x !== "number" || typeof y !== "number") return null;
45636
+ if (typeof w !== "number" || typeof h !== "number") return null;
45637
+ return {
45638
+ x,
45639
+ y,
45640
+ w,
45641
+ h
45642
+ };
45643
+ }
45644
+ function numberOr(value, fallback) {
45645
+ return typeof value === "number" ? value : fallback;
45646
+ }
45647
+ function stringOrUndefined(value) {
45648
+ return typeof value === "string" ? value : void 0;
45649
+ }
45650
+ var StationaryEvidenceLedger = class {
45651
+ ledger;
45652
+ logger;
45653
+ /** deviceId → candidate keys, so a device's candidates are found without
45654
+ * scanning the fleet. */
45655
+ byDevice = /* @__PURE__ */ new Map();
45656
+ /** `deviceId:trackId` → candidate key. RAM-only, see the file header. */
45657
+ binding = /* @__PURE__ */ new Map();
45658
+ /** Keys whose staged tallies have not been persisted yet. */
45659
+ dirty = /* @__PURE__ */ new Set();
45660
+ anchorSeq = 0;
45661
+ constructor(deps) {
45662
+ this.logger = deps.logger;
45663
+ const spec = {
45664
+ collection: STATIONARY_EVIDENCE_COLLECTION,
45665
+ columns: [...STATIONARY_EVIDENCE_COLUMNS],
45666
+ indexes: [...STATIONARY_EVIDENCE_INDEXES],
45667
+ writeMode: "write-behind",
45668
+ keyOf: (row) => row.key,
45669
+ toValue: (row) => ({
45670
+ deviceId: row.deviceId,
45671
+ className: row.className,
45672
+ anchor: { ...row.anchor },
45673
+ agreed: row.agreed,
45674
+ seen: row.seen,
45675
+ firstAgreedAt: row.firstAgreedAt,
45676
+ lastAgreedAt: row.lastAgreedAt,
45677
+ bestBbox: { ...row.bestBbox },
45678
+ ...row.sourceTrackId !== void 0 ? { sourceTrackId: row.sourceTrackId } : {},
45679
+ ...row.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: row.keyFrameMediaKey } : {},
45680
+ ...row.label !== void 0 ? { label: row.label } : {},
45681
+ sessionAgreed: row.sessionAgreed,
45682
+ burstAgreed: row.burstAgreed,
45683
+ updatedAt: row.updatedAt
45684
+ }),
45685
+ fromRecord: (key, data) => {
45686
+ const deviceId = data.deviceId;
45687
+ const className = data.className;
45688
+ if (typeof deviceId !== "number" || typeof className !== "string") return null;
45689
+ const anchor = parseBbox(data.anchor);
45690
+ const bestBbox = parseBbox(data.bestBbox) ?? anchor;
45691
+ if (anchor === null || bestBbox === null) return null;
45692
+ const sourceTrackId = stringOrUndefined(data.sourceTrackId);
45693
+ const keyFrameMediaKey = stringOrUndefined(data.keyFrameMediaKey);
45694
+ const label = stringOrUndefined(data.label);
45695
+ const updatedAt = numberOr(data.updatedAt, 0);
45696
+ return {
45697
+ key,
45698
+ deviceId,
45699
+ className,
45700
+ anchor,
45701
+ agreed: numberOr(data.agreed, 0),
45702
+ seen: numberOr(data.seen, 0),
45703
+ firstAgreedAt: numberOr(data.firstAgreedAt, updatedAt),
45704
+ lastAgreedAt: numberOr(data.lastAgreedAt, updatedAt),
45705
+ bestBbox,
45706
+ ...sourceTrackId !== void 0 ? { sourceTrackId } : {},
45707
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
45708
+ ...label !== void 0 ? { label } : {},
45709
+ sessionAgreed: numberOr(data.sessionAgreed, 0),
45710
+ burstAgreed: numberOr(data.burstAgreed, 0),
45711
+ updatedAt
45712
+ };
45713
+ },
45714
+ deviceIdOf: (row) => row.deviceId
45715
+ };
45716
+ this.ledger = new DurableLedger({
45717
+ spec,
45718
+ store: deps.store,
45719
+ logger: deps.logger
45720
+ });
45721
+ }
45722
+ declare() {
45723
+ return this.ledger.declare();
45724
+ }
45725
+ /** Boot reseed. Returns how many candidates came back — "loaded 0" after a
45726
+ * container recreate is the one line that explains a silent regression. */
45727
+ async load() {
45728
+ const rows = await this.ledger.load();
45729
+ for (const row of rows) this.index(row);
45730
+ return rows.length;
45731
+ }
45732
+ get size() {
45733
+ return this.ledger.snapshot().length;
45734
+ }
45735
+ candidatesFor(deviceId) {
45736
+ const keys = this.byDevice.get(deviceId);
45737
+ if (!keys) return [];
45738
+ const rows = [];
45739
+ for (const key of keys) {
45740
+ const row = this.ledger.get(key);
45741
+ if (row) rows.push(row);
45742
+ }
45743
+ return rows;
45744
+ }
45745
+ /**
45746
+ * Fold ONE observation in and say whether it just earned an entry.
45747
+ *
45748
+ * Synchronous by construction: the verdict is read from the RAM mirror, so a
45749
+ * slow or failing store can never change what this frame decides. The
45750
+ * durable half is fired behind the verdict.
45751
+ */
45752
+ observe(observation, classEnabled) {
45753
+ const policy = evidencePolicyFor(observation.className);
45754
+ this.expire(observation.deviceId, observation.timestamp);
45755
+ const bindKey = observation.trackId === void 0 ? void 0 : `${observation.deviceId}:${observation.trackId}`;
45756
+ const candidates = this.candidatesFor(observation.deviceId);
45757
+ let row;
45758
+ const boundKey = bindKey === void 0 ? void 0 : this.binding.get(bindKey);
45759
+ if (boundKey !== void 0) row = this.ledger.get(boundKey);
45760
+ let overlap = row === void 0 ? 0 : iou(row.anchor, observation.bbox);
45761
+ let created = false;
45762
+ if (row === void 0) {
45763
+ const match = matchEvidenceCandidate(candidates, observation.className, observation.bbox, policy.agreeIou);
45764
+ if (match) {
45765
+ row = match.row;
45766
+ overlap = match.overlap;
45767
+ } else {
45768
+ this.anchorSeq += 1;
45769
+ row = newEvidenceRow(observation, `a${this.anchorSeq}`);
45770
+ overlap = 1;
45771
+ created = true;
45772
+ this.index(row);
45773
+ this.write(row);
45774
+ if (bindKey !== void 0) this.binding.set(bindKey, row.key);
45775
+ this.capDevice(observation.deviceId);
45776
+ return {
45777
+ row,
45778
+ verdict: evaluateStationaryEvidence(row, policy, classEnabled),
45779
+ promoted: false
45780
+ };
45781
+ }
45782
+ if (bindKey !== void 0) this.binding.set(bindKey, row.key);
45783
+ }
45784
+ const agrees = overlap >= policy.agreeIou;
45785
+ const next = foldObservation(row, observation, agrees, overlap);
45786
+ this.ledger.stage(next);
45787
+ this.dirty.add(next.key);
45788
+ if (created) this.capDevice(observation.deviceId);
45789
+ const verdict = evaluateStationaryEvidence(next, policy, classEnabled);
45790
+ if (verdict.kind !== "promote") return {
45791
+ row: next,
45792
+ verdict,
45793
+ promoted: false
45794
+ };
45795
+ this.forgetKey(next.key);
45796
+ return {
45797
+ row: next,
45798
+ verdict,
45799
+ promoted: true
45800
+ };
45801
+ }
45802
+ /** Persist the staged tallies. Called from the registry's 5-minute sweep. */
45803
+ async flush() {
45804
+ const keys = [...this.dirty];
45805
+ this.dirty.clear();
45806
+ let written = 0;
45807
+ for (const key of keys) {
45808
+ const row = this.ledger.get(key);
45809
+ if (!row) continue;
45810
+ await this.write(row);
45811
+ written += 1;
45812
+ }
45813
+ return written;
45814
+ }
45815
+ /** Drop a device's evidence wholesale (camera removed / analytics reset). */
45816
+ async clearDevice(deviceId) {
45817
+ for (const row of this.candidatesFor(deviceId)) await this.forgetKeyDurable(row.key);
45818
+ this.byDevice.delete(deviceId);
45819
+ for (const [bindKey] of this.binding) if (bindKey.startsWith(`${deviceId}:`)) this.binding.delete(bindKey);
45820
+ }
45821
+ /** Forget the bindings of tracks the tracker no longer carries, so the map
45822
+ * cannot grow for the process's lifetime on a busy camera. */
45823
+ retainTracks(deviceId, liveTrackIds) {
45824
+ const prefix = `${deviceId}:`;
45825
+ for (const bindKey of [...this.binding.keys()]) {
45826
+ if (!bindKey.startsWith(prefix)) continue;
45827
+ if (!liveTrackIds.has(bindKey.slice(prefix.length))) this.binding.delete(bindKey);
45828
+ }
45829
+ }
45830
+ forgetKey(key) {
45831
+ this.forgetKeyDurable(key);
45832
+ }
45833
+ async forgetKeyDurable(key) {
45834
+ const row = this.ledger.get(key);
45835
+ this.dirty.delete(key);
45836
+ if (row) this.byDevice.get(row.deviceId)?.delete(key);
45837
+ for (const [bindKey, boundTo] of this.binding) if (boundTo === key) this.binding.delete(bindKey);
45838
+ await this.ledger.forget(key);
45839
+ }
45840
+ async write(row) {
45841
+ this.dirty.delete(row.key);
45842
+ await this.ledger.put(row);
45843
+ }
45844
+ index(row) {
45845
+ let keys = this.byDevice.get(row.deviceId);
45846
+ if (!keys) {
45847
+ keys = /* @__PURE__ */ new Set();
45848
+ this.byDevice.set(row.deviceId, keys);
45849
+ }
45850
+ keys.add(row.key);
45851
+ }
45852
+ /** Drop candidates untouched for {@link EVIDENCE_WINDOW_MS}. Evidence
45853
+ * describes an object that can drive away; a stale candidate is not a
45854
+ * weaker one, it is one about a thing that is gone. */
45855
+ expire(deviceId, now) {
45856
+ for (const row of this.candidatesFor(deviceId)) {
45857
+ if (now - row.updatedAt <= 216e5) continue;
45858
+ this.logger.debug("stationary evidence expired — candidate untouched past the window", {
45859
+ tags: { deviceId },
45860
+ meta: {
45861
+ className: row.className,
45862
+ agreed: row.agreed,
45863
+ seen: row.seen,
45864
+ ageMs: now - row.updatedAt
45865
+ }
45866
+ });
45867
+ this.forgetKey(row.key);
45868
+ }
45869
+ }
45870
+ capDevice(deviceId) {
45871
+ const rows = this.candidatesFor(deviceId);
45872
+ if (rows.length <= 512) return;
45873
+ const excess = [...rows].sort((a, b) => a.updatedAt - b.updatedAt).slice(0, rows.length - 512);
45874
+ this.logger.info("stationary evidence capped — least-recently-touched candidates evicted", {
45875
+ tags: { deviceId },
45876
+ meta: {
45877
+ evicted: excess.length,
45878
+ cap: 512
45879
+ }
45880
+ });
45881
+ for (const row of excess) this.forgetKey(row.key);
45882
+ }
45883
+ };
45884
+ //#endregion
45183
45885
  //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
45184
45886
  /**
45185
45887
  * @durable class=ledger owner=pipeline-analytics
@@ -55825,8 +56527,8 @@ function resolveTrackingSettings(raw) {
55825
56527
  * with no drift. Guarded by a unit test asserting the equality.
55826
56528
  */
55827
56529
  var StationarySettingsSchema = object({
55828
- /** Master switch. Off ⇒ no promotion + no suppression for this camera (every
55829
- * parked object keeps spawning normal tracks). */
56530
+ /** Master switch. Off ⇒ this camera gathers no evidence and suppresses
56531
+ * nothing (every parked object keeps spawning normal tracks). */
55830
56532
  enabled: boolean().default(true),
55831
56533
  /** IoU at/above which a detection is the same parked object, unmoved →
55832
56534
  * suppress its spawn. `SUPPRESS_IOU`. */
@@ -55835,9 +56537,22 @@ var StationarySettingsSchema = object({
55835
56537
  * wake it) — the overlap gate that stops a different vehicle from waking a
55836
56538
  * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
55837
56539
  wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
55838
- /** Recent-window stillness a track must hold to be PROMOTED to a parked
55839
- * entry (also the minimum track age). `PROMOTION_WINDOW_MS`. */
55840
- promotionWindowMs: number().int().min(5e3).max(12e4).default(PROMOTION_WINDOW_MS),
56540
+ /** Recent-window stillness after which a track stops holding its motion
56541
+ * session open (also the minimum track age). Creates nothing — birth is
56542
+ * decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
56543
+ sessionStillnessWindowMs: number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
56544
+ /**
56545
+ * Let a motionless PERSON become a stationary entry on this camera.
56546
+ *
56547
+ * Off, and it is a measurement rather than caution. Replaying 24 h of person
56548
+ * observations from devices 617/1443/590/592 through the evidence ledger
56549
+ * produces 6-22 entries a day depending on how strictly it is tuned, and none
56550
+ * of them can be tuned to zero: device 592 alone yielded eleven candidates at
56551
+ * a perfect agreement ratio between 02:00 and 05:00. A stationary entry
56552
+ * SUPPRESSES its object, so a wrong one makes the camera blind to a person —
56553
+ * turn this on only for a camera where you know what is standing there.
56554
+ */
56555
+ promotePersons: boolean().default(false),
55841
56556
  /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
55842
56557
  * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
55843
56558
  entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
@@ -55856,7 +56571,8 @@ function resolveStationarySettings(raw) {
55856
56571
  enabled: pick("enabled"),
55857
56572
  suppressIou: pick("suppressIou"),
55858
56573
  wakeAssocIou: pick("wakeAssocIou"),
55859
- promotionWindowMs: pick("promotionWindowMs"),
56574
+ sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
56575
+ promotePersons: pick("promotePersons"),
55860
56576
  entryTtlMs: pick("entryTtlMs")
55861
56577
  };
55862
56578
  }
@@ -56381,17 +57097,24 @@ function buildDetectionSettingsSections() {
56381
57097
  },
56382
57098
  {
56383
57099
  type: "slider",
56384
- key: "promotionWindowMs",
56385
- label: "Promotion stillness window",
56386
- description: "How long (ms) an object must stay put — and the track must have existed — before it is promoted to a parked entry. Longer = slower to stop a re-spawn flood.",
57100
+ key: "sessionStillnessWindowMs",
57101
+ label: "Session stillness window",
57102
+ description: "How long (ms) a tracked object must stay put before it stops holding a motion session open. It creates nothing: a parked object becomes an entry by being seen repeatedly in the same place, not by one track surviving this long.",
56387
57103
  min: 5e3,
56388
57104
  max: 12e4,
56389
57105
  step: 1e3,
56390
- default: STATIONARY_DEFAULTS.promotionWindowMs,
57106
+ default: STATIONARY_DEFAULTS.sessionStillnessWindowMs,
56391
57107
  showValue: true,
56392
57108
  unit: "s",
56393
57109
  displayScale: 1e3
56394
57110
  },
57111
+ {
57112
+ type: "boolean",
57113
+ key: "promotePersons",
57114
+ label: "Also park motionless people",
57115
+ description: "Off by default. A stationary entry SUPPRESSES its object, so a wrong one makes this camera blind to a person standing there. Replaying a day of real person detections produced entries on every camera tried, at every threshold tried — turn this on only where you know what is standing still.",
57116
+ default: STATIONARY_DEFAULTS.promotePersons
57117
+ },
56395
57118
  {
56396
57119
  type: "slider",
56397
57120
  key: "entryTtlMs",
@@ -58884,6 +59607,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58884
59607
  * confirmation gate's bar there; one moving track clears it. Null until
58885
59608
  * onInitialize. */
58886
59609
  phantomCells = null;
59610
+ /** Repeated-sighting evidence — the ONLY thing that creates a stationary
59611
+ * entry. Two producers may feed it; only the motion session does today. */
59612
+ stationaryEvidence = null;
58887
59613
  mediaStore = null;
58888
59614
  /** The enrolled gallery's rows. Retained so the photo-enrollment bridge can
58889
59615
  * reach them without going back through the cap surface. Null until S7. */
@@ -60147,6 +60873,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60147
60873
  }
60148
60874
  });
60149
60875
  await this.stationaryRegistry.load();
60876
+ this.stationaryEvidence = new StationaryEvidenceLedger({
60877
+ store: api.settingsStore,
60878
+ logger: logger.child("StationaryEvidence")
60879
+ });
60880
+ await this.stationaryEvidence.declare();
60881
+ const evidenceLoaded = await this.stationaryEvidence.load();
60882
+ logger.info("stationary evidence loaded", { meta: { candidates: evidenceLoaded } });
60150
60883
  this.phantomCells = new PhantomCellRegistry({
60151
60884
  store: api.settingsStore,
60152
60885
  logger: logger.child("PhantomCells")
@@ -61535,6 +62268,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61535
62268
  this.retentionSweepTimer = setInterval(() => {
61536
62269
  this.sweepRetention();
61537
62270
  this.stationaryRegistry?.sweep(Date.now());
62271
+ this.stationaryEvidence?.flush();
61538
62272
  this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
61539
62273
  }, RETENTION_SWEEP_INTERVAL_MS);
61540
62274
  this.ltsFlushTimer = setInterval(() => {
@@ -62369,38 +63103,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62369
63103
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
62370
63104
  const packageSettings = this.packageDropSettingsFromCache(deviceId);
62371
63105
  const packageClasses = new Set(packageSettings.packageDropClassFilter);
62372
- if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
62373
- const dims = this.lastFrameDimsByDevice.get(deviceId);
62374
- if (dims && dims.w > 0 && dims.h > 0) {
62375
- const refDiag = Math.hypot(dims.w, dims.h);
62376
- const promotionConfig = {
62377
- ...DEFAULT_PROMOTION_CONFIG,
62378
- windowMs: stationarySettings.promotionWindowMs
63106
+ if (source === "pipeline" && this.stationaryRegistry && this.stationaryEvidence && stationarySettings.enabled) {
63107
+ const evidence = this.stationaryEvidence;
63108
+ for (const t of result.tracked) {
63109
+ if (packageClasses.has(t.className)) continue;
63110
+ const classEnabled = t.className === "person" ? stationarySettings.promotePersons : evidencePolicyFor(t.className).promotesByDefault;
63111
+ const active = this.trackStore?.peekActive(t.trackId) ?? null;
63112
+ const observedLabel = active === null ? void 0 : displayLabel(active);
63113
+ const observedKeyFrame = this.residents.keyFrameKey(t.trackId);
63114
+ const observation = {
63115
+ deviceId,
63116
+ className: t.className,
63117
+ bbox: { ...t.bbox },
63118
+ timestamp: result.timestamp,
63119
+ source: "session",
63120
+ trackId: t.trackId,
63121
+ ...observedKeyFrame !== void 0 ? { keyFrameMediaKey: observedKeyFrame } : {},
63122
+ ...observedLabel !== void 0 ? { label: observedLabel } : {}
62379
63123
  };
62380
- for (const t of result.tracked) {
62381
- if (packageClasses.has(t.className)) continue;
62382
- const active = this.trackStore.peekActive(t.trackId);
62383
- if (!active) continue;
62384
- const { promote } = evaluateStationaryPromotion({
62385
- positions: active.positions,
62386
- referenceDiagonalPx: refDiag,
62387
- now: result.timestamp,
62388
- config: promotionConfig
62389
- });
62390
- if (!promote) continue;
62391
- this.promoteToStationary({
62392
- deviceId,
62393
- key,
62394
- processor,
62395
- track: t,
62396
- firstSeen: active.firstSeen,
62397
- label: displayLabel(active),
62398
- frameWidth: result.frameWidth,
62399
- frameHeight: result.frameHeight,
62400
- timestamp: result.timestamp
62401
- });
62402
- }
63124
+ const outcome = evidence.observe(observation, classEnabled);
63125
+ if (outcome === void 0 || !outcome.promoted) continue;
63126
+ this.birthStationaryFromEvidence({
63127
+ deviceId,
63128
+ key,
63129
+ processor,
63130
+ track: t,
63131
+ row: outcome.row,
63132
+ frameWidth: result.frameWidth,
63133
+ frameHeight: result.frameHeight,
63134
+ timestamp: result.timestamp,
63135
+ firstSeen: active === null ? outcome.row.firstAgreedAt : active.firstSeen
63136
+ });
62403
63137
  }
63138
+ evidence.retainTracks(deviceId, currentTrackIds);
62404
63139
  }
62405
63140
  if (source === "pipeline") this.runPackageStillness({
62406
63141
  deviceId,
@@ -62693,7 +63428,22 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62693
63428
  }
62694
63429
  overlayDetections = frame.detections;
62695
63430
  }
62696
- const hasMovingTrack = result.tracked.some((t) => t.state === "moving" || t.state === "entered" || t.state === "left");
63431
+ const stillnessConfig = {
63432
+ ...DEFAULT_SESSION_STILLNESS_CONFIG,
63433
+ windowMs: this.stationarySettingsFromCache(deviceId).sessionStillnessWindowMs
63434
+ };
63435
+ const stillnessDims = this.lastFrameDimsByDevice.get(deviceId);
63436
+ const stillnessDiag = stillnessDims && stillnessDims.w > 0 && stillnessDims.h > 0 ? Math.hypot(stillnessDims.w, stillnessDims.h) : 0;
63437
+ const hasLiveTrack = result.tracked.some((t) => {
63438
+ const active = this.trackStore?.peekActive(t.trackId);
63439
+ if (!active) return true;
63440
+ return !trackIsStill({
63441
+ positions: active.positions,
63442
+ referenceDiagonalPx: stillnessDiag,
63443
+ now: result.timestamp,
63444
+ config: stillnessConfig
63445
+ }).still;
63446
+ });
62697
63447
  this.ctx.eventBus.emit({
62698
63448
  id: `pa-${randomUUID()}`,
62699
63449
  timestamp: new Date(result.timestamp),
@@ -62709,7 +63459,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62709
63459
  frameWidth: result.frameWidth,
62710
63460
  frameHeight: result.frameHeight,
62711
63461
  detections: overlayDetections,
62712
- hasMovingTrack
63462
+ hasLiveTrack
62713
63463
  }
62714
63464
  });
62715
63465
  }
@@ -62976,9 +63726,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62976
63726
  /**
62977
63727
  * Count SHORT + MOTIONLESS track closes per frame cell.
62978
63728
  *
62979
- * The stationary registry cannot see this class of phantom. Promotion needs a
62980
- * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
62981
- * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
63729
+ * The stationary registry answers a different question (is this object parked,
63730
+ * and should it be suppressed?). This one raises a spawn bar. The dead
63731
+ * ornamental grass on device 615 produced tracks of 3.8 s,
62982
63732
  * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
62983
63733
  * fifteen of them in a day, all at the same spot. Each one is individually
62984
63734
  * innocent; the RECURRENCE is the signal, and nothing survives a track death
@@ -64515,29 +65265,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
64515
65265
  * track-OWNED entity (both rows carry a `trackId`) and it aged crops out from
64516
65266
  * under tracks still well inside their own window. Only the capacity bound
64517
65267
  * remains (D61).
65268
+ *
65269
+ * The cap is the OPERATOR'S, not the schema default. `bufferMaxPerDevice` is
65270
+ * offered on the settings form and this sweep read `FACE_DEFAULTS` instead,
65271
+ * so an override was a knob that wrote a store nobody read — the D62 shape.
65272
+ * The suggested tier is DERIVED from whatever value is in force
65273
+ * (`deriveSuggestedBufferCap`), so the two can never disagree.
64518
65274
  */
64519
65275
  async holdBufferCaps() {
64520
65276
  const mediaStore = this.mediaStore;
64521
65277
  if (!mediaStore) return;
65278
+ const bufferMaxPerDevice = await this.resolveBufferCap();
65279
+ const maxSuggestedPerDevice = deriveSuggestedBufferCap(bufferMaxPerDevice);
64522
65280
  if (this.faceStore) try {
64523
- const deletedFaceIds = await this.faceStore.pruneCapOverflow({ maxPerDevice: FACE_DEFAULTS.bufferMaxPerDevice });
65281
+ const deletedFaceIds = await this.faceStore.pruneCapOverflow({
65282
+ maxPerDevice: bufferMaxPerDevice,
65283
+ maxSuggestedPerDevice
65284
+ });
64524
65285
  if (deletedFaceIds.length > 0) {
64525
65286
  await mediaStore.deleteForOwner("face", deletedFaceIds);
64526
65287
  this.ctx.logger.info("face buffer capacity prune", { meta: {
64527
65288
  deleted: deletedFaceIds.length,
64528
- cap: FACE_DEFAULTS.bufferMaxPerDevice
65289
+ cap: bufferMaxPerDevice,
65290
+ suggestedCap: maxSuggestedPerDevice
64529
65291
  } });
64530
65292
  }
64531
65293
  } catch (err) {
64532
65294
  this.ctx.logger.debug("face buffer cap prune failed", { meta: { error: String(err) } });
64533
65295
  }
64534
65296
  if (this.plateStore) try {
64535
- const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice: FACE_DEFAULTS.bufferMaxPerDevice });
65297
+ const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice: bufferMaxPerDevice });
64536
65298
  if (deletedPlateIds.length > 0) {
64537
65299
  await mediaStore.deleteForOwner("plate", deletedPlateIds);
64538
65300
  this.ctx.logger.info("plate buffer capacity prune", { meta: {
64539
65301
  deleted: deletedPlateIds.length,
64540
- cap: FACE_DEFAULTS.bufferMaxPerDevice
65302
+ cap: bufferMaxPerDevice
64541
65303
  } });
64542
65304
  }
64543
65305
  } catch (err) {
@@ -64545,6 +65307,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
64545
65307
  }
64546
65308
  }
64547
65309
  /**
65310
+ * The buffer capacity actually in force: the GLOBAL setting, falling back to
65311
+ * the schema default when the store cannot be read.
65312
+ *
65313
+ * Global-only on purpose. The sweep is one pass over the whole collection and
65314
+ * a per-device cascade would mean one settings read per camera on every tick,
65315
+ * for a knob whose whole job is bounding a table. A per-camera capacity is a
65316
+ * decision nobody has asked for; the fallback here is the same number the
65317
+ * sweep used unconditionally before, so a settings outage cannot make the
65318
+ * bound stricter than it has ever been.
65319
+ */
65320
+ async resolveBufferCap() {
65321
+ try {
65322
+ return resolveFaceSettings(await this.resolveGlobalStore()).bufferMaxPerDevice;
65323
+ } catch (err) {
65324
+ this.ctx.logger.debug("face buffer cap: global settings unreadable, using the default", { meta: {
65325
+ error: String(err),
65326
+ cap: FACE_DEFAULTS.bufferMaxPerDevice
65327
+ } });
65328
+ return FACE_DEFAULTS.bufferMaxPerDevice;
65329
+ }
65330
+ }
65331
+ /**
64548
65332
  * Ingest a native (onboard) camera detection push and run it through
64549
65333
  * the SAME analysis layers as the ML pipeline, tagged `source:
64550
65334
  * 'onboard'`. Flag-only pushes (no bbox) can't be zoned/tracked and are
@@ -64831,29 +65615,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
64831
65615
  });
64832
65616
  }
64833
65617
  }
64834
- promoteToStationary(input) {
64835
- const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
64836
- const keyFrameMediaKey = this.residents.keyFrameKey(track.trackId);
64837
- const plausible = isPlausibleThumbnailBox(track.bbox, frameWidth, frameHeight);
64838
- if (!plausible) this.ctx.logger.info("stationary promotion rejected (near-full-frame box)", {
65618
+ /**
65619
+ * Turn a candidate whose evidence is sufficient into a registry entry.
65620
+ *
65621
+ * The BOX, label and key-frame come from the evidence row — the best agreeing
65622
+ * sighting across every track that ever contributed — not from whichever
65623
+ * track happened to be alive on the tipping frame. That is the point of the
65624
+ * ledger: the object outlives its tracks, so the entry must be built from
65625
+ * what was learned about the object, not from the last one to see it.
65626
+ */
65627
+ birthStationaryFromEvidence(input) {
65628
+ const { deviceId, key, processor, track, row, firstSeen, frameWidth, frameHeight, timestamp } = input;
65629
+ const label = row.label;
65630
+ const keyFrameMediaKey = row.keyFrameMediaKey ?? this.residents.keyFrameKey(track.trackId);
65631
+ const plausible = isPlausibleThumbnailBox(row.bestBbox, frameWidth, frameHeight);
65632
+ if (!plausible) this.ctx.logger.info("stationary birth rejected (near-full-frame box)", {
64839
65633
  tags: { deviceId },
64840
65634
  meta: {
64841
65635
  trackId: track.trackId,
64842
- className: track.className,
64843
- bbox: track.bbox
65636
+ className: row.className,
65637
+ bbox: row.bestBbox,
65638
+ agreed: row.agreed,
65639
+ seen: row.seen
64844
65640
  }
64845
65641
  });
64846
65642
  const entry = {
64847
65643
  id: randomUUID(),
64848
65644
  deviceId,
64849
- className: track.className,
64850
- bbox: { ...track.bbox },
65645
+ className: row.className,
65646
+ bbox: { ...row.bestBbox },
64851
65647
  frameWidth,
64852
65648
  frameHeight,
64853
65649
  firstSeenAt: firstSeen,
64854
65650
  becameStationaryAt: timestamp,
64855
65651
  lastConfirmedAt: timestamp,
64856
- sourceTrackId: track.trackId,
65652
+ sourceTrackId: row.sourceTrackId ?? track.trackId,
64857
65653
  ...label !== void 0 ? { label } : {},
64858
65654
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
64859
65655
  };
@@ -64891,12 +65687,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
64891
65687
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
64892
65688
  this.overlayState.onTrackEnded(deviceId, track.trackId);
64893
65689
  this.lastActiveTrackIds.get(key)?.delete(track.trackId);
64894
- this.ctx.logger.info("track promoted to stationary", {
65690
+ this.ctx.logger.info("stationary entry born from repeated evidence", {
64895
65691
  tags: { deviceId },
64896
65692
  meta: {
64897
65693
  trackId: track.trackId,
64898
- className: track.className,
65694
+ className: row.className,
64899
65695
  entryId: entry.id,
65696
+ agreed: row.agreed,
65697
+ seen: row.seen,
65698
+ agreementRatio: row.seen > 0 ? Number((row.agreed / row.seen).toFixed(3)) : 0,
65699
+ spanMs: row.lastAgreedAt - row.firstAgreedAt,
65700
+ sessionAgreed: row.sessionAgreed,
65701
+ burstAgreed: row.burstAgreed,
65702
+ plausible,
64900
65703
  ...label ? { label } : {}
64901
65704
  }
64902
65705
  });
@@ -65338,6 +66141,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65338
66141
  this.residents.closeDevice(input.deviceId);
65339
66142
  this.stationaryRegistry?.clearDevice(input.deviceId);
65340
66143
  this.phantomCells?.clearDevice(input.deviceId);
66144
+ this.stationaryEvidence?.clearDevice(input.deviceId);
65341
66145
  this.packageBackground?.clearDevice(input.deviceId);
65342
66146
  this.overlayState.clearDevice(input.deviceId);
65343
66147
  this.overlaySynthesisWarnAt.delete(input.deviceId);
@@ -66788,6 +67592,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66788
67592
  async wipeAllAnalytics(input) {
66789
67593
  await this.stationaryRegistry?.clearDevice(input.deviceId);
66790
67594
  await this.phantomCells?.clearDevice(input.deviceId);
67595
+ await this.stationaryEvidence?.clearDevice(input.deviceId);
66791
67596
  await this.packageBackground?.clearDevice(input.deviceId);
66792
67597
  return this.pruneTracksBefore({
66793
67598
  deviceId: input.deviceId,