@camstack/addon-post-analysis 1.2.106 → 1.2.107

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-FAVcx3hJ.js");
5
+ const require_dist = require("../dist-wQmgHP6h.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -5574,279 +5574,6 @@ function observeLabel(deviceId, spec, sample, now) {
5574
5574
  spec
5575
5575
  };
5576
5576
  }
5577
- /** JPEG quality for the downscaled full frame — matches the crop path. */
5578
- var FULL_FRAME_QUALITY = 80;
5579
- /**
5580
- * Downscale an already-encoded JPEG full frame to FIT WITHIN
5581
- * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
5582
- * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
5583
- * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
5584
- * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
5585
- * at night) is never stored or served — the privacy fix moved to CAPTURE time.
5586
- */
5587
- async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5588
- return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
5589
- fit: "inside",
5590
- withoutEnlargement: true
5591
- }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5592
- }
5593
- //#endregion
5594
- //#region src/notification-center/still-shelf.ts
5595
- /**
5596
- * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
5597
- * name owns a frame.
5598
- *
5599
- * ## Why a trigger needs one at all
5600
- *
5601
- * The attachment ladder resolves media by OWNER, and most triggers have one: an
5602
- * object or package event owns its crops, a closed track owns its best shot, a
5603
- * doorbell press owns the marker track the same press projected
5604
- * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5605
- * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
5606
- * same reason in both cases — the subject is an ABSENCE:
5607
- *
5608
- * - an **audio** match: nothing was boxed, nothing was tracked, and —
5609
- * deliberately — nothing is persisted at all. A confirmed window is a claim
5610
- * about sound that has already stopped, and `event-intake.ts` states why
5611
- * replaying it later would be wrong.
5612
- * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
5613
- * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
5614
- * nobody and the ladder logs `no still could be resolved … owners=[]`.
5615
- *
5616
- * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5617
- * of the trigger. Not of the sound, not of the object that left — of what the
5618
- * camera can see now that it happened. For a freed parking space that is
5619
- * exactly the answer the operator wants: the space, empty.
5620
- *
5621
- * **One shelf, not two.** The mechanism is identical down to the reuse window,
5622
- * and the only thing that differs between the two triggers is the GATE deciding
5623
- * that a photograph is owed at all — which belongs at the trigger site, where
5624
- * the rules are, and not here. A twin module would be a second derivation of
5625
- * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
5626
- * can say which absence they are about.
5627
- *
5628
- * ## Three properties, and each one is a decision
5629
- *
5630
- * **It is not a record.** The bytes live here, in RAM, under an owner id and a
5631
- * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5632
- * alternative was the doorbell's: materialise a synthetic marker track through
5633
- * `SyntheticTrackMaterializer` and let the notification name it. That would put
5634
- * a durable Track on the camera's timeline for every confirmed window and every
5635
- * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
5636
- * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
5637
- * precisely to bound how many audio markers a camera may emit). A notification
5638
- * must not manufacture timeline history as a side effect of wanting a picture.
5639
- *
5640
- * **The capture STARTS immediately and is never awaited.** The subject is
5641
- * transient — a scream is over before a snapshot round-trip completes, and a
5642
- * freed space is about to be taken by the next car — so the fetch is kicked off
5643
- * at the trigger, before the rule evaluation runs, and the owner id is minted
5644
- * synchronously so the outbox row can name it. The bytes land while the row
5645
- * waits in the queue, and the dispatcher's existing bounded still-wait (or the
5646
- * pause its own footage render already costs) picks them up. A camera that
5647
- * never answers costs the picture and never the notification.
5648
- *
5649
- * **Two triggers seconds apart share ONE capture.** A barking dog confirms
5650
- * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5651
- * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5652
- * Without a reuse window this would photograph a camera at whatever rate the
5653
- * sound happens to occur. Each trigger still gets its OWN owner id, so two
5654
- * outbox rows are never mistaken for one subject; they merely point at the same
5655
- * frame, which is the truth — the scene did not change in ten seconds. The
5656
- * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
5657
- * emptied zone ten seconds apart are two claims about one scene.
5658
- *
5659
- * Nothing here is silent: a capture that lands and a camera that refuses each
5660
- * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
5661
- * and 615 not" is the only form that question is ever asked in.
5662
- */
5663
- /**
5664
- * The owner-id namespace. It is what routes a lookup here instead of to the
5665
- * media store, and it is the reason the dispatcher needs no audio or occupancy
5666
- * branch — `getMediaForOwner` answers for all of them under one signature.
5667
- */
5668
- var NC_STILL_SHELF_PREFIX = "nc-still:";
5669
- /** True for an owner id this shelf minted. */
5670
- function isStillShelfId(id) {
5671
- return id.startsWith(NC_STILL_SHELF_PREFIX);
5672
- }
5673
- /**
5674
- * How long the bytes are held.
5675
- *
5676
- * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
5677
- * ten minutes; fifteen covers that with room for a slow drain. Past it the row
5678
- * ships text-only, which is the correct degradation for a photograph of a scene
5679
- * that is a quarter of an hour stale anyway.
5680
- */
5681
- var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
5682
- /** A hanging snapshot cap must not pin a capture slot forever. */
5683
- var SNAPSHOT_TIMEOUT_MS = 8e3;
5684
- /**
5685
- * Hard bound on held captures. Reached only if every camera on the hub triggers
5686
- * one inside a single TTL; the oldest is dropped first, which costs a
5687
- * fifteen-minute-old picture nobody is waiting for.
5688
- */
5689
- var MAX_CAPTURES = 64;
5690
- var NcStillShelf = class {
5691
- deps;
5692
- /** captureId → the photograph. */
5693
- captures = /* @__PURE__ */ new Map();
5694
- /** ownerId → captureId. Several owners may name one capture (the reuse window). */
5695
- owners = /* @__PURE__ */ new Map();
5696
- /** deviceId → its newest capture, for the reuse window. */
5697
- newest = /* @__PURE__ */ new Map();
5698
- constructor(deps) {
5699
- this.deps = deps;
5700
- }
5701
- /**
5702
- * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
5703
- * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
5704
- * path and the outbox row is built from what this returns.
5705
- */
5706
- capture(deviceId, atMs, trigger) {
5707
- const now = this.deps.now();
5708
- this.prune(now);
5709
- const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
5710
- const recent = this.newest.get(deviceId);
5711
- if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5712
- this.owners.set(ownerId, recent.captureId);
5713
- return ownerId;
5714
- }
5715
- const captureId = (0, node_crypto.randomUUID)();
5716
- this.captures.set(captureId, {
5717
- deviceId,
5718
- trigger,
5719
- startedAt: now,
5720
- expiresAt: now + NC_STILL_SHELF_TTL_MS,
5721
- files: []
5722
- });
5723
- this.newest.set(deviceId, {
5724
- captureId,
5725
- at: now
5726
- });
5727
- this.owners.set(ownerId, captureId);
5728
- this.fetch(captureId, deviceId, atMs, trigger);
5729
- return ownerId;
5730
- }
5731
- /**
5732
- * The media an owner id holds, or `undefined` when this shelf never minted
5733
- * it. An EMPTY array is a different answer: the capture exists and has not
5734
- * landed (or never will), which is exactly the case the dispatcher's bounded
5735
- * wait and its `no still could be resolved` line are for.
5736
- */
5737
- get(ownerId) {
5738
- const captureId = this.owners.get(ownerId);
5739
- if (captureId === void 0) return void 0;
5740
- return this.captures.get(captureId)?.files ?? [];
5741
- }
5742
- /** Drop expired captures and the owners that named them. */
5743
- prune(now) {
5744
- for (const [captureId, held] of this.captures) {
5745
- if (held.expiresAt > now) continue;
5746
- this.captures.delete(captureId);
5747
- if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
5748
- }
5749
- while (this.captures.size > MAX_CAPTURES) {
5750
- const oldest = this.captures.keys().next();
5751
- if (oldest.done === true) break;
5752
- const held = this.captures.get(oldest.value);
5753
- this.captures.delete(oldest.value);
5754
- if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
5755
- }
5756
- for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
5757
- }
5758
- /** Drop everything (shutdown). */
5759
- clear() {
5760
- this.captures.clear();
5761
- this.owners.clear();
5762
- this.newest.clear();
5763
- }
5764
- async fetch(captureId, deviceId, atMs, trigger) {
5765
- try {
5766
- const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5767
- if (shot === null || shot.base64.length === 0) {
5768
- this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
5769
- return;
5770
- }
5771
- const raw = Buffer.from(shot.base64, "base64");
5772
- let data = raw;
5773
- try {
5774
- data = await downscaleFullFrameJpeg(raw, 960, 540);
5775
- } catch {}
5776
- const held = this.captures.get(captureId);
5777
- if (held === void 0) return;
5778
- this.captures.set(captureId, {
5779
- ...held,
5780
- files: stillShelfMedia(data, atMs)
5781
- });
5782
- this.deps.logger.info(`${trigger} still captured`, {
5783
- tags: { deviceId },
5784
- meta: {
5785
- trigger,
5786
- bytes: data.byteLength,
5787
- tookMs: this.deps.now() - held.startedAt
5788
- }
5789
- });
5790
- } catch (err) {
5791
- this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
5792
- }
5793
- }
5794
- /**
5795
- * A branch that drops work says so. This one costs the operator the picture
5796
- * on a notification they DID receive, so it is a warn and it carries the
5797
- * camera — the only key the question is ever asked with.
5798
- */
5799
- reportMiss(deviceId, trigger, reason) {
5800
- this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
5801
- tags: { deviceId },
5802
- meta: {
5803
- trigger,
5804
- reason
5805
- }
5806
- });
5807
- }
5808
- };
5809
- /**
5810
- * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
5811
- *
5812
- * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
5813
- * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
5814
- * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
5815
- * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
5816
- * NOTHING and the notification ships text-only with the ordinary line saying
5817
- * so. Inventing a centre crop would answer a different question from the one
5818
- * the operator asked.
5819
- */
5820
- function stillShelfMedia(data, timestamp) {
5821
- const base64 = data.toString("base64");
5822
- const file = (kind) => ({
5823
- key: `${NC_STILL_SHELF_PREFIX}${kind}`,
5824
- kind,
5825
- base64,
5826
- sizeBytes: data.byteLength,
5827
- timestamp
5828
- });
5829
- return [
5830
- file("keyFrameSmall"),
5831
- file("keyFrame"),
5832
- file("fullFrame")
5833
- ];
5834
- }
5835
- /** Reject if `promise` does not settle within `ms`. */
5836
- function withTimeout$3(promise, ms) {
5837
- return new Promise((resolve, reject) => {
5838
- const timer = setTimeout(() => {
5839
- reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
5840
- }, ms);
5841
- promise.then((value) => {
5842
- clearTimeout(timer);
5843
- resolve(value);
5844
- }, (err) => {
5845
- clearTimeout(timer);
5846
- reject(err instanceof Error ? err : new Error(String(err)));
5847
- });
5848
- });
5849
- }
5850
5577
  //#endregion
5851
5578
  //#region src/shared/llm-vision/prompt-hygiene.ts
5852
5579
  /**
@@ -8907,6 +8634,58 @@ var NcTextCatalog = class {
8907
8634
  }
8908
8635
  };
8909
8636
  //#endregion
8637
+ //#region src/notification-center/trigger-zone-still.ts
8638
+ /**
8639
+ * How far a candidate's instant may sit from the nearest trail point before the
8640
+ * trail stops speaking for it.
8641
+ *
8642
+ * 2 s. The live detection cadence on this install is 100–300 ms and the widest
8643
+ * real gap inside a track is a couple of seconds (a session gap); beyond that
8644
+ * the nearest point is about a different moment, and answering "in the zone"
8645
+ * from it would be a guess. An unanswerable candidate is treated as NOT
8646
+ * qualifying — never as disqualifying the others, and never as a reason to ship
8647
+ * nothing.
8648
+ */
8649
+ var TRAIL_MATCH_MAX_MS = 2e3;
8650
+ /** Overlap of a normalized box with the union of the zones — the maximum over
8651
+ * the polygons, which is what "any of these zones" means for one box. */
8652
+ function zoneOverlap(point, polygons) {
8653
+ let best = 0;
8654
+ for (const polygon of polygons) {
8655
+ if (polygon.length < 3) continue;
8656
+ const overlap = bboxPolygonOverlap(point.bbox, polygon);
8657
+ if (overlap > best) best = overlap;
8658
+ }
8659
+ return best;
8660
+ }
8661
+ /** The trail point closest to `timestamp`, or null when none is close enough. */
8662
+ function trailAt(trail, timestamp) {
8663
+ let best = null;
8664
+ let bestDelta = Number.POSITIVE_INFINITY;
8665
+ for (const point of trail) {
8666
+ const delta = Math.abs(point.timestamp - timestamp);
8667
+ if (delta < bestDelta) {
8668
+ bestDelta = delta;
8669
+ best = point;
8670
+ }
8671
+ }
8672
+ return best !== null && bestDelta <= 2e3 ? best : null;
8673
+ }
8674
+ /**
8675
+ * The candidates that show the subject inside the triggering zone, in the order
8676
+ * they were given. Every candidate comes back unchanged when the question
8677
+ * cannot be asked (no trail, no polygon) or when nobody qualifies.
8678
+ */
8679
+ function selectTriggerZoneCandidates(input) {
8680
+ const { candidates, trail, polygons } = input;
8681
+ if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8682
+ const qualifying = candidates.filter((candidate) => {
8683
+ const point = trailAt(trail, candidate.timestamp);
8684
+ return point !== null && zoneOverlap(point, polygons) > 0;
8685
+ });
8686
+ return qualifying.length > 0 ? qualifying : candidates;
8687
+ }
8688
+ //#endregion
8910
8689
  //#region src/notification-center/dispatcher.ts
8911
8690
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
8912
8691
  /**
@@ -8920,6 +8699,28 @@ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
8920
8699
  * text-only rather than holding the alarm any longer.
8921
8700
  */
8922
8701
  var NC_STILL_WAIT_MS = 3e3;
