@camstack/addon-post-analysis 1.2.18 → 1.2.20

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-CjwPOJKc.js");
5
+ const require_dist = require("../dist-vIJhE1KT.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -1441,6 +1441,94 @@ function resolveDetectionLabel(input) {
1441
1441
  if (top) return top;
1442
1442
  return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
1443
1443
  }
1444
+ var DEFAULT_ZONE_TRANSITION_CONFIG = {
1445
+ enterFrames: 2,
1446
+ exitFrames: 4
1447
+ };
1448
+ var NO_TRANSITIONS = {
1449
+ entered: [],
1450
+ exited: []
1451
+ };
1452
+ function newState() {
1453
+ return {
1454
+ inside: /* @__PURE__ */ new Set(),
1455
+ enterStreak: /* @__PURE__ */ new Map(),
1456
+ exitStreak: /* @__PURE__ */ new Map()
1457
+ };
1458
+ }
1459
+ var ZoneTransitionTracker = class {
1460
+ config;
1461
+ tracks = /* @__PURE__ */ new Map();
1462
+ constructor(config = {}) {
1463
+ this.config = {
1464
+ ...DEFAULT_ZONE_TRANSITION_CONFIG,
1465
+ ...config
1466
+ };
1467
+ }
1468
+ /**
1469
+ * Feed one frame's zone membership for one track.
1470
+ *
1471
+ * `currentZoneIds` is the set the geometry says the box is in RIGHT NOW; the
1472
+ * return value is only what has persisted long enough to be believed.
1473
+ */
1474
+ observe(trackId, currentZoneIds) {
1475
+ const state = this.tracks.get(trackId) ?? newState();
1476
+ this.tracks.set(trackId, state);
1477
+ const now = new Set(currentZoneIds);
1478
+ const entered = [];
1479
+ const exited = [];
1480
+ for (const zoneId of now) {
1481
+ if (state.inside.has(zoneId)) {
1482
+ state.exitStreak.delete(zoneId);
1483
+ continue;
1484
+ }
1485
+ const streak = (state.enterStreak.get(zoneId) ?? 0) + 1;
1486
+ if (streak >= this.config.enterFrames) {
1487
+ state.enterStreak.delete(zoneId);
1488
+ state.inside.add(zoneId);
1489
+ entered.push(zoneId);
1490
+ } else state.enterStreak.set(zoneId, streak);
1491
+ }
1492
+ for (const zoneId of [...state.enterStreak.keys()]) if (!now.has(zoneId)) state.enterStreak.delete(zoneId);
1493
+ for (const zoneId of [...state.inside]) {
1494
+ if (now.has(zoneId)) continue;
1495
+ const streak = (state.exitStreak.get(zoneId) ?? 0) + 1;
1496
+ if (streak >= this.config.exitFrames) {
1497
+ state.exitStreak.delete(zoneId);
1498
+ state.inside.delete(zoneId);
1499
+ exited.push(zoneId);
1500
+ } else state.exitStreak.set(zoneId, streak);
1501
+ }
1502
+ if (entered.length === 0 && exited.length === 0) return NO_TRANSITIONS;
1503
+ return {
1504
+ entered,
1505
+ exited
1506
+ };
1507
+ }
1508
+ /** Zones a track is currently confirmed to be inside. */
1509
+ zonesFor(trackId) {
1510
+ const state = this.tracks.get(trackId);
1511
+ return state ? [...state.inside] : [];
1512
+ }
1513
+ /**
1514
+ * Drop a track and report the zones it was still inside.
1515
+ *
1516
+ * The caller uses these to close the subject's presence — a track that
1517
+ * disappears mid-zone has genuinely left it, and nothing else will ever say
1518
+ * so. Without this, a zone entry would have no matching exit whenever the
1519
+ * subject walked out of frame rather than out of the zone.
1520
+ */
1521
+ forget(trackId) {
1522
+ const state = this.tracks.get(trackId);
1523
+ if (!state) return [];
1524
+ this.tracks.delete(trackId);
1525
+ return [...state.inside];
1526
+ }
1527
+ /** Number of tracks held — a leak check for the caller's tests. */
1528
+ size() {
1529
+ return this.tracks.size;
1530
+ }
1531
+ };
1444
1532
  //#endregion
1445
1533
  //#region src/pipeline-analytics/pipeline/zones/geometry.ts
1446
1534
  /** Ray-casting point-in-polygon test */
@@ -2302,22 +2390,48 @@ function resolveRuleThreshold(rule) {
2302
2390
  */
2303
2391
  var ZoneEngine = class {
2304
2392
  /**
2305
- * Annotate a single detection with its zone memberships.
2306
- * Returns zones where the detection overlaps above any active
2307
- * rule's threshold (or the engine default if no rule sets one).
2393
+ * Annotate a single detection with its zone memberships — every zone whose
2394
+ * polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
2395
+ * the detection's own area).
2396
+ *
2397
+ * The previous version of this comment claimed memberships were returned
2398
+ * "above any active rule's threshold"; they were not — the threshold was
2399
+ * hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
2400
+ * consulted. Read the parameter, not this paragraph.
2401
+ *
2402
+ * `minOverlap` matters because membership is what lands on an event as
2403
+ * `zones`, and a notification rule's `zones` condition is a plain set test
2404
+ * over that field — so this, not the zone-RULE threshold, is what decides
2405
+ * whether a zone-scoped notification fires. At the default of 0 a subject
2406
+ * clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
2407
+ * dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
2308
2408
  */
2309
- annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
2409
+ annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2410
+ return [...this.splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap).memberships];
2411
+ }
2412
+ /**
2413
+ * {@link annotateDetection}, keeping the zones the bar REJECTED as well as
2414
+ * the ones it admitted. Same geometry, one pass — see
2415
+ * {@link ZoneMembershipSplit} for why the rejected side has to survive.
2416
+ */
2417
+ splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2310
2418
  const memberships = [];
2419
+ const belowBar = [];
2311
2420
  for (const zone of zones) {
2312
2421
  const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
2313
2422
  const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
2314
- if (overlap > MEMBERSHIP_MIN_OVERLAP) memberships.push({
2423
+ const entry = {
2315
2424
  zoneId: zone.id,
2316
2425
  zoneName: zone.name,
2317
2426
  overlap
2318
- });
2427
+ };
2428
+ if (overlap > minOverlap) memberships.push(entry);
2429
+ else if (overlap > 0) belowBar.push(entry);
2319
2430
  }
2320
- return memberships;
2431
+ return {
2432
+ memberships,
2433
+ belowBar
2434
+ };
2321
2435
  }
