@camstack/addon-post-analysis 1.2.207 → 1.2.209

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.
@@ -1,4 +1,4 @@
1
- import { $ as audioModeOf, $t as EventCategory, A as NcSnoozeSchema, At as vectorDimFromBase64, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as nodePin, C as NcConditionDescriptorSchema, Ct as readTimelapseGeneratedAt, D as NcRuleTargetSchema, Dt as storageOccupancyCapability, E as NcRuleSchema, Et as sceneMonitorCapability, Ft as CamProfileSchema, G as TimelapseRulePatchSchema, Gt as discriminatedUnion, H as SceneMonitorSchema, Ht as _enum, I as RECORDING_EXPORT_MAX_READ_BYTES, It as DeviceType, J as VISIT_MERGE_GAP_MS, Jt as object, K as TimelapseRuleSchema, Kt as literal, L as RetrainStatusSchema, Lt as createEvent, M as NcSystemEventKindSchema, Mt as zoneAnalyticsCapability, N as NcTaxonomySchema, Nt as errMsg$1, O as NcScheduleSchema, Ot as subKindsOf, P as OpsLogEntrySchema, Pt as BaseAddon, Q as audioMetricsCapability, Qt as unknown, Rt as hydrateSchema, S as NC_TAXONOMY, St as readDeviceStateFrom, T as NcRulePatchSchema, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as array, V as SCENE_DIVERGED, Vt as sleep$1, W as TimelapseRuleInputSchema, Wt as boolean, X as alarmPanelCapability, Xt as record, Y as addonWidgetsSourceCapability, Yt as partialRecord, Z as assertTimelapseCadences, Zt as string, _ as MediaFileKindEnum, _t as notificationRulesCapability, a as DEFAULT_EVENT_COLOR, b as NC_DEFAULT_SNOOZE_MINUTES, bt as pipelineAnalyticsCapability, c as DETECTION_MACRO_CLASSES, ct as evictionPolicyOfLocation, d as EVENT_KIND_BY_CAP, et as buildEventKindDescriptor, f as EVENT_PAD_MS, ft as isDetectionMacroClass, g as MACRO_LABELS, gt as mayWriteToLocation, h as LabelAttributionSchema, ht as kebabToCamel, i as COCO_TO_MACRO, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as videoclipsCapability, k as NcSnoozeInputSchema, kt as systemEventFilterApplies, lt as faceGalleryCapability, m as FailureCounters, mt as isSourceCap, n as BaseDevice, nt as customAction, ot as encodeVectorBase64, p as FULL_IMAGE_BBOX, pt as isScheduleActive, q as TrackSourceSchema, qt as number, r as CLUSTER_MODEL_SCOPED_STEPS, rt as defineCustomActions, st as evaluateSensorEdge, t as AUDIO_MACRO_LABELS, tt as cosineSimilarity$1, u as DeclaredDevices, ut as failureContributionCapability, v as NC_ALARM_SYSTEM_EVENT_KINDS, w as NcRuleInputSchema, wt as resolveLocationMode, xt as plateGalleryCapability, y as NC_CONDITION_CATALOG, yt as pickClusterStepModels, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as isDeviceScopedCap } from "../dist-WtZuLM5F.mjs";
1
+ import { $ as audioModeOf, $t as EventCategory, A as NcSnoozeSchema, At as vectorDimFromBase64, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as nodePin, C as NcConditionDescriptorSchema, Ct as readTimelapseGeneratedAt, D as NcRuleTargetSchema, Dt as storageOccupancyCapability, E as NcRuleSchema, Et as sceneMonitorCapability, Ft as CamProfileSchema, G as TimelapseRulePatchSchema, Gt as discriminatedUnion, H as SceneMonitorSchema, Ht as _enum, I as RECORDING_EXPORT_MAX_READ_BYTES, It as DeviceType, J as VISIT_MERGE_GAP_MS, Jt as object, K as TimelapseRuleSchema, Kt as literal, L as RetrainStatusSchema, Lt as createEvent, M as NcSystemEventKindSchema, Mt as zoneAnalyticsCapability, N as NcTaxonomySchema, Nt as errMsg$1, O as NcScheduleSchema, Ot as subKindsOf, P as OpsLogEntrySchema, Pt as BaseAddon, Q as audioMetricsCapability, Qt as unknown, Rt as hydrateSchema, S as NC_TAXONOMY, St as readDeviceStateFrom, T as NcRulePatchSchema, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as array, V as SCENE_DIVERGED, Vt as sleep$1, W as TimelapseRuleInputSchema, Wt as boolean, X as alarmPanelCapability, Xt as record, Y as addonWidgetsSourceCapability, Yt as partialRecord, Z as assertTimelapseCadences, Zt as string, _ as MediaFileKindEnum, _t as notificationRulesCapability, a as DEFAULT_EVENT_COLOR, b as NC_DEFAULT_SNOOZE_MINUTES, bt as pipelineAnalyticsCapability, c as DETECTION_MACRO_CLASSES, ct as evictionPolicyOfLocation, d as EVENT_KIND_BY_CAP, et as buildEventKindDescriptor, f as EVENT_PAD_MS, ft as isDetectionMacroClass, g as MACRO_LABELS, gt as mayWriteToLocation, h as LabelAttributionSchema, ht as kebabToCamel, i as COCO_TO_MACRO, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as videoclipsCapability, k as NcSnoozeInputSchema, kt as systemEventFilterApplies, lt as faceGalleryCapability, m as FailureCounters, mt as isSourceCap, n as BaseDevice, nt as customAction, ot as encodeVectorBase64, p as FULL_IMAGE_BBOX, pt as isScheduleActive, q as TrackSourceSchema, qt as number, r as CLUSTER_MODEL_SCOPED_STEPS, rt as defineCustomActions, st as evaluateSensorEdge, t as AUDIO_MACRO_LABELS, tt as cosineSimilarity$1, u as DeclaredDevices, ut as failureContributionCapability, v as NC_ALARM_SYSTEM_EVENT_KINDS, w as NcRuleInputSchema, wt as resolveLocationMode, xt as plateGalleryCapability, y as NC_CONDITION_CATALOG, yt as pickClusterStepModels, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as isDeviceScopedCap } from "../dist-BV15Z8DR.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";
@@ -7513,7 +7513,8 @@ function cooldownKey(rule, subject) {
7513
7513
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
7514
7514
  const scopeRef = subject.systemEvent !== void 0 ? systemEventCooldownScope(subject.systemEvent) : `d:${subject.deviceId}`;
7515
7515
  const kindKey = subject.systemEvent !== void 0 ? `:k:${subject.systemEvent.kind}` : "";
7516
- return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
7516
+ const phaseKey = subject.packagePhase !== void 0 ? `:p:${subject.packagePhase}` : "";
7517
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${phaseKey}${classKey}` : `r:${rule.id}:${scopeRef}${phaseKey}${classKey}`;
7517
7518
  }
7518
7519
  /** True when the rule fired within its cooldown window before `now`. */
7519
7520
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -9950,6 +9951,10 @@ var en_default = {
9950
9951
  "one": "{{count}} other {{classNoun}}",
9951
9952
  "other": "{{count}} other {{classNoun}}"
9952
9953
  },
9954
+ "package.delivered.title": "Parcel delivered",
9955
+ "package.delivered.body": "Delivered at {{camera}}{{inZones}}",
9956
+ "package.picked-up.title": "Parcel collected",
9957
+ "package.picked-up.body": "Collected from {{camera}}{{inZones}}",
9953
9958
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
9954
9959
  "occupancy.op.occupied": "occupied",
9955
9960
  "occupancy.op.free": "free",
@@ -10201,6 +10206,10 @@ var it_default = {
10201
10206
  "one": "{{count}} altro {{classNoun}}",
10202
10207
  "other": "{{count}} altri {{classNoun}}"
10203
10208
  },
10209
+ "package.delivered.title": "Pacco consegnato",
10210
+ "package.delivered.body": "Consegnato su {{camera}}{{inZones}}",
10211
+ "package.picked-up.title": "Pacco ritirato",
10212
+ "package.picked-up.body": "Ritirato da {{camera}}{{inZones}}",
10204
10213
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
10205
10214
  "occupancy.op.occupied": "occupata",
10206
10215
  "occupancy.op.free": "libera",
@@ -12043,6 +12052,7 @@ function bodyKeyFor(entry) {
12043
12052
  const subject = entry.payload.subject;
12044
12053
  if (subject.systemEvent !== void 0) return subject.systemEvent.bodyKey ?? "";
12045
12054
  if (subject.occupancy !== void 0) return "occupancy.body";
12055
+ if (subject.packagePhase !== void 0) return `package.${subject.packagePhase}.body`;
12046
12056
  if (entry.payload.enhancement === true) return "detection.body.enhanced";
12047
12057
  return entry.recordKind === "track-end" ? "detection.body.trackEnd" : "detection.body";
12048
12058
  }
@@ -12050,6 +12060,8 @@ function bodyKeyFor(entry) {
12050
12060
  function titleKeyFor(entry) {
12051
12061
  const systemEvent = entry.payload.subject.systemEvent;
12052
12062
  if (systemEvent !== void 0) return systemEvent.titleKey ?? "";
12063
+ const packagePhase = entry.payload.subject.packagePhase;
12064
+ if (packagePhase !== void 0) return `package.${packagePhase}.title`;
12053
12065
  return "detection.title";
12054
12066
  }
12055
12067
  /**
@@ -24834,6 +24846,7 @@ var NotificationCenter = class NotificationCenter {
24834
24846
  zones: renderedZones,
24835
24847
  timestamp: subject.timestamp,
24836
24848
  ...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
24849
+ ...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
24837
24850
  ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
24838
24851
  }
24839
24852
  };
@@ -27572,10 +27585,63 @@ function squareSubjectCropRegion(bbox, frame) {
27572
27585
  h: Math.round(side)
27573
27586
  };
27574
27587
  }
27575
- function wideCentralSquareLayout(bbox, frame, options = {}) {
27576
- const central = squareSubjectCropRegion(bbox, frame);
27588
+ /**
27589
+ * How much of the composed picture may be synthesized fill before the canvas
27590
+ * NARROWS instead (operator directive, 2026-09-08, track a46599d5 device 3829).
27591
+ *
27592
+ * A subject flush against a frame edge for its whole life has no containable
27593
+ * frame, so `containSubject` keeps the middle square on it and pays the
27594
+ * overhang in lateral fill. On that car the bill was 225 px — 21.9% of the
27595
+ * picture, and the operator read the blurred stretch as a mirrored duplicate of
27596
+ * the scene.
27597
+ *
27598
+ * Rather than choose between an uncontained subject and a quarter of ambience,
27599
+ * the canvas gives up WIDTH: it narrows towards its own central square until
27600
+ * the fill is at most this fraction. Aspect then lands between 1:1 and 16:9 —
27601
+ * the square is never crossed, because the square is the region that is
27602
+ * guaranteed to hold the subject.
27603
+ *
27604
+ * The cost is real and it is lateral CONTEXT: a wide surface cover-fitting a
27605
+ * near-square picture crops top and bottom. That is why this is a cap and not a
27606
+ * rule — everything that already composes with a small fill keeps its 16:9.
27607
+ */
27608
+ var MAX_LETTERBOX_FRACTION = .1;
27609
+ /**
27610
+ * The 16:9 width, narrowed just enough to hold the fill under
27611
+ * {@link MAX_LETTERBOX_FRACTION}.
27612
+ *
27613
+ * The canvas is CENTRED on the square, so each side wants `(canvasW - c) / 2`
27614
+ * and gets whatever real frame lies there; the rest is fill. Solving
27615
+ * `(w - c)/2 - available <= cap * w` for `w` gives the widest canvas that
27616
+ * respects the cap on that side, and the binding side is the narrower answer.
27617
+ *
27618
+ * It is computed from the space available BEFORE the slide, which can only
27619
+ * over-estimate the fill: the slide moves the window towards real scene and
27620
+ * never away from it. So a layout that the slide would have rescued anyway may
27621
+ * come out a few percent narrower than strictly necessary — a smaller error
27622
+ * than the band it removes, and in the other direction.
27623
+ *
27624
+ * Never narrower than the square itself: below that the picture would cut the
27625
+ * one region the layout guarantees contains the subject.
27626
+ */
27627
+ function cappedCanvasWidth(c, centralX, frameW) {
27628
+ const full = Math.round(c * 16 / 9);
27629
+ const denominator = .5 - MAX_LETTERBOX_FRACTION;
27630
+ const widest = (available) => (c / 2 + Math.max(0, available)) / denominator;
27631
+ const bound = Math.min(widest(centralX), widest(frameW - (centralX + c)));
27632
+ if (!Number.isFinite(bound) || bound >= full) return full;
27633
+ return Math.max(c, Math.round(bound));
27634
+ }
27635
+ /**
27636
+ * The layout at a GIVEN canvas width. `wideCentralSquareLayout` runs it twice:
27637
+ * once at the full 16:9 width, and — only when that leaves more fill than
27638
+ * {@link MAX_LETTERBOX_FRACTION} — once at the narrowed width. Two passes
27639
+ * rather than one predictive formula because the slide's result is what
27640
+ * decides, and computing the fill after the slide is exact where predicting it
27641
+ * is not.
27642
+ */
27643
+ function layoutAtWidth(bbox, frame, options, central, canvasW) {
27577
27644
  const c = central.w;
27578
- const canvasW = Math.round(c * 16 / 9);
27579
27645
  const centralX0 = Math.round((canvasW - c) / 2);
27580
27646
  const anchorX = central.x - (canvasW - c) / 2;
27581
27647
  let frameOriginX = anchorX;
@@ -27613,6 +27679,21 @@ function wideCentralSquareLayout(bbox, frame, options = {}) {
27613
27679
  frameOriginX
27614
27680
  };
27615
27681
  }
27682
+ /** Synthesized fill as a fraction of the composed picture. */
27683
+ function letterboxFraction(layout, frame) {
27684
+ const left = Math.max(0, -layout.frameOriginX);
27685
+ const right = Math.max(0, layout.frameOriginX + layout.canvasW - frame.W);
27686
+ return layout.canvasW <= 0 ? 0 : Math.max(left, right) / layout.canvasW;
27687
+ }
27688
+ function wideCentralSquareLayout(bbox, frame, options = {}) {
27689
+ const central = squareSubjectCropRegion(bbox, frame);
27690
+ const c = central.w;
27691
+ const full = Math.round(c * 16 / 9);
27692
+ const wide = layoutAtWidth(bbox, frame, options, central, full);
27693
+ if (full > frame.W || letterboxFraction(wide, frame) <= .1) return wide;
27694
+ const narrowed = cappedCanvasWidth(c, central.x, frame.W);
27695
+ return narrowed >= full ? wide : layoutAtWidth(bbox, frame, options, central, narrowed);
27696
+ }
27616
27697
  /**
27617
27698
  * Fraction of the subject bbox's area that lands inside the CENTRAL SQUARE a
27618
27699
  * square consumer center-crops from the composed 16:9 canvas — i.e. how much
@@ -28556,11 +28637,37 @@ var MediaStore = class {
28556
28637
  timestamp,
28557
28638
  deviceId: Number(data["deviceId"])
28558
28639
  };
28559
- } catch {
28640
+ } catch (err) {
28641
+ this.logger.warn("media read failed — row kept, blob not served", {
28642
+ tags: { deviceId: Number(data["deviceId"]) },
28643
+ meta: {
28644
+ key,
28645
+ path,
28646
+ location: mediaRowLocation(data),
28647
+ error: String(err)
28648
+ }
28649
+ });
28560
28650
  return null;
28561
28651
  }
28562
28652
  }
28563
28653
  /**
28654
+ * Can this row's blob be SERVED? The question `getByKey` answers in bytes,
28655
+ * asked without fetching any.
28656
+ *
28657
+ * False covers both permanent causes — no row, and a row whose blob cannot be
28658
+ * reached (deleted, or on a storage location the row names and the cluster no
28659
+ * longer has). They are one answer here because they are one answer to the
28660
+ * viewer: the tile is blank. Which of the two it is belongs in the log line
28661
+ * above, not in a boolean.
28662
+ */
28663
+ async blobServable(key) {
28664
+ try {
28665
+ return await this.referencedBlobState(key) === "exists";
28666
+ } catch {
28667
+ return false;
28668
+ }
28669
+ }
28670
+ /**
28564
28671
  * Delete a single media entry by its key (id).
28565
28672
  * Deletes the blob from storage (best-effort) and removes the index row.
28566
28673
  */
@@ -28758,11 +28865,15 @@ var MediaStore = class {
28758
28865
  ...provenance !== void 0 ? { provenance } : {}
28759
28866
  });
28760
28867
  } catch (err) {
28761
- this.logger.debug("media read failed — row kept but blob missing", { meta: {
28762
- key: row.id,
28763
- path,
28764
- error: String(err)
28765
- } });
28868
+ this.logger.warn("media read failed — row kept, blob not served", {
28869
+ tags: { deviceId: Number(data["deviceId"]) },
28870
+ meta: {
28871
+ key: row.id,
28872
+ path,
28873
+ location: mediaRowLocation(data),
28874
+ error: String(err)
28875
+ }
28876
+ });
28766
28877
  }
28767
28878
  }
28768
28879
  return files;
@@ -30394,6 +30505,8 @@ function analyseDebugTrack(inventory) {
30394
30505
  const durationMs = trackDurationMs(facts);
30395
30506
  const closed = !facts.active;
30396
30507
  const out = [];
30508
+ const unreadable = inventory.unreadableMedia ?? [];
30509
+ if (unreadable.length > 0) out.push(finding("MEDIA_ROW_WITHOUT_BLOB", "error", `${unreadable.length} of ${media.length} media rows exist in the index but their bytes could not be read — the tile renders blank and every count taken from the index overstates what is there. The blob is missing, or its row names a storage location that no longer resolves.`));
30397
30510
  if (media.length === 0) out.push(finding("MEDIA_EVICTED_OR_NEVER", "error", "No media at all — either nothing was captured, or it was evicted (a debug mark does not pin retention)."));
30398
30511
  else if (closed) {
30399
30512
  const keyFrameMissing = !hasKind(media, "keyFrame");
@@ -30573,12 +30686,15 @@ async function gatherTrack(deps, track) {
30573
30686
  for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
30574
30687
  const events = await deps.listEvents(track.trackId);
30575
30688
  const thumbnailProvenance = await deps.trackThumbnailProvenance(track.trackId);
30689
+ const unreadableMedia = [];
30690
+ for (const row of media) if (!await deps.probeMediaBlob(row.key)) unreadableMedia.push(row.key);
30576
30691
  return {
30577
30692
  inventory: {
30578
30693
  facts: toFacts(track),
30579
30694
  media,
30580
30695
  eventCount: events.length,
30581
- thumbnailProvenance
30696
+ thumbnailProvenance,
30697
+ unreadableMedia
30582
30698
  },
30583
30699
  events
30584
30700
  };
@@ -32614,6 +32730,149 @@ var FaceStore = class {
32614
32730
  }
32615
32731
  };
32616
32732
  //#endregion
32733
+ //#region src/pipeline-analytics/identity-audit.ts
32734
+ /**
32735
+ * identity-audit — which of a person's ENROLLED samples do not look like the
32736
+ * rest of that person.
32737
+ *
32738
+ * ## Why this exists
32739
+ *
32740
+ * The gallery is the one input to recognition that nobody re-reads. A sample
32741
+ * enrolled from the wrong face keeps voting forever: it pulls the wrong name
32742
+ * onto every future match near it, and because auto-assignment then looks
32743
+ * confident, nobody goes back to check. The 2026-09-08 sweep found exactly one
32744
+ * such pair on this cluster — a sample under one person scoring 0.61 against
32745
+ * three samples of another, enrolled from the same camera in the same minute,
32746
+ * where every other cross-identity pair on the cluster sits under 0.50.
32747
+ *
32748
+ * Finding that took a script and a day. This module makes it a question the
32749
+ * operator can ask per person, from the Review tab, and act on.
32750
+ *
32751
+ * ## What it measures
32752
+ *
32753
+ * Two numbers per sample, and the second is the one that convicts:
32754
+ *
32755
+ * - `selfMean` — mean cosine against the person's OTHER samples. Low means
32756
+ * "this does not look like the rest of you", which is suspicious but can
32757
+ * also be a legitimate hard angle, bad light, a mask.
32758
+ * - `bestForeign` — the highest cosine against any OTHER person's samples,
32759
+ * with that person named. High means "this looks like someone we can name",
32760
+ * which is what a mis-enrolment actually looks like.
32761
+ *
32762
+ * `suspicion` is the EXCESS — `bestForeign − selfMean` — because that is the
32763
+ * only one of the two that discriminates on real data. Measured on Roberta's
32764
+ * 106 samples, 2026-09-08: `selfMean` has a median of 0.188 and a p90 of 0.235,
32765
+ * so ANY absolute reading of "does not look like itself" fires on the whole
32766
+ * gallery. The first version of this module scored `(1 − selfMean) / 2` and
32767
+ * flagged 56 of 106 — a finding that fires on healthy samples means nothing.
32768
+ * `bestForeign` on the same gallery: median 0.283, p95 0.376, and one single
32769
+ * outlier at 0.612, which is the mis-enrolment.
32770
+ *
32771
+ * ## What it refuses to do
32772
+ *
32773
+ * A cosine between vectors from two different face models is a well-formed
32774
+ * float with no meaning — `face-matcher` refuses the comparison and so does
32775
+ * this (`comparable: false`). It must read as "cannot say", never as an
32776
+ * accusation and never as a clean bill: this list ends in a DELETE button.
32777
+ *
32778
+ * A person with one sample is not judged either. One sample has nothing to
32779
+ * disagree with, and the only thing an outlier score could do there is delete
32780
+ * the only sample a new person has.
32781
+ *
32782
+ * Pure — the caller loads the vectors and performs the removal.
32783
+ */
32784
+ /**
32785
+ * A row is FLAGGED when its best foreign match would auto-assign the face to
32786
+ * that other person — i.e. it reaches the recogniser's own
32787
+ * `similarityThreshold`.
32788
+ *
32789
+ * Tied to the live threshold rather than to a constant of its own, because
32790
+ * that is what the flag MEANS: this sample can steal a match. A second number
32791
+ * invented here would drift from the one the recogniser actually uses, and the
32792
+ * panel would accuse samples that cannot hurt anyone while staying quiet about
32793
+ * ones that can. On the live gallery (threshold 0.40) it flags 2 of 106.
32794
+ */
32795
+ function flaggedCount(rows, autoAssignThreshold) {
32796
+ return rows.filter((row) => (row.bestForeign?.score ?? 0) >= autoAssignThreshold).length;
32797
+ }
32798
+ /**
32799
+ * How far a foreign match has to stand OUT to count as looking like someone
32800
+ * else: `bestForeign - selfMean`. A sample that scores 0.6 against a stranger
32801
+ * and 0.7 against its own person is not a mis-enrolment.
32802
+ */
32803
+ function foreignExcess(selfMean, foreign) {
32804
+ if (foreign === void 0) return 0;
32805
+ if (selfMean === null) return 0;
32806
+ return Math.max(0, foreign.score - selfMean);
32807
+ }
32808
+ function meanCosine(target, pool) {
32809
+ let sum = 0;
32810
+ let n = 0;
32811
+ for (const other of pool) {
32812
+ if (other.sampleId === target.sampleId) continue;
32813
+ sum += cosineSimilarity(target.embedding, other.embedding);
32814
+ n += 1;
32815
+ }
32816
+ return n === 0 ? null : sum / n;
32817
+ }
32818
+ function bestForeignMatch(target, others, modelId) {
32819
+ let best;
32820
+ for (const identity of others) for (const sample of identity.samples) {
32821
+ if (sample.modelId !== modelId) continue;
32822
+ const score = cosineSimilarity(target.embedding, sample.embedding);
32823
+ if (best === void 0 || score > best.score) best = {
32824
+ identityId: identity.identityId,
32825
+ name: identity.name,
32826
+ score
32827
+ };
32828
+ }
32829
+ return best;
32830
+ }
32831
+ /**
32832
+ * Rank one person's samples, worst first.
32833
+ *
32834
+ * `suspicion` is `(1 - selfMean) / 2` — how far the sample sits from its own
32835
+ * person, on [0,1] — plus the foreign EXCESS, which dominates when a sample
32836
+ * genuinely resembles somebody the gallery can name. A sample that agrees with
32837
+ * its person scores near zero on both terms.
32838
+ */
32839
+ function auditIdentitySamples(input) {
32840
+ const comparable = input.samples.filter((s) => s.modelId === input.modelId);
32841
+ const rows = input.samples.map((sample) => {
32842
+ if (sample.modelId !== input.modelId) return {
32843
+ sampleId: sample.sampleId,
32844
+ enrolledAt: sample.enrolledAt,
32845
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId },
32846
+ comparable: false,
32847
+ selfMean: null,
32848
+ suspicion: 0
32849
+ };
32850
+ const selfMean = meanCosine(sample, comparable);
32851
+ const foreign = bestForeignMatch(sample, input.others, input.modelId);
32852
+ const suspicion = foreignExcess(selfMean, foreign);
32853
+ return {
32854
+ sampleId: sample.sampleId,
32855
+ enrolledAt: sample.enrolledAt,
32856
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId },
32857
+ comparable: true,
32858
+ selfMean,
32859
+ ...foreign === void 0 ? {} : { bestForeign: foreign },
32860
+ suspicion
32861
+ };
32862
+ });
32863
+ rows.sort((a, b) => {
32864
+ if (b.suspicion !== a.suspicion) return b.suspicion - a.suspicion;
32865
+ return (a.selfMean ?? Number.POSITIVE_INFINITY) - (b.selfMean ?? Number.POSITIVE_INFINITY);
32866
+ });
32867
+ return {
32868
+ identityId: input.identityId,
32869
+ totalSamples: input.samples.length,
32870
+ comparableSamples: comparable.length,
32871
+ flagged: flaggedCount(rows, input.autoAssignThreshold),
32872
+ rows
32873
+ };
32874
+ }
32875
+ //#endregion
32617
32876
  //#region src/pipeline-analytics/face-gallery-provider.ts
32618
32877
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
32619
32878
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -32665,6 +32924,8 @@ var FaceGalleryProvider = class {
32665
32924
  eventStore;
32666
32925
  logger;
32667
32926
  refreshGallery;
32927
+ resolveClusterModelId;
32928
+ resolveAutoAssignThreshold;
32668
32929
  eventMediaBaseUrl;
32669
32930
  eventMediaPathPrefix;
32670
32931
  emitFaceGalleryChanged;
@@ -32676,6 +32937,8 @@ var FaceGalleryProvider = class {
32676
32937
  this.eventStore = deps.eventStore;
32677
32938
  this.logger = deps.logger;
32678
32939
  this.refreshGallery = deps.refreshGallery;
32940
+ this.resolveClusterModelId = deps.resolveClusterModelId;
32941
+ this.resolveAutoAssignThreshold = deps.resolveAutoAssignThreshold;
32679
32942
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
32680
32943
  this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
32681
32944
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
@@ -32752,6 +33015,69 @@ var FaceGalleryProvider = class {
32752
33015
  }
32753
33016
  return result;
32754
33017
  }
33018
+ /**
33019
+ * Re-analyse one person's enrolled samples against the whole gallery.
33020
+ *
33021
+ * Reads every identity's samples and their vectors — 327 on this cluster —
33022
+ * and ranks the target's own by how little they look like the rest of that
33023
+ * person and how much they look like somebody else (`identity-audit.ts`).
33024
+ * Dot products, no inference, no write.
33025
+ *
33026
+ * The whole gallery is loaded on purpose: the accusation that matters is
33027
+ * "this looks like SOMEONE ELSE", and that cannot be computed from one
33028
+ * person's samples.
33029
+ */
33030
+ async auditIdentitySamples(input) {
33031
+ const modelId = await this.resolveClusterModelId();
33032
+ if (modelId === null) throw new Error("auditIdentitySamples: the cluster face-embedding model could not be resolved");
33033
+ const autoAssignThreshold = await this.resolveAutoAssignThreshold();
33034
+ const identities = await this.identityStore.listIdentities();
33035
+ const target = identities.find((i) => i.id === input.identityId);
33036
+ if (target === void 0) throw new Error(`auditIdentitySamples: no identity ${input.identityId}`);
33037
+ const samplesByIdentity = /* @__PURE__ */ new Map();
33038
+ for (const identity of identities) samplesByIdentity.set(identity.id, await this.identityStore.listSamples(identity.id));
33039
+ const vectors = await this.identityStore.loadSampleVectors([...samplesByIdentity.values()].flat().map((sample) => sample.id));
33040
+ const inputsFor = (identityId) => (samplesByIdentity.get(identityId) ?? []).flatMap((sample) => {
33041
+ const embedding = vectors.get(sample.id);
33042
+ if (embedding === void 0) return [];
33043
+ return [{
33044
+ sampleId: sample.id,
33045
+ embedding,
33046
+ modelId: sample.modelId,
33047
+ enrolledAt: sample.addedAt,
33048
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId }
33049
+ }];
33050
+ });
33051
+ const report = auditIdentitySamples({
33052
+ identityId: input.identityId,
33053
+ samples: inputsFor(input.identityId),
33054
+ others: identities.filter((identity) => identity.id !== input.identityId).map((identity) => ({
33055
+ identityId: identity.id,
33056
+ name: identity.name,
33057
+ samples: inputsFor(identity.id)
33058
+ })),
33059
+ modelId,
33060
+ autoAssignThreshold
33061
+ });
33062
+ const meta = new Map((samplesByIdentity.get(input.identityId) ?? []).map((sample) => [sample.id, sample]));
33063
+ return {
33064
+ identityId: report.identityId,
33065
+ name: target.name,
33066
+ modelId,
33067
+ totalSamples: report.totalSamples,
33068
+ comparableSamples: report.comparableSamples,
33069
+ flagged: report.flagged,
33070
+ flagThreshold: autoAssignThreshold,
33071
+ rows: report.rows.map((row) => {
33072
+ const sample = meta.get(row.sampleId);
33073
+ return {
33074
+ ...row,
33075
+ source: sample?.source ?? "detected",
33076
+ ...sample?.mediaKey === void 0 ? {} : { mediaKey: sample.mediaKey }
33077
+ };
33078
+ })
33079
+ };
33080
+ }
32755
33081
  async removeSample(input) {
32756
33082
  const sample = (await this.identityStore.listSamples(input.identityId)).find((s) => s.id === input.sampleId);
32757
33083
  if (sample?.mediaKey) await this.mediaStore.deleteByKey(sample.mediaKey);
@@ -37378,7 +37704,7 @@ var TrackCloser = class {
37378
37704
  async maybePromoteLastFrame(t, ownedMedia) {
37379
37705
  const promotion = decideLastFramePromotion(ownedMedia);
37380
37706
  if (!promotion.promote) {
37381
- if (promotion.declinedBecause !== "already-last") this.deps.logger.warn("no lastFrame at close", {
37707
+ if (promotion.declinedBecause === "no-key-frame") this.deps.logger.warn("no lastFrame at close", {
37382
37708
  tags: { deviceId: t.deviceId },
37383
37709
  meta: {
37384
37710
  trackId: t.trackId,
@@ -57568,6 +57894,9 @@ async function reconcileGallerySamples(deps, options) {
57568
57894
  let keyless = 0;
57569
57895
  let skippedGrace = 0;
57570
57896
  let stoppedByCeiling = false;
57897
+ let missed = 0;
57898
+ /** Verdicts held until there are enough of them to tell rot from a move. */
57899
+ const held = [];
57571
57900
  const identities = await deps.listIdentities();
57572
57901
  for (const identity of identities) {
57573
57902
  const samples = await deps.listSamples(identity.id);
@@ -57612,6 +57941,43 @@ async function reconcileGallerySamples(deps, options) {
57612
57941
  }
57613
57942
  checked += 1;
57614
57943
  if (state === "exists") continue;
57944
+ missed += 1;
57945
+ const minProbes = options.massMissMinProbes ?? 8;
57946
+ const bar = options.massMissFraction ?? .5;
57947
+ if (checked < minProbes) {
57948
+ held.push({
57949
+ identityId: identity.id,
57950
+ sampleId: sample.id,
57951
+ mediaKey: sample.mediaKey
57952
+ });
57953
+ continue;
57954
+ }
57955
+ if (missed / checked >= bar) {
57956
+ const reason = `mass miss: ${String(missed)} of ${String(checked)} probes found no blob`;
57957
+ deps.logger.warn("gallery reconcile: nearly every blob is missing — that is the volume, not the samples. Aborting and keeping every row; check where the gallery location points.", { meta: {
57958
+ checked,
57959
+ missed,
57960
+ held: held.length,
57961
+ identity: identity.name
57962
+ } });
57963
+ return {
57964
+ checked,
57965
+ reclaimed,
57966
+ keyless,
57967
+ skippedGrace,
57968
+ stoppedByCeiling,
57969
+ aborted: reason
57970
+ };
57971
+ }
57972
+ for (const pending of held.splice(0, held.length)) {
57973
+ await deps.removeSample(pending.identityId, pending.sampleId);
57974
+ reclaimed += 1;
57975
+ deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
57976
+ sampleId: pending.sampleId,
57977
+ mediaKey: pending.mediaKey,
57978
+ state: "missing-blob"
57979
+ } });
57980
+ }
57615
57981
  await deps.removeSample(identity.id, sample.id);
57616
57982
  reclaimed += 1;
57617
57983
  deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
@@ -57622,6 +57988,14 @@ async function reconcileGallerySamples(deps, options) {
57622
57988
  } });
57623
57989
  }
57624
57990
  }
57991
+ for (const pending of held.splice(0, held.length)) {
57992
+ if (reclaimed >= options.maxReclaimPerRun) {
57993
+ stoppedByCeiling = true;
57994
+ break;
57995
+ }
57996
+ await deps.removeSample(pending.identityId, pending.sampleId);
57997
+ reclaimed += 1;
57998
+ }
57625
57999
  if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
57626
58000
  checked,
57627
58001
  reclaimed,
@@ -58949,12 +59323,24 @@ var IdentityStore = class {
58949
59323
  id: r.id,
58950
59324
  source: d.source ?? "detected",
58951
59325
  addedAt: Number(d.addedAt ?? 0),
59326
+ modelId: String(d.modelId ?? ""),
58952
59327
  ...d.mediaKey != null ? { mediaKey: d.mediaKey } : {},
58953
59328
  ...d.sourceMediaKey != null ? { sourceMediaKey: d.sourceMediaKey } : {},
58954
59329
  ...d.deviceId != null ? { deviceId: d.deviceId } : {}
58955
59330
  };
58956
59331
  });
58957
59332
  }
59333
+ /**
59334
+ * The enrolled vectors for these sample ids, straight off the index.
59335
+ *
59336
+ * Exposed because the gallery audit compares samples with each other, and
59337
+ * `loadGallery` — the only other vector read — drops the sample id in favour
59338
+ * of the identity id. An audit that cannot name the sample it accuses has
59339
+ * nothing to offer a Remove button.
59340
+ */
59341
+ async loadSampleVectors(sampleIds) {
59342
+ return this.vectors.load(sampleIds);
59343
+ }
58958
59344
  async removeSample(identityId, sampleId) {
58959
59345
  await this.store.delete.mutate({
58960
59346
  collection: IDENTITY_SAMPLES_COLLECTION,
@@ -70866,9 +71252,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70866
71252
  /**
70867
71253
  * The store seam the `debug.*` actions and the debug bundle both read through.
70868
71254
  *
70869
- * INDEX READS ONLY three media-inventory queries and an event query per
70870
- * track, no blob. The bytes are fetched one at a time, by key, by the export
70871
- * handler, so a bundle of a hundred tracks never holds more than one file.
71255
+ * Index reads, plus ONE existence probe per media row never a blob. The
71256
+ * probe is the only thing here that touches the filesystem, and it earns the
71257
+ * trip: every index read in this file counts rows the two serving reads
71258
+ * (`listByOwner`, `getByKey`) silently drop when the bytes are unreachable,
71259
+ * so without it the tab reports tiles the viewer draws blank. The bytes
71260
+ * themselves are still fetched one at a time, by key, by the export handler,
71261
+ * so a bundle of a hundred tracks never holds more than one file.
70872
71262
  */
70873
71263
  buildDebugServiceDeps() {
70874
71264
  const trackStore = this.trackStore;
@@ -70882,6 +71272,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70882
71272
  listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
70883
71273
  listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
70884
71274
  trackThumbnailProvenance: (trackId) => mediaStore.trackThumbnailProvenance(trackId),
71275
+ probeMediaBlob: (key) => mediaStore.blobServable(key),
70885
71276
  logger: this.ctx.logger.child("debug"),
70886
71277
  now: () => Date.now()
70887
71278
  };
@@ -71583,6 +71974,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71583
71974
  eventStore: stores.eventStore,
71584
71975
  logger: this.ctx.logger,
71585
71976
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
71977
+ resolveAutoAssignThreshold: async () => (await this.readDeviceSettings(0, resolveFaceSettings)).similarityThreshold,
71978
+ resolveClusterModelId: async () => {
71979
+ const api = this.ctx.api;
71980
+ if (!api) return null;
71981
+ return resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
71982
+ },
71586
71983
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
71587
71984
  eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
71588
71985
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Di0Bams-.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DCfZE21-.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.207",
3
+ "version": "1.2.209",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",