@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.
- package/dist/{dist-DiU9qiuA.mjs → dist-Dv2LOr1d.mjs} +66 -5
- package/dist/{dist-FAVcx3hJ.js → dist-wQmgHP6h.js} +66 -5
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C7vEGOd1.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Ds7UUptm.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-DLdsDxyX.mjs → hostInit-B30POcEN.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1701 -740
- package/dist/pipeline-analytics/index.mjs +1699 -740
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-
|
|
1
|
+
import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-Dv2LOr1d.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -5567,279 +5567,6 @@ function observeLabel(deviceId, spec, sample, now) {
|
|
|
5567
5567
|
spec
|
|
5568
5568
|
};
|
|
5569
5569
|
}
|
|
5570
|
-
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
5571
|
-
var FULL_FRAME_QUALITY = 80;
|
|
5572
|
-
/**
|
|
5573
|
-
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
5574
|
-
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
5575
|
-
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
5576
|
-
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
5577
|
-
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
5578
|
-
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
5579
|
-
*/
|
|
5580
|
-
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
5581
|
-
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
5582
|
-
fit: "inside",
|
|
5583
|
-
withoutEnlargement: true
|
|
5584
|
-
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
5585
|
-
}
|
|
5586
|
-
//#endregion
|
|
5587
|
-
//#region src/notification-center/still-shelf.ts
|
|
5588
|
-
/**
|
|
5589
|
-
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
5590
|
-
* name owns a frame.
|
|
5591
|
-
*
|
|
5592
|
-
* ## Why a trigger needs one at all
|
|
5593
|
-
*
|
|
5594
|
-
* The attachment ladder resolves media by OWNER, and most triggers have one: an
|
|
5595
|
-
* object or package event owns its crops, a closed track owns its best shot, a
|
|
5596
|
-
* doorbell press owns the marker track the same press projected
|
|
5597
|
-
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
5598
|
-
* counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
|
|
5599
|
-
* same reason in both cases — the subject is an ABSENCE:
|
|
5600
|
-
*
|
|
5601
|
-
* - an **audio** match: nothing was boxed, nothing was tracked, and —
|
|
5602
|
-
* deliberately — nothing is persisted at all. A confirmed window is a claim
|
|
5603
|
-
* about sound that has already stopped, and `event-intake.ts` states why
|
|
5604
|
-
* replaying it later would be wrong.
|
|
5605
|
-
* - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
|
|
5606
|
-
* whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
|
|
5607
|
-
* nobody and the ladder logs `no still could be resolved … owners=[]`.
|
|
5608
|
-
*
|
|
5609
|
-
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
5610
|
-
* of the trigger. Not of the sound, not of the object that left — of what the
|
|
5611
|
-
* camera can see now that it happened. For a freed parking space that is
|
|
5612
|
-
* exactly the answer the operator wants: the space, empty.
|
|
5613
|
-
*
|
|
5614
|
-
* **One shelf, not two.** The mechanism is identical down to the reuse window,
|
|
5615
|
-
* and the only thing that differs between the two triggers is the GATE deciding
|
|
5616
|
-
* that a photograph is owed at all — which belongs at the trigger site, where
|
|
5617
|
-
* the rules are, and not here. A twin module would be a second derivation of
|
|
5618
|
-
* one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
|
|
5619
|
-
* can say which absence they are about.
|
|
5620
|
-
*
|
|
5621
|
-
* ## Three properties, and each one is a decision
|
|
5622
|
-
*
|
|
5623
|
-
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
5624
|
-
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
5625
|
-
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
5626
|
-
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
5627
|
-
* a durable Track on the camera's timeline for every confirmed window and every
|
|
5628
|
-
* emptied zone, feeding the digest's `listTracks`, retention, and the audio-
|
|
5629
|
-
* marker feature's own operator ceilings (`audio-marker-projector.ts` exists
|
|
5630
|
-
* precisely to bound how many audio markers a camera may emit). A notification
|
|
5631
|
-
* must not manufacture timeline history as a side effect of wanting a picture.
|
|
5632
|
-
*
|
|
5633
|
-
* **The capture STARTS immediately and is never awaited.** The subject is
|
|
5634
|
-
* transient — a scream is over before a snapshot round-trip completes, and a
|
|
5635
|
-
* freed space is about to be taken by the next car — so the fetch is kicked off
|
|
5636
|
-
* at the trigger, before the rule evaluation runs, and the owner id is minted
|
|
5637
|
-
* synchronously so the outbox row can name it. The bytes land while the row
|
|
5638
|
-
* waits in the queue, and the dispatcher's existing bounded still-wait (or the
|
|
5639
|
-
* pause its own footage render already costs) picks them up. A camera that
|
|
5640
|
-
* never answers costs the picture and never the notification.
|
|
5641
|
-
*
|
|
5642
|
-
* **Two triggers seconds apart share ONE capture.** A barking dog confirms
|
|
5643
|
-
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
5644
|
-
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
5645
|
-
* Without a reuse window this would photograph a camera at whatever rate the
|
|
5646
|
-
* sound happens to occur. Each trigger still gets its OWN owner id, so two
|
|
5647
|
-
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
5648
|
-
* frame, which is the truth — the scene did not change in ten seconds. The
|
|
5649
|
-
* window is per CAMERA and trigger-agnostic for the same reason: a sound and an
|
|
5650
|
-
* emptied zone ten seconds apart are two claims about one scene.
|
|
5651
|
-
*
|
|
5652
|
-
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
5653
|
-
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
5654
|
-
* and 615 not" is the only form that question is ever asked in.
|
|
5655
|
-
*/
|
|
5656
|
-
/**
|
|
5657
|
-
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
5658
|
-
* media store, and it is the reason the dispatcher needs no audio or occupancy
|
|
5659
|
-
* branch — `getMediaForOwner` answers for all of them under one signature.
|
|
5660
|
-
*/
|
|
5661
|
-
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
5662
|
-
/** True for an owner id this shelf minted. */
|
|
5663
|
-
function isStillShelfId(id) {
|
|
5664
|
-
return id.startsWith(NC_STILL_SHELF_PREFIX);
|
|
5665
|
-
}
|
|
5666
|
-
/**
|
|
5667
|
-
* How long the bytes are held.
|
|
5668
|
-
*
|
|
5669
|
-
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
5670
|
-
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
5671
|
-
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
5672
|
-
* that is a quarter of an hour stale anyway.
|
|
5673
|
-
*/
|
|
5674
|
-
var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
|
|
5675
|
-
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
5676
|
-
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
5677
|
-
/**
|
|
5678
|
-
* Hard bound on held captures. Reached only if every camera on the hub triggers
|
|
5679
|
-
* one inside a single TTL; the oldest is dropped first, which costs a
|
|
5680
|
-
* fifteen-minute-old picture nobody is waiting for.
|
|
5681
|
-
*/
|
|
5682
|
-
var MAX_CAPTURES = 64;
|
|
5683
|
-
var NcStillShelf = class {
|
|
5684
|
-
deps;
|
|
5685
|
-
/** captureId → the photograph. */
|
|
5686
|
-
captures = /* @__PURE__ */ new Map();
|
|
5687
|
-
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
5688
|
-
owners = /* @__PURE__ */ new Map();
|
|
5689
|
-
/** deviceId → its newest capture, for the reuse window. */
|
|
5690
|
-
newest = /* @__PURE__ */ new Map();
|
|
5691
|
-
constructor(deps) {
|
|
5692
|
-
this.deps = deps;
|
|
5693
|
-
}
|
|
5694
|
-
/**
|
|
5695
|
-
* Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
|
|
5696
|
-
* subject should name. SYNCHRONOUS by contract: the caller is on the trigger
|
|
5697
|
-
* path and the outbox row is built from what this returns.
|
|
5698
|
-
*/
|
|
5699
|
-
capture(deviceId, atMs, trigger) {
|
|
5700
|
-
const now = this.deps.now();
|
|
5701
|
-
this.prune(now);
|
|
5702
|
-
const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
|
|
5703
|
-
const recent = this.newest.get(deviceId);
|
|
5704
|
-
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
5705
|
-
this.owners.set(ownerId, recent.captureId);
|
|
5706
|
-
return ownerId;
|
|
5707
|
-
}
|
|
5708
|
-
const captureId = randomUUID();
|
|
5709
|
-
this.captures.set(captureId, {
|
|
5710
|
-
deviceId,
|
|
5711
|
-
trigger,
|
|
5712
|
-
startedAt: now,
|
|
5713
|
-
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
5714
|
-
files: []
|
|
5715
|
-
});
|
|
5716
|
-
this.newest.set(deviceId, {
|
|
5717
|
-
captureId,
|
|
5718
|
-
at: now
|
|
5719
|
-
});
|
|
5720
|
-
this.owners.set(ownerId, captureId);
|
|
5721
|
-
this.fetch(captureId, deviceId, atMs, trigger);
|
|
5722
|
-
return ownerId;
|
|
5723
|
-
}
|
|
5724
|
-
/**
|
|
5725
|
-
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
5726
|
-
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
5727
|
-
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
5728
|
-
* wait and its `no still could be resolved` line are for.
|
|
5729
|
-
*/
|
|
5730
|
-
get(ownerId) {
|
|
5731
|
-
const captureId = this.owners.get(ownerId);
|
|
5732
|
-
if (captureId === void 0) return void 0;
|
|
5733
|
-
return this.captures.get(captureId)?.files ?? [];
|
|
5734
|
-
}
|
|
5735
|
-
/** Drop expired captures and the owners that named them. */
|
|
5736
|
-
prune(now) {
|
|
5737
|
-
for (const [captureId, held] of this.captures) {
|
|
5738
|
-
if (held.expiresAt > now) continue;
|
|
5739
|
-
this.captures.delete(captureId);
|
|
5740
|
-
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
5741
|
-
}
|
|
5742
|
-
while (this.captures.size > MAX_CAPTURES) {
|
|
5743
|
-
const oldest = this.captures.keys().next();
|
|
5744
|
-
if (oldest.done === true) break;
|
|
5745
|
-
const held = this.captures.get(oldest.value);
|
|
5746
|
-
this.captures.delete(oldest.value);
|
|
5747
|
-
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
5748
|
-
}
|
|
5749
|
-
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
5750
|
-
}
|
|
5751
|
-
/** Drop everything (shutdown). */
|
|
5752
|
-
clear() {
|
|
5753
|
-
this.captures.clear();
|
|
5754
|
-
this.owners.clear();
|
|
5755
|
-
this.newest.clear();
|
|
5756
|
-
}
|
|
5757
|
-
async fetch(captureId, deviceId, atMs, trigger) {
|
|
5758
|
-
try {
|
|
5759
|
-
const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
5760
|
-
if (shot === null || shot.base64.length === 0) {
|
|
5761
|
-
this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
|
|
5762
|
-
return;
|
|
5763
|
-
}
|
|
5764
|
-
const raw = Buffer.from(shot.base64, "base64");
|
|
5765
|
-
let data = raw;
|
|
5766
|
-
try {
|
|
5767
|
-
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
5768
|
-
} catch {}
|
|
5769
|
-
const held = this.captures.get(captureId);
|
|
5770
|
-
if (held === void 0) return;
|
|
5771
|
-
this.captures.set(captureId, {
|
|
5772
|
-
...held,
|
|
5773
|
-
files: stillShelfMedia(data, atMs)
|
|
5774
|
-
});
|
|
5775
|
-
this.deps.logger.info(`${trigger} still captured`, {
|
|
5776
|
-
tags: { deviceId },
|
|
5777
|
-
meta: {
|
|
5778
|
-
trigger,
|
|
5779
|
-
bytes: data.byteLength,
|
|
5780
|
-
tookMs: this.deps.now() - held.startedAt
|
|
5781
|
-
}
|
|
5782
|
-
});
|
|
5783
|
-
} catch (err) {
|
|
5784
|
-
this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
|
|
5785
|
-
}
|
|
5786
|
-
}
|
|
5787
|
-
/**
|
|
5788
|
-
* A branch that drops work says so. This one costs the operator the picture
|
|
5789
|
-
* on a notification they DID receive, so it is a warn and it carries the
|
|
5790
|
-
* camera — the only key the question is ever asked with.
|
|
5791
|
-
*/
|
|
5792
|
-
reportMiss(deviceId, trigger, reason) {
|
|
5793
|
-
this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
|
|
5794
|
-
tags: { deviceId },
|
|
5795
|
-
meta: {
|
|
5796
|
-
trigger,
|
|
5797
|
-
reason
|
|
5798
|
-
}
|
|
5799
|
-
});
|
|
5800
|
-
}
|
|
5801
|
-
};
|
|
5802
|
-
/**
|
|
5803
|
-
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
5804
|
-
*
|
|
5805
|
-
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
5806
|
-
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
5807
|
-
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
5808
|
-
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
5809
|
-
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
5810
|
-
* so. Inventing a centre crop would answer a different question from the one
|
|
5811
|
-
* the operator asked.
|
|
5812
|
-
*/
|
|
5813
|
-
function stillShelfMedia(data, timestamp) {
|
|
5814
|
-
const base64 = data.toString("base64");
|
|
5815
|
-
const file = (kind) => ({
|
|
5816
|
-
key: `${NC_STILL_SHELF_PREFIX}${kind}`,
|
|
5817
|
-
kind,
|
|
5818
|
-
base64,
|
|
5819
|
-
sizeBytes: data.byteLength,
|
|
5820
|
-
timestamp
|
|
5821
|
-
});
|
|
5822
|
-
return [
|
|
5823
|
-
file("keyFrameSmall"),
|
|
5824
|
-
file("keyFrame"),
|
|
5825
|
-
file("fullFrame")
|
|
5826
|
-
];
|
|
5827
|
-
}
|
|
5828
|
-
/** Reject if `promise` does not settle within `ms`. */
|
|
5829
|
-
function withTimeout$3(promise, ms) {
|
|
5830
|
-
return new Promise((resolve, reject) => {
|
|
5831
|
-
const timer = setTimeout(() => {
|
|
5832
|
-
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
5833
|
-
}, ms);
|
|
5834
|
-
promise.then((value) => {
|
|
5835
|
-
clearTimeout(timer);
|
|
5836
|
-
resolve(value);
|
|
5837
|
-
}, (err) => {
|
|
5838
|
-
clearTimeout(timer);
|
|
5839
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
5840
|
-
});
|
|
5841
|
-
});
|
|
5842
|
-
}
|
|
5843
5570
|
//#endregion
|
|
5844
5571
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5845
5572
|
/**
|
|
@@ -8900,6 +8627,58 @@ var NcTextCatalog = class {
|
|
|
8900
8627
|
}
|
|
8901
8628
|
};
|
|
8902
8629
|
//#endregion
|
|
8630
|
+
//#region src/notification-center/trigger-zone-still.ts
|
|
8631
|
+
/**
|
|
8632
|
+
* How far a candidate's instant may sit from the nearest trail point before the
|
|
8633
|
+
* trail stops speaking for it.
|
|
8634
|
+
*
|
|
8635
|
+
* 2 s. The live detection cadence on this install is 100–300 ms and the widest
|
|
8636
|
+
* real gap inside a track is a couple of seconds (a session gap); beyond that
|
|
8637
|
+
* the nearest point is about a different moment, and answering "in the zone"
|
|
8638
|
+
* from it would be a guess. An unanswerable candidate is treated as NOT
|
|
8639
|
+
* qualifying — never as disqualifying the others, and never as a reason to ship
|
|
8640
|
+
* nothing.
|
|
8641
|
+
*/
|
|
8642
|
+
var TRAIL_MATCH_MAX_MS = 2e3;
|
|
8643
|
+
/** Overlap of a normalized box with the union of the zones — the maximum over
|
|
8644
|
+
* the polygons, which is what "any of these zones" means for one box. */
|
|
8645
|
+
function zoneOverlap(point, polygons) {
|
|
8646
|
+
let best = 0;
|
|
8647
|
+
for (const polygon of polygons) {
|
|
8648
|
+
if (polygon.length < 3) continue;
|
|
8649
|
+
const overlap = bboxPolygonOverlap(point.bbox, polygon);
|
|
8650
|
+
if (overlap > best) best = overlap;
|
|
8651
|
+
}
|
|
8652
|
+
return best;
|
|
8653
|
+
}
|
|
8654
|
+
/** The trail point closest to `timestamp`, or null when none is close enough. */
|
|
8655
|
+
function trailAt(trail, timestamp) {
|
|
8656
|
+
let best = null;
|
|
8657
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
8658
|
+
for (const point of trail) {
|
|
8659
|
+
const delta = Math.abs(point.timestamp - timestamp);
|
|
8660
|
+
if (delta < bestDelta) {
|
|
8661
|
+
bestDelta = delta;
|
|
8662
|
+
best = point;
|
|
8663
|
+
}
|
|
8664
|
+
}
|
|
8665
|
+
return best !== null && bestDelta <= 2e3 ? best : null;
|
|
8666
|
+
}
|
|
8667
|
+
/**
|
|
8668
|
+
* The candidates that show the subject inside the triggering zone, in the order
|
|
8669
|
+
* they were given. Every candidate comes back unchanged when the question
|
|
8670
|
+
* cannot be asked (no trail, no polygon) or when nobody qualifies.
|
|
8671
|
+
*/
|
|
8672
|
+
function selectTriggerZoneCandidates(input) {
|
|
8673
|
+
const { candidates, trail, polygons } = input;
|
|
8674
|
+
if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
|
|
8675
|
+
const qualifying = candidates.filter((candidate) => {
|
|
8676
|
+
const point = trailAt(trail, candidate.timestamp);
|
|
8677
|
+
return point !== null && zoneOverlap(point, polygons) > 0;
|
|
8678
|
+
});
|
|
8679
|
+
return qualifying.length > 0 ? qualifying : candidates;
|
|
8680
|
+
}
|
|
8681
|
+
//#endregion
|
|
8903
8682
|
//#region src/notification-center/dispatcher.ts
|
|
8904
8683
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
8905
8684
|
/**
|
|
@@ -8913,6 +8692,28 @@ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
|
8913
8692
|
* text-only rather than holding the alarm any longer.
|
|
8914
8693
|
*/
|
|
8915
8694
|
var NC_STILL_WAIT_MS = 3e3;
|
|
8695
|
+
/**
|
|
8696
|
+
* How far a still's OWN instant may sit from the trigger and still count as
|
|
8697
|
+
* being ABOUT the trigger, for a `delivery: 'immediate'` rule.
|
|
8698
|
+
*
|
|
8699
|
+
* Derived from {@link TRAIL_MATCH_MAX_MS} rather than repeated: that constant
|
|
8700
|
+
* already answers "can the subject's trail speak for this instant", and the two
|
|
8701
|
+
* gates run back to back on the same candidate list (moment, then place). If
|
|
8702
|
+
* they disagreed, a frame could be judged in-zone for a moment the age gate had
|
|
8703
|
+
* already ruled a different one.
|
|
8704
|
+
*
|
|
8705
|
+
* The value is measured, not chosen: on the 54 live fires of 'Persona su Uscio'
|
|
8706
|
+
* a full-scene frame within ±1 s of the trigger existed for 52 (96%) and within
|
|
8707
|
+
* ±2 s for 53 (98%), while the frame that produced the operator's report was
|
|
8708
|
+
* +4901 ms. Widening this to 5 s would re-admit exactly that frame.
|
|
8709
|
+
*
|
|
8710
|
+
* It is NOT the ladder's top rung that has to be in band — that rung is the
|
|
8711
|
+
* native keyFrame, whose capture misses and retries (36 misses over 105 track
|
|
8712
|
+
* starts on device 615 in 6 h), so it is in band only 46% of the time. Holding
|
|
8713
|
+
* out for the KIND the rule asked for would wait for the late frame. The gate
|
|
8714
|
+
* is the instant; the ladder then runs among the frames that are about it.
|
|
8715
|
+
*/
|
|
8716
|
+
var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
|
|
8916
8717
|
var NcDispatcher = class {
|
|
8917
8718
|
deps;
|
|
8918
8719
|
targetCache = null;
|
|
@@ -9495,9 +9296,10 @@ var NcDispatcher = class {
|
|
|
9495
9296
|
const out = [];
|
|
9496
9297
|
const zoneIdsWanted = entry.payload.mediaZoneIds;
|
|
9497
9298
|
const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
|
|
9498
|
-
const firstLook = await this.resolveAttachment(entry, stillOverride);
|
|
9499
|
-
const
|
|
9500
|
-
const still = firstLook !== null || entry.payload.media === "none" ? firstLook :
|
|
9299
|
+
const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
|
|
9300
|
+
const footagePending = this.renderFootage(entry);
|
|
9301
|
+
const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
|
|
9302
|
+
const footage = await footagePending;
|
|
9501
9303
|
if (still !== null) {
|
|
9502
9304
|
const zoneIds = zoneIdsWanted;
|
|
9503
9305
|
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
@@ -9603,20 +9405,27 @@ var NcDispatcher = class {
|
|
|
9603
9405
|
/**
|
|
9604
9406
|
* Poll the still ladder for {@link NC_STILL_WAIT_MS}, then give up.
|
|
9605
9407
|
*
|
|
9606
|
-
*
|
|
9607
|
-
*
|
|
9608
|
-
*
|
|
9609
|
-
*
|
|
9610
|
-
*
|
|
9408
|
+
* Runs for EVERY rule now, footage or not, and concurrently with the render:
|
|
9409
|
+
* the still's deadline is its own. Track media lands 1.5–5.5 s after the
|
|
9410
|
+
* trigger, so a notification built at the instant of the trigger routinely
|
|
9411
|
+
* resolves nothing and this is what recovers it.
|
|
9412
|
+
*
|
|
9413
|
+
* Each attempt asks for a frame from the trigger instant and nothing else, so
|
|
9414
|
+
* a poll that has not found one keeps waiting rather than settling for a
|
|
9415
|
+
* later frame that happens to be ranked higher. The give-up pass is the one
|
|
9416
|
+
* place a later frame is accepted, and it says so.
|
|
9417
|
+
*
|
|
9418
|
+
* Bounded and then abandoned — a text-only notification about a person at the
|
|
9419
|
+
* door beats a punctual one nobody received.
|
|
9611
9420
|
*/
|
|
9612
9421
|
async waitForStill(entry, policyOverride) {
|
|
9613
9422
|
const subject = entry.payload.subject;
|
|
9614
|
-
if (this.now() - subject.timestamp > 3e4) return
|
|
9423
|
+
if (this.now() - subject.timestamp > 3e4) return this.resolveAttachment(entry, policyOverride, "any");
|
|
9615
9424
|
if (subject.eventId === void 0 && subject.trackId === void 0) return null;
|
|
9616
9425
|
const attempts = Math.floor(NC_STILL_WAIT_MS / 500);
|
|
9617
9426
|
for (let i = 0; i < attempts; i += 1) {
|
|
9618
9427
|
await this.sleep(500);
|
|
9619
|
-
const still = await this.resolveAttachment(entry, policyOverride);
|
|
9428
|
+
const still = await this.resolveAttachment(entry, policyOverride, "trigger-only");
|
|
9620
9429
|
if (still !== null) {
|
|
9621
9430
|
this.deps.logger.info("the still landed during the bounded wait", {
|
|
9622
9431
|
tags: { deviceId: entry.payload.subject.deviceId },
|
|
@@ -9630,7 +9439,7 @@ var NcDispatcher = class {
|
|
|
9630
9439
|
return still;
|
|
9631
9440
|
}
|
|
9632
9441
|
}
|
|
9633
|
-
return
|
|
9442
|
+
return this.resolveAttachment(entry, policyOverride, "any");
|
|
9634
9443
|
}
|
|
9635
9444
|
/** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
|
|
9636
9445
|
* pixel rect via sharp metadata). Null on any failure — caller falls back
|
|
@@ -9721,7 +9530,86 @@ var NcDispatcher = class {
|
|
|
9721
9530
|
bytes.set(composed.bytes);
|
|
9722
9531
|
return bytes;
|
|
9723
9532
|
}
|
|
9724
|
-
|
|
9533
|
+
/**
|
|
9534
|
+
* Drop the candidate stills that show the subject OUTSIDE the zone that fired
|
|
9535
|
+
* the rule. A filter, never a re-rank: the ladder still decides which kind of
|
|
9536
|
+
* picture the rule gets, among the frames that are about the right thing.
|
|
9537
|
+
*
|
|
9538
|
+
* Every unanswerable case returns the list untouched — no frozen zones, no
|
|
9539
|
+
* track to trail, no dep wired, no polygon resolved, an empty trail, or a
|
|
9540
|
+
* throw. And when nothing qualifies, the full list comes back: a real picture
|
|
9541
|
+
* beats no picture (the same rule D212 settled for the best shot).
|
|
9542
|
+
*/
|
|
9543
|
+
async narrowToTriggerZone(entry, ordered) {
|
|
9544
|
+
const zoneIds = entry.payload.triggerZoneIds;
|
|
9545
|
+
const trackId = entry.payload.subject.trackId;
|
|
9546
|
+
const getTrail = this.deps.getSubjectTrail;
|
|
9547
|
+
const deviceId = entry.payload.subject.deviceId;
|
|
9548
|
+
if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
|
|
9549
|
+
try {
|
|
9550
|
+
const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
|
|
9551
|
+
const kept = selectTriggerZoneCandidates({
|
|
9552
|
+
candidates: ordered,
|
|
9553
|
+
trail,
|
|
9554
|
+
polygons,
|
|
9555
|
+
triggerAt: entry.payload.subject.timestamp
|
|
9556
|
+
});
|
|
9557
|
+
if (kept.length === ordered.length) return kept;
|
|
9558
|
+
this.deps.logger.info("still narrowed to the zone that fired the rule", {
|
|
9559
|
+
tags: { deviceId },
|
|
9560
|
+
meta: {
|
|
9561
|
+
ruleId: entry.ruleId,
|
|
9562
|
+
trackId,
|
|
9563
|
+
zoneIds: zoneIds.join(","),
|
|
9564
|
+
was: ordered[0]?.kind ?? null,
|
|
9565
|
+
wasOffsetMs: ordered[0] !== void 0 ? ordered[0].timestamp - entry.payload.subject.timestamp : null,
|
|
9566
|
+
now: kept[0]?.kind ?? null,
|
|
9567
|
+
nowOffsetMs: kept[0] !== void 0 ? kept[0].timestamp - entry.payload.subject.timestamp : null,
|
|
9568
|
+
dropped: ordered.length - kept.length
|
|
9569
|
+
}
|
|
9570
|
+
});
|
|
9571
|
+
return kept;
|
|
9572
|
+
} catch (err) {
|
|
9573
|
+
this.deps.logger.debug("trigger-zone still narrowing failed — shipping the plain ladder", {
|
|
9574
|
+
tags: { deviceId },
|
|
9575
|
+
meta: {
|
|
9576
|
+
ruleId: entry.ruleId,
|
|
9577
|
+
error: String(err)
|
|
9578
|
+
}
|
|
9579
|
+
});
|
|
9580
|
+
return ordered;
|
|
9581
|
+
}
|
|
9582
|
+
}
|
|
9583
|
+
/**
|
|
9584
|
+
* Keep only the stills that are ABOUT the trigger instant.
|
|
9585
|
+
*
|
|
9586
|
+
* Applies to `delivery: 'immediate'` and nothing else: a track-end rule is
|
|
9587
|
+
* legitimately about the whole track, and a system event has no instant of
|
|
9588
|
+
* its own to be near. Under `any` the list is never emptied — the best
|
|
9589
|
+
* out-of-band frame ships instead, and the degrade is REPORTED, because
|
|
9590
|
+
* "the picture is from nine seconds later" is the difference between a
|
|
9591
|
+
* notification that is late and one that is about something else.
|
|
9592
|
+
*/
|
|
9593
|
+
narrowToTriggerInstant(entry, ordered, freshness) {
|
|
9594
|
+
if (entry.payload.delivery !== "immediate" || ordered.length === 0) return ordered;
|
|
9595
|
+
const triggerAt = entry.payload.subject.timestamp;
|
|
9596
|
+
const inBand = ordered.filter((file) => Math.abs(file.timestamp - triggerAt) <= NC_TRIGGER_STILL_MAX_AGE_MS);
|
|
9597
|
+
if (inBand.length > 0 || freshness === "trigger-only") return inBand;
|
|
9598
|
+
const chosen = ordered[0];
|
|
9599
|
+
if (chosen !== void 0) this.deps.logger.info("no still from the trigger instant — shipping a later frame", {
|
|
9600
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
9601
|
+
meta: {
|
|
9602
|
+
ruleId: entry.ruleId,
|
|
9603
|
+
recordId: entry.recordId,
|
|
9604
|
+
kind: chosen.kind,
|
|
9605
|
+
offsetMs: chosen.timestamp - triggerAt,
|
|
9606
|
+
boundMs: NC_TRIGGER_STILL_MAX_AGE_MS,
|
|
9607
|
+
waitedMs: NC_STILL_WAIT_MS
|
|
9608
|
+
}
|
|
9609
|
+
});
|
|
9610
|
+
return ordered;
|
|
9611
|
+
}
|
|
9612
|
+
async resolveAttachment(entry, policyOverride, freshness = "any") {
|
|
9725
9613
|
const policy = policyOverride ?? entry.payload.media;
|
|
9726
9614
|
if (policy === "none") return null;
|
|
9727
9615
|
const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
|
|
@@ -9741,9 +9629,12 @@ var NcDispatcher = class {
|
|
|
9741
9629
|
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
9742
9630
|
if (files.length === 0) continue;
|
|
9743
9631
|
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
9744
|
-
|
|
9745
|
-
|
|
9746
|
-
|
|
9632
|
+
const ordered = [];
|
|
9633
|
+
for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
|
|
9634
|
+
const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
|
|
9635
|
+
if (inBand.length === 0) continue;
|
|
9636
|
+
const shortlist = await this.narrowToTriggerZone(entry, inBand);
|
|
9637
|
+
for (const file of shortlist) {
|
|
9747
9638
|
const raw = Buffer.from(file.base64, "base64");
|
|
9748
9639
|
if (raw.byteLength === 0) continue;
|
|
9749
9640
|
const bytes = new Uint8Array(raw.byteLength);
|
|
@@ -11092,268 +10983,6 @@ function rowToRecord(id, data) {
|
|
|
11092
10983
|
sealed: data["sealed"] === true
|
|
11093
10984
|
};
|
|
11094
10985
|
}
|
|
11095
|
-
//#endregion
|
|
11096
|
-
//#region src/notification-center/liveness-ledger.ts
|
|
11097
|
-
/**
|
|
11098
|
-
* @durable class=ledger owner=notification-center
|
|
11099
|
-
* write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
|
|
11100
|
-
* 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)."
|
|
11101
|
-
*/
|
|
11102
|
-
var NC_LIVENESS_COLLECTION = "notification-center:liveness";
|
|
11103
|
-
var NC_LIVENESS_COLUMNS = [
|
|
11104
|
-
(
|
|
11105
|
-
/** The subject the intake minted (`device:617`, `node:agent-1`) — one row
|
|
11106
|
-
* each, so the two families can never collide on a key. */
|
|
11107
|
-
{
|
|
11108
|
-
name: "subject",
|
|
11109
|
-
type: "TEXT",
|
|
11110
|
-
primaryKey: true,
|
|
11111
|
-
notNull: true
|
|
11112
|
-
}),
|
|
11113
|
-
(
|
|
11114
|
-
/** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
|
|
11115
|
-
* row and a `device-*` event describing the same fact compare equal. */
|
|
11116
|
-
{
|
|
11117
|
-
name: "state",
|
|
11118
|
-
type: "TEXT",
|
|
11119
|
-
notNull: true
|
|
11120
|
-
}),
|
|
11121
|
-
(
|
|
11122
|
-
/** The kind that last wrote the row. Telemetry: it makes the table readable
|
|
11123
|
-
* when someone asks "why did 617 not notify". */
|
|
11124
|
-
{
|
|
11125
|
-
name: "kind",
|
|
11126
|
-
type: "TEXT",
|
|
11127
|
-
notNull: true
|
|
11128
|
-
}),
|
|
11129
|
-
(
|
|
11130
|
-
/** Absent on a node row. Indexed — every question about a device is asked
|
|
11131
|
-
* per-device. */
|
|
11132
|
-
{
|
|
11133
|
-
name: "deviceId",
|
|
11134
|
-
type: "INTEGER"
|
|
11135
|
-
}),
|
|
11136
|
-
{
|
|
11137
|
-
name: "updatedAt",
|
|
11138
|
-
type: "INTEGER",
|
|
11139
|
-
notNull: true
|
|
11140
|
-
}
|
|
11141
|
-
];
|
|
11142
|
-
var NC_LIVENESS_INDEXES = [{
|
|
11143
|
-
name: "idx_nc_liveness_device",
|
|
11144
|
-
columns: ["deviceId"]
|
|
11145
|
-
}];
|
|
11146
|
-
/**
|
|
11147
|
-
* The kinds the ledger governs — see the module docblock for why the stream
|
|
11148
|
-
* and switch families are absent.
|
|
11149
|
-
*/
|
|
11150
|
-
var NC_LEDGERED_KINDS = new Set([
|
|
11151
|
-
"device-online",
|
|
11152
|
-
"device-offline",
|
|
11153
|
-
"node-online",
|
|
11154
|
-
"node-offline",
|
|
11155
|
-
"camera-online",
|
|
11156
|
-
"camera-offline"
|
|
11157
|
-
]);
|
|
11158
|
-
/**
|
|
11159
|
-
* Reduce a system-event subject to a ledger observation, or `null` when the
|
|
11160
|
-
* kind is not one the ledger governs.
|
|
11161
|
-
*
|
|
11162
|
-
* The ONE place that decides which kinds are gated: a caller that branched on
|
|
11163
|
-
* a `kind.startsWith('device-')` prefix would silently pull the operator's
|
|
11164
|
-
* disable switch into a liveness ledger, which is the mistake the docblock
|
|
11165
|
-
* spells out.
|
|
11166
|
-
*/
|
|
11167
|
-
function ledgeredLiveness(system) {
|
|
11168
|
-
if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
|
|
11169
|
-
const state = system.kind.endsWith("-offline") ? "offline" : "online";
|
|
11170
|
-
return {
|
|
11171
|
-
subject: system.subject,
|
|
11172
|
-
kind: system.kind,
|
|
11173
|
-
state,
|
|
11174
|
-
...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
|
|
11175
|
-
};
|
|
11176
|
-
}
|
|
11177
|
-
/**
|
|
11178
|
-
* The ledger's mechanics, declared once. Everything policy-shaped is right
|
|
11179
|
-
* here in this object: what the KEY is, what the persisted row looks like, and
|
|
11180
|
-
* — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
|
|
11181
|
-
* FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
|
|
11182
|
-
* and a `device-online` event describing it compare equal, and a repeat never
|
|
11183
|
-
* moves `updatedAt`.
|
|
11184
|
-
*
|
|
11185
|
-
* The reseed is uncapped in practice: the row set is bounded by the
|
|
11186
|
-
* installation — one row per device plus one per node — so the primitive's
|
|
11187
|
-
* default ceiling still bounds a pathological read. Deliberately NOT pruned
|
|
11188
|
-
* against the device directory: a prune is work DESTROYED on a fallible read
|
|
11189
|
-
* (D49), and a directory that answered empty once would wipe the ledger and
|
|
11190
|
-
* re-arm the very flood this file exists to prevent. A removed device leaves
|
|
11191
|
-
* exactly one row behind, and a re-added one re-seeds correctly.
|
|
11192
|
-
*/
|
|
11193
|
-
var NC_LIVENESS_SPEC = {
|
|
11194
|
-
collection: NC_LIVENESS_COLLECTION,
|
|
11195
|
-
columns: NC_LIVENESS_COLUMNS,
|
|
11196
|
-
indexes: NC_LIVENESS_INDEXES,
|
|
11197
|
-
writeMode: "write-behind",
|
|
11198
|
-
keyOf: (row) => row.subject,
|
|
11199
|
-
toValue: rowToValue$1,
|
|
11200
|
-
fromRecord: recordToRow$2,
|
|
11201
|
-
equalFact: (held, incoming) => held.state === incoming.state,
|
|
11202
|
-
deviceIdOf: (row) => row.deviceId
|
|
11203
|
-
};
|
|
11204
|
-
var NcLivenessLedger = class {
|
|
11205
|
-
ledger;
|
|
11206
|
-
constructor(deps) {
|
|
11207
|
-
this.ledger = new DurableLedger({
|
|
11208
|
-
spec: NC_LIVENESS_SPEC,
|
|
11209
|
-
store: deps.store,
|
|
11210
|
-
logger: deps.logger
|
|
11211
|
-
});
|
|
11212
|
-
}
|
|
11213
|
-
static declare(store) {
|
|
11214
|
-
return DurableLedger.declare(store, NC_LIVENESS_SPEC);
|
|
11215
|
-
}
|
|
11216
|
-
/**
|
|
11217
|
-
* Boot reseed — the whole point of the file. Returns the row count so the
|
|
11218
|
-
* caller can say out loud how much memory it recovered; "loaded 0" after a
|
|
11219
|
-
* container recreate is the one line that would have explained tonight.
|
|
11220
|
-
*
|
|
11221
|
-
* A failed load KEEPS whatever is already mirrored (the primitive's contract
|
|
11222
|
-
* rule 2) rather than clearing it: dropping the memory is what caused the
|
|
11223
|
-
* flood, so no error path may do it on purpose.
|
|
11224
|
-
*/
|
|
11225
|
-
async load() {
|
|
11226
|
-
return (await this.ledger.load()).length;
|
|
11227
|
-
}
|
|
11228
|
-
/**
|
|
11229
|
-
* Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
|
|
11230
|
-
* the fix for the outage this ledger used to swallow.
|
|
11231
|
-
*
|
|
11232
|
-
* ## The hole
|
|
11233
|
-
*
|
|
11234
|
-
* {@link load} recovers what the ledger itself accepted. A device that has
|
|
11235
|
-
* never produced a liveness event since the ledger was created has no row at
|
|
11236
|
-
* all, so its FIRST observation ever seeds silently — and if that first
|
|
11237
|
-
* observation is the camera DYING, the outage is thrown away. Live cost,
|
|
11238
|
-
* 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
|
|
11239
|
-
* notifications. `device:584` and `device:587` had never spoken; their
|
|
11240
|
-
* unplugging was recorded and never announced. A camera that is quiet until
|
|
11241
|
-
* it dies is a camera whose death is silent, indefinitely.
|
|
11242
|
-
*
|
|
11243
|
-
* ## Why only the ONLINE ones
|
|
11244
|
-
*
|
|
11245
|
-
* The directory is the device manager's own `online` flag (D132 §3: the
|
|
11246
|
-
* authority seeds the mirror at boot and stays the authority). Seeding only
|
|
11247
|
-
* the devices it calls ONLINE is what makes this change one-directional:
|
|
11248
|
-
*
|
|
11249
|
-
* - a row seeded `online` can only ever turn a later `device.offline` into a
|
|
11250
|
-
* notification — a true sentence about a device that just went away;
|
|
11251
|
-
* - it can NEVER turn a `device.online` into one, and that is the only shape
|
|
11252
|
-
* the 2026-08-13 notification flood (D130) ever had.
|
|
11253
|
-
*
|
|
11254
|
-
* A device the directory calls offline — which is also what a cold or
|
|
11255
|
-
* still-settling directory would say — gets no row and keeps today's
|
|
11256
|
-
* behaviour exactly. Failing toward discard, unchanged.
|
|
11257
|
-
*
|
|
11258
|
-
* ## D49
|
|
11259
|
-
*
|
|
11260
|
-
* An EXISTING row is never overwritten. The ledger is the authority for what
|
|
11261
|
-
* it accepted; the directory is a mirror refreshed on a tick and may be
|
|
11262
|
-
* stale. A stale `online` overwriting an accepted `offline` would make the
|
|
11263
|
-
* camera's real recovery a no-flip and lose it. A failed directory read
|
|
11264
|
-
* simply yields an empty list here and seeds nothing.
|
|
11265
|
-
*
|
|
11266
|
-
* ## Staged, not persisted
|
|
11267
|
-
*
|
|
11268
|
-
* The seed is DERIVED from an authority that is read again on every boot, so
|
|
11269
|
-
* losing it costs nothing — the next boot re-derives it. `stage` therefore
|
|
11270
|
-
* advances the mirror without a write, which matters on this hub: the
|
|
11271
|
-
* pipeline-analytics runner respawns several times an hour, and persisting
|
|
11272
|
-
* ~500 re-derivable rows on each respawn would offer thousands of pointless
|
|
11273
|
-
* commits a day to the checkpoint lottery (D96). The first real
|
|
11274
|
-
* {@link observe} flip persists through the ordinary write-behind path.
|
|
11275
|
-
*
|
|
11276
|
-
* Returns how many rows were seeded, so the caller can say it out loud.
|
|
11277
|
-
*/
|
|
11278
|
-
seedFromDirectory(identities, now) {
|
|
11279
|
-
let seeded = 0;
|
|
11280
|
-
for (const identity of identities) {
|
|
11281
|
-
if (identity.online !== true) continue;
|
|
11282
|
-
const subject = `device:${identity.deviceId}`;
|
|
11283
|
-
if (this.ledger.has(subject)) continue;
|
|
11284
|
-
this.ledger.stage({
|
|
11285
|
-
subject,
|
|
11286
|
-
state: "online",
|
|
11287
|
-
kind: "device-online",
|
|
11288
|
-
deviceId: identity.deviceId,
|
|
11289
|
-
updatedAt: now
|
|
11290
|
-
});
|
|
11291
|
-
seeded += 1;
|
|
11292
|
-
}
|
|
11293
|
-
return seeded;
|
|
11294
|
-
}
|
|
11295
|
-
/** The in-RAM mirror, subject-ordered so two readers agree. */
|
|
11296
|
-
snapshot() {
|
|
11297
|
-
return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
|
|
11298
|
-
}
|
|
11299
|
-
/** The state the ledger currently accepts for a subject, if any. */
|
|
11300
|
-
stateOf(subject) {
|
|
11301
|
-
return this.ledger.get(subject)?.state;
|
|
11302
|
-
}
|
|
11303
|
-
/**
|
|
11304
|
-
* Judge ONE observation and advance the ledger.
|
|
11305
|
-
*
|
|
11306
|
-
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
11307
|
-
* alone, so an event can never be gated on an I/O that might fail (D49). The
|
|
11308
|
-
* durable write is kicked off behind it and its failure changes no verdict.
|
|
11309
|
-
*
|
|
11310
|
-
* `no-flip` leaves the row untouched — including its `updatedAt`, which
|
|
11311
|
-
* therefore means "when this subject last CHANGED", not "when it last
|
|
11312
|
-
* spoke". That is the timestamp anyone reading the table wants.
|
|
11313
|
-
*/
|
|
11314
|
-
observe(observation, now) {
|
|
11315
|
-
return this.ledger.observe({
|
|
11316
|
-
subject: observation.subject,
|
|
11317
|
-
state: observation.state,
|
|
11318
|
-
kind: observation.kind,
|
|
11319
|
-
...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
|
|
11320
|
-
updatedAt: now
|
|
11321
|
-
});
|
|
11322
|
-
}
|
|
11323
|
-
};
|
|
11324
|
-
/** The persisted column map (the `subject` PK is passed separately). */
|
|
11325
|
-
function rowToValue$1(row) {
|
|
11326
|
-
return {
|
|
11327
|
-
state: row.state,
|
|
11328
|
-
kind: row.kind,
|
|
11329
|
-
...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
|
|
11330
|
-
updatedAt: row.updatedAt
|
|
11331
|
-
};
|
|
11332
|
-
}
|
|
11333
|
-
/**
|
|
11334
|
-
* Structurally validate one persisted record. A row whose state is not one of
|
|
11335
|
-
* the two known values is `null` — skipped, so the subject re-seeds cold
|
|
11336
|
-
* (silent) rather than hydrating a state nothing can ever equal, which would
|
|
11337
|
-
* make every future event a phantom flip.
|
|
11338
|
-
*/
|
|
11339
|
-
function recordToRow$2(subject, data) {
|
|
11340
|
-
if (subject.length === 0) return null;
|
|
11341
|
-
const state = data["state"];
|
|
11342
|
-
if (state !== "online" && state !== "offline") return null;
|
|
11343
|
-
const kind = data["kind"];
|
|
11344
|
-
if (typeof kind !== "string" || kind.length === 0) return null;
|
|
11345
|
-
const updatedAt = Number(data["updatedAt"]);
|
|
11346
|
-
if (!Number.isFinite(updatedAt)) return null;
|
|
11347
|
-
const rawDeviceId = data["deviceId"];
|
|
11348
|
-
const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
|
|
11349
|
-
return {
|
|
11350
|
-
subject,
|
|
11351
|
-
state,
|
|
11352
|
-
kind,
|
|
11353
|
-
...deviceId !== void 0 ? { deviceId } : {},
|
|
11354
|
-
updatedAt
|
|
11355
|
-
};
|
|
11356
|
-
}
|
|
11357
10986
|
function isDeviceLivenessKind(kind) {
|
|
11358
10987
|
return kind === "device-online" || kind === "device-offline" || kind === "camera-online" || kind === "camera-offline";
|
|
11359
10988
|
}
|
|
@@ -11416,6 +11045,268 @@ var DeviceLivenessHoldoff = class {
|
|
|
11416
11045
|
}
|
|
11417
11046
|
};
|
|
11418
11047
|
//#endregion
|
|
11048
|
+
//#region src/notification-center/liveness-ledger.ts
|
|
11049
|
+
/**
|
|
11050
|
+
* @durable class=ledger owner=notification-center
|
|
11051
|
+
* write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
|
|
11052
|
+
* 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)."
|
|
11053
|
+
*/
|
|
11054
|
+
var NC_LIVENESS_COLLECTION = "notification-center:liveness";
|
|
11055
|
+
var NC_LIVENESS_COLUMNS = [
|
|
11056
|
+
(
|
|
11057
|
+
/** The subject the intake minted (`device:617`, `node:agent-1`) — one row
|
|
11058
|
+
* each, so the two families can never collide on a key. */
|
|
11059
|
+
{
|
|
11060
|
+
name: "subject",
|
|
11061
|
+
type: "TEXT",
|
|
11062
|
+
primaryKey: true,
|
|
11063
|
+
notNull: true
|
|
11064
|
+
}),
|
|
11065
|
+
(
|
|
11066
|
+
/** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
|
|
11067
|
+
* row and a `device-*` event describing the same fact compare equal. */
|
|
11068
|
+
{
|
|
11069
|
+
name: "state",
|
|
11070
|
+
type: "TEXT",
|
|
11071
|
+
notNull: true
|
|
11072
|
+
}),
|
|
11073
|
+
(
|
|
11074
|
+
/** The kind that last wrote the row. Telemetry: it makes the table readable
|
|
11075
|
+
* when someone asks "why did 617 not notify". */
|
|
11076
|
+
{
|
|
11077
|
+
name: "kind",
|
|
11078
|
+
type: "TEXT",
|
|
11079
|
+
notNull: true
|
|
11080
|
+
}),
|
|
11081
|
+
(
|
|
11082
|
+
/** Absent on a node row. Indexed — every question about a device is asked
|
|
11083
|
+
* per-device. */
|
|
11084
|
+
{
|
|
11085
|
+
name: "deviceId",
|
|
11086
|
+
type: "INTEGER"
|
|
11087
|
+
}),
|
|
11088
|
+
{
|
|
11089
|
+
name: "updatedAt",
|
|
11090
|
+
type: "INTEGER",
|
|
11091
|
+
notNull: true
|
|
11092
|
+
}
|
|
11093
|
+
];
|
|
11094
|
+
var NC_LIVENESS_INDEXES = [{
|
|
11095
|
+
name: "idx_nc_liveness_device",
|
|
11096
|
+
columns: ["deviceId"]
|
|
11097
|
+
}];
|
|
11098
|
+
/**
|
|
11099
|
+
* The kinds the ledger governs — see the module docblock for why the stream
|
|
11100
|
+
* and switch families are absent.
|
|
11101
|
+
*/
|
|
11102
|
+
var NC_LEDGERED_KINDS = new Set([
|
|
11103
|
+
"device-online",
|
|
11104
|
+
"device-offline",
|
|
11105
|
+
"node-online",
|
|
11106
|
+
"node-offline",
|
|
11107
|
+
"camera-online",
|
|
11108
|
+
"camera-offline"
|
|
11109
|
+
]);
|
|
11110
|
+
/**
|
|
11111
|
+
* Reduce a system-event subject to a ledger observation, or `null` when the
|
|
11112
|
+
* kind is not one the ledger governs.
|
|
11113
|
+
*
|
|
11114
|
+
* The ONE place that decides which kinds are gated: a caller that branched on
|
|
11115
|
+
* a `kind.startsWith('device-')` prefix would silently pull the operator's
|
|
11116
|
+
* disable switch into a liveness ledger, which is the mistake the docblock
|
|
11117
|
+
* spells out.
|
|
11118
|
+
*/
|
|
11119
|
+
function ledgeredLiveness(system) {
|
|
11120
|
+
if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
|
|
11121
|
+
const state = system.kind.endsWith("-offline") ? "offline" : "online";
|
|
11122
|
+
return {
|
|
11123
|
+
subject: system.subject,
|
|
11124
|
+
kind: system.kind,
|
|
11125
|
+
state,
|
|
11126
|
+
...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
|
|
11127
|
+
};
|
|
11128
|
+
}
|
|
11129
|
+
/**
|
|
11130
|
+
* The ledger's mechanics, declared once. Everything policy-shaped is right
|
|
11131
|
+
* here in this object: what the KEY is, what the persisted row looks like, and
|
|
11132
|
+
* — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
|
|
11133
|
+
* FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
|
|
11134
|
+
* and a `device-online` event describing it compare equal, and a repeat never
|
|
11135
|
+
* moves `updatedAt`.
|
|
11136
|
+
*
|
|
11137
|
+
* The reseed is uncapped in practice: the row set is bounded by the
|
|
11138
|
+
* installation — one row per device plus one per node — so the primitive's
|
|
11139
|
+
* default ceiling still bounds a pathological read. Deliberately NOT pruned
|
|
11140
|
+
* against the device directory: a prune is work DESTROYED on a fallible read
|
|
11141
|
+
* (D49), and a directory that answered empty once would wipe the ledger and
|
|
11142
|
+
* re-arm the very flood this file exists to prevent. A removed device leaves
|
|
11143
|
+
* exactly one row behind, and a re-added one re-seeds correctly.
|
|
11144
|
+
*/
|
|
11145
|
+
var NC_LIVENESS_SPEC = {
|
|
11146
|
+
collection: NC_LIVENESS_COLLECTION,
|
|
11147
|
+
columns: NC_LIVENESS_COLUMNS,
|
|
11148
|
+
indexes: NC_LIVENESS_INDEXES,
|
|
11149
|
+
writeMode: "write-behind",
|
|
11150
|
+
keyOf: (row) => row.subject,
|
|
11151
|
+
toValue: rowToValue$1,
|
|
11152
|
+
fromRecord: recordToRow$2,
|
|
11153
|
+
equalFact: (held, incoming) => held.state === incoming.state,
|
|
11154
|
+
deviceIdOf: (row) => row.deviceId
|
|
11155
|
+
};
|
|
11156
|
+
var NcLivenessLedger = class {
|
|
11157
|
+
ledger;
|
|
11158
|
+
constructor(deps) {
|
|
11159
|
+
this.ledger = new DurableLedger({
|
|
11160
|
+
spec: NC_LIVENESS_SPEC,
|
|
11161
|
+
store: deps.store,
|
|
11162
|
+
logger: deps.logger
|
|
11163
|
+
});
|
|
11164
|
+
}
|
|
11165
|
+
static declare(store) {
|
|
11166
|
+
return DurableLedger.declare(store, NC_LIVENESS_SPEC);
|
|
11167
|
+
}
|
|
11168
|
+
/**
|
|
11169
|
+
* Boot reseed — the whole point of the file. Returns the row count so the
|
|
11170
|
+
* caller can say out loud how much memory it recovered; "loaded 0" after a
|
|
11171
|
+
* container recreate is the one line that would have explained tonight.
|
|
11172
|
+
*
|
|
11173
|
+
* A failed load KEEPS whatever is already mirrored (the primitive's contract
|
|
11174
|
+
* rule 2) rather than clearing it: dropping the memory is what caused the
|
|
11175
|
+
* flood, so no error path may do it on purpose.
|
|
11176
|
+
*/
|
|
11177
|
+
async load() {
|
|
11178
|
+
return (await this.ledger.load()).length;
|
|
11179
|
+
}
|
|
11180
|
+
/**
|
|
11181
|
+
* Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
|
|
11182
|
+
* the fix for the outage this ledger used to swallow.
|
|
11183
|
+
*
|
|
11184
|
+
* ## The hole
|
|
11185
|
+
*
|
|
11186
|
+
* {@link load} recovers what the ledger itself accepted. A device that has
|
|
11187
|
+
* never produced a liveness event since the ledger was created has no row at
|
|
11188
|
+
* all, so its FIRST observation ever seeds silently — and if that first
|
|
11189
|
+
* observation is the camera DYING, the outage is thrown away. Live cost,
|
|
11190
|
+
* 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
|
|
11191
|
+
* notifications. `device:584` and `device:587` had never spoken; their
|
|
11192
|
+
* unplugging was recorded and never announced. A camera that is quiet until
|
|
11193
|
+
* it dies is a camera whose death is silent, indefinitely.
|
|
11194
|
+
*
|
|
11195
|
+
* ## Why only the ONLINE ones
|
|
11196
|
+
*
|
|
11197
|
+
* The directory is the device manager's own `online` flag (D132 §3: the
|
|
11198
|
+
* authority seeds the mirror at boot and stays the authority). Seeding only
|
|
11199
|
+
* the devices it calls ONLINE is what makes this change one-directional:
|
|
11200
|
+
*
|
|
11201
|
+
* - a row seeded `online` can only ever turn a later `device.offline` into a
|
|
11202
|
+
* notification — a true sentence about a device that just went away;
|
|
11203
|
+
* - it can NEVER turn a `device.online` into one, and that is the only shape
|
|
11204
|
+
* the 2026-08-13 notification flood (D130) ever had.
|
|
11205
|
+
*
|
|
11206
|
+
* A device the directory calls offline — which is also what a cold or
|
|
11207
|
+
* still-settling directory would say — gets no row and keeps today's
|
|
11208
|
+
* behaviour exactly. Failing toward discard, unchanged.
|
|
11209
|
+
*
|
|
11210
|
+
* ## D49
|
|
11211
|
+
*
|
|
11212
|
+
* An EXISTING row is never overwritten. The ledger is the authority for what
|
|
11213
|
+
* it accepted; the directory is a mirror refreshed on a tick and may be
|
|
11214
|
+
* stale. A stale `online` overwriting an accepted `offline` would make the
|
|
11215
|
+
* camera's real recovery a no-flip and lose it. A failed directory read
|
|
11216
|
+
* simply yields an empty list here and seeds nothing.
|
|
11217
|
+
*
|
|
11218
|
+
* ## Staged, not persisted
|
|
11219
|
+
*
|
|
11220
|
+
* The seed is DERIVED from an authority that is read again on every boot, so
|
|
11221
|
+
* losing it costs nothing — the next boot re-derives it. `stage` therefore
|
|
11222
|
+
* advances the mirror without a write, which matters on this hub: the
|
|
11223
|
+
* pipeline-analytics runner respawns several times an hour, and persisting
|
|
11224
|
+
* ~500 re-derivable rows on each respawn would offer thousands of pointless
|
|
11225
|
+
* commits a day to the checkpoint lottery (D96). The first real
|
|
11226
|
+
* {@link observe} flip persists through the ordinary write-behind path.
|
|
11227
|
+
*
|
|
11228
|
+
* Returns how many rows were seeded, so the caller can say it out loud.
|
|
11229
|
+
*/
|
|
11230
|
+
seedFromDirectory(identities, now) {
|
|
11231
|
+
let seeded = 0;
|
|
11232
|
+
for (const identity of identities) {
|
|
11233
|
+
if (identity.online !== true) continue;
|
|
11234
|
+
const subject = `device:${identity.deviceId}`;
|
|
11235
|
+
if (this.ledger.has(subject)) continue;
|
|
11236
|
+
this.ledger.stage({
|
|
11237
|
+
subject,
|
|
11238
|
+
state: "online",
|
|
11239
|
+
kind: "device-online",
|
|
11240
|
+
deviceId: identity.deviceId,
|
|
11241
|
+
updatedAt: now
|
|
11242
|
+
});
|
|
11243
|
+
seeded += 1;
|
|
11244
|
+
}
|
|
11245
|
+
return seeded;
|
|
11246
|
+
}
|
|
11247
|
+
/** The in-RAM mirror, subject-ordered so two readers agree. */
|
|
11248
|
+
snapshot() {
|
|
11249
|
+
return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
|
|
11250
|
+
}
|
|
11251
|
+
/** The state the ledger currently accepts for a subject, if any. */
|
|
11252
|
+
stateOf(subject) {
|
|
11253
|
+
return this.ledger.get(subject)?.state;
|
|
11254
|
+
}
|
|
11255
|
+
/**
|
|
11256
|
+
* Judge ONE observation and advance the ledger.
|
|
11257
|
+
*
|
|
11258
|
+
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
11259
|
+
* alone, so an event can never be gated on an I/O that might fail (D49). The
|
|
11260
|
+
* durable write is kicked off behind it and its failure changes no verdict.
|
|
11261
|
+
*
|
|
11262
|
+
* `no-flip` leaves the row untouched — including its `updatedAt`, which
|
|
11263
|
+
* therefore means "when this subject last CHANGED", not "when it last
|
|
11264
|
+
* spoke". That is the timestamp anyone reading the table wants.
|
|
11265
|
+
*/
|
|
11266
|
+
observe(observation, now) {
|
|
11267
|
+
return this.ledger.observe({
|
|
11268
|
+
subject: observation.subject,
|
|
11269
|
+
state: observation.state,
|
|
11270
|
+
kind: observation.kind,
|
|
11271
|
+
...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
|
|
11272
|
+
updatedAt: now
|
|
11273
|
+
});
|
|
11274
|
+
}
|
|
11275
|
+
};
|
|
11276
|
+
/** The persisted column map (the `subject` PK is passed separately). */
|
|
11277
|
+
function rowToValue$1(row) {
|
|
11278
|
+
return {
|
|
11279
|
+
state: row.state,
|
|
11280
|
+
kind: row.kind,
|
|
11281
|
+
...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
|
|
11282
|
+
updatedAt: row.updatedAt
|
|
11283
|
+
};
|
|
11284
|
+
}
|
|
11285
|
+
/**
|
|
11286
|
+
* Structurally validate one persisted record. A row whose state is not one of
|
|
11287
|
+
* the two known values is `null` — skipped, so the subject re-seeds cold
|
|
11288
|
+
* (silent) rather than hydrating a state nothing can ever equal, which would
|
|
11289
|
+
* make every future event a phantom flip.
|
|
11290
|
+
*/
|
|
11291
|
+
function recordToRow$2(subject, data) {
|
|
11292
|
+
if (subject.length === 0) return null;
|
|
11293
|
+
const state = data["state"];
|
|
11294
|
+
if (state !== "online" && state !== "offline") return null;
|
|
11295
|
+
const kind = data["kind"];
|
|
11296
|
+
if (typeof kind !== "string" || kind.length === 0) return null;
|
|
11297
|
+
const updatedAt = Number(data["updatedAt"]);
|
|
11298
|
+
if (!Number.isFinite(updatedAt)) return null;
|
|
11299
|
+
const rawDeviceId = data["deviceId"];
|
|
11300
|
+
const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
|
|
11301
|
+
return {
|
|
11302
|
+
subject,
|
|
11303
|
+
state,
|
|
11304
|
+
kind,
|
|
11305
|
+
...deviceId !== void 0 ? { deviceId } : {},
|
|
11306
|
+
updatedAt
|
|
11307
|
+
};
|
|
11308
|
+
}
|
|
11309
|
+
//#endregion
|
|
11419
11310
|
//#region src/notification-center/occupancy-watcher.ts
|
|
11420
11311
|
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
11421
11312
|
var FRAME_SCOPE = "@frame";
|
|
@@ -13782,6 +13673,279 @@ var NcSnoozeStore = class {
|
|
|
13782
13673
|
return parsed.success ? parsed.data : null;
|
|
13783
13674
|
}
|
|
13784
13675
|
};
|
|
13676
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
13677
|
+
var FULL_FRAME_QUALITY = 80;
|
|
13678
|
+
/**
|
|
13679
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
13680
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
13681
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
13682
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
13683
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
13684
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
13685
|
+
*/
|
|
13686
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
13687
|
+
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
13688
|
+
fit: "inside",
|
|
13689
|
+
withoutEnlargement: true
|
|
13690
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
13691
|
+
}
|
|
13692
|
+
//#endregion
|
|
13693
|
+
//#region src/notification-center/still-shelf.ts
|
|
13694
|
+
/**
|
|
13695
|
+
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
13696
|
+
* name owns a frame.
|
|
13697
|
+
*
|
|
13698
|
+
* ## Why a trigger needs one at all
|
|
13699
|
+
*
|
|
13700
|
+
* The attachment ladder resolves media by OWNER, and most triggers have one: an
|
|
13701
|
+
* object or package event owns its crops, a closed track owns its best shot, a
|
|
13702
|
+
* doorbell press owns the marker track the same press projected
|
|
13703
|
+
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
13704
|
+
* counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
|
|
13705
|
+
* same reason in both cases — the subject is an ABSENCE:
|
|
13706
|
+
*
|
|
13707
|
+
* - an **audio** match: nothing was boxed, nothing was tracked, and —
|
|
13708
|
+
* deliberately — nothing is persisted at all. A confirmed window is a claim
|
|
13709
|
+
* about sound that has already stopped, and `event-intake.ts` states why
|
|
13710
|
+
* replaying it later would be wrong.
|
|
13711
|
+
* - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
|
|
13712
|
+
* whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
|
|
13713
|
+
* nobody and the ladder logs `no still could be resolved … owners=[]`.
|
|
13714
|
+
*
|
|
13715
|
+
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
13716
|
+
* of the trigger. Not of the sound, not of the object that left — of what the
|
|
13717
|
+
* camera can see now that it happened. For a freed parking space that is
|
|
13718
|
+
* exactly the answer the operator wants: the space, empty.
|
|
13719
|
+
*
|
|
13720
|
+
* **One shelf, not two.** The mechanism is identical down to the reuse window,
|
|
13721
|
+
* and the only thing that differs between the two triggers is the GATE deciding
|
|
13722
|
+
* that a photograph is owed at all — which belongs at the trigger site, where
|
|
13723
|
+
* the rules are, and not here. A twin module would be a second derivation of
|
|
13724
|
+
* one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
|
|
13725
|
+
* can say which absence they are about.
|
|
13726
|
+
*
|
|
13727
|
+
* ## Three properties, and each one is a decision
|
|
13728
|
+
*
|
|
13729
|
+
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
13730
|
+
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
13731
|
+
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
13732
|
+
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
13733
|
+
* a durable Track on the camera's timeline for every confirmed window and every
|
|
13734
|
+
* emptied zone, feeding the digest's `listTracks`, retention, and the audio-
|
|
13735
|
+
* marker feature's own operator ceilings (`audio-marker-projector.ts` exists
|
|
13736
|
+
* precisely to bound how many audio markers a camera may emit). A notification
|
|
13737
|
+
* must not manufacture timeline history as a side effect of wanting a picture.
|
|
13738
|
+
*
|
|
13739
|
+
* **The capture STARTS immediately and is never awaited.** The subject is
|
|
13740
|
+
* transient — a scream is over before a snapshot round-trip completes, and a
|
|
13741
|
+
* freed space is about to be taken by the next car — so the fetch is kicked off
|
|
13742
|
+
* at the trigger, before the rule evaluation runs, and the owner id is minted
|
|
13743
|
+
* synchronously so the outbox row can name it. The bytes land while the row
|
|
13744
|
+
* waits in the queue, and the dispatcher's existing bounded still-wait (or the
|
|
13745
|
+
* pause its own footage render already costs) picks them up. A camera that
|
|
13746
|
+
* never answers costs the picture and never the notification.
|
|
13747
|
+
*
|
|
13748
|
+
* **Two triggers seconds apart share ONE capture.** A barking dog confirms
|
|
13749
|
+
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
13750
|
+
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
13751
|
+
* Without a reuse window this would photograph a camera at whatever rate the
|
|
13752
|
+
* sound happens to occur. Each trigger still gets its OWN owner id, so two
|
|
13753
|
+
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
13754
|
+
* frame, which is the truth — the scene did not change in ten seconds. The
|
|
13755
|
+
* window is per CAMERA and trigger-agnostic for the same reason: a sound and an
|
|
13756
|
+
* emptied zone ten seconds apart are two claims about one scene.
|
|
13757
|
+
*
|
|
13758
|
+
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
13759
|
+
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
13760
|
+
* and 615 not" is the only form that question is ever asked in.
|
|
13761
|
+
*/
|
|
13762
|
+
/**
|
|
13763
|
+
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
13764
|
+
* media store, and it is the reason the dispatcher needs no audio or occupancy
|
|
13765
|
+
* branch — `getMediaForOwner` answers for all of them under one signature.
|
|
13766
|
+
*/
|
|
13767
|
+
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
13768
|
+
/** True for an owner id this shelf minted. */
|
|
13769
|
+
function isStillShelfId(id) {
|
|
13770
|
+
return id.startsWith(NC_STILL_SHELF_PREFIX);
|
|
13771
|
+
}
|
|
13772
|
+
/**
|
|
13773
|
+
* How long the bytes are held.
|
|
13774
|
+
*
|
|
13775
|
+
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
13776
|
+
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
13777
|
+
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
13778
|
+
* that is a quarter of an hour stale anyway.
|
|
13779
|
+
*/
|
|
13780
|
+
var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
|
|
13781
|
+
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
13782
|
+
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
13783
|
+
/**
|
|
13784
|
+
* Hard bound on held captures. Reached only if every camera on the hub triggers
|
|
13785
|
+
* one inside a single TTL; the oldest is dropped first, which costs a
|
|
13786
|
+
* fifteen-minute-old picture nobody is waiting for.
|
|
13787
|
+
*/
|
|
13788
|
+
var MAX_CAPTURES = 64;
|
|
13789
|
+
var NcStillShelf = class {
|
|
13790
|
+
deps;
|
|
13791
|
+
/** captureId → the photograph. */
|
|
13792
|
+
captures = /* @__PURE__ */ new Map();
|
|
13793
|
+
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
13794
|
+
owners = /* @__PURE__ */ new Map();
|
|
13795
|
+
/** deviceId → its newest capture, for the reuse window. */
|
|
13796
|
+
newest = /* @__PURE__ */ new Map();
|
|
13797
|
+
constructor(deps) {
|
|
13798
|
+
this.deps = deps;
|
|
13799
|
+
}
|
|
13800
|
+
/**
|
|
13801
|
+
* Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
|
|
13802
|
+
* subject should name. SYNCHRONOUS by contract: the caller is on the trigger
|
|
13803
|
+
* path and the outbox row is built from what this returns.
|
|
13804
|
+
*/
|
|
13805
|
+
capture(deviceId, atMs, trigger) {
|
|
13806
|
+
const now = this.deps.now();
|
|
13807
|
+
this.prune(now);
|
|
13808
|
+
const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
|
|
13809
|
+
const recent = this.newest.get(deviceId);
|
|
13810
|
+
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
13811
|
+
this.owners.set(ownerId, recent.captureId);
|
|
13812
|
+
return ownerId;
|
|
13813
|
+
}
|
|
13814
|
+
const captureId = randomUUID();
|
|
13815
|
+
this.captures.set(captureId, {
|
|
13816
|
+
deviceId,
|
|
13817
|
+
trigger,
|
|
13818
|
+
startedAt: now,
|
|
13819
|
+
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
13820
|
+
files: []
|
|
13821
|
+
});
|
|
13822
|
+
this.newest.set(deviceId, {
|
|
13823
|
+
captureId,
|
|
13824
|
+
at: now
|
|
13825
|
+
});
|
|
13826
|
+
this.owners.set(ownerId, captureId);
|
|
13827
|
+
this.fetch(captureId, deviceId, atMs, trigger);
|
|
13828
|
+
return ownerId;
|
|
13829
|
+
}
|
|
13830
|
+
/**
|
|
13831
|
+
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
13832
|
+
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
13833
|
+
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
13834
|
+
* wait and its `no still could be resolved` line are for.
|
|
13835
|
+
*/
|
|
13836
|
+
get(ownerId) {
|
|
13837
|
+
const captureId = this.owners.get(ownerId);
|
|
13838
|
+
if (captureId === void 0) return void 0;
|
|
13839
|
+
return this.captures.get(captureId)?.files ?? [];
|
|
13840
|
+
}
|
|
13841
|
+
/** Drop expired captures and the owners that named them. */
|
|
13842
|
+
prune(now) {
|
|
13843
|
+
for (const [captureId, held] of this.captures) {
|
|
13844
|
+
if (held.expiresAt > now) continue;
|
|
13845
|
+
this.captures.delete(captureId);
|
|
13846
|
+
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
13847
|
+
}
|
|
13848
|
+
while (this.captures.size > MAX_CAPTURES) {
|
|
13849
|
+
const oldest = this.captures.keys().next();
|
|
13850
|
+
if (oldest.done === true) break;
|
|
13851
|
+
const held = this.captures.get(oldest.value);
|
|
13852
|
+
this.captures.delete(oldest.value);
|
|
13853
|
+
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
13854
|
+
}
|
|
13855
|
+
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
13856
|
+
}
|
|
13857
|
+
/** Drop everything (shutdown). */
|
|
13858
|
+
clear() {
|
|
13859
|
+
this.captures.clear();
|
|
13860
|
+
this.owners.clear();
|
|
13861
|
+
this.newest.clear();
|
|
13862
|
+
}
|
|
13863
|
+
async fetch(captureId, deviceId, atMs, trigger) {
|
|
13864
|
+
try {
|
|
13865
|
+
const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
13866
|
+
if (shot === null || shot.base64.length === 0) {
|
|
13867
|
+
this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
|
|
13868
|
+
return;
|
|
13869
|
+
}
|
|
13870
|
+
const raw = Buffer.from(shot.base64, "base64");
|
|
13871
|
+
let data = raw;
|
|
13872
|
+
try {
|
|
13873
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
13874
|
+
} catch {}
|
|
13875
|
+
const held = this.captures.get(captureId);
|
|
13876
|
+
if (held === void 0) return;
|
|
13877
|
+
this.captures.set(captureId, {
|
|
13878
|
+
...held,
|
|
13879
|
+
files: stillShelfMedia(data, atMs)
|
|
13880
|
+
});
|
|
13881
|
+
this.deps.logger.info(`${trigger} still captured`, {
|
|
13882
|
+
tags: { deviceId },
|
|
13883
|
+
meta: {
|
|
13884
|
+
trigger,
|
|
13885
|
+
bytes: data.byteLength,
|
|
13886
|
+
tookMs: this.deps.now() - held.startedAt
|
|
13887
|
+
}
|
|
13888
|
+
});
|
|
13889
|
+
} catch (err) {
|
|
13890
|
+
this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
|
|
13891
|
+
}
|
|
13892
|
+
}
|
|
13893
|
+
/**
|
|
13894
|
+
* A branch that drops work says so. This one costs the operator the picture
|
|
13895
|
+
* on a notification they DID receive, so it is a warn and it carries the
|
|
13896
|
+
* camera — the only key the question is ever asked with.
|
|
13897
|
+
*/
|
|
13898
|
+
reportMiss(deviceId, trigger, reason) {
|
|
13899
|
+
this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
|
|
13900
|
+
tags: { deviceId },
|
|
13901
|
+
meta: {
|
|
13902
|
+
trigger,
|
|
13903
|
+
reason
|
|
13904
|
+
}
|
|
13905
|
+
});
|
|
13906
|
+
}
|
|
13907
|
+
};
|
|
13908
|
+
/**
|
|
13909
|
+
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
13910
|
+
*
|
|
13911
|
+
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
13912
|
+
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
13913
|
+
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
13914
|
+
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
13915
|
+
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
13916
|
+
* so. Inventing a centre crop would answer a different question from the one
|
|
13917
|
+
* the operator asked.
|
|
13918
|
+
*/
|
|
13919
|
+
function stillShelfMedia(data, timestamp) {
|
|
13920
|
+
const base64 = data.toString("base64");
|
|
13921
|
+
const file = (kind) => ({
|
|
13922
|
+
key: `${NC_STILL_SHELF_PREFIX}${kind}`,
|
|
13923
|
+
kind,
|
|
13924
|
+
base64,
|
|
13925
|
+
sizeBytes: data.byteLength,
|
|
13926
|
+
timestamp
|
|
13927
|
+
});
|
|
13928
|
+
return [
|
|
13929
|
+
file("keyFrameSmall"),
|
|
13930
|
+
file("keyFrame"),
|
|
13931
|
+
file("fullFrame")
|
|
13932
|
+
];
|
|
13933
|
+
}
|
|
13934
|
+
/** Reject if `promise` does not settle within `ms`. */
|
|
13935
|
+
function withTimeout$3(promise, ms) {
|
|
13936
|
+
return new Promise((resolve, reject) => {
|
|
13937
|
+
const timer = setTimeout(() => {
|
|
13938
|
+
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
13939
|
+
}, ms);
|
|
13940
|
+
promise.then((value) => {
|
|
13941
|
+
clearTimeout(timer);
|
|
13942
|
+
resolve(value);
|
|
13943
|
+
}, (err) => {
|
|
13944
|
+
clearTimeout(timer);
|
|
13945
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
13946
|
+
});
|
|
13947
|
+
});
|
|
13948
|
+
}
|
|
13785
13949
|
//#endregion
|
|
13786
13950
|
//#region src/notification-center/summary/summary-ai.ts
|
|
13787
13951
|
/**
|
|
@@ -21841,6 +22005,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
21841
22005
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
21842
22006
|
media: noMedia ? "none" : mediaRule.media.attach,
|
|
21843
22007
|
...!noMedia && mediaRule.media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
22008
|
+
...!noMedia && cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
|
|
21844
22009
|
...confirm !== void 0 ? {
|
|
21845
22010
|
confirm,
|
|
21846
22011
|
...cooldown !== void 0 ? { cooldown } : {}
|
|
@@ -25917,37 +26082,61 @@ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples)
|
|
|
25917
26082
|
return eligible;
|
|
25918
26083
|
}
|
|
25919
26084
|
/**
|
|
25920
|
-
*
|
|
25921
|
-
* `modelId` AND
|
|
25922
|
-
*
|
|
25923
|
-
*
|
|
26085
|
+
* THE scoring authority. Every identity with at least one sample comparable to
|
|
26086
|
+
* the probe (same `modelId` AND same dimension — model-version safety), scored
|
|
26087
|
+
* against its best such sample, sorted best-first.
|
|
26088
|
+
*
|
|
26089
|
+
* Extracted from `matchEmbedding`, which is now a thin decision on top of it,
|
|
26090
|
+
* so that "what would the pipeline score this face" has exactly ONE
|
|
26091
|
+
* implementation. `face-rescore.ts` — the operator's recompute button — reads
|
|
26092
|
+
* the same rows the matcher decides from; a second scoring pass written for the
|
|
26093
|
+
* UI would be free to drift, and a UI that disagrees with the pipeline is worse
|
|
26094
|
+
* than no UI.
|
|
25924
26095
|
*/
|
|
25925
|
-
function
|
|
26096
|
+
function rankIdentities(probe, gallery, minIdentitySamples) {
|
|
25926
26097
|
const probeVec = new Float32Array(probe.embedding);
|
|
25927
|
-
const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
|
|
25928
26098
|
const bestByIdentity = /* @__PURE__ */ new Map();
|
|
26099
|
+
const countByIdentity = /* @__PURE__ */ new Map();
|
|
25929
26100
|
for (const s of gallery) {
|
|
25930
26101
|
if (s.modelId !== probe.modelId) continue;
|
|
25931
26102
|
if (s.embedding.length !== probe.embedding.length) continue;
|
|
25932
|
-
if (!eligible.has(s.identityId)) continue;
|
|
25933
26103
|
const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
|
|
25934
26104
|
const prev = bestByIdentity.get(s.identityId);
|
|
25935
26105
|
if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
|
|
26106
|
+
countByIdentity.set(s.identityId, (countByIdentity.get(s.identityId) ?? 0) + 1);
|
|
25936
26107
|
}
|
|
25937
|
-
|
|
25938
|
-
|
|
25939
|
-
|
|
25940
|
-
|
|
25941
|
-
|
|
25942
|
-
|
|
25943
|
-
|
|
25944
|
-
|
|
25945
|
-
|
|
25946
|
-
|
|
25947
|
-
|
|
26108
|
+
const rows = [];
|
|
26109
|
+
for (const [identityId, score] of bestByIdentity) {
|
|
26110
|
+
const sampleCount = countByIdentity.get(identityId) ?? 0;
|
|
26111
|
+
rows.push({
|
|
26112
|
+
identityId,
|
|
26113
|
+
score,
|
|
26114
|
+
sampleCount,
|
|
26115
|
+
eligible: sampleCount >= minIdentitySamples
|
|
26116
|
+
});
|
|
26117
|
+
}
|
|
26118
|
+
rows.sort((a, b) => b.score - a.score);
|
|
26119
|
+
return rows;
|
|
26120
|
+
}
|
|
26121
|
+
/**
|
|
26122
|
+
* Match a probe embedding against the gallery. Only samples with the same
|
|
26123
|
+
* `modelId` AND the same dimension are compared (model-version safety). Returns
|
|
26124
|
+
* the best identity when its score ≥ threshold and it beats the best OTHER
|
|
26125
|
+
* identity by ≥ margin; otherwise null.
|
|
26126
|
+
*
|
|
26127
|
+
* The scores come from {@link rankIdentities}; this function is only the
|
|
26128
|
+
* decision (eligibility filter, threshold, ambiguity guard).
|
|
26129
|
+
*/
|
|
26130
|
+
function matchEmbedding(probe, gallery, opts) {
|
|
26131
|
+
const ranked = rankIdentities(probe, gallery, opts.minIdentitySamples ?? 1).filter((r) => r.eligible);
|
|
26132
|
+
const best = ranked[0];
|
|
26133
|
+
if (best === void 0) return null;
|
|
26134
|
+
if (best.score < opts.threshold) return null;
|
|
26135
|
+
const second = ranked[1];
|
|
26136
|
+
if (second !== void 0 && best.score - second.score < opts.margin) return null;
|
|
25948
26137
|
return {
|
|
25949
|
-
identityId:
|
|
25950
|
-
score:
|
|
26138
|
+
identityId: best.identityId,
|
|
26139
|
+
score: best.score
|
|
25951
26140
|
};
|
|
25952
26141
|
}
|
|
25953
26142
|
/**
|
|
@@ -26086,17 +26275,6 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
26086
26275
|
/** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
|
|
26087
26276
|
var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
|
|
26088
26277
|
/**
|
|
26089
|
-
* Model id assumed for a face row that recorded none.
|
|
26090
|
-
*
|
|
26091
|
-
* Every row written before 2026-08-20 predates `Face.embeddingModelId`, and
|
|
26092
|
-
* every one of them was embedded with `arcface-r100` — it was the only face
|
|
26093
|
-
* model the step had ever defaulted to. So this is a statement about history,
|
|
26094
|
-
* NOT a default for new work: a face that names its model is stamped with the
|
|
26095
|
-
* model it names, because a sample that lies about its feature space defeats
|
|
26096
|
-
* the only guard `face-matcher` has.
|
|
26097
|
-
*/
|
|
26098
|
-
var LEGACY_MODEL_ID = "arcface-r100";
|
|
26099
|
-
/**
|
|
26100
26278
|
* How many face crops `listRecentFaces` resolves at once.
|
|
26101
26279
|
*
|
|
26102
26280
|
* Each crop costs a store round-trip plus a disk read, so the page is I/O-bound
|
|
@@ -26338,7 +26516,7 @@ var FaceGalleryProvider = class {
|
|
|
26338
26516
|
}
|
|
26339
26517
|
const currentFace = await this.faceStore.get(faceId);
|
|
26340
26518
|
if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
|
|
26341
|
-
const enrolledModelId = currentFace.embeddingModelId ??
|
|
26519
|
+
const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
|
|
26342
26520
|
const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
|
|
26343
26521
|
if (verdict !== null) {
|
|
26344
26522
|
this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
|
|
@@ -26511,6 +26689,314 @@ var FaceGalleryProvider = class {
|
|
|
26511
26689
|
}
|
|
26512
26690
|
};
|
|
26513
26691
|
//#endregion
|
|
26692
|
+
//#region src/pipeline-analytics/face-rescore.ts
|
|
26693
|
+
/**
|
|
26694
|
+
* face-rescore — score ONE face against the gallery AS IT IS RIGHT NOW.
|
|
26695
|
+
*
|
|
26696
|
+
* ## Why this exists: the suggestion on a track is stale by construction
|
|
26697
|
+
*
|
|
26698
|
+
* `Face.suggestedIdentityId` / `suggestedMatchScore` are not computed when you
|
|
26699
|
+
* look at a track. They are computed DURING live detection, frame by frame
|
|
26700
|
+
* (`face-recognizer.ts` — the suggestion band), and the per-track peak is
|
|
26701
|
+
* persisted when the track CLOSES. They are a photograph of what the gallery
|
|
26702
|
+
* said at that instant.
|
|
26703
|
+
*
|
|
26704
|
+
* So every enrolment after that instant is invisible to them. The operator's
|
|
26705
|
+
* real workflow is the exact case this breaks: assign ten faces to build a
|
|
26706
|
+
* person up, then open an older unassigned track and ask "does it match NOW?"
|
|
26707
|
+
* — and read a number that was decided before any of those ten existed. There
|
|
26708
|
+
* is no background job that refreshes it and there should not be one (the
|
|
26709
|
+
* gallery changes on every assign; re-scoring every buffered face each time is
|
|
26710
|
+
* work nobody asked for). The honest fix is to make the question askable on
|
|
26711
|
+
* demand. That is this module, and that is the button.
|
|
26712
|
+
*
|
|
26713
|
+
* ## What it does NOT do
|
|
26714
|
+
*
|
|
26715
|
+
* It does not run inference. The face's embedding is already persisted, and
|
|
26716
|
+
* matching is pure arithmetic over vectors — so a recompute is a read plus a
|
|
26717
|
+
* few thousand dot products, not a pipeline pass. Re-embedding is a SEPARATE,
|
|
26718
|
+
* explicitly-requested action (see the action's `reembed` flag): a button
|
|
26719
|
+
* labelled "recompute" must never silently spend GPU.
|
|
26720
|
+
*
|
|
26721
|
+
* ## The model gate is not an optimisation, it is the difference between a
|
|
26722
|
+
* number and noise
|
|
26723
|
+
*
|
|
26724
|
+
* A cosine between vectors from two different face models is a well-formed
|
|
26725
|
+
* float with no meaning. `face-matcher` has always refused to compare across
|
|
26726
|
+
* `modelId`, which means a face embedded before a model flip scores against
|
|
26727
|
+
* NOTHING — and the trap is that "nothing" renders identically to "no match".
|
|
26728
|
+
* This module names that state (`model-mismatch`) so the UI can say "this face
|
|
26729
|
+
* is in an old feature space" instead of showing 0% and letting the operator
|
|
26730
|
+
* conclude the person is not there.
|
|
26731
|
+
*/
|
|
26732
|
+
/** The empty report every "cannot score" path returns, so shape never varies. */
|
|
26733
|
+
function emptyReport(verdict, input, comparable) {
|
|
26734
|
+
return {
|
|
26735
|
+
verdict,
|
|
26736
|
+
comparable,
|
|
26737
|
+
probeModelId: input.probe.modelId,
|
|
26738
|
+
clusterModelId: input.clusterModelId,
|
|
26739
|
+
params: input.params,
|
|
26740
|
+
identities: []
|
|
26741
|
+
};
|
|
26742
|
+
}
|
|
26743
|
+
/**
|
|
26744
|
+
* Decide the verdict from the ELIGIBLE ranking, using exactly the gates
|
|
26745
|
+
* `matchEmbedding` uses — it is called here rather than re-implemented, so the
|
|
26746
|
+
* "would auto-assign" claim is the matcher's own answer and cannot drift.
|
|
26747
|
+
*/
|
|
26748
|
+
function decide(eligible, input) {
|
|
26749
|
+
const best = eligible[0];
|
|
26750
|
+
if (best === void 0) return {
|
|
26751
|
+
verdict: "no-eligible-identity",
|
|
26752
|
+
winnerId: null
|
|
26753
|
+
};
|
|
26754
|
+
const match = matchEmbedding(input.probe, input.gallery, {
|
|
26755
|
+
threshold: input.params.threshold,
|
|
26756
|
+
margin: input.params.margin,
|
|
26757
|
+
minIdentitySamples: input.params.minIdentitySamples
|
|
26758
|
+
});
|
|
26759
|
+
if (match !== null) return {
|
|
26760
|
+
verdict: "auto-assignable",
|
|
26761
|
+
winnerId: match.identityId
|
|
26762
|
+
};
|
|
26763
|
+
if (best.score >= input.params.threshold) return {
|
|
26764
|
+
verdict: "ambiguous",
|
|
26765
|
+
winnerId: null
|
|
26766
|
+
};
|
|
26767
|
+
if (best.score >= input.params.suggestionMinCosine) return {
|
|
26768
|
+
verdict: "suggestion",
|
|
26769
|
+
winnerId: null
|
|
26770
|
+
};
|
|
26771
|
+
return {
|
|
26772
|
+
verdict: "below-threshold",
|
|
26773
|
+
winnerId: null
|
|
26774
|
+
};
|
|
26775
|
+
}
|
|
26776
|
+
/**
|
|
26777
|
+
* Score one probe against the gallery as it stands, and say what the pipeline
|
|
26778
|
+
* would do with it. Pure: every input is passed in, nothing is read or written.
|
|
26779
|
+
*/
|
|
26780
|
+
function buildFaceRescoreReport(input) {
|
|
26781
|
+
if (input.probe.modelId !== input.clusterModelId) return emptyReport("model-mismatch", input, false);
|
|
26782
|
+
const ranked = rankIdentities(input.probe, input.gallery, input.params.minIdentitySamples);
|
|
26783
|
+
if (ranked.length === 0) return emptyReport("empty-gallery", input, true);
|
|
26784
|
+
const eligible = ranked.filter((r) => r.eligible);
|
|
26785
|
+
const { verdict, winnerId } = decide(eligible, input);
|
|
26786
|
+
const identities = ranked.map((r) => ({
|
|
26787
|
+
identityId: r.identityId,
|
|
26788
|
+
name: input.identityNames.get(r.identityId) ?? r.identityId,
|
|
26789
|
+
score: r.score,
|
|
26790
|
+
sampleCount: r.sampleCount,
|
|
26791
|
+
eligible: r.eligible,
|
|
26792
|
+
meetsThreshold: r.score >= input.params.threshold,
|
|
26793
|
+
wouldAutoAssign: r.identityId === winnerId
|
|
26794
|
+
}));
|
|
26795
|
+
const [best, second] = eligible;
|
|
26796
|
+
return {
|
|
26797
|
+
verdict,
|
|
26798
|
+
comparable: true,
|
|
26799
|
+
probeModelId: input.probe.modelId,
|
|
26800
|
+
clusterModelId: input.clusterModelId,
|
|
26801
|
+
params: input.params,
|
|
26802
|
+
identities,
|
|
26803
|
+
...best !== void 0 && second !== void 0 ? { runnerUpGap: best.score - second.score } : {}
|
|
26804
|
+
};
|
|
26805
|
+
}
|
|
26806
|
+
//#endregion
|
|
26807
|
+
//#region src/pipeline-analytics/face-rescore-actions.ts
|
|
26808
|
+
/**
|
|
26809
|
+
* face-rescore-actions — "score this face against the gallery AS IT IS NOW".
|
|
26810
|
+
*
|
|
26811
|
+
* ## Why an `addons.custom` action and not a `faceGallery` cap method
|
|
26812
|
+
*
|
|
26813
|
+
* Same reason `photo.*`, `embedding.*` and `debug.*` ride this bridge: a cap
|
|
26814
|
+
* method's wire schema lives in `@camstack/types`, which travels inside the
|
|
26815
|
+
* `@camstack/server` closure, so a new one is not callable until a server train
|
|
26816
|
+
* ships — however current the addon is. Here the schema lives in the addon's
|
|
26817
|
+
* own dist and the hub forwards an opaque envelope, so `camstack deploy` is the
|
|
26818
|
+
* whole delivery.
|
|
26819
|
+
*
|
|
26820
|
+
* ## Why re-embedding is an explicit flag and not a fallback
|
|
26821
|
+
*
|
|
26822
|
+
* `face.rescoreTrack` with `reembed: false` (the default) reads a persisted
|
|
26823
|
+
* vector and does arithmetic — microseconds, no GPU, no node hop. With
|
|
26824
|
+
* `reembed: true` it runs the `face-embedding` step on a cluster node. Those
|
|
26825
|
+
* are different enough in cost and in consequence (a re-embed WRITES the face
|
|
26826
|
+
* row) that a button labelled "recompute" must not choose between them on the
|
|
26827
|
+
* operator's behalf. The report says whether a re-embed is possible
|
|
26828
|
+
* (`reembedAvailable`); the operator asks for it.
|
|
26829
|
+
*
|
|
26830
|
+
* See `face-rescore.ts` for why the number already on the track is stale by
|
|
26831
|
+
* construction — that is the whole reason this action exists.
|
|
26832
|
+
*/
|
|
26833
|
+
var FaceRescoreVerdictSchema = _enum([
|
|
26834
|
+
"auto-assignable",
|
|
26835
|
+
"ambiguous",
|
|
26836
|
+
"suggestion",
|
|
26837
|
+
"below-threshold",
|
|
26838
|
+
"no-eligible-identity",
|
|
26839
|
+
"empty-gallery",
|
|
26840
|
+
"model-mismatch"
|
|
26841
|
+
]);
|
|
26842
|
+
var FaceRescoreParamsSchema = object({
|
|
26843
|
+
threshold: number(),
|
|
26844
|
+
margin: number(),
|
|
26845
|
+
minIdentitySamples: number().int(),
|
|
26846
|
+
/** DERIVED from `threshold`, never stored (D222) — see `suggestionBandFactor`. */
|
|
26847
|
+
suggestionMinCosine: number(),
|
|
26848
|
+
/** The fraction of `threshold` that `suggestionMinCosine` is, so the report
|
|
26849
|
+
* states the RULE and not only its current result. */
|
|
26850
|
+
suggestionBandFactor: number()
|
|
26851
|
+
});
|
|
26852
|
+
var FaceRescoreIdentityRowSchema = object({
|
|
26853
|
+
identityId: string(),
|
|
26854
|
+
name: string(),
|
|
26855
|
+
/** Raw cosine. The UI formats; the wire stays in the matcher's units. */
|
|
26856
|
+
score: number(),
|
|
26857
|
+
sampleCount: number().int(),
|
|
26858
|
+
eligible: boolean(),
|
|
26859
|
+
meetsThreshold: boolean(),
|
|
26860
|
+
wouldAutoAssign: boolean()
|
|
26861
|
+
});
|
|
26862
|
+
/**
|
|
26863
|
+
* Why a re-embed did not happen, in the addon's vocabulary.
|
|
26864
|
+
*
|
|
26865
|
+
* `not-a-template` is the one that matters and the one that is easy to get
|
|
26866
|
+
* wrong: a face's stored crop is the 112×112 ArcFace template ONLY when the
|
|
26867
|
+
* detection carried landmarks. When it does not, the crop is a padded bbox cut
|
|
26868
|
+
* — and feeding THAT to `face-embedding` as a whole frame produces a vector
|
|
26869
|
+
* that is plausible and useless, exactly the trap `face-reembed-pass.ts`
|
|
26870
|
+
* documents from the other direction. Refusing is the honest answer.
|
|
26871
|
+
*/
|
|
26872
|
+
var FaceReembedRefusalSchema = _enum([
|
|
26873
|
+
"not-requested",
|
|
26874
|
+
"no-pixels",
|
|
26875
|
+
"not-a-template",
|
|
26876
|
+
"no-capable-node",
|
|
26877
|
+
"failed",
|
|
26878
|
+
"not-persisted"
|
|
26879
|
+
]);
|
|
26880
|
+
var FaceReembedReportSchema = object({
|
|
26881
|
+
attempted: boolean(),
|
|
26882
|
+
/** True only when a NEW vector was embedded AND read back off the face row. */
|
|
26883
|
+
succeeded: boolean(),
|
|
26884
|
+
reason: FaceReembedRefusalSchema.optional(),
|
|
26885
|
+
detail: string().optional(),
|
|
26886
|
+
/** The model the re-embed pinned — the cluster row's, never a substitute. */
|
|
26887
|
+
modelId: string().optional()
|
|
26888
|
+
});
|
|
26889
|
+
var faceRescoreActions = defineCustomActions({
|
|
26890
|
+
/**
|
|
26891
|
+
* Read-only by default. `admin` because the report names every enrolled
|
|
26892
|
+
* person and how well an arbitrary face matches them — the gallery's shape,
|
|
26893
|
+
* which is not a viewer-level fact.
|
|
26894
|
+
*/
|
|
26895
|
+
"face.rescoreTrack": customAction(object({
|
|
26896
|
+
deviceId: number().int(),
|
|
26897
|
+
trackId: string().min(1),
|
|
26898
|
+
/**
|
|
26899
|
+
* Run `face-embedding` over the face's stored aligned template first, so a
|
|
26900
|
+
* face left in a superseded feature space becomes comparable again. Costs a
|
|
26901
|
+
* node round-trip and WRITES the face row. Never implicit.
|
|
26902
|
+
*/
|
|
26903
|
+
reembed: boolean().default(false)
|
|
26904
|
+
}), object({
|
|
26905
|
+
faceId: string(),
|
|
26906
|
+
deviceId: number().int(),
|
|
26907
|
+
trackId: string(),
|
|
26908
|
+
/** The face's current assignment, so the UI can mark "already this person". */
|
|
26909
|
+
assigned: boolean(),
|
|
26910
|
+
assignedIdentityId: string().optional(),
|
|
26911
|
+
/** What the track has been SHOWING until now — persisted at detection time.
|
|
26912
|
+
* Returned so the UI can put the stale number beside the fresh one. */
|
|
26913
|
+
storedSuggestedIdentityId: string().optional(),
|
|
26914
|
+
storedSuggestedMatchScore: number().optional(),
|
|
26915
|
+
verdict: FaceRescoreVerdictSchema,
|
|
26916
|
+
comparable: boolean(),
|
|
26917
|
+
probeModelId: string(),
|
|
26918
|
+
clusterModelId: string(),
|
|
26919
|
+
params: FaceRescoreParamsSchema,
|
|
26920
|
+
identities: array(FaceRescoreIdentityRowSchema).readonly(),
|
|
26921
|
+
runnerUpGap: number().optional(),
|
|
26922
|
+
/** Whether a `reembed: true` retry could actually do anything. Lets the UI
|
|
26923
|
+
* offer the escape hatch only when it exists. */
|
|
26924
|
+
reembedAvailable: boolean(),
|
|
26925
|
+
reembed: FaceReembedReportSchema
|
|
26926
|
+
}), {
|
|
26927
|
+
kind: "mutation",
|
|
26928
|
+
auth: "admin"
|
|
26929
|
+
}) });
|
|
26930
|
+
//#endregion
|
|
26931
|
+
//#region src/pipeline-analytics/pipeline/closed-row-media-gate.ts
|
|
26932
|
+
var DEFAULT_LOG_MIN_GAP_MS = 6e4;
|
|
26933
|
+
var DEFAULT_MAX_TRACKED = 512;
|
|
26934
|
+
var WRITE = {
|
|
26935
|
+
refuse: false,
|
|
26936
|
+
log: false,
|
|
26937
|
+
closedAgeMs: 0,
|
|
26938
|
+
suppressed: 0
|
|
26939
|
+
};
|
|
26940
|
+
var ClosedRowMediaGate = class {
|
|
26941
|
+
logMinGapMs;
|
|
26942
|
+
maxTracked;
|
|
26943
|
+
/** trackId → sampling state, insertion-ordered so the oldest evicts first. */
|
|
26944
|
+
sampling = /* @__PURE__ */ new Map();
|
|
26945
|
+
constructor(options = {}) {
|
|
26946
|
+
this.logMinGapMs = options.logMinGapMs ?? DEFAULT_LOG_MIN_GAP_MS;
|
|
26947
|
+
this.maxTracked = options.maxTracked ?? DEFAULT_MAX_TRACKED;
|
|
26948
|
+
}
|
|
26949
|
+
/**
|
|
26950
|
+
* @param rowClosedAt when the store closed this track's row, or `undefined`
|
|
26951
|
+
* when it holds no such record — which always means WRITE.
|
|
26952
|
+
*/
|
|
26953
|
+
evaluate(trackId, rowClosedAt, nowMs) {
|
|
26954
|
+
if (rowClosedAt === void 0) return WRITE;
|
|
26955
|
+
const closedAgeMs = Math.max(0, nowMs - rowClosedAt);
|
|
26956
|
+
const existing = this.sampling.get(trackId);
|
|
26957
|
+
if (existing === void 0) {
|
|
26958
|
+
this.remember(trackId, {
|
|
26959
|
+
lastLoggedAt: nowMs,
|
|
26960
|
+
suppressed: 0
|
|
26961
|
+
});
|
|
26962
|
+
return {
|
|
26963
|
+
refuse: true,
|
|
26964
|
+
log: true,
|
|
26965
|
+
closedAgeMs,
|
|
26966
|
+
suppressed: 0
|
|
26967
|
+
};
|
|
26968
|
+
}
|
|
26969
|
+
if (nowMs - existing.lastLoggedAt < this.logMinGapMs) {
|
|
26970
|
+
existing.suppressed += 1;
|
|
26971
|
+
return {
|
|
26972
|
+
refuse: true,
|
|
26973
|
+
log: false,
|
|
26974
|
+
closedAgeMs,
|
|
26975
|
+
suppressed: existing.suppressed
|
|
26976
|
+
};
|
|
26977
|
+
}
|
|
26978
|
+
const suppressed = existing.suppressed;
|
|
26979
|
+
existing.lastLoggedAt = nowMs;
|
|
26980
|
+
existing.suppressed = 0;
|
|
26981
|
+
return {
|
|
26982
|
+
refuse: true,
|
|
26983
|
+
log: true,
|
|
26984
|
+
closedAgeMs,
|
|
26985
|
+
suppressed
|
|
26986
|
+
};
|
|
26987
|
+
}
|
|
26988
|
+
/** Drop all sampling state (addon dispose). */
|
|
26989
|
+
clear() {
|
|
26990
|
+
this.sampling.clear();
|
|
26991
|
+
}
|
|
26992
|
+
remember(trackId, state) {
|
|
26993
|
+
this.sampling.set(trackId, state);
|
|
26994
|
+
if (this.sampling.size <= this.maxTracked) return;
|
|
26995
|
+
const oldest = this.sampling.keys().next();
|
|
26996
|
+
if (!oldest.done) this.sampling.delete(oldest.value);
|
|
26997
|
+
}
|
|
26998
|
+
};
|
|
26999
|
+
//#endregion
|
|
26514
27000
|
//#region src/pipeline-analytics/pipeline/edge-clear.ts
|
|
26515
27001
|
/**
|
|
26516
27002
|
* edge-clear — pure geometry for "is the subject fully in frame?" best-frame
|
|
@@ -35295,6 +35781,210 @@ async function resolveDefaultEventMedia(deps) {
|
|
|
35295
35781
|
return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
|
|
35296
35782
|
}
|
|
35297
35783
|
//#endregion
|
|
35784
|
+
//#region src/pipeline-analytics/face-settings.ts
|
|
35785
|
+
/**
|
|
35786
|
+
* Per-device face-recognition settings. Cascade: a per-device override on top
|
|
35787
|
+
* of the global default, resolved per field (an invalid/missing value falls
|
|
35788
|
+
* back to its default — parse never throws). Mirrors `audio-detection-settings`.
|
|
35789
|
+
*/
|
|
35790
|
+
var FaceSettingsSchema = object({
|
|
35791
|
+
/**
|
|
35792
|
+
* GLOBAL master switch only — a temporary kill for the whole face-recognition
|
|
35793
|
+
* post-processor. On by default; the recognizer runs whenever the per-device
|
|
35794
|
+
* detection pipeline produces face embeddings (the pipeline steps ARE the
|
|
35795
|
+
* per-camera control). Not a per-device override — see
|
|
35796
|
+
* `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
|
|
35797
|
+
* stripped from the per-device schema).
|
|
35798
|
+
*/
|
|
35799
|
+
enabled: boolean().default(true),
|
|
35800
|
+
/**
|
|
35801
|
+
* Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
35802
|
+
*
|
|
35803
|
+
* History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
|
|
35804
|
+
* dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
|
|
35805
|
+
* with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
|
|
35806
|
+
* left almost nothing recognisable. That retune lived only as a RUNTIME
|
|
35807
|
+
* override until 2026-08-19; it is the schema default now, because a default
|
|
35808
|
+
* nobody runs is a decision nobody made.
|
|
35809
|
+
*
|
|
35810
|
+
* ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
|
|
35811
|
+
* quality, where most of what reaches ArcFace is not a correctly aligned
|
|
35812
|
+
* face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
|
|
35813
|
+
* aligned, 18% marginal, 62% misaligned or containing no detectable face).
|
|
35814
|
+
* On correctly aligned crops the SAME models separate at a far lower
|
|
35815
|
+
* operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
|
|
35816
|
+
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
35817
|
+
*
|
|
35818
|
+
* Recalibration plan, in this order:
|
|
35819
|
+
* 1. land the landmark-precision gate (the alignment fix),
|
|
35820
|
+
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
35821
|
+
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
35822
|
+
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
35823
|
+
* Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
|
|
35824
|
+
* scores ~0.9 against everything, so the degenerate population sits ABOVE
|
|
35825
|
+
* any threshold you could pick — the knob cannot reach it.
|
|
35826
|
+
*/
|
|
35827
|
+
similarityThreshold: number().min(0).max(1).default(.55),
|
|
35828
|
+
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
35829
|
+
* (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
|
|
35830
|
+
* same story and the same recalibration plan as {@link similarityThreshold}. */
|
|
35831
|
+
margin: number().min(0).max(1).default(.12),
|
|
35832
|
+
/** Minimum face-detection confidence for a face to be considered. */
|
|
35833
|
+
minFaceConfidence: number().min(0).max(1).default(.5),
|
|
35834
|
+
/**
|
|
35835
|
+
* Minimum face bbox size (px, shorter side, NATIVE scale when the runner
|
|
35836
|
+
* measured it, else detection-frame space) for a face to be DETECTED and
|
|
35837
|
+
* COLLECTED into the recent-faces buffer. Below this the face is dropped
|
|
35838
|
+
* BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
|
|
35839
|
+
* NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
|
|
35840
|
+
*/
|
|
35841
|
+
minFacePx: number().min(0).default(30),
|
|
35842
|
+
/**
|
|
35843
|
+
* Minimum face short side (px, NATIVE scale when the runner measured it, else
|
|
35844
|
+
* detection-frame space) for a collected face to be eligible for AUTO-MATCH
|
|
35845
|
+
* (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
|
|
35846
|
+
* between `minFacePx` and this floor are still detected, cropped, and stored
|
|
35847
|
+
* in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
|
|
35848
|
+
* an identity. ArcFace embeddings below ~48px are unreliable and drove the
|
|
35849
|
+
* observed false positives (2026-07-23 face-quality batch). A face below this
|
|
35850
|
+
* floor keeps `recognizedIdentityId` UNSET (fail-safe).
|
|
35851
|
+
*/
|
|
35852
|
+
recognitionMinFacePx: number().min(0).default(48),
|
|
35853
|
+
/**
|
|
35854
|
+
* Minimum enrolled-sample count an identity must have before it can be an
|
|
35855
|
+
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
35856
|
+
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
35857
|
+
* automatic matching until more samples are enrolled (#26.3).
|
|
35858
|
+
*/
|
|
35859
|
+
minIdentitySamples: number().int().min(1).default(2),
|
|
35860
|
+
/** Frames an identity must be confirmed before a track is assigned. Floor of
|
|
35861
|
+
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
35862
|
+
* such a value falls back to the default). */
|
|
35863
|
+
confirmFrames: number().int().min(1).default(3),
|
|
35864
|
+
/**
|
|
35865
|
+
* Upper bound on the RAW ArcFace feature magnitude a face may have and still
|
|
35866
|
+
* be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
|
|
35867
|
+
* the gate.
|
|
35868
|
+
*
|
|
35869
|
+
* This exists because a degenerate crop does not score LOW, it scores HIGH:
|
|
35870
|
+
* measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
|
|
35871
|
+
* faces exceeded cosine 0.90 and the worst 40% of the face population had
|
|
35872
|
+
* collapsed onto effectively one vector (mean pairwise cosine 0.976). No
|
|
35873
|
+
* similarity threshold can reach that — it sits above every threshold — so the
|
|
35874
|
+
* only defence is to refuse the embedding before it is stored or compared.
|
|
35875
|
+
*
|
|
35876
|
+
* Direction: for this model the DEGENERATE crops carry the LARGER norm (the
|
|
35877
|
+
* inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
|
|
35878
|
+
* through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
|
|
35879
|
+
* 5.25) regardless of size, grey level or blur, while information-free crops
|
|
35880
|
+
* jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
|
|
35881
|
+
* 93.2% of degenerate ones.
|
|
35882
|
+
*
|
|
35883
|
+
* Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
|
|
35884
|
+
* magnitude has never been observable here — `result-assembler` discarded it
|
|
35885
|
+
* one line after computing it. The field and its logging ship first; set a
|
|
35886
|
+
* real bound once a week of live `face: embedding magnitude` lines says where
|
|
35887
|
+
* this cluster's populations actually sit. Enabling a gate against a number
|
|
35888
|
+
* nobody has measured in production is how recognition silently stops.
|
|
35889
|
+
*/
|
|
35890
|
+
maxEmbeddingMagnitude: number().min(0).default(0),
|
|
35891
|
+
/** Max buffered faces kept per device. */
|
|
35892
|
+
bufferMaxPerDevice: number().int().min(0).default(50)
|
|
35893
|
+
});
|
|
35894
|
+
var FACE_DEFAULTS = FaceSettingsSchema.parse({});
|
|
35895
|
+
function resolveFaceSettings(raw) {
|
|
35896
|
+
const pick = (key) => {
|
|
35897
|
+
const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
|
|
35898
|
+
return parsed.success ? parsed.data : FACE_DEFAULTS[key];
|
|
35899
|
+
};
|
|
35900
|
+
return {
|
|
35901
|
+
enabled: pick("enabled"),
|
|
35902
|
+
similarityThreshold: pick("similarityThreshold"),
|
|
35903
|
+
margin: pick("margin"),
|
|
35904
|
+
minFaceConfidence: pick("minFaceConfidence"),
|
|
35905
|
+
minFacePx: pick("minFacePx"),
|
|
35906
|
+
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
35907
|
+
minIdentitySamples: pick("minIdentitySamples"),
|
|
35908
|
+
maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
|
|
35909
|
+
confirmFrames: pick("confirmFrames"),
|
|
35910
|
+
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
35911
|
+
};
|
|
35912
|
+
}
|
|
35913
|
+
/**
|
|
35914
|
+
* The suggestion band opens at 80% of the assignment threshold.
|
|
35915
|
+
*
|
|
35916
|
+
* ## Why derived and not a knob
|
|
35917
|
+
*
|
|
35918
|
+
* `suggestionMinCosine` used to be its own operator-overridable field. Two
|
|
35919
|
+
* independent numbers described ONE band, so they could contradict each other
|
|
35920
|
+
* — and on the live hub they did. The 2026-08-19 retune lowered
|
|
35921
|
+
* `similarityThreshold` to 0.45 and left the suggestion floor at its 0.50
|
|
35922
|
+
* default, which INVERTS the band: `[0.50, 0.45)` is empty, so from that moment
|
|
35923
|
+
* the recognizer could not produce a single suggestion. Nothing errored,
|
|
35924
|
+
* nothing logged, and the form showed two plausible numbers side by side. That
|
|
35925
|
+
* is the D62 shape — a second authority over a fact that already had one — and
|
|
35926
|
+
* the fix is the same: delete the second authority rather than police it.
|
|
35927
|
+
*
|
|
35928
|
+
* Derived, the band cannot invert for ANY threshold, which is the property
|
|
35929
|
+
* `face-settings.spec.ts` pins over the whole domain rather than at samples.
|
|
35930
|
+
*
|
|
35931
|
+
* ## Why 0.8
|
|
35932
|
+
*
|
|
35933
|
+
* The operator's number, and it matches what the band was for: a near-miss is
|
|
35934
|
+
* a probe that scored within a fifth of the bar. At the schema default (0.55)
|
|
35935
|
+
* it reproduces a 0.44 floor — close to the 0.50 the band shipped with in
|
|
35936
|
+
* 2026-07 — and it TRACKS every future retune, which is the whole point: the
|
|
35937
|
+
* recalibration plan on {@link FaceSettingsSchema.shape.similarityThreshold}
|
|
35938
|
+
* expects the threshold to fall toward ~0.30 once alignment lands, and a fixed
|
|
35939
|
+
* floor would have to be remembered and moved by hand each time.
|
|
35940
|
+
*/
|
|
35941
|
+
var SUGGESTION_BAND_FACTOR = .8;
|
|
35942
|
+
/** Decimal places the derived floor is rounded to. `0.8 * 0.45` is
|
|
35943
|
+
* `0.36000000000000004` in IEEE754, and an operator reading "36.000000000004%"
|
|
35944
|
+
* in a report has been told the number is untrustworthy. */
|
|
35945
|
+
var DERIVED_PRECISION = 1e4;
|
|
35946
|
+
/**
|
|
35947
|
+
* The cosine floor of the suggestion band for a given assignment threshold.
|
|
35948
|
+
*
|
|
35949
|
+
* INVARIANT, total over `[0, 1]`: the result is never ABOVE the threshold, and
|
|
35950
|
+
* is strictly below it for every threshold > 0 — so the band
|
|
35951
|
+
* `[floor, threshold)` can never be empty. Rounding is applied only when it
|
|
35952
|
+
* preserves that (for a threshold small enough that rounding would reach it,
|
|
35953
|
+
* the exact product is returned instead).
|
|
35954
|
+
*/
|
|
35955
|
+
function deriveSuggestionMinCosine(similarityThreshold) {
|
|
35956
|
+
const exact = similarityThreshold * SUGGESTION_BAND_FACTOR;
|
|
35957
|
+
const rounded = Math.round(exact * DERIVED_PRECISION) / DERIVED_PRECISION;
|
|
35958
|
+
return rounded < similarityThreshold ? rounded : exact;
|
|
35959
|
+
}
|
|
35960
|
+
/**
|
|
35961
|
+
* The settings key that used to hold the suggestion floor. Named once, here,
|
|
35962
|
+
* because the ONLY code allowed to mention it now is the migration that removes
|
|
35963
|
+
* it from a store.
|
|
35964
|
+
*/
|
|
35965
|
+
var STORED_SUGGESTION_MIN_COSINE_KEY = "suggestionMinCosine";
|
|
35966
|
+
/**
|
|
35967
|
+
* Plan the removal of a persisted suggestion floor from one settings blob.
|
|
35968
|
+
*
|
|
35969
|
+
* `null` = nothing to do, which is the steady state after the first pass and
|
|
35970
|
+
* for every store that never had one. Absence of the key IS the migration
|
|
35971
|
+
* marker — no flag, no version row, and re-running is a no-op.
|
|
35972
|
+
*
|
|
35973
|
+
* The patch uses `undefined`, never `null`: the settings merge preserves an
|
|
35974
|
+
* explicit `null` as a STORED value, which would leave exactly the
|
|
35975
|
+
* accepted-but-ignored residue this exists to remove, while `undefined`
|
|
35976
|
+
* survives the merge as a key with no value and is dropped by the JSON
|
|
35977
|
+
* serialisation the blob round-trips through.
|
|
35978
|
+
*/
|
|
35979
|
+
function planStoredSuggestionPurge(raw) {
|
|
35980
|
+
const was = raw[STORED_SUGGESTION_MIN_COSINE_KEY];
|
|
35981
|
+
if (was === void 0) return null;
|
|
35982
|
+
return {
|
|
35983
|
+
was,
|
|
35984
|
+
patch: { [STORED_SUGGESTION_MIN_COSINE_KEY]: void 0 }
|
|
35985
|
+
};
|
|
35986
|
+
}
|
|
35987
|
+
//#endregion
|
|
35298
35988
|
//#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
|
|
35299
35989
|
/**
|
|
35300
35990
|
* `null` = accept. A verdict = refuse, carrying both numbers so the caller can
|
|
@@ -35542,7 +36232,7 @@ var FaceRecognizer = class {
|
|
|
35542
36232
|
embedding: c.embedding,
|
|
35543
36233
|
modelId: c.embeddingModelId
|
|
35544
36234
|
}, this.gallery, {
|
|
35545
|
-
threshold: settings.
|
|
36235
|
+
threshold: deriveSuggestionMinCosine(settings.similarityThreshold),
|
|
35546
36236
|
margin: settings.margin,
|
|
35547
36237
|
minIdentitySamples: settings.minIdentitySamples
|
|
35548
36238
|
});
|
|
@@ -35835,148 +36525,6 @@ var FaceRecognizer = class {
|
|
|
35835
36525
|
}
|
|
35836
36526
|
};
|
|
35837
36527
|
//#endregion
|
|
35838
|
-
//#region src/pipeline-analytics/face-settings.ts
|
|
35839
|
-
/**
|
|
35840
|
-
* Per-device face-recognition settings. Cascade: a per-device override on top
|
|
35841
|
-
* of the global default, resolved per field (an invalid/missing value falls
|
|
35842
|
-
* back to its default — parse never throws). Mirrors `audio-detection-settings`.
|
|
35843
|
-
*/
|
|
35844
|
-
var FaceSettingsSchema = object({
|
|
35845
|
-
/**
|
|
35846
|
-
* GLOBAL master switch only — a temporary kill for the whole face-recognition
|
|
35847
|
-
* post-processor. On by default; the recognizer runs whenever the per-device
|
|
35848
|
-
* detection pipeline produces face embeddings (the pipeline steps ARE the
|
|
35849
|
-
* per-camera control). Not a per-device override — see
|
|
35850
|
-
* `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
|
|
35851
|
-
* stripped from the per-device schema).
|
|
35852
|
-
*/
|
|
35853
|
-
enabled: boolean().default(true),
|
|
35854
|
-
/**
|
|
35855
|
-
* Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
35856
|
-
*
|
|
35857
|
-
* History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
|
|
35858
|
-
* dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
|
|
35859
|
-
* with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
|
|
35860
|
-
* left almost nothing recognisable. That retune lived only as a RUNTIME
|
|
35861
|
-
* override until 2026-08-19; it is the schema default now, because a default
|
|
35862
|
-
* nobody runs is a decision nobody made.
|
|
35863
|
-
*
|
|
35864
|
-
* ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
|
|
35865
|
-
* quality, where most of what reaches ArcFace is not a correctly aligned
|
|
35866
|
-
* face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
|
|
35867
|
-
* aligned, 18% marginal, 62% misaligned or containing no detectable face).
|
|
35868
|
-
* On correctly aligned crops the SAME models separate at a far lower
|
|
35869
|
-
* operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
|
|
35870
|
-
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
35871
|
-
*
|
|
35872
|
-
* Recalibration plan, in this order:
|
|
35873
|
-
* 1. land the landmark-precision gate (the alignment fix),
|
|
35874
|
-
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
35875
|
-
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
35876
|
-
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
35877
|
-
* Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
|
|
35878
|
-
* scores ~0.9 against everything, so the degenerate population sits ABOVE
|
|
35879
|
-
* any threshold you could pick — the knob cannot reach it.
|
|
35880
|
-
*/
|
|
35881
|
-
similarityThreshold: number().min(0).max(1).default(.55),
|
|
35882
|
-
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
35883
|
-
* (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
|
|
35884
|
-
* same story and the same recalibration plan as {@link similarityThreshold}. */
|
|
35885
|
-
margin: number().min(0).max(1).default(.12),
|
|
35886
|
-
/** Minimum face-detection confidence for a face to be considered. */
|
|
35887
|
-
minFaceConfidence: number().min(0).max(1).default(.5),
|
|
35888
|
-
/**
|
|
35889
|
-
* Minimum face bbox size (px, shorter side, NATIVE scale when the runner
|
|
35890
|
-
* measured it, else detection-frame space) for a face to be DETECTED and
|
|
35891
|
-
* COLLECTED into the recent-faces buffer. Below this the face is dropped
|
|
35892
|
-
* BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
|
|
35893
|
-
* NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
|
|
35894
|
-
*/
|
|
35895
|
-
minFacePx: number().min(0).default(30),
|
|
35896
|
-
/**
|
|
35897
|
-
* Minimum face short side (px, NATIVE scale when the runner measured it, else
|
|
35898
|
-
* detection-frame space) for a collected face to be eligible for AUTO-MATCH
|
|
35899
|
-
* (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
|
|
35900
|
-
* between `minFacePx` and this floor are still detected, cropped, and stored
|
|
35901
|
-
* in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
|
|
35902
|
-
* an identity. ArcFace embeddings below ~48px are unreliable and drove the
|
|
35903
|
-
* observed false positives (2026-07-23 face-quality batch). A face below this
|
|
35904
|
-
* floor keeps `recognizedIdentityId` UNSET (fail-safe).
|
|
35905
|
-
*/
|
|
35906
|
-
recognitionMinFacePx: number().min(0).default(48),
|
|
35907
|
-
/**
|
|
35908
|
-
* Lower cosine bound of the SUGGESTION band (2026-07-24). A face whose best
|
|
35909
|
-
* gallery match MISSES auto-assignment but is still plausible surfaces as a
|
|
35910
|
-
* SUGGESTION (persisted `suggestedIdentityId`/`suggestedMatchScore`, never an
|
|
35911
|
-
* assignment) when EITHER: its match cosine is in [`suggestionMinCosine`,
|
|
35912
|
-
* `similarityThreshold`) AND its face clears the recognition size floor; OR its
|
|
35913
|
-
* cosine is ≥ `similarityThreshold` but the face is below the recognition floor
|
|
35914
|
-
* (blocked ONLY by size). Below this cosine nothing is suggested. Operator-
|
|
35915
|
-
* overridable per field, like the other face thresholds.
|
|
35916
|
-
*/
|
|
35917
|
-
suggestionMinCosine: number().min(0).max(1).default(.5),
|
|
35918
|
-
/**
|
|
35919
|
-
* Minimum enrolled-sample count an identity must have before it can be an
|
|
35920
|
-
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
35921
|
-
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
35922
|
-
* automatic matching until more samples are enrolled (#26.3).
|
|
35923
|
-
*/
|
|
35924
|
-
minIdentitySamples: number().int().min(1).default(2),
|
|
35925
|
-
/** Frames an identity must be confirmed before a track is assigned. Floor of
|
|
35926
|
-
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
35927
|
-
* such a value falls back to the default). */
|
|
35928
|
-
confirmFrames: number().int().min(1).default(3),
|
|
35929
|
-
/**
|
|
35930
|
-
* Upper bound on the RAW ArcFace feature magnitude a face may have and still
|
|
35931
|
-
* be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
|
|
35932
|
-
* the gate.
|
|
35933
|
-
*
|
|
35934
|
-
* This exists because a degenerate crop does not score LOW, it scores HIGH:
|
|
35935
|
-
* measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
|
|
35936
|
-
* faces exceeded cosine 0.90 and the worst 40% of the face population had
|
|
35937
|
-
* collapsed onto effectively one vector (mean pairwise cosine 0.976). No
|
|
35938
|
-
* similarity threshold can reach that — it sits above every threshold — so the
|
|
35939
|
-
* only defence is to refuse the embedding before it is stored or compared.
|
|
35940
|
-
*
|
|
35941
|
-
* Direction: for this model the DEGENERATE crops carry the LARGER norm (the
|
|
35942
|
-
* inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
|
|
35943
|
-
* through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
|
|
35944
|
-
* 5.25) regardless of size, grey level or blur, while information-free crops
|
|
35945
|
-
* jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
|
|
35946
|
-
* 93.2% of degenerate ones.
|
|
35947
|
-
*
|
|
35948
|
-
* Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
|
|
35949
|
-
* magnitude has never been observable here — `result-assembler` discarded it
|
|
35950
|
-
* one line after computing it. The field and its logging ship first; set a
|
|
35951
|
-
* real bound once a week of live `face: embedding magnitude` lines says where
|
|
35952
|
-
* this cluster's populations actually sit. Enabling a gate against a number
|
|
35953
|
-
* nobody has measured in production is how recognition silently stops.
|
|
35954
|
-
*/
|
|
35955
|
-
maxEmbeddingMagnitude: number().min(0).default(0),
|
|
35956
|
-
/** Max buffered faces kept per device. */
|
|
35957
|
-
bufferMaxPerDevice: number().int().min(0).default(50)
|
|
35958
|
-
});
|
|
35959
|
-
var FACE_DEFAULTS = FaceSettingsSchema.parse({});
|
|
35960
|
-
function resolveFaceSettings(raw) {
|
|
35961
|
-
const pick = (key) => {
|
|
35962
|
-
const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
|
|
35963
|
-
return parsed.success ? parsed.data : FACE_DEFAULTS[key];
|
|
35964
|
-
};
|
|
35965
|
-
return {
|
|
35966
|
-
enabled: pick("enabled"),
|
|
35967
|
-
similarityThreshold: pick("similarityThreshold"),
|
|
35968
|
-
margin: pick("margin"),
|
|
35969
|
-
minFaceConfidence: pick("minFaceConfidence"),
|
|
35970
|
-
minFacePx: pick("minFacePx"),
|
|
35971
|
-
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
35972
|
-
suggestionMinCosine: pick("suggestionMinCosine"),
|
|
35973
|
-
minIdentitySamples: pick("minIdentitySamples"),
|
|
35974
|
-
maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
|
|
35975
|
-
confirmFrames: pick("confirmFrames"),
|
|
35976
|
-
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
35977
|
-
};
|
|
35978
|
-
}
|
|
35979
|
-
//#endregion
|
|
35980
36528
|
//#region src/pipeline-analytics/location-aware-media-storage.ts
|
|
35981
36529
|
/**
|
|
35982
36530
|
* Location-aware blob storage for event media (entity-routing spec, Phase 3).
|
|
@@ -46163,7 +46711,8 @@ function parseCloseReason(value) {
|
|
|
46163
46711
|
}
|
|
46164
46712
|
var DEFAULT_CONFIG = {
|
|
46165
46713
|
ttlMs: 3e4,
|
|
46166
|
-
maxPositionHistory: 300
|
|
46714
|
+
maxPositionHistory: 300,
|
|
46715
|
+
closedRowMemory: 4096
|
|
46167
46716
|
};
|
|
46168
46717
|
/** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
|
|
46169
46718
|
var RECENT_DEFAULT_LIMIT = 200;
|
|
@@ -46480,6 +47029,20 @@ function cloneTrack(t) {
|
|
|
46480
47029
|
}
|
|
46481
47030
|
var TrackStore = class {
|
|
46482
47031
|
active = /* @__PURE__ */ new Map();
|
|
47032
|
+
/**
|
|
47033
|
+
* trackId → the instant THIS store closed (or deleted) the track's row.
|
|
47034
|
+
*
|
|
47035
|
+
* The `active` map alone cannot answer "is this row still open?": a sighting
|
|
47036
|
+
* for an already-closed id re-creates a fresh active entry (`upsert`), which
|
|
47037
|
+
* is exactly how a person track closed 4.4 minutes earlier received a dog's
|
|
47038
|
+
* `thumbnail`, `keyFrame` and `lastFrame` on 2026-08-21. This is POSITIVE
|
|
47039
|
+
* knowledge of a close the store performed — an id it has never heard of, or
|
|
47040
|
+
* one evicted by the bound below, reads as WRITABLE (D49): losing a live
|
|
47041
|
+
* track's media is worse than tolerating a late write.
|
|
47042
|
+
*
|
|
47043
|
+
* Insertion-ordered and bounded by `closedRowMemory` (oldest evicted first).
|
|
47044
|
+
*/
|
|
47045
|
+
closedRows = /* @__PURE__ */ new Map();
|
|
46483
47046
|
config;
|
|
46484
47047
|
logger;
|
|
46485
47048
|
store;
|
|
@@ -46501,6 +47064,25 @@ var TrackStore = class {
|
|
|
46501
47064
|
indexes: [...TRACKS_INDEXES]
|
|
46502
47065
|
});
|
|
46503
47066
|
}
|
|
47067
|
+
/**
|
|
47068
|
+
* When this store CLOSED the track's row (TTL expiry, early close, or the
|
|
47069
|
+
* cascade delete), or `undefined` when it holds no such record.
|
|
47070
|
+
*
|
|
47071
|
+
* `undefined` is not "still open" — it is "no evidence of a close", and every
|
|
47072
|
+
* caller must treat it as permission to write (D49). Read by the frame path's
|
|
47073
|
+
* best-media gate; in-memory and infallible by construction.
|
|
47074
|
+
*/
|
|
47075
|
+
rowClosedAt(trackId) {
|
|
47076
|
+
return this.closedRows.get(trackId);
|
|
47077
|
+
}
|
|
47078
|
+
/** Remember a close. Bounded, oldest-first — an evicted entry fails OPEN. */
|
|
47079
|
+
noteRowClosed(trackId, atMs) {
|
|
47080
|
+
this.closedRows.delete(trackId);
|
|
47081
|
+
this.closedRows.set(trackId, atMs);
|
|
47082
|
+
if (this.closedRows.size <= this.config.closedRowMemory) return;
|
|
47083
|
+
const oldest = this.closedRows.keys().next();
|
|
47084
|
+
if (!oldest.done) this.closedRows.delete(oldest.value);
|
|
47085
|
+
}
|
|
46504
47086
|
/** Create or update the track record for a sighting in this frame. */
|
|
46505
47087
|
upsert(params) {
|
|
46506
47088
|
const existing = this.active.get(params.trackId);
|
|
@@ -46699,6 +47281,7 @@ var TrackStore = class {
|
|
|
46699
47281
|
});
|
|
46700
47282
|
}
|
|
46701
47283
|
this.active.delete(trackId);
|
|
47284
|
+
this.noteRowClosed(trackId, nowMs);
|
|
46702
47285
|
expired.push(record);
|
|
46703
47286
|
}
|
|
46704
47287
|
return expired;
|
|
@@ -47066,6 +47649,7 @@ var TrackStore = class {
|
|
|
47066
47649
|
}
|
|
47067
47650
|
clearAll() {
|
|
47068
47651
|
this.active.clear();
|
|
47652
|
+
this.closedRows.clear();
|
|
47069
47653
|
}
|
|
47070
47654
|
/**
|
|
47071
47655
|
* Drop a single active track WITHOUT persisting it as a historical row. Used
|
|
@@ -47113,6 +47697,7 @@ var TrackStore = class {
|
|
|
47113
47697
|
const record = cloneTrack(t);
|
|
47114
47698
|
await this.persistCompleted(record);
|
|
47115
47699
|
this.active.delete(trackId);
|
|
47700
|
+
this.noteRowClosed(trackId, Date.now());
|
|
47116
47701
|
return true;
|
|
47117
47702
|
}
|
|
47118
47703
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
@@ -47123,6 +47708,7 @@ var TrackStore = class {
|
|
|
47123
47708
|
key: trackId
|
|
47124
47709
|
});
|
|
47125
47710
|
this.active.delete(trackId);
|
|
47711
|
+
this.noteRowClosed(trackId, Date.now());
|
|
47126
47712
|
}
|
|
47127
47713
|
/**
|
|
47128
47714
|
* A page of persisted track ids for a device whose `lastSeen < cutoffMs`
|
|
@@ -52452,13 +53038,38 @@ function retagRetentionSection(sections) {
|
|
|
52452
53038
|
} : s);
|
|
52453
53039
|
}
|
|
52454
53040
|
/**
|
|
53041
|
+
* Key of the read-only readout that replaced the `suggestionMinCosine` knob
|
|
53042
|
+
* (D222).
|
|
53043
|
+
*
|
|
53044
|
+
* It is `readonlyField: true`, so the form renders it display-only with no
|
|
53045
|
+
* mutation handler and it can never enter a settings patch — it carries a
|
|
53046
|
+
* derived value and must not become the second stored authority it replaced.
|
|
53047
|
+
* Its value is injected at hydrate time by the addon's `getGlobalSettings`
|
|
53048
|
+
* override, the same mechanism `detection-pipeline` uses for its live
|
|
53049
|
+
* model-substitution readout.
|
|
53050
|
+
*/
|
|
53051
|
+
var SUGGESTION_THRESHOLD_READOUT_KEY = "suggestionThresholdReadout";
|
|
53052
|
+
/** Percent, at most one decimal, without a trailing `.0`. Cosines here are
|
|
53053
|
+
* two-decimal operator values; `36.0%` reads like false precision. */
|
|
53054
|
+
function asPercent(value) {
|
|
53055
|
+
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
|
53056
|
+
}
|
|
53057
|
+
/**
|
|
53058
|
+
* The sentence beside the similarity threshold: what is being suggested from,
|
|
53059
|
+
* and the rule it comes from. Both, always — a number that moves whenever
|
|
53060
|
+
* another number moves reads as a bug unless the rule is on screen with it.
|
|
53061
|
+
*/
|
|
53062
|
+
function formatSuggestionThresholdReadout(similarityThreshold) {
|
|
53063
|
+
return `${asPercent(deriveSuggestionMinCosine(similarityThreshold))} — ${asPercent(SUGGESTION_BAND_FACTOR)} of the ${asPercent(similarityThreshold)} match threshold`;
|
|
53064
|
+
}
|
|
53065
|
+
/**
|
|
52455
53066
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
52456
53067
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
52457
53068
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
52458
53069
|
* by each device's detection-pipeline steps, so a per-device toggle would be
|
|
52459
53070
|
* misleading. Keyed by section id → set of field keys to drop.
|
|
52460
53071
|
*/
|
|
52461
|
-
var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled"]) };
|
|
53072
|
+
var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled", SUGGESTION_THRESHOLD_READOUT_KEY]) };
|
|
52462
53073
|
/** Whole sections that are cluster-wide (global page only) and must NOT appear
|
|
52463
53074
|
* in a per-device contribution. */
|
|
52464
53075
|
var GLOBAL_ONLY_SECTIONS = new Set(["cluster-post-processing"]);
|
|
@@ -52940,7 +53551,7 @@ function buildGlobalSettingsSchema() {
|
|
|
52940
53551
|
type: "number",
|
|
52941
53552
|
key: "similarityThreshold",
|
|
52942
53553
|
label: "Similarity threshold",
|
|
52943
|
-
description: "Cosine similarity (0–1) required to match a face to a known identity. Higher = stricter.",
|
|
53554
|
+
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.",
|
|
52944
53555
|
min: 0,
|
|
52945
53556
|
max: 1,
|
|
52946
53557
|
step: .05,
|
|
@@ -52987,14 +53598,12 @@ function buildGlobalSettingsSchema() {
|
|
|
52987
53598
|
unit: "px"
|
|
52988
53599
|
},
|
|
52989
53600
|
{
|
|
52990
|
-
type: "
|
|
52991
|
-
key:
|
|
52992
|
-
label: "Suggestion threshold",
|
|
52993
|
-
description: "
|
|
52994
|
-
|
|
52995
|
-
|
|
52996
|
-
step: .05,
|
|
52997
|
-
default: FACE_DEFAULTS.suggestionMinCosine
|
|
53601
|
+
type: "text",
|
|
53602
|
+
key: SUGGESTION_THRESHOLD_READOUT_KEY,
|
|
53603
|
+
label: "Suggestion threshold (derived)",
|
|
53604
|
+
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.",
|
|
53605
|
+
readonlyField: true,
|
|
53606
|
+
default: ""
|
|
52998
53607
|
},
|
|
52999
53608
|
{
|
|
53000
53609
|
type: "number",
|
|
@@ -54477,7 +55086,8 @@ var customActions = {
|
|
|
54477
55086
|
...debugActions,
|
|
54478
55087
|
...orphanAuditActions,
|
|
54479
55088
|
...viewerSettingsActions,
|
|
54480
|
-
...photoEnrollActions
|
|
55089
|
+
...photoEnrollActions,
|
|
55090
|
+
...faceRescoreActions
|
|
54481
55091
|
};
|
|
54482
55092
|
/**
|
|
54483
55093
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -54666,7 +55276,7 @@ var MOTIONLESS_MAX_PX = 8;
|
|
|
54666
55276
|
var PHANTOM_CELL_PX = 32;
|
|
54667
55277
|
/** How long a cell remembers its closes. */
|
|
54668
55278
|
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
54669
|
-
var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
55279
|
+
var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
54670
55280
|
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
54671
55281
|
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
54672
55282
|
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
@@ -55198,6 +55808,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55198
55808
|
* queued captures via the onClose hook) and `closeDevice` finally clears
|
|
55199
55809
|
* per-track state on device removal (the two documented leaks). */
|
|
55200
55810
|
residents = new TrackResidentState({ onClose: (trackId) => this.captureScheduler.cancelForTrack(trackId) });
|
|
55811
|
+
/** Defence in depth for the 2026-08-21 `b50e09f7` incident: a row the track
|
|
55812
|
+
* store has already CLOSED never earns another media target, whatever the
|
|
55813
|
+
* tracker keeps handing back. Reads the store's in-RAM close record only —
|
|
55814
|
+
* see `pipeline/closed-row-media-gate.ts` for why it can only fail OPEN. */
|
|
55815
|
+
closedRowMediaGate = new ClosedRowMediaGate();
|
|
55201
55816
|
/** Windowed per-device aggregation of the "media capture" diagnostic — the
|
|
55202
55817
|
* per-capture line is debug; a 60s per-counter SUM lands at info (~1
|
|
55203
55818
|
* line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
|
|
@@ -55535,13 +56150,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55535
56150
|
...debugActions,
|
|
55536
56151
|
...orphanAuditActions,
|
|
55537
56152
|
...viewerSettingsActions,
|
|
55538
|
-
...photoEnrollActions
|
|
56153
|
+
...photoEnrollActions,
|
|
56154
|
+
...faceRescoreActions
|
|
55539
56155
|
},
|
|
55540
56156
|
actionHandlers: {
|
|
55541
56157
|
...ncHandlers,
|
|
55542
56158
|
...this.buildEmbeddingActionHandlers(),
|
|
55543
56159
|
...this.buildDebugActionHandlers(),
|
|
55544
56160
|
...this.buildPhotoEnrollActionHandlers(),
|
|
56161
|
+
...this.buildFaceRescoreActionHandlers(),
|
|
55545
56162
|
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
55546
56163
|
}
|
|
55547
56164
|
} : {}
|
|
@@ -56413,6 +57030,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
56413
57030
|
});
|
|
56414
57031
|
},
|
|
56415
57032
|
getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
|
|
57033
|
+
getSubjectTrail: async (deviceId, trackId) => {
|
|
57034
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
57035
|
+
if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];
|
|
57036
|
+
const track = stores.trackStore.getActiveByTrack(trackId) ?? await stores.trackStore.getPersistedByTrackId(trackId);
|
|
57037
|
+
if (track === null || track === void 0) return [];
|
|
57038
|
+
return track.positions.map((p) => ({
|
|
57039
|
+
timestamp: p.timestamp,
|
|
57040
|
+
bbox: {
|
|
57041
|
+
x: p.bbox.x / dims.w,
|
|
57042
|
+
y: p.bbox.y / dims.h,
|
|
57043
|
+
w: p.bbox.w / dims.w,
|
|
57044
|
+
h: p.bbox.h / dims.h
|
|
57045
|
+
}
|
|
57046
|
+
}));
|
|
57047
|
+
},
|
|
56416
57048
|
getDeviceName,
|
|
56417
57049
|
buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
|
|
56418
57050
|
},
|
|
@@ -59090,6 +59722,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
59090
59722
|
const targets = [];
|
|
59091
59723
|
for (const t of tracked) {
|
|
59092
59724
|
if (t.matchedThisFrame === false) continue;
|
|
59725
|
+
const verdict = this.closedRowMediaGate.evaluate(t.trackId, this.trackStore.rowClosedAt(t.trackId), Date.now());
|
|
59726
|
+
if (verdict.refuse) {
|
|
59727
|
+
if (verdict.log) this.ctx.logger.warn("track media refused: row already closed", {
|
|
59728
|
+
tags: { deviceId },
|
|
59729
|
+
meta: {
|
|
59730
|
+
trackId: t.trackId,
|
|
59731
|
+
className: t.className,
|
|
59732
|
+
closedAgeMs: verdict.closedAgeMs,
|
|
59733
|
+
suppressed: verdict.suppressed
|
|
59734
|
+
}
|
|
59735
|
+
});
|
|
59736
|
+
continue;
|
|
59737
|
+
}
|
|
59093
59738
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
59094
59739
|
const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
|
|
59095
59740
|
lastSnapshotAt: lastSnap,
|
|
@@ -59719,6 +60364,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
59719
60364
|
try {
|
|
59720
60365
|
const deviceIds = await this.retentionDeviceIds();
|
|
59721
60366
|
await this.migrateRetiredRetentionSettings(deviceIds);
|
|
60367
|
+
await this.purgeStoredSuggestionThreshold(deviceIds);
|
|
59722
60368
|
await this.runTrackRetentionSweep(deviceIds, now);
|
|
59723
60369
|
const eventStore = this.eventStore;
|
|
59724
60370
|
const sensorEventStore = this.sensorEventStore;
|
|
@@ -59838,6 +60484,96 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
59838
60484
|
}
|
|
59839
60485
|
}
|
|
59840
60486
|
}
|
|
60487
|
+
/** Whether the GLOBAL store has been checked for a stored suggestion floor
|
|
60488
|
+
* this process, and which devices have. One announcement, not a heartbeat. */
|
|
60489
|
+
storedSuggestionCheckedGlobal = false;
|
|
60490
|
+
storedSuggestionCheckedDevices = /* @__PURE__ */ new Set();
|
|
60491
|
+
/**
|
|
60492
|
+
* One-time: announce the suggestion floor D222 retired, then unset it —
|
|
60493
|
+
* globally and per device.
|
|
60494
|
+
*
|
|
60495
|
+
* Removing the field from the schema already makes a stored value inert
|
|
60496
|
+
* (`resolveFaceSettings` reads declared fields only). That is not enough. A
|
|
60497
|
+
* number sitting in the blob is an authority waiting to be re-adopted by the
|
|
60498
|
+
* next reader who greps for it, and it is what the operator last typed — so
|
|
60499
|
+
* it must leave the store AND be said out loud, because the value in force
|
|
60500
|
+
* changes: this hub carried `suggestionMinCosine: 0.33` against a 0.45
|
|
60501
|
+
* threshold, and the band is 0.36 from now on. Silence here would be the
|
|
60502
|
+
* same defect one layer up — an operator discovering three weeks later that
|
|
60503
|
+
* their number stopped applying.
|
|
60504
|
+
*
|
|
60505
|
+
* Absence of the key IS the marker: no flag, no version row, re-running is a
|
|
60506
|
+
* no-op. The write goes through `updateGlobalSettings` / `writeDeviceStore`
|
|
60507
|
+
* (never a raw store write), and the patch unsets with `undefined` — an
|
|
60508
|
+
* explicit `null` would survive the merge as exactly the residue being
|
|
60509
|
+
* removed.
|
|
60510
|
+
*/
|
|
60511
|
+
async purgeStoredSuggestionThreshold(deviceIds) {
|
|
60512
|
+
const settings = this.ctxIfReady?.settings;
|
|
60513
|
+
if (!settings) return;
|
|
60514
|
+
if (!this.storedSuggestionCheckedGlobal) {
|
|
60515
|
+
this.storedSuggestionCheckedGlobal = true;
|
|
60516
|
+
try {
|
|
60517
|
+
const plan = planStoredSuggestionPurge(await this.resolveGlobalStore());
|
|
60518
|
+
if (plan !== null) {
|
|
60519
|
+
const global = resolveFaceSettings(await this.resolveGlobalStore());
|
|
60520
|
+
this.announceRetiredSuggestionThreshold({
|
|
60521
|
+
scope: "global",
|
|
60522
|
+
was: plan.was,
|
|
60523
|
+
threshold: global.similarityThreshold
|
|
60524
|
+
});
|
|
60525
|
+
await this.updateGlobalSettings(plan.patch);
|
|
60526
|
+
}
|
|
60527
|
+
} catch (err) {
|
|
60528
|
+
this.storedSuggestionCheckedGlobal = false;
|
|
60529
|
+
this.ctx.logger.warn("face suggestion threshold purge failed — will retry", { meta: {
|
|
60530
|
+
scope: "global",
|
|
60531
|
+
error: String(err)
|
|
60532
|
+
} });
|
|
60533
|
+
}
|
|
60534
|
+
}
|
|
60535
|
+
for (const deviceId of deviceIds) {
|
|
60536
|
+
if (this.storedSuggestionCheckedDevices.has(deviceId)) continue;
|
|
60537
|
+
this.storedSuggestionCheckedDevices.add(deviceId);
|
|
60538
|
+
try {
|
|
60539
|
+
const plan = planStoredSuggestionPurge(await settings.readDeviceStore(deviceId));
|
|
60540
|
+
if (plan === null) continue;
|
|
60541
|
+
const effective = await this.resolveDeviceFaceSettings(deviceId);
|
|
60542
|
+
this.announceRetiredSuggestionThreshold({
|
|
60543
|
+
scope: "device",
|
|
60544
|
+
was: plan.was,
|
|
60545
|
+
threshold: effective.similarityThreshold,
|
|
60546
|
+
deviceId
|
|
60547
|
+
});
|
|
60548
|
+
await settings.writeDeviceStore(deviceId, plan.patch);
|
|
60549
|
+
} catch (err) {
|
|
60550
|
+
this.storedSuggestionCheckedDevices.delete(deviceId);
|
|
60551
|
+
this.ctx.logger.warn("face suggestion threshold purge failed — will retry", {
|
|
60552
|
+
tags: { deviceId },
|
|
60553
|
+
meta: {
|
|
60554
|
+
scope: "device",
|
|
60555
|
+
error: String(err)
|
|
60556
|
+
}
|
|
60557
|
+
});
|
|
60558
|
+
}
|
|
60559
|
+
}
|
|
60560
|
+
}
|
|
60561
|
+
/** The one line the operator can read to see what their number became. Both
|
|
60562
|
+
* numbers, and the rule between them, or it does not explain anything. */
|
|
60563
|
+
announceRetiredSuggestionThreshold(found) {
|
|
60564
|
+
this.ctx.logger.warn("face suggestion threshold RETIRED — it is derived from the match threshold now", {
|
|
60565
|
+
...found.deviceId !== void 0 ? { tags: { deviceId: found.deviceId } } : {},
|
|
60566
|
+
meta: {
|
|
60567
|
+
scope: found.scope,
|
|
60568
|
+
setting: STORED_SUGGESTION_MIN_COSINE_KEY,
|
|
60569
|
+
wasSetTo: found.was,
|
|
60570
|
+
similarityThreshold: found.threshold,
|
|
60571
|
+
bandFactor: SUGGESTION_BAND_FACTOR,
|
|
60572
|
+
nowInForce: deriveSuggestionMinCosine(found.threshold),
|
|
60573
|
+
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)."
|
|
60574
|
+
}
|
|
60575
|
+
});
|
|
60576
|
+
}
|
|
59841
60577
|
/**
|
|
59842
60578
|
* Hold the unassigned face + plate buffers to their per-device capacity.
|
|
59843
60579
|
*
|
|
@@ -61344,6 +62080,205 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
61344
62080
|
byIdentity: [...progress.byIdentity]
|
|
61345
62081
|
};
|
|
61346
62082
|
}
|
|
62083
|
+
/**
|
|
62084
|
+
* Side of the canonical ArcFace aligned template, in pixels
|
|
62085
|
+
* (`ARCFACE_TEMPLATE_112` / `face-align.ts` on the runner). A stored face crop
|
|
62086
|
+
* of exactly this size IS the template the embedder was fed; any other size
|
|
62087
|
+
* is a padded bbox cut that only looks like one.
|
|
62088
|
+
*/
|
|
62089
|
+
static ARCFACE_TEMPLATE_PX = 112;
|
|
62090
|
+
/**
|
|
62091
|
+
* Re-embed ONE buffered face from its stored crop, into the cluster's current
|
|
62092
|
+
* `face-embedding` space, and persist the result on the face row.
|
|
62093
|
+
*
|
|
62094
|
+
* Persisting is not optional. `assignFace` enrols `face.embedding` verbatim,
|
|
62095
|
+
* so a rescore that computed a fresh vector, showed the operator a convincing
|
|
62096
|
+
* percentage and then threw the vector away would enrol the STALE one the
|
|
62097
|
+
* moment they acted on it — the gallery would gain a sample in a space the
|
|
62098
|
+
* matcher cannot reach, which is precisely the failure the whole re-embed
|
|
62099
|
+
* pass exists to undo.
|
|
62100
|
+
*
|
|
62101
|
+
* The 112×112 check is the load-bearing guard. A face's crop is the ArcFace
|
|
62102
|
+
* template only when the detection carried landmarks; otherwise it is a
|
|
62103
|
+
* padded bbox cut, and `face-embedding` over THAT as a whole frame returns a
|
|
62104
|
+
* vector that passes every gate and means nothing (`face-reembed-pass.ts`
|
|
62105
|
+
* documents the mirror-image trap). Refusing beats answering.
|
|
62106
|
+
*/
|
|
62107
|
+
async reembedBufferedFace(face, clusterModelId) {
|
|
62108
|
+
const api = this.ctx.api;
|
|
62109
|
+
const faceStore = this.faceStore;
|
|
62110
|
+
const mediaStore = this.mediaStore;
|
|
62111
|
+
const refuse = (reason, detail) => {
|
|
62112
|
+
this.ctx.logger.warn("face rescore: re-embed refused", {
|
|
62113
|
+
tags: { deviceId: face.deviceId },
|
|
62114
|
+
meta: {
|
|
62115
|
+
faceId: face.id,
|
|
62116
|
+
trackId: face.trackId,
|
|
62117
|
+
reason,
|
|
62118
|
+
detail,
|
|
62119
|
+
clusterModelId
|
|
62120
|
+
}
|
|
62121
|
+
});
|
|
62122
|
+
return {
|
|
62123
|
+
report: {
|
|
62124
|
+
attempted: true,
|
|
62125
|
+
succeeded: false,
|
|
62126
|
+
reason,
|
|
62127
|
+
detail,
|
|
62128
|
+
modelId: clusterModelId
|
|
62129
|
+
},
|
|
62130
|
+
embedding: null
|
|
62131
|
+
};
|
|
62132
|
+
};
|
|
62133
|
+
if (!api || !faceStore || !mediaStore) return refuse("failed", "the addon API or the face/media stores are not ready");
|
|
62134
|
+
if (face.mediaKey === void 0 || face.mediaKey === "") return refuse("no-pixels", "this face kept no crop — there is nothing to re-embed from");
|
|
62135
|
+
const file = await mediaStore.getByKey(face.mediaKey);
|
|
62136
|
+
if (file === null) return refuse("no-pixels", `the crop blob ${face.mediaKey} is gone`);
|
|
62137
|
+
const raster = await decodeJpegToRgb(file.base64);
|
|
62138
|
+
const templatePx = PipelineAnalyticsAddon.ARCFACE_TEMPLATE_PX;
|
|
62139
|
+
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`);
|
|
62140
|
+
const outcome = await new FaceReembedEmbedder({
|
|
62141
|
+
runStatelessStep: (nodeId, stepInput) => this.runRebuildStepOn(nodeId, stepInput),
|
|
62142
|
+
logger: { warn: (m, e) => this.ctx.logger.warn(m, e) }
|
|
62143
|
+
}, await this.resolveRebuildNodes(api, void 0), clusterModelId).embed({
|
|
62144
|
+
sampleId: face.id,
|
|
62145
|
+
identityId: face.recognizedIdentityId ?? "",
|
|
62146
|
+
identityName: `track ${face.trackId}`,
|
|
62147
|
+
modelId: face.embeddingModelId ?? "arcface-r100",
|
|
62148
|
+
dim: face.embedding.length,
|
|
62149
|
+
mediaKey: face.mediaKey
|
|
62150
|
+
}, Buffer.from(file.base64, "base64"));
|
|
62151
|
+
if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
|
|
62152
|
+
if (outcome.kind === "failed") return refuse("failed", outcome.reason);
|
|
62153
|
+
await faceStore.update(face.id, {
|
|
62154
|
+
embedding: outcome.embedding,
|
|
62155
|
+
embeddingModelId: clusterModelId
|
|
62156
|
+
});
|
|
62157
|
+
if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
|
|
62158
|
+
this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
|
|
62159
|
+
tags: { deviceId: face.deviceId },
|
|
62160
|
+
meta: {
|
|
62161
|
+
faceId: face.id,
|
|
62162
|
+
trackId: face.trackId,
|
|
62163
|
+
from: face.embeddingModelId ?? "arcface-r100",
|
|
62164
|
+
to: clusterModelId,
|
|
62165
|
+
dim: outcome.dim
|
|
62166
|
+
}
|
|
62167
|
+
});
|
|
62168
|
+
return {
|
|
62169
|
+
report: {
|
|
62170
|
+
attempted: true,
|
|
62171
|
+
succeeded: true,
|
|
62172
|
+
modelId: clusterModelId
|
|
62173
|
+
},
|
|
62174
|
+
embedding: outcome.embedding
|
|
62175
|
+
};
|
|
62176
|
+
}
|
|
62177
|
+
/**
|
|
62178
|
+
* The matcher parameters in force for one device, as the report echoes them.
|
|
62179
|
+
*
|
|
62180
|
+
* The suggestion floor is DERIVED here and nowhere else in this class
|
|
62181
|
+
* (D222) — and it is reported WITH `suggestionBandFactor`, because a floor
|
|
62182
|
+
* that moves whenever the threshold moves reads as a bug unless the rule is
|
|
62183
|
+
* on screen beside it. There is no stored `suggestionMinCosine` to report:
|
|
62184
|
+
* the one the live hub carried (0.33) decided nothing from the moment this
|
|
62185
|
+
* shipped, and the retention pass removes it.
|
|
62186
|
+
*/
|
|
62187
|
+
faceRescoreParams(settings) {
|
|
62188
|
+
return {
|
|
62189
|
+
threshold: settings.similarityThreshold,
|
|
62190
|
+
margin: settings.margin,
|
|
62191
|
+
minIdentitySamples: settings.minIdentitySamples,
|
|
62192
|
+
suggestionMinCosine: deriveSuggestionMinCosine(settings.similarityThreshold),
|
|
62193
|
+
suggestionBandFactor: SUGGESTION_BAND_FACTOR
|
|
62194
|
+
};
|
|
62195
|
+
}
|
|
62196
|
+
/**
|
|
62197
|
+
* `face.rescoreTrack` — the operator's "what does the gallery say NOW?".
|
|
62198
|
+
*
|
|
62199
|
+
* See `face-rescore.ts` for why the number already on the track cannot answer
|
|
62200
|
+
* that: it was computed during live detection and persisted at track close,
|
|
62201
|
+
* so every enrolment made since is invisible to it.
|
|
62202
|
+
*/
|
|
62203
|
+
async rescoreFaceForTrack(input) {
|
|
62204
|
+
const api = this.ctx.api;
|
|
62205
|
+
if (!api) throw new Error("face.rescoreTrack: the addon API is not available");
|
|
62206
|
+
const faceStore = this.faceStore;
|
|
62207
|
+
if (!faceStore) throw new Error("face.rescoreTrack: the face store is not ready");
|
|
62208
|
+
const identityStore = this.identityStore;
|
|
62209
|
+
if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
|
|
62210
|
+
const faceId = `face-${input.trackId}`;
|
|
62211
|
+
const face = await faceStore.get(faceId);
|
|
62212
|
+
if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
|
|
62213
|
+
if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
|
|
62214
|
+
const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
|
|
62215
|
+
if (clusterModelId === null) throw new Error("face.rescoreTrack: the cluster face-embedding model could not be resolved");
|
|
62216
|
+
const settings = await this.resolveDeviceFaceSettings(input.deviceId);
|
|
62217
|
+
const params = this.faceRescoreParams(settings);
|
|
62218
|
+
let probeEmbedding = face.embedding;
|
|
62219
|
+
let probeModelId = face.embeddingModelId ?? "arcface-r100";
|
|
62220
|
+
let reembed = {
|
|
62221
|
+
attempted: false,
|
|
62222
|
+
succeeded: false,
|
|
62223
|
+
reason: "not-requested"
|
|
62224
|
+
};
|
|
62225
|
+
if (input.reembed) {
|
|
62226
|
+
const outcome = await this.reembedBufferedFace(face, clusterModelId);
|
|
62227
|
+
reembed = outcome.report;
|
|
62228
|
+
if (outcome.embedding !== null) {
|
|
62229
|
+
probeEmbedding = outcome.embedding;
|
|
62230
|
+
probeModelId = clusterModelId;
|
|
62231
|
+
}
|
|
62232
|
+
}
|
|
62233
|
+
const [gallery, identities] = await Promise.all([identityStore.loadGallery(), identityStore.listIdentities()]);
|
|
62234
|
+
const report = buildFaceRescoreReport({
|
|
62235
|
+
probe: {
|
|
62236
|
+
embedding: probeEmbedding,
|
|
62237
|
+
modelId: probeModelId
|
|
62238
|
+
},
|
|
62239
|
+
gallery,
|
|
62240
|
+
identityNames: new Map(identities.map((i) => [i.id, i.name])),
|
|
62241
|
+
clusterModelId,
|
|
62242
|
+
params
|
|
62243
|
+
});
|
|
62244
|
+
this.ctx.logger.info("face rescore", {
|
|
62245
|
+
tags: { deviceId: input.deviceId },
|
|
62246
|
+
meta: {
|
|
62247
|
+
trackId: input.trackId,
|
|
62248
|
+
faceId,
|
|
62249
|
+
verdict: report.verdict,
|
|
62250
|
+
probeModelId,
|
|
62251
|
+
clusterModelId,
|
|
62252
|
+
gallerySamples: gallery.length,
|
|
62253
|
+
scored: report.identities.length,
|
|
62254
|
+
best: report.identities[0]?.name ?? null,
|
|
62255
|
+
bestScore: report.identities[0]?.score ?? null,
|
|
62256
|
+
storedSuggestion: face.suggestedMatchScore ?? null,
|
|
62257
|
+
reembedded: reembed.succeeded
|
|
62258
|
+
}
|
|
62259
|
+
});
|
|
62260
|
+
return {
|
|
62261
|
+
faceId,
|
|
62262
|
+
deviceId: face.deviceId,
|
|
62263
|
+
trackId: face.trackId,
|
|
62264
|
+
assigned: face.assigned,
|
|
62265
|
+
...face.recognizedIdentityId != null ? { assignedIdentityId: face.recognizedIdentityId } : {},
|
|
62266
|
+
...face.suggestedIdentityId != null ? { storedSuggestedIdentityId: face.suggestedIdentityId } : {},
|
|
62267
|
+
...face.suggestedMatchScore != null ? { storedSuggestedMatchScore: face.suggestedMatchScore } : {},
|
|
62268
|
+
verdict: report.verdict,
|
|
62269
|
+
comparable: report.comparable,
|
|
62270
|
+
probeModelId: report.probeModelId,
|
|
62271
|
+
clusterModelId: report.clusterModelId,
|
|
62272
|
+
params: report.params,
|
|
62273
|
+
identities: report.identities,
|
|
62274
|
+
...report.runnerUpGap !== void 0 ? { runnerUpGap: report.runnerUpGap } : {},
|
|
62275
|
+
reembedAvailable: probeModelId !== clusterModelId && face.mediaKey !== void 0 && face.mediaKey !== "",
|
|
62276
|
+
reembed
|
|
62277
|
+
};
|
|
62278
|
+
}
|
|
62279
|
+
buildFaceRescoreActionHandlers() {
|
|
62280
|
+
return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
|
|
62281
|
+
}
|
|
61347
62282
|
buildPhotoEnrollActionHandlers() {
|
|
61348
62283
|
return {
|
|
61349
62284
|
"photo.analyze": async (input) => {
|
|
@@ -62040,6 +62975,30 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
62040
62975
|
globalSettingsSchema() {
|
|
62041
62976
|
return this.schema(buildGlobalSettingsSchema());
|
|
62042
62977
|
}
|
|
62978
|
+
/**
|
|
62979
|
+
* The only reason this is overridden: the face suggestion floor is DERIVED
|
|
62980
|
+
* (D222), so the form has to be able to SHOW it without storing it.
|
|
62981
|
+
*
|
|
62982
|
+
* The schema is a static declaration and cannot interpolate a live value, so
|
|
62983
|
+
* the readout's value is injected into the hydrate blob here — the same
|
|
62984
|
+
* mechanism `detection-pipeline` uses for its model-substitution readout.
|
|
62985
|
+
* It is a `readonlyField`, so it renders display-only, never enters a patch,
|
|
62986
|
+
* and never reaches the store: the operator sees what their threshold
|
|
62987
|
+
* implies and has nothing to contradict it with. Everything else about this
|
|
62988
|
+
* call is `BaseAddon`'s.
|
|
62989
|
+
*/
|
|
62990
|
+
async getGlobalSettings(overlay, cap, nodeId) {
|
|
62991
|
+
const stored = await this.resolveGlobalStore(nodeId, cap);
|
|
62992
|
+
const merged = overlay ? {
|
|
62993
|
+
...stored,
|
|
62994
|
+
...overlay
|
|
62995
|
+
} : stored;
|
|
62996
|
+
const readout = formatSuggestionThresholdReadout(resolveFaceSettings(merged).similarityThreshold);
|
|
62997
|
+
return super.getGlobalSettings({
|
|
62998
|
+
...merged,
|
|
62999
|
+
[SUGGESTION_THRESHOLD_READOUT_KEY]: readout
|
|
63000
|
+
}, cap, nodeId);
|
|
63001
|
+
}
|
|
62043
63002
|
async getDeviceSettingsContribution(input) {
|
|
62044
63003
|
if (!await this.isCameraDevice(input.deviceId)) return null;
|
|
62045
63004
|
const schema = this.globalSettingsSchema();
|