2322
2436
  /**
2323
2437
  * Filter detections through a zone-rule set. `zones` provides the
@@ -2378,6 +2492,66 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
2378
2492
  return false;
2379
2493
  }
2380
2494
  //#endregion
2495
+ //#region src/pipeline-analytics/pipeline/rider-pairing.ts
2496
+ /**
2497
+ * Fine classes that carry a rider. A scooter/moped reaches us as
2498
+ * `motorcycle` (COCO) or `motorbike` depending on the model's label set.
2499
+ */
2500
+ var TWO_WHEELERS = new Set([
2501
+ "bicycle",
2502
+ "motorcycle",
2503
+ "motorbike"
2504
+ ]);
2505
+ /** Intersection area of two boxes. */
2506
+ function intersection(a, b) {
2507
+ return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
2508
+ }
2509
+ /** True when `vehicle` is a two-wheeler that `person` is riding. */
2510
+ function isRiderPair(person, vehicle) {
2511
+ if (person.macroClass !== "person") return false;
2512
+ if (vehicle.macroClass !== "vehicle") return false;
2513
+ if (!TWO_WHEELERS.has((vehicle.originalClass ?? "").toLowerCase())) return false;
2514
+ const personArea = person.bbox.w * person.bbox.h;
2515
+ if (!(personArea > 0)) return false;
2516
+ if (intersection(person.bbox, vehicle.bbox) / personArea < .25) return false;
2517
+ return person.bbox.y + person.bbox.h / 2 < vehicle.bbox.y + vehicle.bbox.h / 2;
2518
+ }
2519
+ /**
2520
+ * Pair every rider with their machine. A person is paired at most once — with
2521
+ * the two-wheeler they overlap MOST — so two bikes side by side cannot both
2522
+ * claim the same rider.
2523
+ */
2524
+ function pairRiders(detections) {
2525
+ const people = detections.filter((d) => d.macroClass === "person");
2526
+ const twoWheelers = detections.filter((d) => d.macroClass === "vehicle" && TWO_WHEELERS.has((d.originalClass ?? "").toLowerCase()));
2527
+ if (people.length === 0 || twoWheelers.length === 0) return [];
2528
+ const pairs = [];
2529
+ const claimed = /* @__PURE__ */ new Set();
2530
+ for (const person of people) {
2531
+ let best = null;
2532
+ const personArea = person.bbox.w * person.bbox.h;
2533
+ if (!(personArea > 0)) continue;
2534
+ for (const vehicle of twoWheelers) {
2535
+ if (claimed.has(vehicle.id)) continue;
2536
+ if (!isRiderPair(person, vehicle)) continue;
2537
+ const overlap = intersection(person.bbox, vehicle.bbox) / personArea;
2538
+ if (!best || overlap > best.overlap) best = {
2539
+ vehicle,
2540
+ overlap
2541
+ };
2542
+ }
2543
+ if (best) {
2544
+ claimed.add(best.vehicle.id);
2545
+ pairs.push({
2546
+ personId: person.id,
2547
+ vehicleId: best.vehicle.id,
2548
+ overlap: Math.round(best.overlap * 1e3) / 1e3
2549
+ });
2550
+ }
2551
+ }
2552
+ return pairs;
2553
+ }
2554
+ //#endregion
2381
2555
  //#region src/pipeline-analytics/pipeline/frame-processor.ts
2382
2556
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
2383
2557
  * canonical TrackState enum used on tracks + events. */
@@ -2411,6 +2585,21 @@ var FrameProcessor = class {
2411
2585
  * runners that haven't picked up the new gating yet.
2412
2586
  */
2413
2587
  detectionRules;
2588
+ /** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
2589
+ zoneMembershipMinOverlap;
2590
+ /** See {@link getLastZoneOverlaps}. */
2591
+ lastZoneOverlaps;
2592
+ /** See {@link getLastZoneRejections}. */
2593
+ lastZoneRejections;
2594
+ /** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
2595
+ zoneTransitions = new ZoneTransitionTracker();
2596
+ /**
2597
+ * Last zone membership seen per track, so an event for a track that is GONE
2598
+ * this frame still carries the zones it was in. See the use site.
2599
+ */
2600
+ lastZonesByTrack = /* @__PURE__ */ new Map();
2601
+ /** See {@link getLastRiderPairs}. */
2602
+ lastRiderPairs = [];
2414
2603
  zoneEngine = new ZoneEngine();
2415
2604
  /** Optional stationary-object gate (parked-object suppression). Null until
2416
2605
  * the addon wires it via {@link setStationaryGate}. */
@@ -2423,10 +2612,46 @@ var FrameProcessor = class {
2423
2612
  this.eventEmitter = new DetectionEventEmitter(emitterConfig);
2424
2613
  this.zones = [];
2425
2614
  this.detectionRules = [];
2615
+ this.zoneMembershipMinOverlap = 0;
2616
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
2617
+ this.lastZoneRejections = /* @__PURE__ */ new Map();
2426
2618
  }
2427
2619
  setZones(zones) {
2428
2620
  this.zones = zones;
2429
2621
  }
2622
+ /**
2623
+ * How much of a detection's box must lie inside a zone for the zone to be
2624
+ * stamped onto it (0–1 fraction of the box's own area).
2625
+ *
2626
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
2627
+ * positive overlap counted. Raising it is an operator decision and needs
2628
+ * evidence: a bar set blind removes notifications silently, which is the
2629
+ * failure mode this whole area keeps producing. {@link lastZoneOverlaps}
2630
+ * exists so the distribution can be read before a number is picked.
2631
+ */
2632
+ setZoneMembershipMinOverlap(minOverlap) {
2633
+ this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
2634
+ }
2635
+ /** Rider pairings folded on the most recent frame — the PERSON half that was
2636
+ * dropped so the passage counts once, as a vehicle. Reported so both
2637
+ * entities stay on the record rather than one silently disappearing. */
2638
+ getLastRiderPairs() {
2639
+ return this.lastRiderPairs;
2640
+ }
2641
+ /** Per-track zone memberships WITH their overlap fractions, from the most
2642
+ * recent frame. The engine computes these and the pipeline previously
2643
+ * discarded everything but the ids — which is why no amount of production
2644
+ * data could say how far inside the zone a notifying subject actually was. */
2645
+ getLastZoneOverlaps() {
2646
+ return this.lastZoneOverlaps;
2647
+ }
2648
+ /** Per-track zones the membership bar REJECTED (0 < overlap ≤ the bar), from
2649
+ * the most recent frame. Raising the bar otherwise makes the stamping log go
2650
+ * quiet for exactly the cases it was armed to measure — see
2651
+ * `ZoneMembershipSplit` in `zones/zone-engine.ts`. */
2652
+ getLastZoneRejections() {
2653
+ return this.lastZoneRejections;
2654
+ }
2430
2655
  setDetectionRules(rules) {
2431
2656
  this.detectionRules = rules;
2432
2657
  }
@@ -2519,7 +2744,24 @@ var FrameProcessor = class {
2519
2744
  });
2520
2745
  }
2521
2746
  const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