8702
+ /**
8703
+ * How far a still's OWN instant may sit from the trigger and still count as
8704
+ * being ABOUT the trigger, for a `delivery: 'immediate'` rule.
8705
+ *
8706
+ * Derived from {@link TRAIL_MATCH_MAX_MS} rather than repeated: that constant
8707
+ * already answers "can the subject's trail speak for this instant", and the two
8708
+ * gates run back to back on the same candidate list (moment, then place). If
8709
+ * they disagreed, a frame could be judged in-zone for a moment the age gate had
8710
+ * already ruled a different one.
8711
+ *
8712
+ * The value is measured, not chosen: on the 54 live fires of 'Persona su Uscio'
8713
+ * a full-scene frame within ±1 s of the trigger existed for 52 (96%) and within
8714
+ * ±2 s for 53 (98%), while the frame that produced the operator's report was
8715
+ * +4901 ms. Widening this to 5 s would re-admit exactly that frame.
8716
+ *
8717
+ * It is NOT the ladder's top rung that has to be in band — that rung is the
8718
+ * native keyFrame, whose capture misses and retries (36 misses over 105 track
8719
+ * starts on device 615 in 6 h), so it is in band only 46% of the time. Holding
8720
+ * out for the KIND the rule asked for would wait for the late frame. The gate
8721
+ * is the instant; the ladder then runs among the frames that are about it.
8722
+ */
8723
+ var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
8923
8724
  var NcDispatcher = class {
8924
8725
  deps;
8925
8726
  targetCache = null;
@@ -9502,9 +9303,10 @@ var NcDispatcher = class {
9502
9303
  const out = [];
9503
9304
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9504
9305
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9505
- const firstLook = await this.resolveAttachment(entry, stillOverride);
9506
- const footage = await this.renderFootage(entry);
9507
- const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) ?? await this.waitForStill(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
9306
+ const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9307
+ const footagePending = this.renderFootage(entry);
9308
+ const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9309
+ const footage = await footagePending;
9508
9310
  if (still !== null) {
9509
9311
  const zoneIds = zoneIdsWanted;
9510
9312
  if (zoneIds !== void 0 && zoneIds.length > 0) {
@@ -9610,20 +9412,27 @@ var NcDispatcher = class {
9610
9412
  /**
9611
9413
  * Poll the still ladder for {@link NC_STILL_WAIT_MS}, then give up.
9612
9414
  *
9613
- * Only reached when the rule asked for NO footage: with a render in the
9614
- * dispatch there is a 7–8 s wait to look again after, and this would add
9615
- * latency to a notification that is already late. Bounded and then abandoned
9616
- * a text-only notification about a person at the door beats a punctual one
9617
- * nobody received.
9415
+ * Runs for EVERY rule now, footage or not, and concurrently with the render:
9416
+ * the still's deadline is its own. Track media lands 1.5–5.5 s after the
9417
+ * trigger, so a notification built at the instant of the trigger routinely
9418
+ * resolves nothing and this is what recovers it.
9419
+ *
9420
+ * Each attempt asks for a frame from the trigger instant and nothing else, so
9421
+ * a poll that has not found one keeps waiting rather than settling for a
9422
+ * later frame that happens to be ranked higher. The give-up pass is the one
9423
+ * place a later frame is accepted, and it says so.
9424
+ *
9425
+ * Bounded and then abandoned — a text-only notification about a person at the
9426
+ * door beats a punctual one nobody received.
9618
9427
  */
9619
9428
  async waitForStill(entry, policyOverride) {
9620
9429
  const subject = entry.payload.subject;
9621
- if (this.now() - subject.timestamp > 3e4) return null;
9430
+ if (this.now() - subject.timestamp > 3e4) return this.resolveAttachment(entry, policyOverride, "any");
9622
9431
  if (subject.eventId === void 0 && subject.trackId === void 0) return null;
9623
9432
  const attempts = Math.floor(NC_STILL_WAIT_MS / 500);
9624
9433
  for (let i = 0; i < attempts; i += 1) {
9625
9434
  await this.sleep(500);
9626
- const still = await this.resolveAttachment(entry, policyOverride);
9435
+ const still = await this.resolveAttachment(entry, policyOverride, "trigger-only");
9627
9436
  if (still !== null) {
9628
9437
  this.deps.logger.info("the still landed during the bounded wait", {
9629
9438
  tags: { deviceId: entry.payload.subject.deviceId },
@@ -9637,7 +9446,7 @@ var NcDispatcher = class {
9637
9446
  return still;
9638
9447
  }
9639
9448
  }
9640
- return null;
9449
+ return this.resolveAttachment(entry, policyOverride, "any");
9641
9450
  }
9642
9451
  /** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
9643
9452
  * pixel rect via sharp metadata). Null on any failure — caller falls back
@@ -9728,7 +9537,86 @@ var NcDispatcher = class {
9728
9537
  bytes.set(composed.bytes);
9729
9538
  return bytes;
9730
9539
  }
9731
- async resolveAttachment(entry, policyOverride) {
9540
+ /**
9541
+ * Drop the candidate stills that show the subject OUTSIDE the zone that fired
9542
+ * the rule. A filter, never a re-rank: the ladder still decides which kind of
9543
+ * picture the rule gets, among the frames that are about the right thing.
9544
+ *
9545
+ * Every unanswerable case returns the list untouched — no frozen zones, no
9546
+ * track to trail, no dep wired, no polygon resolved, an empty trail, or a
9547
+ * throw. And when nothing qualifies, the full list comes back: a real picture
9548
+ * beats no picture (the same rule D212 settled for the best shot).
9549
+ */
9550
+ async narrowToTriggerZone(entry, ordered) {
9551
+ const zoneIds = entry.payload.triggerZoneIds;
9552
+ const trackId = entry.payload.subject.trackId;
9553
+ const getTrail = this.deps.getSubjectTrail;
9554
+ const deviceId = entry.payload.subject.deviceId;
9555
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9556
+ try {
9557
+ const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9558
+ const kept = selectTriggerZoneCandidates({
9559
+ candidates: ordered,
9560
+ trail,
9561
+ polygons,
9562
+ triggerAt: entry.payload.subject.timestamp
9563
+ });
9564
+ if (kept.length === ordered.length) return kept;
9565
+ this.deps.logger.info("still narrowed to the zone that fired the rule", {
9566
+ tags: { deviceId },
9567
+ meta: {
9568
+ ruleId: entry.ruleId,
9569
+ trackId,
9570
+ zoneIds: zoneIds.join(","),
9571
+ was: ordered[0]?.kind ?? null,
9572
+ wasOffsetMs: ordered[0] !== void 0 ? ordered[0].timestamp - entry.payload.subject.timestamp : null,
9573
+ now: kept[0]?.kind ?? null,
9574
+ nowOffsetMs: kept[0] !== void 0 ? kept[0].timestamp - entry.payload.subject.timestamp : null,
9575
+ dropped: ordered.length - kept.length
9576
+ }
9577
+ });
9578
+ return kept;
9579
+ } catch (err) {
9580
+ this.deps.logger.debug("trigger-zone still narrowing failed — shipping the plain ladder", {
9581
+ tags: { deviceId },
9582
+ meta: {
9583
+ ruleId: entry.ruleId,
9584
+ error: String(err)
9585
+ }
9586
+ });
9587
+ return ordered;
9588
+ }
9589
+ }
9590
+ /**
9591
+ * Keep only the stills that are ABOUT the trigger instant.
9592
+ *
9593
+ * Applies to `delivery: 'immediate'` and nothing else: a track-end rule is
9594
+ * legitimately about the whole track, and a system event has no instant of
9595
+ * its own to be near. Under `any` the list is never emptied — the best
9596
+ * out-of-band frame ships instead, and the degrade is REPORTED, because
9597
+ * "the picture is from nine seconds later" is the difference between a
9598
+ * notification that is late and one that is about something else.
9599
+ */
9600
+ narrowToTriggerInstant(entry, ordered, freshness) {
9601
+ if (entry.payload.delivery !== "immediate" || ordered.length === 0) return ordered;
9602
+ const triggerAt = entry.payload.subject.timestamp;
9603
+ const inBand = ordered.filter((file) => Math.abs(file.timestamp - triggerAt) <= NC_TRIGGER_STILL_MAX_AGE_MS);
9604
+ if (inBand.length > 0 || freshness === "trigger-only") return inBand;
9605
+ const chosen = ordered[0];
9606
+ if (chosen !== void 0) this.deps.logger.info("no still from the trigger instant — shipping a later frame", {
9607
+ tags: { deviceId: entry.payload.subject.deviceId },
9608
+ meta: {
9609
+ ruleId: entry.ruleId,
9610
+ recordId: entry.recordId,
9611
+ kind: chosen.kind,
9612
+ offsetMs: chosen.timestamp - triggerAt,
9613
+ boundMs: NC_TRIGGER_STILL_MAX_AGE_MS,
9614
+ waitedMs: NC_STILL_WAIT_MS
9615
+ }
9616
+ });
9617
+ return ordered;
9618
+ }
9619
+ async resolveAttachment(entry, policyOverride, freshness = "any") {
9732
9620
  const policy = policyOverride ?? entry.payload.media;
9733
9621
  if (policy === "none") return null;
9734
9622
  const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
@@ -9748,9 +9636,12 @@ var NcDispatcher = class {
9748
9636
  const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
9749
9637
  if (files.length === 0) continue;
9750
9638
  const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
9751
- for (const kind of preference) {
9752
- const file = files.find((f) => f.kind === kind);
9753
- if (file === void 0) continue;
9639
+ const ordered = [];
9640
+ for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9641
+ const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9642
+ if (inBand.length === 0) continue;
9643
+ const shortlist = await this.narrowToTriggerZone(entry, inBand);
9644
+ for (const file of shortlist) {
9754
9645
  const raw = Buffer.from(file.base64, "base64");
9755
9646
  if (raw.byteLength === 0) continue;
9756
9647
  const bytes = new Uint8Array(raw.byteLength);
@@ -11099,268 +10990,6 @@ function rowToRecord(id, data) {
11099
10990
  sealed: data["sealed"] === true
11100
10991
  };
11101
10992
  }
11102
- //#endregion
11103
- //#region src/notification-center/liveness-ledger.ts
11104
- /**
11105
- * @durable class=ledger owner=notification-center
11106
- * write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
11107
- * retention="none, deliberately — one row per device plus one per node, bounded by the installation. NOT pruned against the device directory: a prune is work destroyed on a fallible read (D49/D130)."
11108
- */
11109
- var NC_LIVENESS_COLLECTION = "notification-center:liveness";
11110
- var NC_LIVENESS_COLUMNS = [
11111
- (
11112
- /** The subject the intake minted (`device:617`, `node:agent-1`) — one row
11113
- * each, so the two families can never collide on a key. */
11114
- {
11115
- name: "subject",
11116
- type: "TEXT",
11117
- primaryKey: true,
11118
- notNull: true
11119
- }),
11120
- (
11121
- /** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
11122
- * row and a `device-*` event describing the same fact compare equal. */
11123
- {
11124
- name: "state",
11125
- type: "TEXT",
11126
- notNull: true
11127
- }),
11128
- (
11129
- /** The kind that last wrote the row. Telemetry: it makes the table readable
11130
- * when someone asks "why did 617 not notify". */
11131
- {
11132
- name: "kind",
11133
- type: "TEXT",
11134
- notNull: true
11135
- }),
11136
- (
11137
- /** Absent on a node row. Indexed — every question about a device is asked
11138
- * per-device. */
11139
- {
11140
- name: "deviceId",
11141
- type: "INTEGER"
11142
- }),
11143
- {
11144
- name: "updatedAt",
11145
- type: "INTEGER",
11146
- notNull: true
11147
- }
11148
- ];
11149
- var NC_LIVENESS_INDEXES = [{
11150
- name: "idx_nc_liveness_device",
11151
- columns: ["deviceId"]
11152
- }];
11153
- /**
11154
- * The kinds the ledger governs — see the module docblock for why the stream
11155
- * and switch families are absent.
11156
- */
11157
- var NC_LEDGERED_KINDS = new Set([
11158
- "device-online",
11159
- "device-offline",
11160
- "node-online",
11161
- "node-offline",
11162
- "camera-online",
11163
- "camera-offline"
11164
- ]);
11165
- /**
11166
- * Reduce a system-event subject to a ledger observation, or `null` when the
11167
- * kind is not one the ledger governs.
11168
- *
11169
- * The ONE place that decides which kinds are gated: a caller that branched on
11170
- * a `kind.startsWith('device-')` prefix would silently pull the operator's
11171
- * disable switch into a liveness ledger, which is the mistake the docblock
11172
- * spells out.
11173
- */
11174
- function ledgeredLiveness(system) {
11175
- if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
11176
- const state = system.kind.endsWith("-offline") ? "offline" : "online";
11177
- return {
11178
- subject: system.subject,
11179
- kind: system.kind,
11180
- state,
11181
- ...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
11182
- };
11183
- }
11184
- /**
11185
- * The ledger's mechanics, declared once. Everything policy-shaped is right
11186
- * here in this object: what the KEY is, what the persisted row looks like, and
11187
- * — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
11188
- * FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
11189
- * and a `device-online` event describing it compare equal, and a repeat never
11190
- * moves `updatedAt`.
11191
- *
11192
- * The reseed is uncapped in practice: the row set is bounded by the
11193
- * installation — one row per device plus one per node — so the primitive's
11194
- * default ceiling still bounds a pathological read. Deliberately NOT pruned
11195
- * against the device directory: a prune is work DESTROYED on a fallible read
11196
- * (D49), and a directory that answered empty once would wipe the ledger and
11197
- * re-arm the very flood this file exists to prevent. A removed device leaves
11198
- * exactly one row behind, and a re-added one re-seeds correctly.
11199
- */
11200
- var NC_LIVENESS_SPEC = {
11201
- collection: NC_LIVENESS_COLLECTION,
11202
- columns: NC_LIVENESS_COLUMNS,
11203
- indexes: NC_LIVENESS_INDEXES,
11204
- writeMode: "write-behind",
11205
- keyOf: (row) => row.subject,
11206
- toValue: rowToValue$1,
11207
- fromRecord: recordToRow$2,
11208
- equalFact: (held, incoming) => held.state === incoming.state,
11209
- deviceIdOf: (row) => row.deviceId
11210
- };
11211
- var NcLivenessLedger = class {
11212
- ledger;
11213
- constructor(deps) {
11214
- this.ledger = new DurableLedger({
11215
- spec: NC_LIVENESS_SPEC,
11216
- store: deps.store,
11217
- logger: deps.logger
11218
- });
11219
- }
11220
- static declare(store) {
11221
- return DurableLedger.declare(store, NC_LIVENESS_SPEC);
11222
- }
11223
- /**
11224
- * Boot reseed — the whole point of the file. Returns the row count so the
11225
- * caller can say out loud how much memory it recovered; "loaded 0" after a
11226
- * container recreate is the one line that would have explained tonight.
11227
- *
11228
- * A failed load KEEPS whatever is already mirrored (the primitive's contract
11229
- * rule 2) rather than clearing it: dropping the memory is what caused the
11230
- * flood, so no error path may do it on purpose.
11231
- */
11232
- async load() {
11233
- return (await this.ledger.load()).length;
11234
- }
11235
- /**
11236
- * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
11237
- * the fix for the outage this ledger used to swallow.
11238
- *
11239
- * ## The hole
11240
- *
11241
- * {@link load} recovers what the ledger itself accepted. A device that has
11242
- * never produced a liveness event since the ledger was created has no row at
11243
- * all, so its FIRST observation ever seeds silently — and if that first
11244
- * observation is the camera DYING, the outage is thrown away. Live cost,
11245
- * 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
11246
- * notifications. `device:584` and `device:587` had never spoken; their
11247
- * unplugging was recorded and never announced. A camera that is quiet until
11248
- * it dies is a camera whose death is silent, indefinitely.
11249
- *
11250
- * ## Why only the ONLINE ones
11251
- *
11252
- * The directory is the device manager's own `online` flag (D132 §3: the
11253
- * authority seeds the mirror at boot and stays the authority). Seeding only
11254
- * the devices it calls ONLINE is what makes this change one-directional:
11255
- *
11256
- * - a row seeded `online` can only ever turn a later `device.offline` into a
11257
- * notification — a true sentence about a device that just went away;
11258
- * - it can NEVER turn a `device.online` into one, and that is the only shape
11259
- * the 2026-08-13 notification flood (D130) ever had.
11260
- *
11261
- * A device the directory calls offline — which is also what a cold or
11262
- * still-settling directory would say — gets no row and keeps today's
11263
- * behaviour exactly. Failing toward discard, unchanged.
11264
- *
11265
- * ## D49
11266
- *
11267
- * An EXISTING row is never overwritten. The ledger is the authority for what
11268
- * it accepted; the directory is a mirror refreshed on a tick and may be
11269
- * stale. A stale `online` overwriting an accepted `offline` would make the
11270
- * camera's real recovery a no-flip and lose it. A failed directory read
11271
- * simply yields an empty list here and seeds nothing.
11272
- *
11273
- * ## Staged, not persisted
11274
- *
11275
- * The seed is DERIVED from an authority that is read again on every boot, so
11276
- * losing it costs nothing — the next boot re-derives it. `stage` therefore
11277
- * advances the mirror without a write, which matters on this hub: the
11278
- * pipeline-analytics runner respawns several times an hour, and persisting
11279
- * ~500 re-derivable rows on each respawn would offer thousands of pointless
11280
- * commits a day to the checkpoint lottery (D96). The first real
11281
- * {@link observe} flip persists through the ordinary write-behind path.
11282
- *
11283
- * Returns how many rows were seeded, so the caller can say it out loud.
11284
- */
11285
- seedFromDirectory(identities, now) {
11286
- let seeded = 0;
11287
- for (const identity of identities) {
11288
- if (identity.online !== true) continue;
11289
- const subject = `device:${identity.deviceId}`;
11290
- if (this.ledger.has(subject)) continue;
11291
- this.ledger.stage({
11292
- subject,
11293
- state: "online",
11294
- kind: "device-online",
11295
- deviceId: identity.deviceId,
11296
- updatedAt: now
11297
- });
11298
- seeded += 1;
11299
- }
11300
- return seeded;
11301
- }
11302
- /** The in-RAM mirror, subject-ordered so two readers agree. */
11303
- snapshot() {
11304
- return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
11305
- }
11306
- /** The state the ledger currently accepts for a subject, if any. */
11307
- stateOf(subject) {
11308
- return this.ledger.get(subject)?.state;
11309
- }
11310
- /**
11311
- * Judge ONE observation and advance the ledger.
11312
- *
11313
- * Synchronous on purpose: the verdict is a function of the in-RAM mirror
11314
- * alone, so an event can never be gated on an I/O that might fail (D49). The
11315
- * durable write is kicked off behind it and its failure changes no verdict.
11316
- *
11317
- * `no-flip` leaves the row untouched — including its `updatedAt`, which
11318
- * therefore means "when this subject last CHANGED", not "when it last
11319
- * spoke". That is the timestamp anyone reading the table wants.
11320
- */
11321
- observe(observation, now) {
11322
- return this.ledger.observe({
11323
- subject: observation.subject,
11324
- state: observation.state,
11325
- kind: observation.kind,
11326
- ...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
11327
- updatedAt: now
11328
- });
11329
- }
11330
- };
11331
- /** The persisted column map (the `subject` PK is passed separately). */
11332
- function rowToValue$1(row) {
11333
- return {
11334
- state: row.state,
11335
- kind: row.kind,
11336
- ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
11337
- updatedAt: row.updatedAt
11338
- };
11339
- }
11340
- /**
11341
- * Structurally validate one persisted record. A row whose state is not one of
11342
- * the two known values is `null` — skipped, so the subject re-seeds cold
11343
- * (silent) rather than hydrating a state nothing can ever equal, which would
11344
- * make every future event a phantom flip.
11345
- */
11346
- function recordToRow$2(subject, data) {
11347
- if (subject.length === 0) return null;
11348
- const state = data["state"];
11349
- if (state !== "online" && state !== "offline") return null;
11350
- const kind = data["kind"];
11351
- if (typeof kind !== "string" || kind.length === 0) return null;
11352
- const updatedAt = Number(data["updatedAt"]);
11353
- if (!Number.isFinite(updatedAt)) return null;
11354
- const rawDeviceId = data["deviceId"];
11355
- const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
11356
- return {
11357
- subject,
11358
- state,
11359
- kind,
11360
- ...deviceId !== void 0 ? { deviceId } : {},
11361
- updatedAt
11362
- };
11363
- }
11364
10993
  function isDeviceLivenessKind(kind) {
11365
10994
  return kind === "device-online" || kind === "device-offline" || kind === "camera-online" || kind === "camera-offline";
11366
10995
  }
@@ -11423,6 +11052,268 @@ var DeviceLivenessHoldoff = class {
11423
11052
  }
11424
11053
  };
11425
11054
  //#endregion
11055
+ //#region src/notification-center/liveness-ledger.ts
11056
+ /**
11057
+ * @durable class=ledger owner=notification-center
11058
+ * write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
11059
+ * retention="none, deliberately — one row per device plus one per node, bounded by the installation. NOT pruned against the device directory: a prune is work destroyed on a fallible read (D49/D130)."
11060
+ */
11061
+ var NC_LIVENESS_COLLECTION = "notification-center:liveness";
11062
+ var NC_LIVENESS_COLUMNS = [
11063
+ (
11064
+ /** The subject the intake minted (`device:617`, `node:agent-1`) — one row
11065
+ * each, so the two families can never collide on a key. */
11066
+ {
11067
+ name: "subject",
11068
+ type: "TEXT",
11069
+ primaryKey: true,
11070
+ notNull: true
11071
+ }),
11072
+ (
11073
+ /** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
11074
+ * row and a `device-*` event describing the same fact compare equal. */
11075
+ {
11076
+ name: "state",
11077
+ type: "TEXT",
11078
+ notNull: true
11079
+ }),
11080
+ (
11081
+ /** The kind that last wrote the row. Telemetry: it makes the table readable
11082
+ * when someone asks "why did 617 not notify". */
11083
+ {
11084
+ name: "kind",
11085
+ type: "TEXT",
11086
+ notNull: true
11087
+ }),
11088
+ (
11089
+ /** Absent on a node row. Indexed — every question about a device is asked
11090
+ * per-device. */
11091
+ {
11092
+ name: "deviceId",
11093
+ type: "INTEGER"
11094
+ }),
11095
+ {
11096
+ name: "updatedAt",
11097
+ type: "INTEGER",
11098
+ notNull: true
11099
+ }
11100
+ ];
11101
+ var NC_LIVENESS_INDEXES = [{
11102
+ name: "idx_nc_liveness_device",
11103
+ columns: ["deviceId"]
11104
+ }];
11105
+ /**
11106
+ * The kinds the ledger governs — see the module docblock for why the stream
11107
+ * and switch families are absent.
11108
+ */
11109
+ var NC_LEDGERED_KINDS = new Set([
11110
+ "device-online",
11111
+ "device-offline",
11112
+ "node-online",
11113
+ "node-offline",
11114
+ "camera-online",
11115
+ "camera-offline"
11116
+ ]);
11117
+ /**
11118
+ * Reduce a system-event subject to a ledger observation, or `null` when the
11119
+ * kind is not one the ledger governs.
11120
+ *
11121
+ * The ONE place that decides which kinds are gated: a caller that branched on
11122
+ * a `kind.startsWith('device-')` prefix would silently pull the operator's
11123
+ * disable switch into a liveness ledger, which is the mistake the docblock
11124
+ * spells out.
11125
+ */
11126
+ function ledgeredLiveness(system) {
11127
+ if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
11128
+ const state = system.kind.endsWith("-offline") ? "offline" : "online";
11129
+ return {
11130
+ subject: system.subject,
11131
+ kind: system.kind,
11132
+ state,
11133
+ ...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
11134
+ };
11135
+ }
11136
+ /**
11137
+ * The ledger's mechanics, declared once. Everything policy-shaped is right
11138
+ * here in this object: what the KEY is, what the persisted row looks like, and
11139
+ * — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
11140
+ * FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
11141
+ * and a `device-online` event describing it compare equal, and a repeat never
11142
+ * moves `updatedAt`.
11143
+ *
11144
+ * The reseed is uncapped in practice: the row set is bounded by the
11145
+ * installation — one row per device plus one per node — so the primitive's
11146
+ * default ceiling still bounds a pathological read. Deliberately NOT pruned
11147
+ * against the device directory: a prune is work DESTROYED on a fallible read
11148
+ * (D49), and a directory that answered empty once would wipe the ledger and
11149
+ * re-arm the very flood this file exists to prevent. A removed device leaves
11150
+ * exactly one row behind, and a re-added one re-seeds correctly.
11151
+ */
11152
+ var NC_LIVENESS_SPEC = {
11153
+ collection: NC_LIVENESS_COLLECTION,
11154
+ columns: NC_LIVENESS_COLUMNS,
11155
+ indexes: NC_LIVENESS_INDEXES,
11156
+ writeMode: "write-behind",
11157
+ keyOf: (row) => row.subject,
11158
+ toValue: rowToValue$1,
11159
+ fromRecord: recordToRow$2,
11160
+ equalFact: (held, incoming) => held.state === incoming.state,
11161
+ deviceIdOf: (row) => row.deviceId
11162
+ };
11163
+ var NcLivenessLedger = class {
11164
+ ledger;
11165
+ constructor(deps) {
11166
+ this.ledger = new DurableLedger({
11167
+ spec: NC_LIVENESS_SPEC,
11168
+ store: deps.store,
11169
+ logger: deps.logger
11170
+ });
11171
+ }
11172
+ static declare(store) {
11173
+ return DurableLedger.declare(store, NC_LIVENESS_SPEC);
11174
+ }
11175
+ /**
11176
+ * Boot reseed — the whole point of the file. Returns the row count so the
11177
+ * caller can say out loud how much memory it recovered; "loaded 0" after a
11178
+ * container recreate is the one line that would have explained tonight.
11179
+ *
11180
+ * A failed load KEEPS whatever is already mirrored (the primitive's contract
11181
+ * rule 2) rather than clearing it: dropping the memory is what caused the
11182
+ * flood, so no error path may do it on purpose.
11183
+ */
11184
+ async load() {
11185
+ return (await this.ledger.load()).length;
11186
+ }
11187
+ /**
11188
+ * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
11189
+ * the fix for the outage this ledger used to swallow.
11190
+ *
11191
+ * ## The hole
11192
+ *
11193
+ * {@link load} recovers what the ledger itself accepted. A device that has
11194
+ * never produced a liveness event since the ledger was created has no row at
11195
+ * all, so its FIRST observation ever seeds silently — and if that first
11196
+ * observation is the camera DYING, the outage is thrown away. Live cost,
11197
+ * 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
11198
+ * notifications. `device:584` and `device:587` had never spoken; their
11199
+ * unplugging was recorded and never announced. A camera that is quiet until
11200
+ * it dies is a camera whose death is silent, indefinitely.
11201
+ *
11202
+ * ## Why only the ONLINE ones
11203
+ *
11204
+ * The directory is the device manager's own `online` flag (D132 §3: the
11205
+ * authority seeds the mirror at boot and stays the authority). Seeding only
11206
+ * the devices it calls ONLINE is what makes this change one-directional:
11207
+ *
11208
+ * - a row seeded `online` can only ever turn a later `device.offline` into a
11209
+ * notification — a true sentence about a device that just went away;
11210
+ * - it can NEVER turn a `device.online` into one, and that is the only shape
11211
+ * the 2026-08-13 notification flood (D130) ever had.
11212
+ *
11213
+ * A device the directory calls offline — which is also what a cold or
11214
+ * still-settling directory would say — gets no row and keeps today's
11215
+ * behaviour exactly. Failing toward discard, unchanged.
11216
+ *
11217
+ * ## D49
11218
+ *
11219
+ * An EXISTING row is never overwritten. The ledger is the authority for what
11220
+ * it accepted; the directory is a mirror refreshed on a tick and may be
11221
+ * stale. A stale `online` overwriting an accepted `offline` would make the
11222
+ * camera's real recovery a no-flip and lose it. A failed directory read
11223
+ * simply yields an empty list here and seeds nothing.
11224
+ *
11225
+ * ## Staged, not persisted
11226
+ *
11227
+ * The seed is DERIVED from an authority that is read again on every boot, so
11228
+ * losing it costs nothing — the next boot re-derives it. `stage` therefore
11229
+ * advances the mirror without a write, which matters on this hub: the
11230
+ * pipeline-analytics runner respawns several times an hour, and persisting
11231
+ * ~500 re-derivable rows on each respawn would offer thousands of pointless
11232
+ * commits a day to the checkpoint lottery (D96). The first real
11233
+ * {@link observe} flip persists through the ordinary write-behind path.
11234
+ *
11235
+ * Returns how many rows were seeded, so the caller can say it out loud.
11236
+ */
11237
+ seedFromDirectory(identities, now) {
11238
+ let seeded = 0;
11239
+ for (const identity of identities) {
11240
+ if (identity.online !== true) continue;
11241
+ const subject = `device:${identity.deviceId}`;
11242
+ if (this.ledger.has(subject)) continue;
11243
+ this.ledger.stage({
11244
+ subject,
11245
+ state: "online",
11246
+ kind: "device-online",
11247
+ deviceId: identity.deviceId,
11248
+ updatedAt: now
11249
+ });
11250
+ seeded += 1;
11251
+ }
11252
+ return seeded;
11253
+ }
11254
+ /** The in-RAM mirror, subject-ordered so two readers agree. */
11255
+ snapshot() {
11256
+ return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
11257
+ }
11258
+ /** The state the ledger currently accepts for a subject, if any. */
11259
+ stateOf(subject) {
11260
+ return this.ledger.get(subject)?.state;
11261
+ }
11262
+ /**
11263
+ * Judge ONE observation and advance the ledger.
11264
+ *
11265
+ * Synchronous on purpose: the verdict is a function of the in-RAM mirror
11266
+ * alone, so an event can never be gated on an I/O that might fail (D49). The
11267
+ * durable write is kicked off behind it and its failure changes no verdict.
11268
+ *
11269
+ * `no-flip` leaves the row untouched — including its `updatedAt`, which
11270
+ * therefore means "when this subject last CHANGED", not "when it last
11271
+ * spoke". That is the timestamp anyone reading the table wants.
11272
+ */
11273
+ observe(observation, now) {
11274
+ return this.ledger.observe({
11275
+ subject: observation.subject,
11276
+ state: observation.state,
11277
+ kind: observation.kind,
11278
+ ...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
11279
+ updatedAt: now
11280
+ });
11281
+ }
11282
+ };
11283
+ /** The persisted column map (the `subject` PK is passed separately). */
11284
+ function rowToValue$1(row) {
11285
+ return {
11286
+ state: row.state,
11287
+ kind: row.kind,
11288
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
11289
+ updatedAt: row.updatedAt
11290
+ };
11291
+ }
11292
+ /**
11293
+ * Structurally validate one persisted record. A row whose state is not one of
11294
+ * the two known values is `null` — skipped, so the subject re-seeds cold
11295
+ * (silent) rather than hydrating a state nothing can ever equal, which would
11296
+ * make every future event a phantom flip.
11297
+ */
11298
+ function recordToRow$2(subject, data) {
11299
+ if (subject.length === 0) return null;
11300
+ const state = data["state"];
11301
+ if (state !== "online" && state !== "offline") return null;
11302
+ const kind = data["kind"];
11303
+ if (typeof kind !== "string" || kind.length === 0) return null;
11304
+ const updatedAt = Number(data["updatedAt"]);
11305
+ if (!Number.isFinite(updatedAt)) return null;
11306
+ const rawDeviceId = data["deviceId"];
11307
+ const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
11308
+ return {
11309
+ subject,
11310
+ state,
11311
+ kind,
11312
+ ...deviceId !== void 0 ? { deviceId } : {},
11313
+ updatedAt
11314
+ };
11315
+ }
11316
+ //#endregion
11426
11317
  //#region src/notification-center/occupancy-watcher.ts
11427
11318
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
11428
11319
  var FRAME_SCOPE = "@frame";
@@ -13789,6 +13680,279 @@ var NcSnoozeStore = class {
13789
13680
  return parsed.success ? parsed.data : null;
13790
13681
  }
13791
13682
  };
13683
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
13684
+ var FULL_FRAME_QUALITY = 80;
13685
+ /**
13686
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
13687
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
13688
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
13689
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
13690
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
13691
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
13692
+ */
13693
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
13694
+ return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
13695
+ fit: "inside",
13696
+ withoutEnlargement: true
13697
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
13698
+ }
13699
+ //#endregion
13700
+ //#region src/notification-center/still-shelf.ts
13701
+ /**
13702
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
13703
+ * name owns a frame.
13704
+ *
13705
+ * ## Why a trigger needs one at all
13706
+ *
13707
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
13708
+ * object or package event owns its crops, a closed track owns its best shot, a
13709
+ * doorbell press owns the marker track the same press projected
13710
+ * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
13711
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
13712
+ * same reason in both cases — the subject is an ABSENCE:
13713
+ *
13714
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
13715
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
13716
+ * about sound that has already stopped, and `event-intake.ts` states why
13717
+ * replaying it later would be wrong.
13718
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
13719
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
13720
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
13721
+ *
13722
+ * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
13723
+ * of the trigger. Not of the sound, not of the object that left — of what the
13724
+ * camera can see now that it happened. For a freed parking space that is
13725
+ * exactly the answer the operator wants: the space, empty.
13726
+ *
13727
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
13728
+ * and the only thing that differs between the two triggers is the GATE deciding
13729
+ * that a photograph is owed at all — which belongs at the trigger site, where
13730
+ * the rules are, and not here. A twin module would be a second derivation of
13731
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
13732
+ * can say which absence they are about.
13733
+ *
13734
+ * ## Three properties, and each one is a decision
13735
+ *
13736
+ * **It is not a record.** The bytes live here, in RAM, under an owner id and a
13737
+ * TTL that covers the outbox's whole retry horizon — and nowhere else. The
13738
+ * alternative was the doorbell's: materialise a synthetic marker track through
13739
+ * `SyntheticTrackMaterializer` and let the notification name it. That would put
13740
+ * a durable Track on the camera's timeline for every confirmed window and every
13741
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
13742
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
13743
+ * precisely to bound how many audio markers a camera may emit). A notification
13744
+ * must not manufacture timeline history as a side effect of wanting a picture.
13745
+ *
13746
+ * **The capture STARTS immediately and is never awaited.** The subject is
13747
+ * transient — a scream is over before a snapshot round-trip completes, and a
13748
+ * freed space is about to be taken by the next car — so the fetch is kicked off
13749
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
13750
+ * synchronously so the outbox row can name it. The bytes land while the row
13751
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
13752
+ * pause its own footage render already costs) picks them up. A camera that
13753
+ * never answers costs the picture and never the notification.
13754
+ *
13755
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
13756
+ * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
13757
+ * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
13758
+ * Without a reuse window this would photograph a camera at whatever rate the
13759
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
13760
+ * outbox rows are never mistaken for one subject; they merely point at the same
13761
+ * frame, which is the truth — the scene did not change in ten seconds. The
13762
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
13763
+ * emptied zone ten seconds apart are two claims about one scene.
13764
+ *
13765
+ * Nothing here is silent: a capture that lands and a camera that refuses each
13766
+ * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
13767
+ * and 615 not" is the only form that question is ever asked in.
13768
+ */
13769
+ /**
13770
+ * The owner-id namespace. It is what routes a lookup here instead of to the
13771
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
13772
+ * branch — `getMediaForOwner` answers for all of them under one signature.
13773
+ */
13774
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
13775
+ /** True for an owner id this shelf minted. */
13776
+ function isStillShelfId(id) {
13777
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
13778
+ }
13779
+ /**
13780
+ * How long the bytes are held.
13781
+ *
13782
+ * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
13783
+ * ten minutes; fifteen covers that with room for a slow drain. Past it the row
13784
+ * ships text-only, which is the correct degradation for a photograph of a scene
13785
+ * that is a quarter of an hour stale anyway.
13786
+ */
13787
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
13788
+ /** A hanging snapshot cap must not pin a capture slot forever. */
13789
+ var SNAPSHOT_TIMEOUT_MS = 8e3;
13790
+ /**
13791
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
13792
+ * one inside a single TTL; the oldest is dropped first, which costs a
13793
+ * fifteen-minute-old picture nobody is waiting for.
13794
+ */
13795
+ var MAX_CAPTURES = 64;
13796
+ var NcStillShelf = class {
13797
+ deps;
13798
+ /** captureId → the photograph. */
13799
+ captures = /* @__PURE__ */ new Map();
13800
+ /** ownerId → captureId. Several owners may name one capture (the reuse window). */
13801
+ owners = /* @__PURE__ */ new Map();
13802
+ /** deviceId → its newest capture, for the reuse window. */
13803
+ newest = /* @__PURE__ */ new Map();
13804
+ constructor(deps) {
13805
+ this.deps = deps;
13806
+ }
13807
+ /**
13808
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
13809
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
13810
+ * path and the outbox row is built from what this returns.
13811
+ */
13812
+ capture(deviceId, atMs, trigger) {
13813
+ const now = this.deps.now();
13814
+ this.prune(now);
13815
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
13816
+ const recent = this.newest.get(deviceId);
13817
+ if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
13818
+ this.owners.set(ownerId, recent.captureId);
13819
+ return ownerId;
13820
+ }
13821
+ const captureId = (0, node_crypto.randomUUID)();
13822
+ this.captures.set(captureId, {
13823
+ deviceId,
13824
+ trigger,
13825
+ startedAt: now,
13826
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
13827
+ files: []
13828
+ });
13829
+ this.newest.set(deviceId, {
13830
+ captureId,
13831
+ at: now
13832
+ });
13833
+ this.owners.set(ownerId, captureId);
13834
+ this.fetch(captureId, deviceId, atMs, trigger);
13835
+ return ownerId;
13836
+ }
13837
+ /**
13838
+ * The media an owner id holds, or `undefined` when this shelf never minted
13839
+ * it. An EMPTY array is a different answer: the capture exists and has not
13840
+ * landed (or never will), which is exactly the case the dispatcher's bounded
13841
+ * wait and its `no still could be resolved` line are for.
13842
+ */
13843
+ get(ownerId) {
13844
+ const captureId = this.owners.get(ownerId);
13845
+ if (captureId === void 0) return void 0;
13846
+ return this.captures.get(captureId)?.files ?? [];
13847
+ }
13848
+ /** Drop expired captures and the owners that named them. */
13849
+ prune(now) {
13850
+ for (const [captureId, held] of this.captures) {
13851
+ if (held.expiresAt > now) continue;
13852
+ this.captures.delete(captureId);
13853
+ if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
13854
+ }
13855
+ while (this.captures.size > MAX_CAPTURES) {
13856
+ const oldest = this.captures.keys().next();
13857
+ if (oldest.done === true) break;
13858
+ const held = this.captures.get(oldest.value);
13859
+ this.captures.delete(oldest.value);
13860
+ if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
13861
+ }
13862
+ for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
13863
+ }
13864
+ /** Drop everything (shutdown). */
13865
+ clear() {
13866
+ this.captures.clear();
13867
+ this.owners.clear();
13868
+ this.newest.clear();
13869
+ }
13870
+ async fetch(captureId, deviceId, atMs, trigger) {
13871
+ try {
13872
+ const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
13873
+ if (shot === null || shot.base64.length === 0) {
13874
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
13875
+ return;
13876
+ }
13877
+ const raw = Buffer.from(shot.base64, "base64");
13878
+ let data = raw;
13879
+ try {
13880
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
13881
+ } catch {}
13882
+ const held = this.captures.get(captureId);
13883
+ if (held === void 0) return;
13884
+ this.captures.set(captureId, {
13885
+ ...held,
13886
+ files: stillShelfMedia(data, atMs)
13887
+ });
13888
+ this.deps.logger.info(`${trigger} still captured`, {
13889
+ tags: { deviceId },
13890
+ meta: {
13891
+ trigger,
13892
+ bytes: data.byteLength,
13893
+ tookMs: this.deps.now() - held.startedAt
13894
+ }
13895
+ });
13896
+ } catch (err) {
13897
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
13898
+ }
13899
+ }
13900
+ /**
13901
+ * A branch that drops work says so. This one costs the operator the picture
13902
+ * on a notification they DID receive, so it is a warn and it carries the
13903
+ * camera — the only key the question is ever asked with.
13904
+ */
13905
+ reportMiss(deviceId, trigger, reason) {
13906
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
13907
+ tags: { deviceId },
13908
+ meta: {
13909
+ trigger,
13910
+ reason
13911
+ }
13912
+ });
13913
+ }
13914
+ };
13915
+ /**
13916
+ * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
13917
+ *
13918
+ * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
13919
+ * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
13920
+ * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
13921
+ * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
13922
+ * NOTHING and the notification ships text-only with the ordinary line saying
13923
+ * so. Inventing a centre crop would answer a different question from the one
13924
+ * the operator asked.
13925
+ */
13926
+ function stillShelfMedia(data, timestamp) {
13927
+ const base64 = data.toString("base64");
13928
+ const file = (kind) => ({
13929
+ key: `${NC_STILL_SHELF_PREFIX}${kind}`,
13930
+ kind,
13931
+ base64,
13932
+ sizeBytes: data.byteLength,
13933
+ timestamp
13934
+ });
13935
+ return [
13936
+ file("keyFrameSmall"),
13937
+ file("keyFrame"),
13938
+ file("fullFrame")
13939
+ ];
13940
+ }
13941
+ /** Reject if `promise` does not settle within `ms`. */
13942
+ function withTimeout$3(promise, ms) {
13943
+ return new Promise((resolve, reject) => {
13944
+ const timer = setTimeout(() => {
13945
+ reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
13946
+ }, ms);
13947
+ promise.then((value) => {
13948
+ clearTimeout(timer);
13949
+ resolve(value);
13950
+ }, (err) => {
13951
+ clearTimeout(timer);
13952
+ reject(err instanceof Error ? err : new Error(String(err)));
13953
+ });
13954
+ });
13955
+ }
13792
13956
  //#endregion
13793
13957
  //#region src/notification-center/summary/summary-ai.ts
13794
13958
  /**
@@ -21863,6 +22027,7 @@ var NotificationCenter = class NotificationCenter {
21863
22027
  ...rule.template !== void 0 ? { template: rule.template } : {},
21864
22028
  media: noMedia ? "none" : mediaRule.media.attach,
21865
22029
  ...!noMedia && mediaRule.media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
22030
+ ...!noMedia && cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
21866
22031
  ...confirm !== void 0 ? {
21867
22032
  confirm,
21868
22033
  ...cooldown !== void 0 ? { cooldown } : {}
@@ -25939,37 +26104,61 @@ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples)
25939
26104
  return eligible;
25940
26105
  }
25941
26106
  /**
25942
- * Match a probe embedding against the gallery. Only samples with the same
25943
- * `modelId` AND the same dimension are compared (model-version safety). Returns
25944
- * the best identity when its score threshold and it beats the best OTHER
25945
- * identity by ≥ margin; otherwise null.
26107
+ * THE scoring authority. Every identity with at least one sample comparable to
26108
+ * the probe (same `modelId` AND same dimension model-version safety), scored
26109
+ * against its best such sample, sorted best-first.
26110
+ *
26111
+ * Extracted from `matchEmbedding`, which is now a thin decision on top of it,
26112
+ * so that "what would the pipeline score this face" has exactly ONE
26113
+ * implementation. `face-rescore.ts` — the operator's recompute button — reads
26114
+ * the same rows the matcher decides from; a second scoring pass written for the
26115
+ * UI would be free to drift, and a UI that disagrees with the pipeline is worse
26116
+ * than no UI.
25946
26117
  */
25947
- function matchEmbedding(probe, gallery, opts) {
26118
+ function rankIdentities(probe, gallery, minIdentitySamples) {
25948
26119
  const probeVec = new Float32Array(probe.embedding);
25949
- const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
25950
26120
  const bestByIdentity = /* @__PURE__ */ new Map();
26121
+ const countByIdentity = /* @__PURE__ */ new Map();
25951
26122
  for (const s of gallery) {
25952
26123
  if (s.modelId !== probe.modelId) continue;
25953
26124
  if (s.embedding.length !== probe.embedding.length) continue;
25954
- if (!eligible.has(s.identityId)) continue;
25955
26125
  const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
25956
26126
  const prev = bestByIdentity.get(s.identityId);
25957
26127
  if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
26128
+ countByIdentity.set(s.identityId, (countByIdentity.get(s.identityId) ?? 0) + 1);
25958
26129
  }
25959
- if (bestByIdentity.size === 0) return null;
25960
- let bestId = null;
25961
- let bestScore = -Infinity;
25962
- let secondScore = -Infinity;
25963
- for (const [id, score] of bestByIdentity) if (score > bestScore) {
25964
- secondScore = bestScore;
25965
- bestScore = score;
25966
- bestId = id;
25967
- } else if (score > secondScore) secondScore = score;
25968
- if (bestId === null || bestScore < opts.threshold) return null;
25969
- if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
26130
+ const rows = [];
26131
+ for (const [identityId, score] of bestByIdentity) {
26132
+ const sampleCount = countByIdentity.get(identityId) ?? 0;
26133
+ rows.push({
26134
+ identityId,
26135
+ score,
26136
+ sampleCount,
26137
+ eligible: sampleCount >= minIdentitySamples
26138
+ });
26139
+ }
26140
+ rows.sort((a, b) => b.score - a.score);
26141
+ return rows;
26142
+ }
26143
+ /**
26144
+ * Match a probe embedding against the gallery. Only samples with the same
26145
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
26146
+ * the best identity when its score ≥ threshold and it beats the best OTHER
26147
+ * identity by ≥ margin; otherwise null.
26148
+ *
26149
+ * The scores come from {@link rankIdentities}; this function is only the
26150
+ * decision (eligibility filter, threshold, ambiguity guard).
26151
+ */
26152
+ function matchEmbedding(probe, gallery, opts) {
26153
+ const ranked = rankIdentities(probe, gallery, opts.minIdentitySamples ?? 1).filter((r) => r.eligible);
26154
+ const best = ranked[0];
26155
+ if (best === void 0) return null;
26156
+ if (best.score < opts.threshold) return null;
26157
+ const second = ranked[1];
26158
+ if (second !== void 0 && best.score - second.score < opts.margin) return null;
25970
26159
  return {
25971
- identityId: bestId,
25972
- score: bestScore
26160
+ identityId: best.identityId,
26161
+ score: best.score
25973
26162
  };
25974
26163
  }
25975
26164
  /**
@@ -26108,17 +26297,6 @@ function updateTrackAggregate(prev, match, opts) {
26108
26297
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
26109
26298
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
26110
26299
  /**
26111
- * Model id assumed for a face row that recorded none.
26112
- *
26113
- * Every row written before 2026-08-20 predates `Face.embeddingModelId`, and
26114
- * every one of them was embedded with `arcface-r100` — it was the only face
26115
- * model the step had ever defaulted to. So this is a statement about history,
26116
- * NOT a default for new work: a face that names its model is stamped with the
26117
- * model it names, because a sample that lies about its feature space defeats
26118
- * the only guard `face-matcher` has.
26119
- */
26120
- var LEGACY_MODEL_ID = "arcface-r100";
26121
- /**
26122
26300
  * How many face crops `listRecentFaces` resolves at once.
26123
26301
  *
26124
26302
  * Each crop costs a store round-trip plus a disk read, so the page is I/O-bound
@@ -26360,7 +26538,7 @@ var FaceGalleryProvider = class {
26360
26538
  }
26361
26539
  const currentFace = await this.faceStore.get(faceId);
26362
26540
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
26363
- const enrolledModelId = currentFace.embeddingModelId ?? LEGACY_MODEL_ID;
26541
+ const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
26364
26542
  const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
26365
26543
  if (verdict !== null) {
26366
26544
  this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
@@ -26533,6 +26711,316 @@ var FaceGalleryProvider = class {
26533
26711
  }
26534
26712
  };
26535
26713
  //#endregion
26714
+ //#region src/pipeline-analytics/face-rescore.ts
26715
+ /**
26716
+ * face-rescore — score ONE face against the gallery AS IT IS RIGHT NOW.
26717
+ *
26718
+ * ## Why this exists: the suggestion on a track is stale by construction
26719
+ *
26720
+ * `Face.suggestedIdentityId` / `suggestedMatchScore` are not computed when you
26721
+ * look at a track. They are computed DURING live detection, frame by frame
26722
+ * (`face-recognizer.ts` — the suggestion band), and the per-track peak is
26723
+ * persisted when the track CLOSES. They are a photograph of what the gallery
26724
+ * said at that instant.
26725
+ *
26726
+ * So every enrolment after that instant is invisible to them. The operator's
26727
+ * real workflow is the exact case this breaks: assign ten faces to build a
26728
+ * person up, then open an older unassigned track and ask "does it match NOW?"
26729
+ * — and read a number that was decided before any of those ten existed. There
26730
+ * is no background job that refreshes it and there should not be one (the
26731
+ * gallery changes on every assign; re-scoring every buffered face each time is
26732
+ * work nobody asked for). The honest fix is to make the question askable on
26733
+ * demand. That is this module, and that is the button.
26734
+ *
26735
+ * ## What it does NOT do
26736
+ *
26737
+ * It does not run inference. The face's embedding is already persisted, and
26738
+ * matching is pure arithmetic over vectors — so a recompute is a read plus a
26739
+ * few thousand dot products, not a pipeline pass. Re-embedding is a SEPARATE,
26740
+ * explicitly-requested action (see the action's `reembed` flag): a button
26741
+ * labelled "recompute" must never silently spend GPU.
26742
+ *
26743
+ * ## The model gate is not an optimisation, it is the difference between a
26744
+ * number and noise
26745
+ *
26746
+ * A cosine between vectors from two different face models is a well-formed
26747
+ * float with no meaning. `face-matcher` has always refused to compare across
26748
+ * `modelId`, which means a face embedded before a model flip scores against
26749
+ * NOTHING — and the trap is that "nothing" renders identically to "no match".
26750
+ * This module names that state (`model-mismatch`) so the UI can say "this face
26751
+ * is in an old feature space" instead of showing 0% and letting the operator
26752
+ * conclude the person is not there.
26753
+ */
26754
+ /** The empty report every "cannot score" path returns, so shape never varies. */
26755
+ function emptyReport(verdict, input, comparable) {
26756
+ return {
26757
+ verdict,
26758
+ comparable,
26759
+ probeModelId: input.probe.modelId,
26760
+ clusterModelId: input.clusterModelId,
26761
+ params: input.params,
26762
+ identities: []
26763
+ };
26764
+ }
26765
+ /**
26766
+ * Decide the verdict from the ELIGIBLE ranking, using exactly the gates
26767
+ * `matchEmbedding` uses — it is called here rather than re-implemented, so the
26768
+ * "would auto-assign" claim is the matcher's own answer and cannot drift.
26769
+ */
26770
+ function decide(eligible, input) {
26771
+ const best = eligible[0];
26772
+ if (best === void 0) return {
26773
+ verdict: "no-eligible-identity",
26774
+ winnerId: null
26775
+ };
26776
+ const match = matchEmbedding(input.probe, input.gallery, {
26777
+ threshold: input.params.threshold,
26778
+ margin: input.params.margin,
26779
+ minIdentitySamples: input.params.minIdentitySamples
26780
+ });
26781
+ if (match !== null) return {
26782
+ verdict: "auto-assignable",
26783
+ winnerId: match.identityId
26784
+ };
26785
+ if (best.score >= input.params.threshold) return {
26786
+ verdict: "ambiguous",
26787
+ winnerId: null
26788
+ };
26789
+ if (best.score >= input.params.suggestionMinCosine) return {
26790
+ verdict: "suggestion",
26791
+ winnerId: null
26792
+ };
26793
+ return {
26794
+ verdict: "below-threshold",
26795
+ winnerId: null
26796
+ };
26797
+ }
26798
+ /**
26799
+ * Score one probe against the gallery as it stands, and say what the pipeline
26800
+ * would do with it. Pure: every input is passed in, nothing is read or written.
26801
+ */
26802
+ function buildFaceRescoreReport(input) {
26803
+ if (input.probe.modelId !== input.clusterModelId) return emptyReport("model-mismatch", input, false);
26804
+ const ranked = rankIdentities(input.probe, input.gallery, input.params.minIdentitySamples);
26805
+ if (ranked.length === 0) return emptyReport("empty-gallery", input, true);
26806
+ const eligible = ranked.filter((r) => r.eligible);
26807
+ const { verdict, winnerId } = decide(eligible, input);
26808
+ const identities = ranked.map((r) => ({
26809
+ identityId: r.identityId,
26810
+ name: input.identityNames.get(r.identityId) ?? r.identityId,
26811
+ score: r.score,
26812
+ sampleCount: r.sampleCount,
26813
+ eligible: r.eligible,
26814
+ meetsThreshold: r.score >= input.params.threshold,
26815
+ wouldAutoAssign: r.identityId === winnerId
26816
+ }));
26817
+ const [best, second] = eligible;
26818
+ return {
26819
+ verdict,
26820
+ comparable: true,
26821
+ probeModelId: input.probe.modelId,
26822
+ clusterModelId: input.clusterModelId,
26823
+ params: input.params,
26824
+ identities,
26825
+ ...best !== void 0 && second !== void 0 ? { runnerUpGap: best.score - second.score } : {}
26826
+ };
26827
+ }
26828
+ //#endregion
26829
+ //#region src/pipeline-analytics/face-rescore-actions.ts
26830
+ /**
26831
+ * face-rescore-actions — "score this face against the gallery AS IT IS NOW".
26832
+ *
26833
+ * ## Why an `addons.custom` action and not a `faceGallery` cap method
26834
+ *
26835
+ * Same reason `photo.*`, `embedding.*` and `debug.*` ride this bridge: a cap
26836
+ * method's wire schema lives in `@camstack/types`, which travels inside the
26837
+ * `@camstack/server` closure, so a new one is not callable until a server train
26838
+ * ships — however current the addon is. Here the schema lives in the addon's
26839
+ * own dist and the hub forwards an opaque envelope, so `camstack deploy` is the
26840
+ * whole delivery.
26841
+ *
26842
+ * ## Why re-embedding is an explicit flag and not a fallback
26843
+ *
26844
+ * `face.rescoreTrack` with `reembed: false` (the default) reads a persisted
26845
+ * vector and does arithmetic — microseconds, no GPU, no node hop. With
26846
+ * `reembed: true` it runs the `face-embedding` step on a cluster node. Those
26847
+ * are different enough in cost and in consequence (a re-embed WRITES the face
26848
+ * row) that a button labelled "recompute" must not choose between them on the
26849
+ * operator's behalf. The report says whether a re-embed is possible
26850
+ * (`reembedAvailable`); the operator asks for it.
26851
+ *
26852
+ * See `face-rescore.ts` for why the number already on the track is stale by
26853
+ * construction — that is the whole reason this action exists.
26854
+ */
26855
+ var FaceRescoreVerdictSchema = require_dist._enum([
26856
+ "auto-assignable",
26857
+ "ambiguous",
26858
+ "suggestion",
26859
+ "below-threshold",
26860
+ "no-eligible-identity",
26861
+ "empty-gallery",
26862
+ "model-mismatch"
26863
+ ]);
26864
+ var FaceRescoreParamsSchema = require_dist.object({
26865
+ threshold: require_dist.number(),
26866
+ margin: require_dist.number(),
26867
+ minIdentitySamples: require_dist.number().int(),
26868
+ /** DERIVED from `threshold`, never stored (D222) — see `suggestionBandFactor`. */
26869
+ suggestionMinCosine: require_dist.number(),
26870
+ /** The fraction of `threshold` that `suggestionMinCosine` is, so the report
26871
+ * states the RULE and not only its current result. */
26872
+ suggestionBandFactor: require_dist.number()
26873
+ });
26874
+ var FaceRescoreIdentityRowSchema = require_dist.object({
26875
+ identityId: require_dist.string(),
26876
+ name: require_dist.string(),
26877
+ /** Raw cosine. The UI formats; the wire stays in the matcher's units. */
26878
+ score: require_dist.number(),
26879
+ sampleCount: require_dist.number().int(),
26880
+ eligible: require_dist.boolean(),
26881
+ meetsThreshold: require_dist.boolean(),
26882
+ wouldAutoAssign: require_dist.boolean()
26883
+ });
26884
+ /**
26885
+ * Why a re-embed did not happen, in the addon's vocabulary.
26886
+ *
26887
+ * `not-a-template` is the one that matters and the one that is easy to get
26888
+ * wrong: a face's stored crop is the 112×112 ArcFace template ONLY when the
26889
+ * detection carried landmarks. When it does not, the crop is a padded bbox cut
26890
+ * — and feeding THAT to `face-embedding` as a whole frame produces a vector
26891
+ * that is plausible and useless, exactly the trap `face-reembed-pass.ts`
26892
+ * documents from the other direction. Refusing is the honest answer.
26893
+ */
26894
+ var FaceReembedRefusalSchema = require_dist._enum([
26895
+ "not-requested",
26896
+ "no-pixels",
26897
+ "not-a-template",
26898
+ "no-capable-node",
26899
+ "failed",
26900
+ "not-persisted"
26901
+ ]);
26902
+ var FaceReembedReportSchema = require_dist.object({
26903
+ attempted: require_dist.boolean(),
26904
+ /** True only when a NEW vector was embedded AND read back off the face row. */
26905
+ succeeded: require_dist.boolean(),
26906
+ reason: FaceReembedRefusalSchema.optional(),
26907
+ detail: require_dist.string().optional(),
26908
+ /** The model the re-embed pinned — the cluster row's, never a substitute. */
26909
+ modelId: require_dist.string().optional()
26910
+ });
26911
+ var FaceRescoreInputSchema = require_dist.object({
26912
+ deviceId: require_dist.number().int(),
26913
+ trackId: require_dist.string().min(1),
26914
+ /**
26915
+ * Run `face-embedding` over the face's stored aligned template first, so a
26916
+ * face left in a superseded feature space becomes comparable again. Costs a
26917
+ * node round-trip and WRITES the face row. Never implicit.
26918
+ */
26919
+ reembed: require_dist.boolean().default(false)
26920
+ });
26921
+ var FaceRescoreResultSchema = require_dist.object({
26922
+ faceId: require_dist.string(),
26923
+ deviceId: require_dist.number().int(),
26924
+ trackId: require_dist.string(),
26925
+ /** The face's current assignment, so the UI can mark "already this person". */
26926
+ assigned: require_dist.boolean(),
26927
+ assignedIdentityId: require_dist.string().optional(),
26928
+ /** What the track has been SHOWING until now — persisted at detection time.
26929
+ * Returned so the UI can put the stale number beside the fresh one. */
26930
+ storedSuggestedIdentityId: require_dist.string().optional(),
26931
+ storedSuggestedMatchScore: require_dist.number().optional(),
26932
+ verdict: FaceRescoreVerdictSchema,
26933
+ comparable: require_dist.boolean(),
26934
+ probeModelId: require_dist.string(),
26935
+ clusterModelId: require_dist.string(),
26936
+ params: FaceRescoreParamsSchema,
26937
+ identities: require_dist.array(FaceRescoreIdentityRowSchema).readonly(),
26938
+ runnerUpGap: require_dist.number().optional(),
26939
+ /** Whether a `reembed: true` retry could actually do anything. Lets the UI
26940
+ * offer the escape hatch only when it exists. */
26941
+ reembedAvailable: require_dist.boolean(),
26942
+ reembed: FaceReembedReportSchema
26943
+ });
26944
+ var faceRescoreActions = require_dist.defineCustomActions({
26945
+ /**
26946
+ * Read-only by default. `admin` because the report names every enrolled
26947
+ * person and how well an arbitrary face matches them — the gallery's shape,
26948
+ * which is not a viewer-level fact.
26949
+ */
26950
+ "face.rescoreTrack": require_dist.customAction(FaceRescoreInputSchema, FaceRescoreResultSchema, {
26951
+ kind: "mutation",
26952
+ auth: "admin"
26953
+ }) });
26954
+ //#endregion
26955
+ //#region src/pipeline-analytics/pipeline/closed-row-media-gate.ts
26956
+ var DEFAULT_LOG_MIN_GAP_MS = 6e4;
26957
+ var DEFAULT_MAX_TRACKED = 512;
26958
+ var WRITE = {
26959
+ refuse: false,
26960
+ log: false,
26961
+ closedAgeMs: 0,
26962
+ suppressed: 0
26963
+ };
26964
+ var ClosedRowMediaGate = class {
26965
+ logMinGapMs;
26966
+ maxTracked;
26967
+ /** trackId → sampling state, insertion-ordered so the oldest evicts first. */
26968
+ sampling = /* @__PURE__ */ new Map();
26969
+ constructor(options = {}) {
26970
+ this.logMinGapMs = options.logMinGapMs ?? DEFAULT_LOG_MIN_GAP_MS;
26971
+ this.maxTracked = options.maxTracked ?? DEFAULT_MAX_TRACKED;
26972
+ }
26973
+ /**
26974
+ * @param rowClosedAt when the store closed this track's row, or `undefined`
26975
+ * when it holds no such record — which always means WRITE.
26976
+ */
26977
+ evaluate(trackId, rowClosedAt, nowMs) {
26978
+ if (rowClosedAt === void 0) return WRITE;
26979
+ const closedAgeMs = Math.max(0, nowMs - rowClosedAt);
26980
+ const existing = this.sampling.get(trackId);
26981
+ if (existing === void 0) {
26982
+ this.remember(trackId, {
26983
+ lastLoggedAt: nowMs,
26984
+ suppressed: 0
26985
+ });
26986
+ return {
26987
+ refuse: true,
26988
+ log: true,
26989
+ closedAgeMs,
26990
+ suppressed: 0
26991
+ };
26992
+ }
26993
+ if (nowMs - existing.lastLoggedAt < this.logMinGapMs) {
26994
+ existing.suppressed += 1;
26995
+ return {
26996
+ refuse: true,
26997
+ log: false,
26998
+ closedAgeMs,
26999
+ suppressed: existing.suppressed
27000
+ };
27001
+ }
27002
+ const suppressed = existing.suppressed;
27003
+ existing.lastLoggedAt = nowMs;
27004
+ existing.suppressed = 0;
27005
+ return {
27006
+ refuse: true,
27007
+ log: true,
27008
+ closedAgeMs,
27009
+ suppressed
27010
+ };
27011
+ }
27012
+ /** Drop all sampling state (addon dispose). */
27013
+ clear() {
27014
+ this.sampling.clear();
27015
+ }
27016
+ remember(trackId, state) {
27017
+ this.sampling.set(trackId, state);
27018
+ if (this.sampling.size <= this.maxTracked) return;
27019
+ const oldest = this.sampling.keys().next();
27020
+ if (!oldest.done) this.sampling.delete(oldest.value);
27021
+ }
27022
+ };
27023
+ //#endregion
26536
27024
  //#region src/pipeline-analytics/pipeline/edge-clear.ts
26537
27025
  /**
26538
27026
  * edge-clear — pure geometry for "is the subject fully in frame?" best-frame
@@ -35355,6 +35843,210 @@ async function resolveDefaultEventMedia(deps) {
35355
35843
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
35356
35844
  }
35357
35845
  //#endregion
35846
+ //#region src/pipeline-analytics/face-settings.ts
35847
+ /**
35848
+ * Per-device face-recognition settings. Cascade: a per-device override on top
35849
+ * of the global default, resolved per field (an invalid/missing value falls
35850
+ * back to its default — parse never throws). Mirrors `audio-detection-settings`.
35851
+ */
35852
+ var FaceSettingsSchema = require_dist.object({
35853
+ /**
35854
+ * GLOBAL master switch only — a temporary kill for the whole face-recognition
35855
+ * post-processor. On by default; the recognizer runs whenever the per-device
35856
+ * detection pipeline produces face embeddings (the pipeline steps ARE the
35857
+ * per-camera control). Not a per-device override — see
35858
+ * `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
35859
+ * stripped from the per-device schema).
35860
+ */
35861
+ enabled: require_dist.boolean().default(true),
35862
+ /**
35863
+ * Cosine similarity (on L2-normalized arcface vectors) required to match.
35864
+ *
35865
+ * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
35866
+ * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
35867
+ * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
35868
+ * left almost nothing recognisable. That retune lived only as a RUNTIME
35869
+ * override until 2026-08-19; it is the schema default now, because a default
35870
+ * nobody runs is a decision nobody made.
35871
+ *
35872
+ * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
35873
+ * quality, where most of what reaches ArcFace is not a correctly aligned
35874
+ * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
35875
+ * aligned, 18% marginal, 62% misaligned or containing no detectable face).
35876
+ * On correctly aligned crops the SAME models separate at a far lower
35877
+ * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
35878
+ * 0.175, best accuracy 98.3% at threshold 0.28.
35879
+ *
35880
+ * Recalibration plan, in this order:
35881
+ * 1. land the landmark-precision gate (the alignment fix),
35882
+ * 2. re-enrol the gallery from crops produced AFTER it,
35883
+ * 3. re-measure genuine/impostor over a week of post-fix traffic,
35884
+ * 4. expect to lower this toward ~0.30, and `margin` with it.
35885
+ * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
35886
+ * scores ~0.9 against everything, so the degenerate population sits ABOVE
35887
+ * any threshold you could pick — the knob cannot reach it.
35888
+ */
35889
+ similarityThreshold: require_dist.number().min(0).max(1).default(.55),
35890
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
35891
+ * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
35892
+ * same story and the same recalibration plan as {@link similarityThreshold}. */
35893
+ margin: require_dist.number().min(0).max(1).default(.12),
35894
+ /** Minimum face-detection confidence for a face to be considered. */
35895
+ minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
35896
+ /**
35897
+ * Minimum face bbox size (px, shorter side, NATIVE scale when the runner
35898
+ * measured it, else detection-frame space) for a face to be DETECTED and
35899
+ * COLLECTED into the recent-faces buffer. Below this the face is dropped
35900
+ * BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
35901
+ * NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
35902
+ */
35903
+ minFacePx: require_dist.number().min(0).default(30),
35904
+ /**
35905
+ * Minimum face short side (px, NATIVE scale when the runner measured it, else
35906
+ * detection-frame space) for a collected face to be eligible for AUTO-MATCH
35907
+ * (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
35908
+ * between `minFacePx` and this floor are still detected, cropped, and stored
35909
+ * in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
35910
+ * an identity. ArcFace embeddings below ~48px are unreliable and drove the
35911
+ * observed false positives (2026-07-23 face-quality batch). A face below this
35912
+ * floor keeps `recognizedIdentityId` UNSET (fail-safe).
35913
+ */
35914
+ recognitionMinFacePx: require_dist.number().min(0).default(48),
35915
+ /**
35916
+ * Minimum enrolled-sample count an identity must have before it can be an
35917
+ * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
35918
+ * enrolment attracted 81% of matches); identities below this are excluded from
35919
+ * automatic matching until more samples are enrolled (#26.3).
35920
+ */
35921
+ minIdentitySamples: require_dist.number().int().min(1).default(2),
35922
+ /** Frames an identity must be confirmed before a track is assigned. Floor of
35923
+ * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
35924
+ * such a value falls back to the default). */
35925
+ confirmFrames: require_dist.number().int().min(1).default(3),
35926
+ /**
35927
+ * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35928
+ * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35929
+ * the gate.
35930
+ *
35931
+ * This exists because a degenerate crop does not score LOW, it scores HIGH:
35932
+ * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35933
+ * faces exceeded cosine 0.90 and the worst 40% of the face population had
35934
+ * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35935
+ * similarity threshold can reach that — it sits above every threshold — so the
35936
+ * only defence is to refuse the embedding before it is stored or compared.
35937
+ *
35938
+ * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35939
+ * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35940
+ * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35941
+ * 5.25) regardless of size, grey level or blur, while information-free crops
35942
+ * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35943
+ * 93.2% of degenerate ones.
35944
+ *
35945
+ * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35946
+ * magnitude has never been observable here — `result-assembler` discarded it
35947
+ * one line after computing it. The field and its logging ship first; set a
35948
+ * real bound once a week of live `face: embedding magnitude` lines says where
35949
+ * this cluster's populations actually sit. Enabling a gate against a number
35950
+ * nobody has measured in production is how recognition silently stops.
35951
+ */
35952
+ maxEmbeddingMagnitude: require_dist.number().min(0).default(0),
35953
+ /** Max buffered faces kept per device. */
35954
+ bufferMaxPerDevice: require_dist.number().int().min(0).default(50)
35955
+ });
35956
+ var FACE_DEFAULTS = FaceSettingsSchema.parse({});
35957
+ function resolveFaceSettings(raw) {
35958
+ const pick = (key) => {
35959
+ const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
35960
+ return parsed.success ? parsed.data : FACE_DEFAULTS[key];
35961
+ };
35962
+ return {
35963
+ enabled: pick("enabled"),
35964
+ similarityThreshold: pick("similarityThreshold"),
35965
+ margin: pick("margin"),
35966
+ minFaceConfidence: pick("minFaceConfidence"),
35967
+ minFacePx: pick("minFacePx"),
35968
+ recognitionMinFacePx: pick("recognitionMinFacePx"),
35969
+ minIdentitySamples: pick("minIdentitySamples"),
35970
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
35971
+ confirmFrames: pick("confirmFrames"),
35972
+ bufferMaxPerDevice: pick("bufferMaxPerDevice")
35973
+ };
35974
+ }
35975
+ /**
35976
+ * The suggestion band opens at 80% of the assignment threshold.
35977
+ *
35978
+ * ## Why derived and not a knob
35979
+ *
35980
+ * `suggestionMinCosine` used to be its own operator-overridable field. Two
35981
+ * independent numbers described ONE band, so they could contradict each other
35982
+ * — and on the live hub they did. The 2026-08-19 retune lowered
35983
+ * `similarityThreshold` to 0.45 and left the suggestion floor at its 0.50
35984
+ * default, which INVERTS the band: `[0.50, 0.45)` is empty, so from that moment
35985
+ * the recognizer could not produce a single suggestion. Nothing errored,
35986
+ * nothing logged, and the form showed two plausible numbers side by side. That
35987
+ * is the D62 shape — a second authority over a fact that already had one — and
35988
+ * the fix is the same: delete the second authority rather than police it.
35989
+ *
35990
+ * Derived, the band cannot invert for ANY threshold, which is the property
35991
+ * `face-settings.spec.ts` pins over the whole domain rather than at samples.
35992
+ *
35993
+ * ## Why 0.8
35994
+ *
35995
+ * The operator's number, and it matches what the band was for: a near-miss is
35996
+ * a probe that scored within a fifth of the bar. At the schema default (0.55)
35997
+ * it reproduces a 0.44 floor — close to the 0.50 the band shipped with in
35998
+ * 2026-07 — and it TRACKS every future retune, which is the whole point: the
35999
+ * recalibration plan on {@link FaceSettingsSchema.shape.similarityThreshold}
36000
+ * expects the threshold to fall toward ~0.30 once alignment lands, and a fixed
36001
+ * floor would have to be remembered and moved by hand each time.
36002
+ */
36003
+ var SUGGESTION_BAND_FACTOR = .8;
36004
+ /** Decimal places the derived floor is rounded to. `0.8 * 0.45` is
36005
+ * `0.36000000000000004` in IEEE754, and an operator reading "36.000000000004%"
36006
+ * in a report has been told the number is untrustworthy. */
36007
+ var DERIVED_PRECISION = 1e4;
36008
+ /**
36009
+ * The cosine floor of the suggestion band for a given assignment threshold.
36010
+ *
36011
+ * INVARIANT, total over `[0, 1]`: the result is never ABOVE the threshold, and
36012
+ * is strictly below it for every threshold > 0 — so the band
36013
+ * `[floor, threshold)` can never be empty. Rounding is applied only when it
36014
+ * preserves that (for a threshold small enough that rounding would reach it,
36015
+ * the exact product is returned instead).
36016
+ */
36017
+ function deriveSuggestionMinCosine(similarityThreshold) {
36018
+ const exact = similarityThreshold * SUGGESTION_BAND_FACTOR;
36019
+ const rounded = Math.round(exact * DERIVED_PRECISION) / DERIVED_PRECISION;
36020
+ return rounded < similarityThreshold ? rounded : exact;
36021
+ }
36022
+ /**
36023
+ * The settings key that used to hold the suggestion floor. Named once, here,
36024
+ * because the ONLY code allowed to mention it now is the migration that removes
36025
+ * it from a store.
36026
+ */
36027
+ var STORED_SUGGESTION_MIN_COSINE_KEY = "suggestionMinCosine";
36028
+ /**
36029
+ * Plan the removal of a persisted suggestion floor from one settings blob.
36030
+ *
36031
+ * `null` = nothing to do, which is the steady state after the first pass and
36032
+ * for every store that never had one. Absence of the key IS the migration
36033
+ * marker — no flag, no version row, and re-running is a no-op.
36034
+ *
36035
+ * The patch uses `undefined`, never `null`: the settings merge preserves an
36036
+ * explicit `null` as a STORED value, which would leave exactly the
36037
+ * accepted-but-ignored residue this exists to remove, while `undefined`
36038
+ * survives the merge as a key with no value and is dropped by the JSON
36039
+ * serialisation the blob round-trips through.
36040
+ */
36041
+ function planStoredSuggestionPurge(raw) {
36042
+ const was = raw[STORED_SUGGESTION_MIN_COSINE_KEY];
36043
+ if (was === void 0) return null;
36044
+ return {
36045
+ was,
36046
+ patch: { [STORED_SUGGESTION_MIN_COSINE_KEY]: void 0 }
36047
+ };
36048
+ }
36049
+ //#endregion
35358
36050
  //#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
35359
36051
  /**
35360
36052
  * `null` = accept. A verdict = refuse, carrying both numbers so the caller can
@@ -35602,7 +36294,7 @@ var FaceRecognizer = class {
35602
36294
  embedding: c.embedding,
35603
36295
  modelId: c.embeddingModelId
35604
36296
  }, this.gallery, {
35605
- threshold: settings.suggestionMinCosine,
36297
+ threshold: deriveSuggestionMinCosine(settings.similarityThreshold),
35606
36298
  margin: settings.margin,
35607
36299
  minIdentitySamples: settings.minIdentitySamples
35608
36300
  });
@@ -35895,148 +36587,6 @@ var FaceRecognizer = class {
35895
36587
  }
35896
36588
  };
35897
36589
  //#endregion
35898
- //#region src/pipeline-analytics/face-settings.ts
35899
- /**
35900
- * Per-device face-recognition settings. Cascade: a per-device override on top
35901
- * of the global default, resolved per field (an invalid/missing value falls
35902
- * back to its default — parse never throws). Mirrors `audio-detection-settings`.
35903
- */
35904
- var FaceSettingsSchema = require_dist.object({
35905
- /**
35906
- * GLOBAL master switch only — a temporary kill for the whole face-recognition
35907
- * post-processor. On by default; the recognizer runs whenever the per-device
35908
- * detection pipeline produces face embeddings (the pipeline steps ARE the
35909
- * per-camera control). Not a per-device override — see
35910
- * `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
35911
- * stripped from the per-device schema).
35912
- */
35913
- enabled: require_dist.boolean().default(true),
35914
- /**
35915
- * Cosine similarity (on L2-normalized arcface vectors) required to match.
35916
- *
35917
- * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
35918
- * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
35919
- * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
35920
- * left almost nothing recognisable. That retune lived only as a RUNTIME
35921
- * override until 2026-08-19; it is the schema default now, because a default
35922
- * nobody runs is a decision nobody made.
35923
- *
35924
- * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
35925
- * quality, where most of what reaches ArcFace is not a correctly aligned
35926
- * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
35927
- * aligned, 18% marginal, 62% misaligned or containing no detectable face).
35928
- * On correctly aligned crops the SAME models separate at a far lower
35929
- * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
35930
- * 0.175, best accuracy 98.3% at threshold 0.28.
35931
- *
35932
- * Recalibration plan, in this order:
35933
- * 1. land the landmark-precision gate (the alignment fix),
35934
- * 2. re-enrol the gallery from crops produced AFTER it,
35935
- * 3. re-measure genuine/impostor over a week of post-fix traffic,
35936
- * 4. expect to lower this toward ~0.30, and `margin` with it.
35937
- * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
35938
- * scores ~0.9 against everything, so the degenerate population sits ABOVE
35939
- * any threshold you could pick — the knob cannot reach it.
35940
- */
35941
- similarityThreshold: require_dist.number().min(0).max(1).default(.55),
35942
- /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
35943
- * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
35944
- * same story and the same recalibration plan as {@link similarityThreshold}. */
35945
- margin: require_dist.number().min(0).max(1).default(.12),
35946
- /** Minimum face-detection confidence for a face to be considered. */
35947
- minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
35948
- /**
35949
- * Minimum face bbox size (px, shorter side, NATIVE scale when the runner
35950
- * measured it, else detection-frame space) for a face to be DETECTED and
35951
- * COLLECTED into the recent-faces buffer. Below this the face is dropped
35952
- * BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
35953
- * NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
35954
- */
35955
- minFacePx: require_dist.number().min(0).default(30),
35956
- /**
35957
- * Minimum face short side (px, NATIVE scale when the runner measured it, else
35958
- * detection-frame space) for a collected face to be eligible for AUTO-MATCH
35959
- * (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
35960
- * between `minFacePx` and this floor are still detected, cropped, and stored
35961
- * in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
35962
- * an identity. ArcFace embeddings below ~48px are unreliable and drove the
35963
- * observed false positives (2026-07-23 face-quality batch). A face below this
35964
- * floor keeps `recognizedIdentityId` UNSET (fail-safe).
35965
- */
35966
- recognitionMinFacePx: require_dist.number().min(0).default(48),
35967
- /**
35968
- * Lower cosine bound of the SUGGESTION band (2026-07-24). A face whose best
35969
- * gallery match MISSES auto-assignment but is still plausible surfaces as a
35970
- * SUGGESTION (persisted `suggestedIdentityId`/`suggestedMatchScore`, never an
35971
- * assignment) when EITHER: its match cosine is in [`suggestionMinCosine`,
35972
- * `similarityThreshold`) AND its face clears the recognition size floor; OR its
35973
- * cosine is ≥ `similarityThreshold` but the face is below the recognition floor
35974
- * (blocked ONLY by size). Below this cosine nothing is suggested. Operator-
35975
- * overridable per field, like the other face thresholds.
35976
- */
35977
- suggestionMinCosine: require_dist.number().min(0).max(1).default(.5),
35978
- /**
35979
- * Minimum enrolled-sample count an identity must have before it can be an
35980
- * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
35981
- * enrolment attracted 81% of matches); identities below this are excluded from
35982
- * automatic matching until more samples are enrolled (#26.3).
35983
- */
35984
- minIdentitySamples: require_dist.number().int().min(1).default(2),
35985
- /** Frames an identity must be confirmed before a track is assigned. Floor of
35986
- * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
35987
- * such a value falls back to the default). */
35988
- confirmFrames: require_dist.number().int().min(1).default(3),
35989
- /**
35990
- * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35991
- * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35992
- * the gate.
35993
- *
35994
- * This exists because a degenerate crop does not score LOW, it scores HIGH:
35995
- * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35996
- * faces exceeded cosine 0.90 and the worst 40% of the face population had
35997
- * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35998
- * similarity threshold can reach that — it sits above every threshold — so the
35999
- * only defence is to refuse the embedding before it is stored or compared.
36000
- *
36001
- * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
36002
- * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
36003
- * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
36004
- * 5.25) regardless of size, grey level or blur, while information-free crops
36005
- * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
36006
- * 93.2% of degenerate ones.
36007
- *
36008
- * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
36009
- * magnitude has never been observable here — `result-assembler` discarded it
36010
- * one line after computing it. The field and its logging ship first; set a
36011
- * real bound once a week of live `face: embedding magnitude` lines says where
36012
- * this cluster's populations actually sit. Enabling a gate against a number
36013
- * nobody has measured in production is how recognition silently stops.
36014
- */
36015
- maxEmbeddingMagnitude: require_dist.number().min(0).default(0),
36016
- /** Max buffered faces kept per device. */
36017
- bufferMaxPerDevice: require_dist.number().int().min(0).default(50)
36018
- });
36019
- var FACE_DEFAULTS = FaceSettingsSchema.parse({});
36020
- function resolveFaceSettings(raw) {
36021
- const pick = (key) => {
36022
- const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
36023
- return parsed.success ? parsed.data : FACE_DEFAULTS[key];
36024
- };
36025
- return {
36026
- enabled: pick("enabled"),
36027
- similarityThreshold: pick("similarityThreshold"),
36028
- margin: pick("margin"),
36029
- minFaceConfidence: pick("minFaceConfidence"),
36030
- minFacePx: pick("minFacePx"),
36031
- recognitionMinFacePx: pick("recognitionMinFacePx"),
36032
- suggestionMinCosine: pick("suggestionMinCosine"),
36033
- minIdentitySamples: pick("minIdentitySamples"),
36034
- maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
36035
- confirmFrames: pick("confirmFrames"),
36036
- bufferMaxPerDevice: pick("bufferMaxPerDevice")
36037
- };
36038
- }
36039
- //#endregion
36040
36590
  //#region src/pipeline-analytics/location-aware-media-storage.ts
36041
36591
  /**
36042
36592
  * Location-aware blob storage for event media (entity-routing spec, Phase 3).
@@ -46223,7 +46773,8 @@ function parseCloseReason(value) {
46223
46773
  }
46224
46774
  var DEFAULT_CONFIG = {
46225
46775
  ttlMs: 3e4,
46226
- maxPositionHistory: 300
46776
+ maxPositionHistory: 300,
46777
+ closedRowMemory: 4096
46227
46778
  };
46228
46779
  /** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
46229
46780
  var RECENT_DEFAULT_LIMIT = 200;
@@ -46540,6 +47091,20 @@ function cloneTrack(t) {
46540
47091
  }
46541
47092
  var TrackStore = class {
46542
47093
  active = /* @__PURE__ */ new Map();
47094
+ /**
47095
+ * trackId → the instant THIS store closed (or deleted) the track's row.
47096
+ *
47097
+ * The `active` map alone cannot answer "is this row still open?": a sighting
47098
+ * for an already-closed id re-creates a fresh active entry (`upsert`), which
47099
+ * is exactly how a person track closed 4.4 minutes earlier received a dog's
47100
+ * `thumbnail`, `keyFrame` and `lastFrame` on 2026-08-21. This is POSITIVE
47101
+ * knowledge of a close the store performed — an id it has never heard of, or
47102
+ * one evicted by the bound below, reads as WRITABLE (D49): losing a live
47103
+ * track's media is worse than tolerating a late write.
47104
+ *
47105
+ * Insertion-ordered and bounded by `closedRowMemory` (oldest evicted first).
47106
+ */
47107
+ closedRows = /* @__PURE__ */ new Map();
46543
47108
  config;
46544
47109
  logger;
46545
47110
  store;
@@ -46561,6 +47126,25 @@ var TrackStore = class {
46561
47126
  indexes: [...TRACKS_INDEXES]
46562
47127
  });
46563
47128
  }
47129
+ /**
47130
+ * When this store CLOSED the track's row (TTL expiry, early close, or the
47131
+ * cascade delete), or `undefined` when it holds no such record.
47132
+ *
47133
+ * `undefined` is not "still open" — it is "no evidence of a close", and every
47134
+ * caller must treat it as permission to write (D49). Read by the frame path's
47135
+ * best-media gate; in-memory and infallible by construction.
47136
+ */
47137
+ rowClosedAt(trackId) {
47138
+ return this.closedRows.get(trackId);
47139
+ }
47140
+ /** Remember a close. Bounded, oldest-first — an evicted entry fails OPEN. */
47141
+ noteRowClosed(trackId, atMs) {
47142
+ this.closedRows.delete(trackId);
47143
+ this.closedRows.set(trackId, atMs);
47144
+ if (this.closedRows.size <= this.config.closedRowMemory) return;
47145
+ const oldest = this.closedRows.keys().next();
47146
+ if (!oldest.done) this.closedRows.delete(oldest.value);
47147
+ }
46564
47148
  /** Create or update the track record for a sighting in this frame. */
46565
47149
  upsert(params) {
46566
47150
  const existing = this.active.get(params.trackId);
@@ -46759,6 +47343,7 @@ var TrackStore = class {
46759
47343
  });
46760
47344
  }
46761
47345
  this.active.delete(trackId);
47346
+ this.noteRowClosed(trackId, nowMs);
46762
47347
  expired.push(record);
46763
47348
  }
46764
47349
  return expired;
@@ -47126,6 +47711,7 @@ var TrackStore = class {
47126
47711
  }
47127
47712
  clearAll() {
47128
47713
  this.active.clear();
47714
+ this.closedRows.clear();
47129
47715
  }
47130
47716
  /**
47131
47717
  * Drop a single active track WITHOUT persisting it as a historical row. Used
@@ -47173,6 +47759,7 @@ var TrackStore = class {
47173
47759
  const record = cloneTrack(t);
47174
47760
  await this.persistCompleted(record);
47175
47761
  this.active.delete(trackId);
47762
+ this.noteRowClosed(trackId, Date.now());
47176
47763
  return true;
47177
47764
  }
47178
47765
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
@@ -47183,6 +47770,7 @@ var TrackStore = class {
47183
47770
  key: trackId
47184
47771
  });
47185
47772
  this.active.delete(trackId);
47773
+ this.noteRowClosed(trackId, Date.now());
47186
47774
  }
47187
47775
  /**
47188
47776
  * A page of persisted track ids for a device whose `lastSeen < cutoffMs`
@@ -52512,13 +53100,38 @@ function retagRetentionSection(sections) {
52512
53100
  } : s);
52513
53101
  }
52514
53102
  /**
53103
+ * Key of the read-only readout that replaced the `suggestionMinCosine` knob
53104
+ * (D222).
53105
+ *
53106
+ * It is `readonlyField: true`, so the form renders it display-only with no
53107
+ * mutation handler and it can never enter a settings patch — it carries a
53108
+ * derived value and must not become the second stored authority it replaced.
53109
+ * Its value is injected at hydrate time by the addon's `getGlobalSettings`
53110
+ * override, the same mechanism `detection-pipeline` uses for its live
53111
+ * model-substitution readout.
53112
+ */
53113
+ var SUGGESTION_THRESHOLD_READOUT_KEY = "suggestionThresholdReadout";
53114
+ /** Percent, at most one decimal, without a trailing `.0`. Cosines here are
53115
+ * two-decimal operator values; `36.0%` reads like false precision. */
53116
+ function asPercent(value) {
53117
+ return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
53118
+ }
53119
+ /**
53120
+ * The sentence beside the similarity threshold: what is being suggested from,
53121
+ * and the rule it comes from. Both, always — a number that moves whenever
53122
+ * another number moves reads as a bug unless the rule is on screen with it.
53123
+ */
53124
+ function formatSuggestionThresholdReadout(similarityThreshold) {
53125
+ return `${asPercent(deriveSuggestionMinCosine(similarityThreshold))} — ${asPercent(SUGGESTION_BAND_FACTOR)} of the ${asPercent(similarityThreshold)} match threshold`;
53126
+ }
53127
+ /**
52515
53128
  * Fields that live ONLY on the global settings page and must never surface in a
52516
53129
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
52517
53130
  * master kill for the whole subsystem — per-camera face production is governed
52518
53131
  * by each device's detection-pipeline steps, so a per-device toggle would be
52519
53132
  * misleading. Keyed by section id → set of field keys to drop.
52520
53133
  */
52521
- var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled"]) };
53134
+ var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled", SUGGESTION_THRESHOLD_READOUT_KEY]) };
52522
53135
  /** Whole sections that are cluster-wide (global page only) and must NOT appear
52523
53136
  * in a per-device contribution. */
52524
53137
  var GLOBAL_ONLY_SECTIONS = new Set(["cluster-post-processing"]);
@@ -53000,7 +53613,7 @@ function buildGlobalSettingsSchema() {
53000
53613
  type: "number",
53001
53614
  key: "similarityThreshold",
53002
53615
  label: "Similarity threshold",
53003
- description: "Cosine similarity (0–1) required to match a face to a known identity. Higher = stricter.",
53616
+ description: "Cosine similarity (0–1) required to match a face to a known identity. Higher = stricter. This is the ONLY face-match bar: the suggestion band below it is derived from this value (80% of it), so it moves with every change you make here and can never end up above it.",
53004
53617
  min: 0,
53005
53618
  max: 1,
53006
53619
  step: .05,
@@ -53047,14 +53660,12 @@ function buildGlobalSettingsSchema() {
53047
53660
  unit: "px"
53048
53661
  },
53049
53662
  {
53050
- type: "number",
53051
- key: "suggestionMinCosine",
53052
- label: "Suggestion threshold",
53053
- description: "Lower cosine bound (0–1) of the SUGGESTION band. A plausible match that misses auto-assignment — cosine between this value and the similarity threshold with a large-enough face, or above the similarity threshold but below the recognition size floor — is surfaced as a one-tap SUGGESTION instead of being assigned. The face stays unassigned. Below this cosine nothing is suggested.",
53054
- min: 0,
53055
- max: 1,
53056
- step: .05,
53057
- default: FACE_DEFAULTS.suggestionMinCosine
53663
+ type: "text",
53664
+ key: SUGGESTION_THRESHOLD_READOUT_KEY,
53665
+ label: "Suggestion threshold (derived)",
53666
+ description: "Read-only. A plausible match that misses auto-assignment — a cosine between this value and the similarity threshold with a large-enough face, or above the similarity threshold but below the recognition size floor — is surfaced as a one-tap SUGGESTION instead of being assigned; the face stays unassigned. This floor is always 80% of the similarity threshold above, so lowering the threshold widens what is offered instead of silently closing the band.",
53667
+ readonlyField: true,
53668
+ default: ""
53058
53669
  },
53059
53670
  {
53060
53671
  type: "number",
@@ -54537,7 +55148,8 @@ var customActions = {
54537
55148
  ...debugActions,
54538
55149
  ...orphanAuditActions,
54539
55150
  ...viewerSettingsActions,
54540
- ...photoEnrollActions
55151
+ ...photoEnrollActions,
55152
+ ...faceRescoreActions
54541
55153
  };
54542
55154
  /**
54543
55155
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -54726,7 +55338,7 @@ var MOTIONLESS_MAX_PX = 8;
54726
55338
  var PHANTOM_CELL_PX = 32;
54727
55339
  /** How long a cell remembers its closes. */
54728
55340
  var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
54729
- var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55341
+ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.BaseAddon {
54730
55342
  /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
54731
55343
  * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
54732
55344
  * filtered against the 6-hour window on write, so it stays bounded. */
@@ -55258,6 +55870,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55258
55870
  * queued captures via the onClose hook) and `closeDevice` finally clears
55259
55871
  * per-track state on device removal (the two documented leaks). */
55260
55872
  residents = new TrackResidentState({ onClose: (trackId) => this.captureScheduler.cancelForTrack(trackId) });
55873
+ /** Defence in depth for the 2026-08-21 `b50e09f7` incident: a row the track
55874
+ * store has already CLOSED never earns another media target, whatever the
55875
+ * tracker keeps handing back. Reads the store's in-RAM close record only —
55876
+ * see `pipeline/closed-row-media-gate.ts` for why it can only fail OPEN. */
55877
+ closedRowMediaGate = new ClosedRowMediaGate();
55261
55878
  /** Windowed per-device aggregation of the "media capture" diagnostic — the
55262
55879
  * per-capture line is debug; a 60s per-counter SUM lands at info (~1
55263
55880
  * line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
@@ -55595,13 +56212,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55595
56212
  ...debugActions,
55596
56213
  ...orphanAuditActions,
55597
56214
  ...viewerSettingsActions,
55598
- ...photoEnrollActions
56215
+ ...photoEnrollActions,
56216
+ ...faceRescoreActions
55599
56217
  },
55600
56218
  actionHandlers: {
55601
56219
  ...ncHandlers,
55602
56220
  ...this.buildEmbeddingActionHandlers(),
55603
56221
  ...this.buildDebugActionHandlers(),
55604
56222
  ...this.buildPhotoEnrollActionHandlers(),
56223
+ ...this.buildFaceRescoreActionHandlers(),
55605
56224
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
55606
56225
  }
55607
56226
  } : {}
@@ -56473,6 +57092,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
56473
57092
  });
56474
57093
  },
56475
57094
  getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
57095
+ getSubjectTrail: async (deviceId, trackId) => {
57096
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
57097
+ if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];
57098
+ const track = stores.trackStore.getActiveByTrack(trackId) ?? await stores.trackStore.getPersistedByTrackId(trackId);
57099
+ if (track === null || track === void 0) return [];
57100
+ return track.positions.map((p) => ({
57101
+ timestamp: p.timestamp,
57102
+ bbox: {
57103
+ x: p.bbox.x / dims.w,
57104
+ y: p.bbox.y / dims.h,
57105
+ w: p.bbox.w / dims.w,
57106
+ h: p.bbox.h / dims.h
57107
+ }
57108
+ }));
57109
+ },
56476
57110
  getDeviceName,
56477
57111
  buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
56478
57112
  },
@@ -59150,6 +59784,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
59150
59784
  const targets = [];
59151
59785
  for (const t of tracked) {
59152
59786
  if (t.matchedThisFrame === false) continue;
59787
+ const verdict = this.closedRowMediaGate.evaluate(t.trackId, this.trackStore.rowClosedAt(t.trackId), Date.now());
59788
+ if (verdict.refuse) {
59789
+ if (verdict.log) this.ctx.logger.warn("track media refused: row already closed", {
59790
+ tags: { deviceId },
59791
+ meta: {
59792
+ trackId: t.trackId,
59793
+ className: t.className,
59794
+ closedAgeMs: verdict.closedAgeMs,
59795
+ suppressed: verdict.suppressed
59796
+ }
59797
+ });
59798
+ continue;
59799
+ }
59153
59800
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
59154
59801
  const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
59155
59802
  lastSnapshotAt: lastSnap,
@@ -59779,6 +60426,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
59779
60426
  try {
59780
60427
  const deviceIds = await this.retentionDeviceIds();
59781
60428
  await this.migrateRetiredRetentionSettings(deviceIds);
60429
+ await this.purgeStoredSuggestionThreshold(deviceIds);
59782
60430
  await this.runTrackRetentionSweep(deviceIds, now);
59783
60431
  const eventStore = this.eventStore;
59784
60432
  const sensorEventStore = this.sensorEventStore;
@@ -59898,6 +60546,96 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
59898
60546
  }
59899
60547
  }
59900
60548
  }
60549
+ /** Whether the GLOBAL store has been checked for a stored suggestion floor
60550
+ * this process, and which devices have. One announcement, not a heartbeat. */
60551
+ storedSuggestionCheckedGlobal = false;
60552
+ storedSuggestionCheckedDevices = /* @__PURE__ */ new Set();
60553
+ /**
60554
+ * One-time: announce the suggestion floor D222 retired, then unset it —
60555
+ * globally and per device.
60556
+ *
60557
+ * Removing the field from the schema already makes a stored value inert
60558
+ * (`resolveFaceSettings` reads declared fields only). That is not enough. A
60559
+ * number sitting in the blob is an authority waiting to be re-adopted by the
60560
+ * next reader who greps for it, and it is what the operator last typed — so
60561
+ * it must leave the store AND be said out loud, because the value in force
60562
+ * changes: this hub carried `suggestionMinCosine: 0.33` against a 0.45
60563
+ * threshold, and the band is 0.36 from now on. Silence here would be the
60564
+ * same defect one layer up — an operator discovering three weeks later that
60565
+ * their number stopped applying.
60566
+ *
60567
+ * Absence of the key IS the marker: no flag, no version row, re-running is a
60568
+ * no-op. The write goes through `updateGlobalSettings` / `writeDeviceStore`
60569
+ * (never a raw store write), and the patch unsets with `undefined` — an
60570
+ * explicit `null` would survive the merge as exactly the residue being
60571
+ * removed.
60572
+ */
60573
+ async purgeStoredSuggestionThreshold(deviceIds) {
60574
+ const settings = this.ctxIfReady?.settings;
60575
+ if (!settings) return;
60576
+ if (!this.storedSuggestionCheckedGlobal) {
60577
+ this.storedSuggestionCheckedGlobal = true;
60578
+ try {
60579
+ const plan = planStoredSuggestionPurge(await this.resolveGlobalStore());
60580
+ if (plan !== null) {
60581
+ const global = resolveFaceSettings(await this.resolveGlobalStore());
60582
+ this.announceRetiredSuggestionThreshold({
60583
+ scope: "global",
60584
+ was: plan.was,
60585
+ threshold: global.similarityThreshold
60586
+ });
60587
+ await this.updateGlobalSettings(plan.patch);
60588
+ }
60589
+ } catch (err) {
60590
+ this.storedSuggestionCheckedGlobal = false;
60591
+ this.ctx.logger.warn("face suggestion threshold purge failed — will retry", { meta: {
60592
+ scope: "global",
60593
+ error: String(err)
60594
+ } });
60595
+ }
60596
+ }
60597
+ for (const deviceId of deviceIds) {
60598
+ if (this.storedSuggestionCheckedDevices.has(deviceId)) continue;
60599
+ this.storedSuggestionCheckedDevices.add(deviceId);
60600
+ try {
60601
+ const plan = planStoredSuggestionPurge(await settings.readDeviceStore(deviceId));
60602
+ if (plan === null) continue;
60603
+ const effective = await this.resolveDeviceFaceSettings(deviceId);
60604
+ this.announceRetiredSuggestionThreshold({
60605
+ scope: "device",
60606
+ was: plan.was,
60607
+ threshold: effective.similarityThreshold,
60608
+ deviceId
60609
+ });
60610
+ await settings.writeDeviceStore(deviceId, plan.patch);
60611
+ } catch (err) {
60612
+ this.storedSuggestionCheckedDevices.delete(deviceId);
60613
+ this.ctx.logger.warn("face suggestion threshold purge failed — will retry", {
60614
+ tags: { deviceId },
60615
+ meta: {
60616
+ scope: "device",
60617
+ error: String(err)
60618
+ }
60619
+ });
60620
+ }
60621
+ }
60622
+ }
60623
+ /** The one line the operator can read to see what their number became. Both
60624
+ * numbers, and the rule between them, or it does not explain anything. */
60625
+ announceRetiredSuggestionThreshold(found) {
60626
+ this.ctx.logger.warn("face suggestion threshold RETIRED — it is derived from the match threshold now", {
60627
+ ...found.deviceId !== void 0 ? { tags: { deviceId: found.deviceId } } : {},
60628
+ meta: {
60629
+ scope: found.scope,
60630
+ setting: STORED_SUGGESTION_MIN_COSINE_KEY,
60631
+ wasSetTo: found.was,
60632
+ similarityThreshold: found.threshold,
60633
+ bandFactor: SUGGESTION_BAND_FACTOR,
60634
+ nowInForce: deriveSuggestionMinCosine(found.threshold),
60635
+ why: "two knobs over one band could disagree, and did — a 0.45 threshold against a 0.50 floor made the suggestion band [0.50, 0.45) empty, so nothing could be suggested at all (D222)."
60636
+ }
60637
+ });
60638
+ }
59901
60639
  /**
59902
60640
  * Hold the unassigned face + plate buffers to their per-device capacity.
59903
60641
  *
@@ -61404,6 +62142,205 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
61404
62142
  byIdentity: [...progress.byIdentity]
61405
62143
  };
61406
62144
  }
62145
+ /**
62146
+ * Side of the canonical ArcFace aligned template, in pixels
62147
+ * (`ARCFACE_TEMPLATE_112` / `face-align.ts` on the runner). A stored face crop
62148
+ * of exactly this size IS the template the embedder was fed; any other size
62149
+ * is a padded bbox cut that only looks like one.
62150
+ */
62151
+ static ARCFACE_TEMPLATE_PX = 112;
62152
+ /**
62153
+ * Re-embed ONE buffered face from its stored crop, into the cluster's current
62154
+ * `face-embedding` space, and persist the result on the face row.
62155
+ *
62156
+ * Persisting is not optional. `assignFace` enrols `face.embedding` verbatim,
62157
+ * so a rescore that computed a fresh vector, showed the operator a convincing
62158
+ * percentage and then threw the vector away would enrol the STALE one the
62159
+ * moment they acted on it — the gallery would gain a sample in a space the
62160
+ * matcher cannot reach, which is precisely the failure the whole re-embed
62161
+ * pass exists to undo.
62162
+ *
62163
+ * The 112×112 check is the load-bearing guard. A face's crop is the ArcFace
62164
+ * template only when the detection carried landmarks; otherwise it is a
62165
+ * padded bbox cut, and `face-embedding` over THAT as a whole frame returns a
62166
+ * vector that passes every gate and means nothing (`face-reembed-pass.ts`
62167
+ * documents the mirror-image trap). Refusing beats answering.
62168
+ */
62169
+ async reembedBufferedFace(face, clusterModelId) {
62170
+ const api = this.ctx.api;
62171
+ const faceStore = this.faceStore;
62172
+ const mediaStore = this.mediaStore;
62173
+ const refuse = (reason, detail) => {
62174
+ this.ctx.logger.warn("face rescore: re-embed refused", {
62175
+ tags: { deviceId: face.deviceId },
62176
+ meta: {
62177
+ faceId: face.id,
62178
+ trackId: face.trackId,
62179
+ reason,
62180
+ detail,
62181
+ clusterModelId
62182
+ }
62183
+ });
62184
+ return {
62185
+ report: {
62186
+ attempted: true,
62187
+ succeeded: false,
62188
+ reason,
62189
+ detail,
62190
+ modelId: clusterModelId
62191
+ },
62192
+ embedding: null
62193
+ };
62194
+ };
62195
+ if (!api || !faceStore || !mediaStore) return refuse("failed", "the addon API or the face/media stores are not ready");
62196
+ if (face.mediaKey === void 0 || face.mediaKey === "") return refuse("no-pixels", "this face kept no crop — there is nothing to re-embed from");
62197
+ const file = await mediaStore.getByKey(face.mediaKey);
62198
+ if (file === null) return refuse("no-pixels", `the crop blob ${face.mediaKey} is gone`);
62199
+ const raster = await decodeJpegToRgb(file.base64);
62200
+ const templatePx = PipelineAnalyticsAddon.ARCFACE_TEMPLATE_PX;
62201
+ if (raster.width !== templatePx || raster.height !== templatePx) return refuse("not-a-template", `the stored crop is ${raster.width}×${raster.height}, not the ${templatePx}² aligned template — this face was captured without landmarks, so re-embedding it would produce a plausible and meaningless vector`);
62202
+ const outcome = await new FaceReembedEmbedder({
62203
+ runStatelessStep: (nodeId, stepInput) => this.runRebuildStepOn(nodeId, stepInput),
62204
+ logger: { warn: (m, e) => this.ctx.logger.warn(m, e) }
62205
+ }, await this.resolveRebuildNodes(api, void 0), clusterModelId).embed({
62206
+ sampleId: face.id,
62207
+ identityId: face.recognizedIdentityId ?? "",
62208
+ identityName: `track ${face.trackId}`,
62209
+ modelId: face.embeddingModelId ?? "arcface-r100",
62210
+ dim: face.embedding.length,
62211
+ mediaKey: face.mediaKey
62212
+ }, Buffer.from(file.base64, "base64"));
62213
+ if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
62214
+ if (outcome.kind === "failed") return refuse("failed", outcome.reason);
62215
+ await faceStore.update(face.id, {
62216
+ embedding: outcome.embedding,
62217
+ embeddingModelId: clusterModelId
62218
+ });
62219
+ if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
62220
+ this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
62221
+ tags: { deviceId: face.deviceId },
62222
+ meta: {
62223
+ faceId: face.id,
62224
+ trackId: face.trackId,
62225
+ from: face.embeddingModelId ?? "arcface-r100",
62226
+ to: clusterModelId,
62227
+ dim: outcome.dim
62228
+ }
62229
+ });
62230
+ return {
62231
+ report: {
62232
+ attempted: true,
62233
+ succeeded: true,
62234
+ modelId: clusterModelId
62235
+ },
62236
+ embedding: outcome.embedding
62237
+ };
62238
+ }
62239
+ /**
62240
+ * The matcher parameters in force for one device, as the report echoes them.
62241
+ *
62242
+ * The suggestion floor is DERIVED here and nowhere else in this class
62243
+ * (D222) — and it is reported WITH `suggestionBandFactor`, because a floor
62244
+ * that moves whenever the threshold moves reads as a bug unless the rule is
62245
+ * on screen beside it. There is no stored `suggestionMinCosine` to report:
62246
+ * the one the live hub carried (0.33) decided nothing from the moment this
62247
+ * shipped, and the retention pass removes it.
62248
+ */
62249
+ faceRescoreParams(settings) {
62250
+ return {
62251
+ threshold: settings.similarityThreshold,
62252
+ margin: settings.margin,
62253
+ minIdentitySamples: settings.minIdentitySamples,
62254
+ suggestionMinCosine: deriveSuggestionMinCosine(settings.similarityThreshold),
62255
+ suggestionBandFactor: SUGGESTION_BAND_FACTOR
62256
+ };
62257
+ }
62258
+ /**
62259
+ * `face.rescoreTrack` — the operator's "what does the gallery say NOW?".
62260
+ *
62261
+ * See `face-rescore.ts` for why the number already on the track cannot answer
62262
+ * that: it was computed during live detection and persisted at track close,
62263
+ * so every enrolment made since is invisible to it.
62264
+ */
62265
+ async rescoreFaceForTrack(input) {
62266
+ const api = this.ctx.api;
62267
+ if (!api) throw new Error("face.rescoreTrack: the addon API is not available");
62268
+ const faceStore = this.faceStore;
62269
+ if (!faceStore) throw new Error("face.rescoreTrack: the face store is not ready");
62270
+ const identityStore = this.identityStore;
62271
+ if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
62272
+ const faceId = `face-${input.trackId}`;
62273
+ const face = await faceStore.get(faceId);
62274
+ if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
62275
+ if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
62276
+ const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
62277
+ if (clusterModelId === null) throw new Error("face.rescoreTrack: the cluster face-embedding model could not be resolved");
62278
+ const settings = await this.resolveDeviceFaceSettings(input.deviceId);
62279
+ const params = this.faceRescoreParams(settings);
62280
+ let probeEmbedding = face.embedding;
62281
+ let probeModelId = face.embeddingModelId ?? "arcface-r100";
62282
+ let reembed = {
62283
+ attempted: false,
62284
+ succeeded: false,
62285
+ reason: "not-requested"
62286
+ };
62287
+ if (input.reembed) {
62288
+ const outcome = await this.reembedBufferedFace(face, clusterModelId);
62289
+ reembed = outcome.report;
62290
+ if (outcome.embedding !== null) {
62291
+ probeEmbedding = outcome.embedding;
62292
+ probeModelId = clusterModelId;
62293
+ }
62294
+ }
62295
+ const [gallery, identities] = await Promise.all([identityStore.loadGallery(), identityStore.listIdentities()]);
62296
+ const report = buildFaceRescoreReport({
62297
+ probe: {
62298
+ embedding: probeEmbedding,
62299
+ modelId: probeModelId
62300
+ },
62301
+ gallery,
62302
+ identityNames: new Map(identities.map((i) => [i.id, i.name])),
62303
+ clusterModelId,
62304
+ params
62305
+ });
62306
+ this.ctx.logger.info("face rescore", {
62307
+ tags: { deviceId: input.deviceId },
62308
+ meta: {
62309
+ trackId: input.trackId,
62310
+ faceId,
62311
+ verdict: report.verdict,
62312
+ probeModelId,
62313
+ clusterModelId,
62314
+ gallerySamples: gallery.length,
62315
+ scored: report.identities.length,
62316
+ best: report.identities[0]?.name ?? null,
62317
+ bestScore: report.identities[0]?.score ?? null,
62318
+ storedSuggestion: face.suggestedMatchScore ?? null,
62319
+ reembedded: reembed.succeeded
62320
+ }
62321
+ });
62322
+ return {
62323
+ faceId,
62324
+ deviceId: face.deviceId,
62325
+ trackId: face.trackId,
62326
+ assigned: face.assigned,
62327
+ ...face.recognizedIdentityId != null ? { assignedIdentityId: face.recognizedIdentityId } : {},
62328
+ ...face.suggestedIdentityId != null ? { storedSuggestedIdentityId: face.suggestedIdentityId } : {},
62329
+ ...face.suggestedMatchScore != null ? { storedSuggestedMatchScore: face.suggestedMatchScore } : {},
62330
+ verdict: report.verdict,
62331
+ comparable: report.comparable,
62332
+ probeModelId: report.probeModelId,
62333
+ clusterModelId: report.clusterModelId,
62334
+ params: report.params,
62335
+ identities: report.identities,
62336
+ ...report.runnerUpGap !== void 0 ? { runnerUpGap: report.runnerUpGap } : {},
62337
+ reembedAvailable: probeModelId !== clusterModelId && face.mediaKey !== void 0 && face.mediaKey !== "",
62338
+ reembed
62339
+ };
62340
+ }
62341
+ buildFaceRescoreActionHandlers() {
62342
+ return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
62343
+ }
61407
62344
  buildPhotoEnrollActionHandlers() {
61408
62345
  return {
61409
62346
  "photo.analyze": async (input) => {
@@ -62100,6 +63037,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
62100
63037
  globalSettingsSchema() {
62101
63038
  return this.schema(buildGlobalSettingsSchema());
62102
63039
  }
63040
+ /**
63041
+ * The only reason this is overridden: the face suggestion floor is DERIVED
63042
+ * (D222), so the form has to be able to SHOW it without storing it.
63043
+ *
63044
+ * The schema is a static declaration and cannot interpolate a live value, so
63045
+ * the readout's value is injected into the hydrate blob here — the same
63046
+ * mechanism `detection-pipeline` uses for its model-substitution readout.
63047
+ * It is a `readonlyField`, so it renders display-only, never enters a patch,
63048
+ * and never reaches the store: the operator sees what their threshold
63049
+ * implies and has nothing to contradict it with. Everything else about this
63050
+ * call is `BaseAddon`'s.
63051
+ */
63052
+ async getGlobalSettings(overlay, cap, nodeId) {
63053
+ const stored = await this.resolveGlobalStore(nodeId, cap);
63054
+ const merged = overlay ? {
63055
+ ...stored,
63056
+ ...overlay
63057
+ } : stored;
63058
+ const readout = formatSuggestionThresholdReadout(resolveFaceSettings(merged).similarityThreshold);
63059
+ return super.getGlobalSettings({
63060
+ ...merged,
63061
+ [SUGGESTION_THRESHOLD_READOUT_KEY]: readout
63062
+ }, cap, nodeId);
63063
+ }
62103
63064
  async getDeviceSettingsContribution(input) {
62104
63065
  if (!await this.isCameraDevice(input.deviceId)) return null;
62105
63066
  const schema = this.globalSettingsSchema();