2522
- const filteredDetections = passed.map((fd) => fd.detection);
2747
+ let filteredDetections = passed.map((fd) => fd.detection);
2748
+ const riderPairs = pairRiders(filteredDetections.map((d, i) => ({
2749
+ id: String(i),
2750
+ macroClass: d.class,
2751
+ ...d.originalClass !== void 0 ? { originalClass: d.originalClass } : {},
2752
+ bbox: d.bbox,
2753
+ score: d.score
2754
+ })));
2755
+ if (riderPairs.length > 0) {
2756
+ const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
2757
+ this.lastRiderPairs = riderPairs.map((p) => ({
2758
+ overlap: p.overlap,
2759
+ personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
2760
+ vehicleScore: filteredDetections[Number(p.vehicleId)]?.score ?? 0,
2761
+ vehicleClass: filteredDetections[Number(p.vehicleId)]?.originalClass ?? "two-wheeler"
2762
+ }));
2763
+ filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
2764
+ } else if (this.lastRiderPairs.length > 0) this.lastRiderPairs = [];
2523
2765
  const gate = this.stationaryGate ? this.stationaryGate.filter({
2524
2766
  detections: filteredDetections,
2525
2767
  frameWidth,
@@ -2535,12 +2777,46 @@ var FrameProcessor = class {
2535
2777
  frameHeight
2536
2778
  });
2537
2779
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2538
- const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2539
2780
  const zonesByTrack = /* @__PURE__ */ new Map();
2781
+ const overlapsByTrack = /* @__PURE__ */ new Map();
2782
+ const rejectedByTrack = /* @__PURE__ */ new Map();
2540
2783
  for (const td of trackedDetections) {
2541
2784
  const m = maskByBbox.get(td.bbox);
2542
- const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
2785
+ const { memberships, belowBar } = this.zoneEngine.splitDetectionZones(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2543
2786
  zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
2787
+ if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
2788
+ if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
2789
+ }
2790
+ this.lastZoneOverlaps = overlapsByTrack;
2791
+ this.lastZoneRejections = rejectedByTrack;
2792
+ for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
2793
+ for (const state of objectStates) {
2794
+ if (zonesByTrack.has(state.trackId)) continue;
2795
+ const remembered = this.lastZonesByTrack.get(state.trackId);
2796
+ if (remembered && remembered.length > 0) zonesByTrack.set(state.trackId, remembered);
2797
+ }
2798
+ const zoneEvents = [];
2799
+ const pushZoneEvent = (type, zoneId, detection) => {
2800
+ const zone = this.zones.find((z) => z.id === zoneId);
2801
+ zoneEvents.push({
2802
+ type,
2803
+ zoneId,
2804
+ zoneName: zone?.name ?? zoneId,
2805
+ trackId: detection.trackId,
2806
+ detection,
2807
+ timestamp
2808
+ });
2809
+ };
2810
+ for (const td of trackedDetections) {
2811
+ const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
2812
+ for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
2813
+ for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
2814
+ }
2815
+ const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
2816
+ for (const state of objectStates) {
2817
+ if (state.state !== "leaving") continue;
2818
+ this.zoneTransitions.forget(state.trackId);
2819
+ this.lastZonesByTrack.delete(state.trackId);
2544
2820
  }
2545
2821
  const tracked = trackedDetections.map((td) => {
2546
2822
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
@@ -2579,10 +2855,22 @@ var FrameProcessor = class {
2579
2855
  } : {}
2580
2856
  };
2581
2857
  });
2858
+ const crossingOf = (e) => {
2859
+ const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
2860
+ if (direction === void 0) return void 0;
2861
+ const ze = e.zoneEvents[0];
2862
+ if (ze === void 0) return void 0;
2863
+ return {
2864
+ direction,
2865
+ zoneId: ze.zoneId,
2866
+ zoneName: ze.zoneName
2867
+ };
2868
+ };
2582
2869
  const toObjectEvent = (e, forcedState) => {
2583
2870
  const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
2584
2871
  const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
2585
2872
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
2873
+ const crossing = crossingOf(e);
2586
2874
  const label = td ? resolveDetectionLabel({
2587
2875
  className: td.class,
2588
2876
  originalClass: td.originalClass,
@@ -2606,6 +2894,7 @@ var FrameProcessor = class {
2606
2894
  },
2607
2895
  zones,
2608
2896
  state,
2897
+ ...crossing !== void 0 ? { zoneCrossing: crossing } : {},
2609
2898
  ...label ? { label } : {},
2610
2899
  frameWidth,
2611
2900
  frameHeight
@@ -3504,7 +3793,8 @@ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
3504
3793
  * for the full contract.
3505
3794
  */
3506
3795
  function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
3507
- if (input.hasMedia) return "persist";
3796
+ if (input.hasBestMedia) return "persist";
3797
+ if (input.hasMedia) return input.hasRasterFallback ? "raster-fallback" : "persist";
3508
3798
  if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
3509
3799
  if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
3510
3800
  return input.hasRasterFallback ? "raster-fallback" : "persist";
@@ -3698,9 +3988,11 @@ var TrackCloser = class {
3698
3988
  const duration = t.lastSeen - t.firstSeen;
3699
3989
  const closure = this.deps.residents.closure(t.trackId);
3700
3990
  const ownedMedia = await this.deps.mediaStore()?.listByOwner("track", t.trackId) ?? [];
3991
+ const hasBestMedia = ownedMedia.some((m) => m.kind === "thumbnail" || m.kind === "keyFrame");
3701
3992
  const outcome = decideZeroMediaPolicy({
3702
3993
  durationMs: duration,
3703
3994
  hasMedia: ownedMedia.length > 0,
3995
+ hasBestMedia,
3704
3996
  confirmed: closure?.confirmed ?? false,
3705
3997
  hasRasterFallback: closure?.rasterFallback !== void 0
3706
3998
  });
@@ -4042,6 +4334,7 @@ function subjectFromObjectEvent(ev) {
4042
4334
  ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
4043
4335
  source: ev.source ?? "pipeline",
4044
4336
  ...ev.importance !== void 0 ? { importance: ev.importance } : {},
4337
+ ...ev.zoneCrossing !== void 0 ? { crossing: ev.zoneCrossing } : {},
4045
4338
  ...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
4046
4339
  };
4047
4340
  }
@@ -4208,6 +4501,7 @@ function presentConditionIds(c) {
4208
4501
  if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
4209
4502
  if (c.zones !== void 0) ids.push("zones");
4210
4503
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
4504
+ if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
4211
4505
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
4212
4506
  if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
4213
4507
  if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
@@ -4243,6 +4537,21 @@ function matchesOccupancy(occ, s) {
4243
4537
  case "<=": return s.occupied === false && s.threshold === occ.count + 1;
4244
4538
  }
4245
4539
  }
4540
+ /**
4541
+ * The zone ids a `zones` / `zonesExclude` condition tests against: the
4542
+ * record's membership PLUS the zone it crossed, when it is a crossing.
4543
+ *
4544
+ * The union is what makes an EXIT addressable. Membership is computed from the
4545
+ * box's current geometry, so on the frame an exit is confirmed the subject is
4546
+ * by definition no longer in the zone — a rule scoped to "Uscio" would never
4547
+ * see the exit from Uscio. An ENTRY's zone is already in the membership, so
4548
+ * this changes nothing for every rule that exists today.
4549
+ */
4550
+ function zonesVisitedBy(subject) {
4551
+ const visited = new Set(subject.zones);
4552
+ if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
4553
+ return visited;
4554
+ }
4246
4555
  function toLowerSet(values) {
4247
4556
  return new Set(values.map((v) => v.trim().toLowerCase()));
4248
4557
  }
@@ -4328,14 +4637,20 @@ function evaluateRule(rule, subject) {
4328
4637
  if (c.minDwellSeconds !== void 0) {
4329
4638
  if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
4330
4639
  }
4640
+ if (c.crossing !== "any") {
4641
+ const direction = subject.crossing?.direction;
4642
+ if (c.crossing === "exit") {
4643
+ if (direction !== "exit") return fail("crossing");
4644
+ } else if (direction === "exit") return fail("crossing");
4645
+ }
4331
4646
  if (c.zones !== void 0) {
4332
- const visited = new Set(subject.zones);
4647
+ const visited = zonesVisitedBy(subject);
4333
4648
  if (c.zones.match === "all") {
4334
4649
  for (const id of c.zones.ids) if (!visited.has(id)) return fail("zones");
4335
4650
  } else if (!c.zones.ids.some((id) => visited.has(id))) return fail("zones");
4336
4651
  }
4337
4652
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) {
4338
- const visited = new Set(subject.zones);
4653
+ const visited = zonesVisitedBy(subject);
4339
4654
  if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
4340
4655
  }
4341
4656
  if (c.customZones !== void 0 && c.customZones.length > 0) {
@@ -4464,10 +4779,28 @@ function matchesPlate(label, values, maxDistance) {
4464
4779
  for (const v of values) if (levenshtein(plate, normalizePlate(v)) <= maxDistance) return true;
4465
4780
  return false;
4466
4781
  }
4467
- /** Stable cooldown key per the rule's throttle scope. */
4782
+ /**
4783
+ * Should this subject's class be part of the cooldown key?
4784
+ *
4785
+ * AUDIO is unconditional and predates the setting: a rule opted into several
4786
+ * audio classes (dog + scream) must not have a safety-relevant scream
4787
+ * swallowed by an unrelated bark's 60 s window.
4788
+ *
4789
+ * Every other subject gets the same treatment only when the rule ASKS for it
4790
+ * (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
4791
+ * Absent / `shared` reproduces the class-agnostic key byte for byte, because
4792
+ * changing how often an operator's existing rules fire is not a side effect
4793
+ * anyone asked for.
4794
+ */
4795
+ function keysPerClass(rule, subject) {
4796
+ if (subject.kind === "audio-event") return true;
4797
+ return rule.throttle.granularity === "per-class";
4798
+ }
4799
+ /** Stable cooldown key per the rule's throttle scope + class granularity. */
4468
4800
  function cooldownKey(rule, subject) {
4469
- const audioClass = subject.kind === "audio-event" && subject.classNames[0] !== void 0 ? `:c:${subject.classNames[0]}` : "";
4470
- return rule.throttle.scope === "rule" ? `r:${rule.id}${audioClass}` : `r:${rule.id}:d:${subject.deviceId}${audioClass}`;
4801
+ const first = subject.classNames[0];
4802
+ const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
4803
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${classKey}` : `r:${rule.id}:d:${subject.deviceId}${classKey}`;
4471
4804
  }
4472
4805
  /** True when the rule fired within its cooldown window before `now`. */
4473
4806
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -4635,7 +4968,9 @@ var NcDispatcher = class {
4635
4968
  ruleId: entry.ruleId,
4636
4969
  target: target.name,
4637
4970
  kind: target.kind,
4638
- recordKind: entry.recordKind
4971
+ recordKind: entry.recordKind,
4972
+ eventId: entry.recordId,
4973
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
4639
4974
  }
4640
4975
  });
4641
4976
  return { ok: true };
@@ -4673,9 +5008,10 @@ var NcDispatcher = class {
4673
5008
  async buildNotification(entry) {
4674
5009
  const subject = entry.payload.subject;
4675
5010
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4676
- const vars = buildTemplateVars(entry, deviceName);
5011
+ const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
5012
+ const vars = buildTemplateVars(entry, deviceName, zoneLabels);
4677
5013
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4678
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
5014
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
4679
5015
  const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4680
5016
  const params = pickParams(entry.payload.params);
4681
5017
  return {
@@ -4874,15 +5210,37 @@ var NcDispatcher = class {
4874
5210
  return null;
4875
5211
  }
4876
5212
  };
4877
- function buildTemplateVars(entry, deviceName) {
5213
+ /**
5214
+ * Map admin zone IDs to their display names for rendering only.
5215
+ *
5216
+ * Order follows `zoneIds` (the order the track visited them), not the zone
5217
+ * catalog. Every failure mode degrades to the ID rather than dropping the
5218
+ * zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
5219
+ * all. A body that silently loses a zone is worse than one that shows a UUID.
5220
+ */
5221
+ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
5222
+ if (zoneIds.length === 0) return [];
5223
+ if (getZoneNames === void 0) return [...zoneIds];
5224
+ try {
5225
+ const zones = await getZoneNames(deviceId);
5226
+ const byId = new Map(zones.map((z) => [z.id, z.name]));
5227
+ return zoneIds.map((id) => {
5228
+ const name = byId.get(id);
5229
+ return name !== void 0 && name.trim().length > 0 ? name : id;
5230
+ });
5231
+ } catch {
5232
+ return [...zoneIds];
5233
+ }
5234
+ }
5235
+ function buildTemplateVars(entry, deviceName, zoneLabels) {
4878
5236
  const subject = entry.payload.subject;
4879
5237
  const occupancy = subject.occupancy;
4880
5238
  return {
4881
5239
  camera: deviceName,
4882
5240
  class: subject.className,
4883
5241
  label: subject.label ?? "",
4884
- zones: subject.zones.join(", "),
4885
- zone: occupancy?.zone ?? subject.zones[0] ?? "",
5242
+ zones: zoneLabels.join(", "),
5243
+ zone: occupancy?.zone ?? zoneLabels[0] ?? "",
4886
5244
  confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4887
5245
  time: new Date(subject.timestamp).toLocaleTimeString(),
4888
5246
  rule: entry.payload.ruleName,
@@ -4900,12 +5258,12 @@ function renderTemplate(template, vars) {
4900
5258
  if (template === void 0 || template.trim().length === 0) return null;
4901
5259
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4902
5260
  }
4903
- function defaultBody(entry, deviceName) {
5261
+ function defaultBody(entry, deviceName, zoneLabels) {
4904
5262
  const subject = entry.payload.subject;
4905
5263
  const occupancy = subject.occupancy;
4906
5264
  if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
4907
5265
  const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4908
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
5266
+ const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
4909
5267
  const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4910
5268
  return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4911
5269
  }
@@ -6232,6 +6590,13 @@ var TimelapseStore = class {
6232
6590
  };
6233
6591
  //#endregion
6234
6592
  //#region src/notification-center/index.ts
6593
+ /**
6594
+ * How often the "matched NO rule" report may fire per device. Long enough that
6595
+ * a busy camera prints one line rather than one per event, short enough that a
6596
+ * rule which has stopped matching is visible within minutes rather than by
6597
+ * comparison with another system fifteen hours later.
6598
+ */
6599
+ var NO_MATCH_REPORT_INTERVAL_MS = 6e4;
6235
6600
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
6236
6601
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
6237
6602
  var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
@@ -6342,6 +6707,9 @@ var NotificationCenter = class NotificationCenter {
6342
6707
  occupancyEnabled = false;
6343
6708
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
6344
6709
  lastFiredAt = /* @__PURE__ */ new Map();
6710
+ /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
6711
+ lastNoMatchReportAt = /* @__PURE__ */ new Map();
6712
+ noMatchSuppressed = /* @__PURE__ */ new Map();
6345
6713
  /**
6346
6714
  * Serialized evaluation chain. The persist hooks are fire-and-forget for
6347
6715
  * the frame path, but two concurrent evaluations of the same device
@@ -6673,9 +7041,12 @@ var NotificationCenter = class NotificationCenter {
6673
7041
  return;
6674
7042
  }
6675
7043
  const now = this.now();
7044
+ let anyMatched = false;
7045
+ const rejections = [];
6676
7046
  for (const rule of candidates) {
6677
7047
  const evaluation = evaluateRule(rule, subject);
6678
7048
  if (!evaluation.matched) {
7049
+ rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
6679
7050
  this.logger.debug("rule did not match", {
6680
7051
  tags: { deviceId: subject.deviceId },
6681
7052
  meta: {
@@ -6683,7 +7054,9 @@ var NotificationCenter = class NotificationCenter {
6683
7054
  rule: rule.name,
6684
7055
  kind,
6685
7056
  failed: evaluation.failedCondition,
6686
- classes: subject.classNames
7057
+ classes: subject.classNames,
7058
+ eventId: subject.recordId,
7059
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6687
7060
  }
6688
7061
  });
6689
7062
  continue;
@@ -6695,7 +7068,9 @@ var NotificationCenter = class NotificationCenter {
6695
7068
  meta: {
6696
7069
  ruleId: rule.id,
6697
7070
  rule: rule.name,
6698
- key
7071
+ key,
7072
+ eventId: subject.recordId,
7073
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6699
7074
  }
6700
7075
  });
6701
7076
  continue;
@@ -6706,12 +7081,42 @@ var NotificationCenter = class NotificationCenter {
6706
7081
  ruleId: rule.id,
6707
7082
  rule: rule.name,
6708
7083
  kind,
6709
- targets: rule.targets.length
7084
+ targets: rule.targets.length,
7085
+ eventId: subject.recordId,
7086
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
7087
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
7088
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {}
6710
7089
  }
6711
7090
  });
6712
7091
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6713
7092
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
7093
+ anyMatched = true;
6714
7094
  }
7095
+ if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
7096
+ }
7097
+ /** See the call site. Bounded to one line per device per window. */
7098
+ reportNoMatch(subject, kind, rejections, now) {
7099
+ const last = this.lastNoMatchReportAt.get(subject.deviceId) ?? 0;
7100
+ const suppressed = this.noMatchSuppressed.get(subject.deviceId) ?? 0;
7101
+ if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
7102
+ this.noMatchSuppressed.set(subject.deviceId, suppressed + 1);
7103
+ return;
7104
+ }
7105
+ this.lastNoMatchReportAt.set(subject.deviceId, now);
7106
+ this.noMatchSuppressed.set(subject.deviceId, 0);
7107
+ this.logger.info("event matched NO rule", {
7108
+ tags: { deviceId: subject.deviceId },
7109
+ meta: {
7110
+ kind,
7111
+ rejectedBy: rejections,
7112
+ classes: subject.classNames,
7113
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
7114
+ ...subject.zones.length > 0 ? { zones: subject.zones } : { zones: [] },
7115
+ eventId: subject.recordId,
7116
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
7117
+ ...suppressed > 0 ? { alsoSuppressedSinceLastReport: suppressed } : {}
7118
+ }
7119
+ });
6715
7120
  }
6716
7121
  buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
6717
7122
  const hasEventMedia = kind === "object-event" || kind === "package-event";
@@ -7806,6 +8211,63 @@ function classifyTrackAppearance(input) {
7806
8211
  return input.positionsCount > 1 ? "resurrection" : "birth";
7807
8212
  }
7808
8213
  //#endregion
8214
+ //#region src/pipeline-analytics/pipeline/suppressed-births.ts
8215
+ /**
8216
+ * Memory of births the confirmation gate rejected, per device.
8217
+ *
8218
+ * The gate runs AFTER the TrackStore upsert, and it has to: the upsert's
8219
+ * position count is what distinguishes a true birth from a tracker
8220
+ * resurrection. So a rejected birth has already been written by the time the
8221
+ * verdict exists. It correctly gets no `start`, no media and no notification —
8222
+ * but the record stayed, and surfaced in `listRecentTracks`. On 2026-07-31 six
8223
+ * full-frame phantoms on device 615 in nine minutes were every one of them
8224
+ * suppressed AND listed.
8225
+ *
8226
+ * Retracting once is not enough. The tracker keeps carrying a rejected id for
8227
+ * as long as the phantom persists — 27 seconds in the worst measured case — and
8228
+ * every later frame would write it straight back. So the rejection is
8229
+ * remembered until the id stops being tracked, and only then forgotten.
8230
+ *
8231
+ * Kept as its own module because the state is the part with real risk: forget
8232
+ * too early and the phantom returns, never forget and a camera producing
8233
+ * phantoms continuously grows this set for the process's lifetime.
8234
+ */
8235
+ var SuppressedBirthRegistry = class {
8236
+ byDevice = /* @__PURE__ */ new Map();
8237
+ /** Record that this birth was rejected; later frames must not re-upsert it. */
8238
+ reject(deviceKey, trackId) {
8239
+ let ids = this.byDevice.get(deviceKey);
8240
+ if (!ids) {
8241
+ ids = /* @__PURE__ */ new Set();
8242
+ this.byDevice.set(deviceKey, ids);
8243
+ }
8244
+ ids.add(trackId);
8245
+ }
8246
+ isRejected(deviceKey, trackId) {
8247
+ return this.byDevice.get(deviceKey)?.has(trackId) === true;
8248
+ }
8249
+ /**
8250
+ * Forget every rejected id the tracker is no longer carrying.
8251
+ *
8252
+ * Called once per frame with the ids present THIS frame. An id absent from
8253
+ * that set can never be upserted again, so holding it serves nothing.
8254
+ */
8255
+ retain(deviceKey, currentTrackIds) {
8256
+ const ids = this.byDevice.get(deviceKey);
8257
+ if (!ids) return;
8258
+ for (const id of ids) if (!currentTrackIds.has(id)) ids.delete(id);
8259
+ if (ids.size === 0) this.byDevice.delete(deviceKey);
8260
+ }
8261
+ /** Rejected ids currently held for a device — diagnostics and tests. */
8262
+ size(deviceKey) {
8263
+ return this.byDevice.get(deviceKey)?.size ?? 0;
8264
+ }
8265
+ /** Drop a device's memory wholesale (device removed / pipeline reset). */
8266
+ clearDevice(deviceKey) {
8267
+ this.byDevice.delete(deviceKey);
8268
+ }
8269
+ };
8270
+ //#endregion
7809
8271
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
7810
8272
  async function rankKeyEvents(candidates, options, peakLookup) {
7811
8273
  const scored = [];
@@ -8310,9 +8772,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
8310
8772
  * padding === 0 is the identity operation. Pure, immutable — always returns a
8311
8773
  * new object.
8312
8774
  *
8313
- * NOTE: clamping is single-sided when the box hugs an edge, the origin is
8314
- * clamped to 0/keeps the full padded extent against the opposite bound, so the
8315
- * crop can extend slightly further on the far side than the symmetric padding
8775
+ * NOTE: clamping SHIFTS, it does not truncate a window that overflows a
8776
+ * bound slides inward and keeps the extent that was asked for, on either side.
8777
+ * It shrinks only when the padded window is larger than the frame itself
8316
8778
  * would (e.g. a left-hugging box gains a bit more right-side context). This is
8317
8779
  * acceptable for detection crops (more context, never out of [0,1]); the
8318
8780
  * downstream `extractCrop` clamps to pixel bounds again regardless.
@@ -8322,13 +8784,13 @@ function padBbox(bbox, padding) {
8322
8784
  const rawY = bbox.y - padding * bbox.h;
8323
8785
  const rawW = bbox.w * (1 + 2 * padding);
8324
8786
  const rawH = bbox.h * (1 + 2 * padding);
8325
- const x = Math.max(0, rawX);
8326
- const y = Math.max(0, rawY);
8787
+ const w = Math.min(rawW, 1);
8788
+ const h = Math.min(rawH, 1);
8327
8789
  return {
8328
- x,
8329
- y,
8330
- w: Math.min(rawW, 1 - x),
8331
- h: Math.min(rawH, 1 - y)
8790
+ x: Math.min(Math.max(0, rawX), 1 - w),
8791
+ y: Math.min(Math.max(0, rawY), 1 - h),
8792
+ w,
8793
+ h
8332
8794
  };
8333
8795
  }
8334
8796
  //#endregion
@@ -12023,7 +12485,11 @@ function squareSubjectCropRegion(bbox, frame) {
12023
12485
  * cases). `C` becomes the middle square of the output.
12024
12486
  * 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
12025
12487
  * 3. anchor: place the canvas so `C` is its horizontal middle →
12026
- * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0).
12488
+ * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
12489
+ * then CLAMP the window fully inside the frame whenever it fits. The
12490
+ * subject therefore drifts off-centre near a frame edge and the output
12491
+ * carries no padding at all. Centring is a preference; containing the
12492
+ * subject is the contract, and clamping cannot break it.
12027
12493
  * 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
12028
12494
  * `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
12029
12495
  * the vertical extent is always in-frame). Its canvas offset is
@@ -12035,13 +12501,8 @@ function wideCentralSquareLayout(bbox, frame) {
12035
12501
  const canvasW = Math.round(c * 16 / 9);
12036
12502
  const centralX0 = Math.round((canvasW - c) / 2);
12037
12503
  let frameOriginX = central.x - (canvasW - c) / 2;
12038
- if (canvasW <= frame.W) {
12039
- const slideRightMax = Math.max(0, bbox.x - central.x);
12040
- const slideLeftMax = Math.max(0, central.x + c - (bbox.x + bbox.w));
12041
- if (frameOriginX < 0) frameOriginX += Math.min(-frameOriginX, slideRightMax);
12042
- const overRight = frameOriginX + canvasW - frame.W;
12043
- if (overRight > 0) frameOriginX -= Math.min(overRight, slideLeftMax);
12044
- } else frameOriginX = (frame.W - canvasW) / 2;
12504
+ if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
12505
+ else frameOriginX = (frame.W - canvasW) / 2;
12045
12506
  const fxa = Math.max(0, frameOriginX);
12046
12507
  const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
12047
12508
  const slabOffsetX = fxa - frameOriginX;
@@ -12090,10 +12551,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
12090
12551
  /**
12091
12552
  * Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
12092
12553
  * in-frame slab fetched for `layout`. The slab is placed at its computed offset
12093
- * on a 16:9 canvas; any lateral part of the window outside the frame (a subject
12094
- * flush against a frame edge the geometry already slides the window in-frame
12095
- * wherever the subject has slack) is filled with a BLURRED, dimmed stretch of
12096
- * the slab itself instead of dead black bars (operator triage 2026-07-22).
12554
+ * on a 16:9 canvas; any lateral part of the window outside the frame is filled
12555
+ * with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
12556
+ * (operator triage 2026-07-22).
12557
+ *
12558
+ * **That fill is now nearly unreachable, and deliberately so.** The geometry
12559
+ * clamps the window fully inside the frame whenever it fits, so a subject
12560
+ * against a frame edge yields real pixels off-centre rather than ambience
12561
+ * (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
12562
+ * still pads — there is no more scene to slide into — which is why this code
12563
+ * stays. If you are looking at a blurred band in a best shot, the window was
12564
+ * wider than the frame; do not go looking for a sliding bug.
12097
12565
  * When the whole window is in-frame (`slab` already spans the full canvas) the
12098
12566
  * slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
12099
12567
  * the canvas is at the slab's native scale; only the out-of-frame ambience fill
@@ -13216,6 +13684,13 @@ var ZoneAnalyticsProvider = class {
13216
13684
  zones: snapshot.zones.length
13217
13685
  }
13218
13686
  });
13687
+ this.ctx.emitOccupancyChanged?.({
13688
+ deviceId: input.deviceId,
13689
+ timestamp: input.timestamp,
13690
+ totalObjects: total,
13691
+ byClass: snapshot.frame.byClass,
13692
+ zones: snapshot.zones.length
13693
+ });
13219
13694
  }
13220
13695
  this.snapshots.set(input.deviceId, snapshot);
13221
13696
  this.appendHistory(input.deviceId, snapshot);
@@ -13853,6 +14328,23 @@ function resolveDetectionSensitivitySettings(raw) {
13853
14328
  };
13854
14329
  }
13855
14330
  var TrackingSettingsSchema = require_dist.object({
14331
+ /**
14332
+ * How much of a detection's box must lie inside a zone (0-1 fraction of the
14333
+ * box's own area) for that zone to be STAMPED onto the detection.
14334
+ *
14335
+ * This is the field a zone-scoped notification rule ultimately depends on: a
14336
+ * rule's `zones` condition is a plain set test over the stamped zone ids, so
14337
+ * a subject that merely clips a zone edge satisfies it. Measured on
14338
+ * 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
14339
+ * inside it.
14340
+ *
14341
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
14342
+ * is deliberately an operator decision: the overlap fractions are now logged
14343
+ * (`zone membership` lines), so the bar can be chosen from the distribution
14344
+ * instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
14345
+ * gates the DETECTION stage, not what gets stamped.
14346
+ */
14347
+ zoneMembershipMinOverlap: require_dist.number().min(0).max(1).default(0),
13856
14348
  /** IoU required to match a (predicted) track to a detection. */
13857
14349
  iouThreshold: require_dist.number().min(0).max(1).default(.3),
13858
14350
  /** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
@@ -13989,6 +14481,7 @@ function resolveTrackingSettings(raw) {
13989
14481
  const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
13990
14482
  const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
13991
14483
  return {
14484
+ zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
13992
14485
  iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
13993
14486
  maxMissedMs,
13994
14487
  minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
@@ -14043,7 +14536,11 @@ function resolveTrackingSettings(raw) {
14043
14536
  * low-res detection frame (a static "person" phantom, a parked-truck ghost, a
14044
14537
  * misclassified static object).
14045
14538
  *
14046
- * Ships DORMANT: `enabled` defaults to `false`, so behaviour is byte-identical
14539
+ * ON by default (`enabled` defaults to TRUE). An earlier version of this line
14540
+ * said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
14541
+ * 2026-07-30 it nearly produced the conclusion that the gate was not running at
14542
+ * all. It is: it suppressed several phantom births on device 615 that same day.
14543
+ * Read the schema, not this paragraph. Historically the intent was byte-identical
14047
14544
  * to today until an operator opts in per camera. The gate is fail-OPEN — any
14048
14545
  * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
14049
14546
  * error, or timeout ALLOWS the birth (a real track is never suppressed because
@@ -14139,10 +14636,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
14139
14636
  if (track === "other" || det === "other") return true;
14140
14637
  return track === det;
14141
14638
  }
14142
- var failOpen = (trackId, reason) => ({
14143
- trackId,
14639
+ var failOpen = (candidate, reason) => ({
14640
+ trackId: candidate.trackId,
14144
14641
  confirmed: true,
14145
- reason
14642
+ reason,
14643
+ className: candidate.className
14146
14644
  });
14147
14645
  function withTimeout(promise, timeoutMs) {
14148
14646
  return new Promise((resolve, reject) => {
@@ -14158,23 +14656,34 @@ function withTimeout(promise, timeoutMs) {
14158
14656
  }
14159
14657
  async function runConfirmation(candidate, config, deps) {
14160
14658
  const crop = await deps.fetchCrop(candidate);
14161
- if (!crop) return failOpen(candidate.trackId, "no-crop");
14659
+ if (!crop) return failOpen(candidate, "no-crop");
14162
14660
  const detections = await deps.redetect(crop);
14163
- if (detections === null) return failOpen(candidate.trackId, "redetect-error");
14164
- const confirmed = detections.some((d) => d.score >= config.minConfidence && isConfirmationCompatible(candidate.className, d.macroClass));
14661
+ if (detections === null) return failOpen(candidate, "redetect-error");
14662
+ let best;
14663
+ let bestIncompatible;
14664
+ for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
14665
+ if (!best || d.score > best.score) best = d;
14666
+ } else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
14667
+ const confirmed = best !== void 0 && best.score >= config.minConfidence;
14165
14668
  return {
14166
14669
  trackId: candidate.trackId,
14167
14670
  confirmed,
14168
- reason: confirmed ? "confirmed" : "suppressed"
14671
+ reason: confirmed ? "confirmed" : "suppressed",
14672
+ className: candidate.className,
14673
+ ...best ? { bestScore: best.score } : {},
14674
+ ...bestIncompatible ? {
14675
+ bestIncompatibleClass: bestIncompatible.macroClass,
14676
+ bestIncompatibleScore: bestIncompatible.score
14677
+ } : {}
14169
14678
  };
14170
14679
  }
14171
14680
  async function confirmOne(candidate, config, deps) {
14172
14681
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
14173
- if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate.trackId, "below-min-crop");
14682
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
14174
14683
  try {
14175
14684
  return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
14176
14685
  } catch {
14177
- return failOpen(candidate.trackId, "timeout");
14686
+ return failOpen(candidate, "timeout");
14178
14687
  }
14179
14688
  }
14180
14689
  /**
@@ -14357,8 +14866,23 @@ function resolveMediaSettings(raw) {
14357
14866
  * §3.3 draft said 45s).
14358
14867
  */
14359
14868
  var PackageDropSettingsSchema = require_dist.object({
14360
- /** Master switch — off by default; opt-in per camera (porch/door cams). */
14361
- packageDropEnabled: require_dist.boolean().default(false),
14869
+ /**
14870
+ * Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
14871
+ * package detection**, not a second switch.
14872
+ *
14873
+ * As an opt-in default-false this was a parallel source of truth. Device 615
14874
+ * on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
14875
+ * (`Pacchetti`, classFilter `['package']`), and a notification rule on
14876
+ * `delivery: 'package-event'` — three layers of operator intent, all defeated
14877
+ * silently by a boolean none of them mentions.
14878
+ *
14879
+ * Defaulting to true costs nothing on cameras nobody configured:
14880
+ * `PackageDropDetector.onAppeared` still returns early when the device has no
14881
+ * enabled `package`-stage rule, so the work is a class check plus one cached
14882
+ * lookup. Set this false to force the feature off on a camera that HAS a zone
14883
+ * rule.
14884
+ */
14885
+ packageDropEnabled: require_dist.boolean().default(true),
14362
14886
  /**
14363
14887
  * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
14364
14888
  * promoted stationary package counts as a delivery. Kills a bag briefly
@@ -14899,6 +15423,15 @@ function buildDetectionSettingsSections() {
14899
15423
  step: .05,
14900
15424
  default: TRACKING_DEFAULTS.rescueIouThreshold
14901
15425
  },
15426
+ {
15427
+ type: "number",
15428
+ key: "rescueCentroidFactor",
15429
+ label: "Rescue centroid distance",
15430
+ description: "The other half of the rescue gate: how far a detection’s centre may sit from the track’s last-known centre, as a fraction of the box diagonal. A rescue must pass BOTH this and the rescue IoU, so raising the IoU alone does not tighten re-attachment.",
15431
+ min: 0,
15432
+ step: .05,
15433
+ default: TRACKING_DEFAULTS.rescueCentroidFactor
15434
+ },
14902
15435
  {
14903
15436
  type: "number",
14904
15437
  key: "stationarySpeedPx",
@@ -15180,6 +15713,16 @@ function buildDetectionSettingsSections() {
15180
15713
  label: "Person/animal group matching",
15181
15714
  description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
15182
15715
  default: TRACKING_DEFAULTS.classGroupAssoc
15716
+ },
15717
+ {
15718
+ type: "number",
15719
+ key: "zoneMembershipMinOverlap",
15720
+ label: "Zone membership minimum overlap",
15721
+ description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
15722
+ min: 0,
15723
+ max: 1,
15724
+ step: .05,
15725
+ default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
15183
15726
  }
15184
15727
  ]
15185
15728
  },
@@ -19577,7 +20120,21 @@ function decodeEmbeddingBase64(base64) {
19577
20120
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
19578
20121
  return Array.from(view);
19579
20122
  }
20123
+ /** A track shorter than this is a candidate phantom, not a subject that came
20124
+ * and went. Brackets the observed plant tracks (3.8-19.0 s). */
20125
+ var SHORT_TRACK_MAX_MS = 25e3;
20126
+ /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
20127
+ var MOTIONLESS_MAX_PX = 8;
20128
+ /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
20129
+ * to the pixel still land in one cell. */
20130
+ var PHANTOM_CELL_PX = 32;
20131
+ /** How long a cell remembers its closes. */
20132
+ var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
19580
20133
  var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20134
+ /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
20135
+ * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
20136
+ * filtered against the 6-hour window on write, so it stays bounded. */
20137
+ shortMotionlessCells = /* @__PURE__ */ new Map();
19581
20138
  processors = /* @__PURE__ */ new Map();
19582
20139
  trackStore = null;
19583
20140
  /** Parked-object registry: promotes a track that stopped moving into a
@@ -19735,6 +20292,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19735
20292
  * dataPlane facility in the current environment). */
19736
20293
  eventMediaBaseUrl = null;
19737
20294
  lastActiveTrackIds = /* @__PURE__ */ new Map();
20295
+ /** See `pipeline/suppressed-births.ts` — rejected births must not be
20296
+ * re-upserted, and must be forgotten when the tracker drops the id. */
20297
+ suppressedBirths = new SuppressedBirthRegistry();
19738
20298
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
19739
20299
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
19740
20300
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
@@ -19887,7 +20447,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19887
20447
  });
19888
20448
  },
19889
20449
  emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
19890
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
20450
+ onTrackClosed: (track, ownedMedia, info) => {
20451
+ this.noteShortMotionlessTrack(track);
20452
+ return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
20453
+ },
19891
20454
  deriveThumbnailFromKeyFrame: async (input) => {
19892
20455
  const derived = await deriveKeyFrameThumbnailJpeg({
19893
20456
  ...input,
@@ -20033,7 +20596,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20033
20596
  let storage = this.ctx.kernel.storage;
20034
20597
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
20035
20598
  if (mediaRoot) {
20036
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Dwh-F2Zf.js"));
20599
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-C1I1svLc.js"));
20037
20600
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
20038
20601
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
20039
20602
  }
@@ -20315,6 +20878,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20315
20878
  const zoneAnalytics = new ZoneAnalyticsProvider({
20316
20879
  logger: logger.child("ZoneAnalytics"),
20317
20880
  fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
20881
+ emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
20882
+ id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
20883
+ timestamp: new Date(payload.timestamp),
20884
+ source: {
20885
+ type: "addon",
20886
+ id: "pipeline-analytics",
20887
+ addonId: "pipeline-analytics"
20888
+ },
20889
+ category: require_dist.EventCategory.ZoneAnalyticsOccupancyChanged,
20890
+ data: {
20891
+ deviceId: payload.deviceId,
20892
+ totalObjects: payload.totalObjects,
20893
+ byClass: payload.byClass,
20894
+ zones: payload.zones
20895
+ }
20896
+ }),
20318
20897
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
20319
20898
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
20320
20899
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
@@ -20374,6 +20953,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20374
20953
  };
20375
20954
  },
20376
20955
  dispatcher: {
20956
+ getZoneNames: async (deviceId) => {
20957
+ return (await api.zones.listZones.query({ deviceId })).map((z) => ({
20958
+ id: z.id,
20959
+ name: z.name
20960
+ }));
20961
+ },
20377
20962
  getZonePolygons: async (deviceId, zoneIds) => {
20378
20963
  const zones = await api.zones.listZones.query({ deviceId });
20379
20964
  const wanted = new Set(zoneIds);
@@ -20751,10 +21336,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20751
21336
  const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
20752
21337
  processor.setZones(liveZones);
20753
21338
  processor.setDetectionRules(liveRules);
21339
+ processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
20754
21340
  const result = processor.process({
20755
21341
  timestamp: frame.timestamp,
20756
21342
  frame
20757
21343
  });
21344
+ for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
21345
+ tags: { deviceId },
21346
+ meta: {
21347
+ vehicleClass: r.vehicleClass,
21348
+ personScore: r.personScore,
21349
+ vehicleScore: r.vehicleScore,
21350
+ overlap: r.overlap
21351
+ }
21352
+ });
20758
21353
  if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
20759
21354
  if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
20760
21355
  deviceId,
@@ -20785,6 +21380,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20785
21380
  const positionsCountById = /* @__PURE__ */ new Map();
20786
21381
  for (const t of result.tracked) {
20787
21382
  currentTrackIds.add(t.trackId);
21383
+ if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
20788
21384
  const center = {
20789
21385
  x: t.bbox.x + t.bbox.w / 2,
20790
21386
  y: t.bbox.y + t.bbox.h / 2
@@ -20851,7 +21447,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20851
21447
  frameHeight: result.frameHeight
20852
21448
  });
20853
21449
  for (const { id, t } of bornCandidates) {
20854
- if (!confirmedBirths.has(id)) continue;
21450
+ if (!confirmedBirths.has(id)) {
21451
+ this.suppressedBirths.reject(key, id);
21452
+ this.trackStore?.dropActive(id);
21453
+ log.info("birth suppressed — track record retracted", { meta: {
21454
+ trackId: id,
21455
+ className: t.className,
21456
+ source
21457
+ } });
21458
+ continue;
21459
+ }
20855
21460
  newTrackCount += 1;
20856
21461
  log.info("track started", { meta: {
20857
21462
  trackId: id,
@@ -20912,6 +21517,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20912
21517
  source
20913
21518
  } });
20914
21519
  }
21520
+ this.suppressedBirths.retain(key, currentTrackIds);
20915
21521
  this.lastActiveTrackIds.set(key, currentTrackIds);
20916
21522
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
20917
21523
  if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
@@ -20981,7 +21587,46 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20981
21587
  } });
20982
21588
  }
20983
21589
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
20984
- if (this.notificationCenter !== null) for (const e of result.objectEvents) this.notificationCenter.onObjectEventPersisted(e);
21590
+ if (this.notificationCenter !== null) {
21591
+ const overlaps = processor.getLastZoneOverlaps();
21592
+ const rejections = processor.getLastZoneRejections();
21593
+ for (const e of result.objectEvents) {
21594
+ if (e.zones && e.zones.length > 0) {
21595
+ const m = e.trackId ? overlaps.get(e.trackId) : void 0;
21596
+ this.ctx.logger.info("zone membership stamped on event", {
21597
+ tags: { deviceId },
21598
+ meta: {
21599
+ eventId: e.id,
21600
+ trackId: e.trackId,
21601
+ className: e.className,
21602
+ minOverlap: trk.zoneMembershipMinOverlap,
21603
+ zones: (m ?? []).map((z) => ({
21604
+ id: z.zoneId,
21605
+ name: z.zoneName,
21606
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21607
+ }))
21608
+ }
21609
+ });
21610
+ }
21611
+ const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
21612
+ if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
21613
+ tags: { deviceId },
21614
+ meta: {
21615
+ eventId: e.id,
21616
+ trackId: e.trackId,
21617
+ className: e.className,
21618
+ minOverlap: trk.zoneMembershipMinOverlap,
21619
+ stamped: e.zones?.length ?? 0,
21620
+ zones: rejected.map((z) => ({
21621
+ id: z.zoneId,
21622
+ name: z.zoneName,
21623
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21624
+ }))
21625
+ }
21626
+ });
21627
+ this.notificationCenter.onObjectEventPersisted(e);
21628
+ }
21629
+ }
20985
21630
  const objectEmbeddingBests = [];
20986
21631
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
20987
21632
  if (!isClipObjectEmbedding(t)) continue;
@@ -21333,19 +21978,28 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21333
21978
  }),
21334
21979
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
21335
21980
  onDecision: (decision) => {
21981
+ const meta = {
21982
+ trackId: decision.trackId,
21983
+ reason: decision.reason,
21984
+ className: decision.className,
21985
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
21986
+ ...decision.bestIncompatibleClass !== void 0 ? {
21987
+ bestIncompatibleClass: decision.bestIncompatibleClass,
21988
+ bestIncompatibleScore: decision.bestIncompatibleScore
21989
+ } : {},
21990
+ minConfidence: config.minConfidence
21991
+ };
21336
21992
  if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
21337
21993
  tags: { deviceId },
21338
- meta: {
21339
- trackId: decision.trackId,
21340
- reason: decision.reason
21341
- }
21994
+ meta
21342
21995
  });
21343
21996
  else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
21344
21997
  tags: { deviceId },
21345
- meta: {
21346
- trackId: decision.trackId,
21347
- reason: decision.reason
21348
- }
21998
+ meta
21999
+ });
22000
+ else this.ctx.logger.info("confirmation gate: birth confirmed", {
22001
+ tags: { deviceId },
22002
+ meta
21349
22003
  });
21350
22004
  }
21351
22005
  });
@@ -21362,6 +22016,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21362
22016
  async resolveDeviceStationarySettings(deviceId) {
21363
22017
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
21364
22018
  }
22019
+ /**
22020
+ * Count SHORT + MOTIONLESS track closes per frame cell.
22021
+ *
22022
+ * The stationary registry cannot see this class of phantom. Promotion needs a
22023
+ * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
22024
+ * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
22025
+ * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
22026
+ * fifteen of them in a day, all at the same spot. Each one is individually
22027
+ * innocent; the RECURRENCE is the signal, and nothing survives a track death
22028
+ * to notice it.
22029
+ *
22030
+ * Lowering the promotion window is not the fix — those 30 s exist so someone
22031
+ * standing still at a door is not declared scenery.
22032
+ *
22033
+ * This is the measurement half: it establishes how often a cell repeats
22034
+ * before any suppression is built, so "N closes in what window" comes from
22035
+ * data rather than intuition. It suppresses NOTHING.
22036
+ */
22037
+ noteShortMotionlessTrack(track) {
22038
+ const lifeMs = track.lastSeen - track.firstSeen;
22039
+ const moved = track.totalDistance ?? 0;
22040
+ if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
22041
+ const first = track.positions?.[0];
22042
+ if (!first) return;
22043
+ const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
22044
+ const key = `${track.deviceId}:${track.className}:${cell}`;
22045
+ const now = Date.now();
22046
+ const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
22047
+ seen.push(now);
22048
+ this.shortMotionlessCells.set(key, seen);
22049
+ this.ctx.logger.info("short motionless track closed", {
22050
+ tags: { deviceId: track.deviceId },
22051
+ meta: {
22052
+ trackId: track.trackId,
22053
+ className: track.className,
22054
+ lifeMs,
22055
+ movedPx: Math.round(moved * 10) / 10,
22056
+ cell,
22057
+ repeatsInWindow: seen.length
22058
+ }
22059
+ });
22060
+ }
21365
22061
  stationarySettingsFromCache(deviceId) {
21366
22062
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
21367
22063
  }
@@ -21371,9 +22067,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21371
22067
  /**
21372
22068
  * Resolve a device's ENABLED `package`-stage zone rules independent of the
21373
22069
  * live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
21374
- * when the cached slice is empty, forces one refresh. The `package` slice
21375
- * is written by the orchestrator's package-stage provider (a later slice);
21376
- * until then this returns `[]` and no package events fire.
22070
+ * when the cached slice is empty, forces one refresh.
22071
+ *
22072
+ * The `package` slice is written through the `zone-rules` capability
22073
+ * (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
22074
+ * version of this comment claimed the provider did not exist yet and that
22075
+ * "no package events fire" — that was stale, and believing it produced a
22076
+ * confidently wrong diagnosis on 2026-07-30. An empty list here means the
22077
+ * operator has drawn no package zone rule, nothing more.
21377
22078
  */
21378
22079
  async resolveDevicePackageRules(deviceId) {
21379
22080
  const proxy = await this.ensureProxy(deviceId);