@camstack/addon-post-analysis 1.2.104 → 1.2.106

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.
@@ -7790,7 +7790,7 @@ var RecordingBandSchema = object({
7790
7790
  * visit is derived, not a second copy of the files.
7791
7791
  */
7792
7792
  var VISIT_MERGE_GAP_MS = {
7793
- preBufferSec: 10,
7793
+ preBufferSec: 15,
7794
7794
  postBufferSec: 30
7795
7795
  }.postBufferSec * 1e3;
7796
7796
  /**
@@ -17208,7 +17208,7 @@ var TrackEnvelopeSchema = object({
17208
17208
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
17209
17209
  * keeps every scalar the list surfaces actually render (ids, class(es),
17210
17210
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
17211
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
17211
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
17212
17212
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
17213
17213
  * `getTrack`. Mirrors the event-store `projection` convention
17214
17214
  * (`getObjectEvents` et al.).
@@ -17503,6 +17503,35 @@ var TrackSchema = object({
17503
17503
  */
17504
17504
  hasFace: boolean().optional(),
17505
17505
  /**
17506
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
17507
+ * face an operator could ASSIGN to an identity.
17508
+ *
17509
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
17510
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
17511
+ * branch, before every gate, and means no more than "a face detector produced
17512
+ * a face detail". This one is stamped at the single moment the gallery row
17513
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
17514
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
17515
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
17516
+ * the crop-store drop. Everything between the detector and that insert can
17517
+ * legitimately refuse the face, so a flag written any earlier promises the
17518
+ * operator something to assign and delivers nothing.
17519
+ *
17520
+ * **Independent of recognition.** A face collected but never auto-matched is
17521
+ * still assignable — it is in fact the face an operator most wants to reach —
17522
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
17523
+ * `subLabel`; this says only that the raw material exists.
17524
+ *
17525
+ * **Set once, never cleared.** A track that produced a gallery row produced
17526
+ * one; deleting the row later is the gallery's business, not this flag's.
17527
+ *
17528
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
17529
+ * before the column omits it, and so does every server that predates the
17530
+ * field. A consumer must test `=== true` and render nothing otherwise —
17531
+ * never infer "no assignable face".
17532
+ */
17533
+ hasEmbeddedFace: boolean().optional(),
17534
+ /**
17506
17535
  * This subject CONTAINS a folded rider — a person the rider-pairing step
17507
17536
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
17508
17537
  * so the passage is tracked once and as a VEHICLE.
@@ -19299,6 +19328,26 @@ var DetailResultSchema = object({
19299
19328
  * the consuming gate treats as accept — never as reject.
19300
19329
  */
19301
19330
  embeddingMagnitude: number().optional(),
19331
+ /**
19332
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
19333
+ * `label`, and for the same reason: the producing node is the only side that
19334
+ * knows which model actually ran, and re-deriving it downstream is exactly
19335
+ * the inference this model exists to forbid.
19336
+ *
19337
+ * Additive and optional, so a runner that predates it degrades to
19338
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
19339
+ * its previous assumption instead of refusing.
19340
+ *
19341
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
19342
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
19343
+ * with nothing on the wire it took the id from the GALLERY, so the gate
19344
+ * compared the gallery against itself and could never fire. That turned the
19345
+ * one guard protecting the face feature space into a tautology, and it is
19346
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
19347
+ * gallery as the catalog comment predicts — it would have scored the new
19348
+ * model's vectors against the old model's samples in one cosine space.
19349
+ */
19350
+ embeddingModelId: string().optional(),
19302
19351
  label: string().optional(),
19303
19352
  /**
19304
19353
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -38885,6 +38934,140 @@ function readTimelapseGeneratedAt(rule, deviceId) {
38885
38934
  if (map !== void 0) return map[String(deviceId)] ?? 0;
38886
38935
  return rule.lastGeneratedAt ?? 0;
38887
38936
  }
38937
+ /**
38938
+ * A model choice has a SCOPE, and a scope change has a workflow.
38939
+ *
38940
+ * ## The two scopes
38941
+ *
38942
+ * Almost every pipeline step's model is a **node** question: which build runs
38943
+ * fastest on THIS accelerator. Two nodes may legitimately answer differently —
38944
+ * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
38945
+ * are compared against nothing. They produce boxes, and a box is a box.
38946
+ *
38947
+ * Two steps are not like that. `face-embedding` and `clip-embedding` produce
38948
+ * **vectors that land in ONE shared index** (`identity_samples`,
38949
+ * `vec_object_clip`) and are ranked against each other by cosine similarity.
38950
+ * Cosine similarity between two encoders is not a weak signal, it is a
38951
+ * meaningless number — and nothing inside a vector can tell you it happened.
38952
+ * So for those two steps the model is a **cluster** question with exactly one
38953
+ * answer, stored in exactly one row.
38954
+ *
38955
+ * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
38956
+ * for the crop convention (`detail-crop.ts`), one level up: the crop decides
38957
+ * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
38958
+ * cluster-wide for the same reason and neither may become per-node.
38959
+ *
38960
+ * ## Why the registry lives here and not in the step catalog
38961
+ *
38962
+ * The scope itself is declared on the step, in
38963
+ * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
38964
+ * — that is where a new step declares what kind of choice its model is. But the
38965
+ * catalog lives inside `addon-pipeline`, and three packages that must NOT
38966
+ * import an addon need to know the set: the orchestrator (which renders and
38967
+ * stores the row), post-analysis (which pins the row for a re-embed pass) and
38968
+ * admin-ui (which mirrors it). So the set is mirrored here, and
38969
+ * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
38970
+ * A hand-copied key that stops matching is exactly how a setting silently stops
38971
+ * arriving while both sides still look correct.
38972
+ *
38973
+ * ## The defaults ARE today's behaviour
38974
+ *
38975
+ * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
38976
+ * of both steps. Declaring the scope therefore changes nothing on the day it
38977
+ * ships: the cluster row starts out saying what every node was already doing.
38978
+ * Changing it is legitimate — that is what the knob is for — but it invalidates
38979
+ * the stored vectors of that surface, and must be followed by a re-embed pass
38980
+ * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
38981
+ */
38982
+ /**
38983
+ * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
38984
+ * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
38985
+ */
38986
+ var CLUSTER_MODEL_SCOPED_STEPS = [{
38987
+ stepId: "face-embedding",
38988
+ label: "Face recognition model",
38989
+ defaultModelId: "arcface-r100",
38990
+ indexName: "the enrolled face gallery",
38991
+ options: [
38992
+ {
38993
+ id: "arcface-r100",
38994
+ label: "ArcFace (ResNet34)"
38995
+ },
38996
+ {
38997
+ id: "auraface-r100",
38998
+ label: "AuraFace R100"
38999
+ },
39000
+ {
39001
+ id: "inception-resnet-v1",
39002
+ label: "Inception ResNet V1"
39003
+ }
39004
+ ]
39005
+ }, {
39006
+ stepId: "clip-embedding",
39007
+ label: "Semantic search model",
39008
+ defaultModelId: "mobileclip-s1",
39009
+ indexName: "the object semantic-search index",
39010
+ options: [{
39011
+ id: "mobileclip-s1",
39012
+ label: "MobileCLIP S1"
39013
+ }, {
39014
+ id: "mobileclip-s2",
39015
+ label: "MobileCLIP S2"
39016
+ }]
39017
+ }];
39018
+ /**
39019
+ * The settings key holding one step's cluster model choice.
39020
+ *
39021
+ * Step-qualified so the keys stay unique across the addon's whole schema, which
39022
+ * is what lets {@link pickClusterStepModels} walk every section instead of
39023
+ * trusting the section id.
39024
+ */
39025
+ function clusterModelSettingKey(stepId) {
39026
+ return `clusterModel:${stepId}`;
39027
+ }
39028
+ /** The cluster row when nobody has configured one — today's catalog defaults. */
39029
+ var DEFAULT_CLUSTER_STEP_MODELS = Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
39030
+ /** A stored choice is a non-empty model id, or it is not a choice. */
39031
+ var ChosenModelIdSchema = string().min(1);
39032
+ /**
39033
+ * Narrow a FLAT settings record to the cluster row.
39034
+ *
39035
+ * Per-FIELD fallback, deliberately (same rule as `readDetailCropConvention`): a
39036
+ * junk face model must not also discard a valid clip model. An absent, empty or
39037
+ * non-string value resolves to the step's catalog default — the historical
39038
+ * behaviour — never to a blank id, because a blank id downstream becomes
39039
+ * "substitute the format default", which is precisely the substitution this
39040
+ * scope exists to forbid.
39041
+ */
39042
+ function readClusterStepModels(config) {
39043
+ const out = {};
39044
+ for (const step of CLUSTER_MODEL_SCOPED_STEPS) {
39045
+ const parsed = ChosenModelIdSchema.safeParse(config[clusterModelSettingKey(step.stepId)]);
39046
+ out[step.stepId] = parsed.success ? parsed.data : step.defaultModelId;
39047
+ }
39048
+ return out;
39049
+ }
39050
+ function isHydratedField$2(entry) {
39051
+ return typeof entry === "object" && entry !== null && "key" in entry;
39052
+ }
39053
+ /**
39054
+ * Extract the cluster row from an `addon-settings.getGlobalSettings` payload.
39055
+ *
39056
+ * Walks EVERY section rather than looking inside {@link CLUSTER_MODEL_SECTION_ID}
39057
+ * alone: the keys are unique across the addon's schema, and a section rename
39058
+ * must not silently revert the whole cluster to the defaults. A `null` payload
39059
+ * (addon mid-boot) is the defaults.
39060
+ */
39061
+ function pickClusterStepModels(view) {
39062
+ if (view === null) return DEFAULT_CLUSTER_STEP_MODELS;
39063
+ const flat = {};
39064
+ const wanted = new Set(CLUSTER_MODEL_SCOPED_STEPS.map((s) => clusterModelSettingKey(s.stepId)));
39065
+ for (const section of view.sections) for (const entry of section.fields) {
39066
+ if (!isHydratedField$2(entry) || typeof entry.key !== "string") continue;
39067
+ if (wanted.has(entry.key)) flat[entry.key] = entry.value;
39068
+ }
39069
+ return readClusterStepModels(flat);
39070
+ }
38888
39071
  object({
38889
39072
  /**
38890
39073
  * Fraction of the box's own size added on EACH side before cutting.
@@ -38905,10 +39088,39 @@ object({
38905
39088
  */
38906
39089
  square: boolean()
38907
39090
  });
38908
- ({
39091
+ /**
39092
+ * The convention in force when nobody has configured one — byte-for-byte the
39093
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
39094
+ */
39095
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
38909
39096
  paddingRatio: .15,
38910
39097
  square: false
38911
- }).paddingRatio;
39098
+ };
39099
+ /**
39100
+ * The normalised box that means "the image IS the subject".
39101
+ *
39102
+ * Used when the stored pixels are ALREADY the model's input — the 112×112
39103
+ * aligned face template a gallery sample carries. There is no rectangle left to
39104
+ * choose, and the convention must not choose one: re-detecting and re-aligning
39105
+ * over a template applies a SECOND warp, passes every gate, and produces a
39106
+ * plausible useless vector (session 2026-08-20 §3).
39107
+ *
39108
+ * Sending it as the whole frame with this box is exact rather than merely
39109
+ * convenient, and the reason is a property of {@link deriveDetailCropRect}, not
39110
+ * a coincidence: padding a full-frame box can only push OUTSIDE the frame, and
39111
+ * both edge modes bring it straight back — unsquared truncates, squared cannot
39112
+ * exceed the frame's short side and then slides inside. So for a SQUARE image
39113
+ * the derivation is the identity under every convention value, and an operator
39114
+ * changing the crop margin cannot silently re-cut an enrolled template.
39115
+ * `face-reembed-pass.spec.ts` asserts that identity against the real function.
39116
+ */
39117
+ var FULL_IMAGE_BBOX = Object.freeze({
39118
+ x: 0,
39119
+ y: 0,
39120
+ w: 1,
39121
+ h: 1
39122
+ });
39123
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
38912
39124
  /**
38913
39125
  * WHICH delivered frames the decode worker retains a native copy of.
38914
39126
  *
@@ -39364,4 +39576,4 @@ function vectorDimFromBase64(encoded) {
39364
39576
  return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
39365
39577
  }
39366
39578
  //#endregion
39367
- export { encodeVectorBase64 as $, RetrainStatusSchema as A, literal as At, TrackSourceSchema as B, NcSnoozeSchema as C, isDeviceScopedCap as Ct, OpsLogEntrySchema as D, array as Dt, NcTaxonomySchema as E, _enum as Et, SceneMonitorSchema as F, string as Ft, audioMetricsCapability as G, addonWidgetsSourceCapability as H, TIMELAPSE_DENSE_FLOOR_SEC as I, unknown as It, cosineSimilarity as J, audioModeOf as K, TimelapseRuleInputSchema as L, EventCategory as Lt, SCENE_DEFAULT_ANCHOR_THRESHOLD as M, object as Mt, SCENE_DEFAULT_UNCOVERED_POLICY as N, partialRecord as Nt, PoolMemoryWatchdog as O, boolean as Ot, SCENE_DIVERGED as P, record as Pt, embeddingEncoderCapability as Q, TimelapseRulePatchSchema as R, NcSnoozeInputSchema as S, hydrateSchema as St, NcSystemEventKindSchema as T, sleep as Tt, alarmPanelCapability as U, VISIT_MERGE_GAP_MS as V, assertTimelapseCadences as W, defineCustomActions as X, customAction as Y, deriveRecordingMode as Z, NcRuleInputSchema as _, zoneAnalyticsCapability as _t, DETECTION_PIPELINE_CAP_NAME as a, notificationRulesCapability as at, NcRuleTargetSchema as b, DeviceType as bt, EVENT_PAD_MS as c, plateGalleryCapability as ct, NC_ALARM_SYSTEM_EVENT_KINDS as d, resolvePoolMemoryPolicy as dt, faceGalleryCapability as et, NC_CONDITION_CATALOG as f, sceneMonitorCapability as ft, NcConditionDescriptorSchema as g, videoclipsCapability as gt, NC_TAXONOMY as h, vectorDimFromBase64 as ht, DETECTION_MACRO_CLASSES as i, kebabToCamel as it, SCENE_CONFIRM_DEFAULT_TIMEOUT_MS as j, number as jt, RECORDING_EXPORT_MAX_READ_BYTES as k, discriminatedUnion as kt, LabelAttributionSchema as l, readDeviceStateFrom as lt, NC_SNOOZE_MAX_MINUTES as m, systemEventFilterApplies as mt, DEFAULT_EVENT_COLOR as n, isDetectionMacroClass as nt, DeclaredDevices as o, parseProcStatus as ot, NC_DEFAULT_SNOOZE_MINUTES as p, subKindsOf as pt, buildEventKindDescriptor as q, DEFAULT_TIMELAPSE_PREVIEW_TEXT as r, isScheduleActive as rt, EVENT_KIND_BY_CAP as s, pipelineAnalyticsCapability as st, BaseDevice as t, hfModelUrl as tt, MACRO_LABELS as u, readTimelapseGeneratedAt as ut, NcRulePatchSchema as v, errMsg as vt, NcSnoozeSuppressedSchema as w, nodePin as wt, NcScheduleSchema as x, createEvent as xt, NcRuleSchema as y, BaseAddon as yt, TimelapseRuleSchema as z };
39579
+ export { deriveRecordingMode as $, PoolMemoryWatchdog as A, array as At, TimelapseRulePatchSchema as B, EventCategory as Bt, NcScheduleSchema as C, DeviceType as Ct, NcSystemEventKindSchema as D, nodePin as Dt, NcSnoozeSuppressedSchema as E, isDeviceScopedCap as Et, SCENE_DEFAULT_UNCOVERED_POLICY as F, object as Ft, alarmPanelCapability as G, TrackSourceSchema as H, SCENE_DIVERGED as I, partialRecord as It, audioModeOf as J, assertTimelapseCadences as K, SceneMonitorSchema as L, record as Lt, RetrainStatusSchema as M, discriminatedUnion as Mt, SCENE_CONFIRM_DEFAULT_TIMEOUT_MS as N, literal as Nt, NcTaxonomySchema as O, sleep as Ot, SCENE_DEFAULT_ANCHOR_THRESHOLD as P, number as Pt, defineCustomActions as Q, TIMELAPSE_DENSE_FLOOR_SEC as R, string as Rt, NcRuleTargetSchema as S, BaseAddon as St, NcSnoozeSchema as T, hydrateSchema as Tt, VISIT_MERGE_GAP_MS as U, TimelapseRuleSchema as V, addonWidgetsSourceCapability as W, cosineSimilarity as X, buildEventKindDescriptor as Y, customAction as Z, NC_TAXONOMY as _, systemEventFilterApplies as _t, DETECTION_MACRO_CLASSES as a, isScheduleActive as at, NcRulePatchSchema as b, zoneAnalyticsCapability as bt, EVENT_KIND_BY_CAP as c, parseProcStatus as ct, LabelAttributionSchema as d, plateGalleryCapability as dt, embeddingEncoderCapability as et, MACRO_LABELS as f, readDeviceStateFrom as ft, NC_SNOOZE_MAX_MINUTES as g, subKindsOf as gt, NC_DEFAULT_SNOOZE_MINUTES as h, sceneMonitorCapability as ht, DEFAULT_TIMELAPSE_PREVIEW_TEXT as i, isDetectionMacroClass as it, RECORDING_EXPORT_MAX_READ_BYTES as j, boolean as jt, OpsLogEntrySchema as k, _enum as kt, EVENT_PAD_MS as l, pickClusterStepModels as lt, NC_CONDITION_CATALOG as m, resolvePoolMemoryPolicy as mt, CLUSTER_MODEL_SCOPED_STEPS as n, faceGalleryCapability as nt, DETECTION_PIPELINE_CAP_NAME as o, kebabToCamel as ot, NC_ALARM_SYSTEM_EVENT_KINDS as p, readTimelapseGeneratedAt as pt, audioMetricsCapability as q, DEFAULT_EVENT_COLOR as r, hfModelUrl as rt, DeclaredDevices as s, notificationRulesCapability as st, BaseDevice as t, encodeVectorBase64 as tt, FULL_IMAGE_BBOX as u, pipelineAnalyticsCapability as ut, NcConditionDescriptorSchema as v, vectorDimFromBase64 as vt, NcSnoozeInputSchema as w, createEvent as wt, NcRuleSchema as x, errMsg as xt, NcRuleInputSchema as y, videoclipsCapability as yt, TimelapseRuleInputSchema as z, unknown as zt };
@@ -7821,7 +7821,7 @@ var RecordingBandSchema = object({
7821
7821
  * visit is derived, not a second copy of the files.
7822
7822
  */
7823
7823
  var VISIT_MERGE_GAP_MS = {
7824
- preBufferSec: 10,
7824
+ preBufferSec: 15,
7825
7825
  postBufferSec: 30
7826
7826
  }.postBufferSec * 1e3;
7827
7827
  /**
@@ -17239,7 +17239,7 @@ var TrackEnvelopeSchema = object({
17239
17239
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
17240
17240
  * keeps every scalar the list surfaces actually render (ids, class(es),
17241
17241
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
17242
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
17242
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
17243
17243
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
17244
17244
  * `getTrack`. Mirrors the event-store `projection` convention
17245
17245
  * (`getObjectEvents` et al.).
@@ -17534,6 +17534,35 @@ var TrackSchema = object({
17534
17534
  */
17535
17535
  hasFace: boolean().optional(),
17536
17536
  /**
17537
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
17538
+ * face an operator could ASSIGN to an identity.
17539
+ *
17540
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
17541
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
17542
+ * branch, before every gate, and means no more than "a face detector produced
17543
+ * a face detail". This one is stamped at the single moment the gallery row
17544
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
17545
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
17546
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
17547
+ * the crop-store drop. Everything between the detector and that insert can
17548
+ * legitimately refuse the face, so a flag written any earlier promises the
17549
+ * operator something to assign and delivers nothing.
17550
+ *
17551
+ * **Independent of recognition.** A face collected but never auto-matched is
17552
+ * still assignable — it is in fact the face an operator most wants to reach —
17553
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
17554
+ * `subLabel`; this says only that the raw material exists.
17555
+ *
17556
+ * **Set once, never cleared.** A track that produced a gallery row produced
17557
+ * one; deleting the row later is the gallery's business, not this flag's.
17558
+ *
17559
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
17560
+ * before the column omits it, and so does every server that predates the
17561
+ * field. A consumer must test `=== true` and render nothing otherwise —
17562
+ * never infer "no assignable face".
17563
+ */
17564
+ hasEmbeddedFace: boolean().optional(),
17565
+ /**
17537
17566
  * This subject CONTAINS a folded rider — a person the rider-pairing step
17538
17567
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
17539
17568
  * so the passage is tracked once and as a VEHICLE.
@@ -19330,6 +19359,26 @@ var DetailResultSchema = object({
19330
19359
  * the consuming gate treats as accept — never as reject.
19331
19360
  */
19332
19361
  embeddingMagnitude: number().optional(),
19362
+ /**
19363
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
19364
+ * `label`, and for the same reason: the producing node is the only side that
19365
+ * knows which model actually ran, and re-deriving it downstream is exactly
19366
+ * the inference this model exists to forbid.
19367
+ *
19368
+ * Additive and optional, so a runner that predates it degrades to
19369
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
19370
+ * its previous assumption instead of refusing.
19371
+ *
19372
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
19373
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
19374
+ * with nothing on the wire it took the id from the GALLERY, so the gate
19375
+ * compared the gallery against itself and could never fire. That turned the
19376
+ * one guard protecting the face feature space into a tautology, and it is
19377
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
19378
+ * gallery as the catalog comment predicts — it would have scored the new
19379
+ * model's vectors against the old model's samples in one cosine space.
19380
+ */
19381
+ embeddingModelId: string().optional(),
19333
19382
  label: string().optional(),
19334
19383
  /**
19335
19384
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -38916,6 +38965,140 @@ function readTimelapseGeneratedAt(rule, deviceId) {
38916
38965
  if (map !== void 0) return map[String(deviceId)] ?? 0;
38917
38966
  return rule.lastGeneratedAt ?? 0;
38918
38967
  }
38968
+ /**
38969
+ * A model choice has a SCOPE, and a scope change has a workflow.
38970
+ *
38971
+ * ## The two scopes
38972
+ *
38973
+ * Almost every pipeline step's model is a **node** question: which build runs
38974
+ * fastest on THIS accelerator. Two nodes may legitimately answer differently —
38975
+ * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
38976
+ * are compared against nothing. They produce boxes, and a box is a box.
38977
+ *
38978
+ * Two steps are not like that. `face-embedding` and `clip-embedding` produce
38979
+ * **vectors that land in ONE shared index** (`identity_samples`,
38980
+ * `vec_object_clip`) and are ranked against each other by cosine similarity.
38981
+ * Cosine similarity between two encoders is not a weak signal, it is a
38982
+ * meaningless number — and nothing inside a vector can tell you it happened.
38983
+ * So for those two steps the model is a **cluster** question with exactly one
38984
+ * answer, stored in exactly one row.
38985
+ *
38986
+ * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
38987
+ * for the crop convention (`detail-crop.ts`), one level up: the crop decides
38988
+ * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
38989
+ * cluster-wide for the same reason and neither may become per-node.
38990
+ *
38991
+ * ## Why the registry lives here and not in the step catalog
38992
+ *
38993
+ * The scope itself is declared on the step, in
38994
+ * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
38995
+ * — that is where a new step declares what kind of choice its model is. But the
38996
+ * catalog lives inside `addon-pipeline`, and three packages that must NOT
38997
+ * import an addon need to know the set: the orchestrator (which renders and
38998
+ * stores the row), post-analysis (which pins the row for a re-embed pass) and
38999
+ * admin-ui (which mirrors it). So the set is mirrored here, and
39000
+ * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
39001
+ * A hand-copied key that stops matching is exactly how a setting silently stops
39002
+ * arriving while both sides still look correct.
39003
+ *
39004
+ * ## The defaults ARE today's behaviour
39005
+ *
39006
+ * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
39007
+ * of both steps. Declaring the scope therefore changes nothing on the day it
39008
+ * ships: the cluster row starts out saying what every node was already doing.
39009
+ * Changing it is legitimate — that is what the knob is for — but it invalidates
39010
+ * the stored vectors of that surface, and must be followed by a re-embed pass
39011
+ * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
39012
+ */
39013
+ /**
39014
+ * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
39015
+ * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
39016
+ */
39017
+ var CLUSTER_MODEL_SCOPED_STEPS = [{
39018
+ stepId: "face-embedding",
39019
+ label: "Face recognition model",
39020
+ defaultModelId: "arcface-r100",
39021
+ indexName: "the enrolled face gallery",
39022
+ options: [
39023
+ {
39024
+ id: "arcface-r100",
39025
+ label: "ArcFace (ResNet34)"
39026
+ },
39027
+ {
39028
+ id: "auraface-r100",
39029
+ label: "AuraFace R100"
39030
+ },
39031
+ {
39032
+ id: "inception-resnet-v1",
39033
+ label: "Inception ResNet V1"
39034
+ }
39035
+ ]
39036
+ }, {
39037
+ stepId: "clip-embedding",
39038
+ label: "Semantic search model",
39039
+ defaultModelId: "mobileclip-s1",
39040
+ indexName: "the object semantic-search index",
39041
+ options: [{
39042
+ id: "mobileclip-s1",
39043
+ label: "MobileCLIP S1"
39044
+ }, {
39045
+ id: "mobileclip-s2",
39046
+ label: "MobileCLIP S2"
39047
+ }]
39048
+ }];
39049
+ /**
39050
+ * The settings key holding one step's cluster model choice.
39051
+ *
39052
+ * Step-qualified so the keys stay unique across the addon's whole schema, which
39053
+ * is what lets {@link pickClusterStepModels} walk every section instead of
39054
+ * trusting the section id.
39055
+ */
39056
+ function clusterModelSettingKey(stepId) {
39057
+ return `clusterModel:${stepId}`;
39058
+ }
39059
+ /** The cluster row when nobody has configured one — today's catalog defaults. */
39060
+ var DEFAULT_CLUSTER_STEP_MODELS = Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
39061
+ /** A stored choice is a non-empty model id, or it is not a choice. */
39062
+ var ChosenModelIdSchema = string().min(1);
39063
+ /**
39064
+ * Narrow a FLAT settings record to the cluster row.
39065
+ *
39066
+ * Per-FIELD fallback, deliberately (same rule as `readDetailCropConvention`): a
39067
+ * junk face model must not also discard a valid clip model. An absent, empty or
39068
+ * non-string value resolves to the step's catalog default — the historical
39069
+ * behaviour — never to a blank id, because a blank id downstream becomes
39070
+ * "substitute the format default", which is precisely the substitution this
39071
+ * scope exists to forbid.
39072
+ */
39073
+ function readClusterStepModels(config) {
39074
+ const out = {};
39075
+ for (const step of CLUSTER_MODEL_SCOPED_STEPS) {
39076
+ const parsed = ChosenModelIdSchema.safeParse(config[clusterModelSettingKey(step.stepId)]);
39077
+ out[step.stepId] = parsed.success ? parsed.data : step.defaultModelId;
39078
+ }
39079
+ return out;
39080
+ }
39081
+ function isHydratedField$2(entry) {
39082
+ return typeof entry === "object" && entry !== null && "key" in entry;
39083
+ }
39084
+ /**
39085
+ * Extract the cluster row from an `addon-settings.getGlobalSettings` payload.
39086
+ *
39087
+ * Walks EVERY section rather than looking inside {@link CLUSTER_MODEL_SECTION_ID}
39088
+ * alone: the keys are unique across the addon's schema, and a section rename
39089
+ * must not silently revert the whole cluster to the defaults. A `null` payload
39090
+ * (addon mid-boot) is the defaults.
39091
+ */
39092
+ function pickClusterStepModels(view) {
39093
+ if (view === null) return DEFAULT_CLUSTER_STEP_MODELS;
39094
+ const flat = {};
39095
+ const wanted = new Set(CLUSTER_MODEL_SCOPED_STEPS.map((s) => clusterModelSettingKey(s.stepId)));
39096
+ for (const section of view.sections) for (const entry of section.fields) {
39097
+ if (!isHydratedField$2(entry) || typeof entry.key !== "string") continue;
39098
+ if (wanted.has(entry.key)) flat[entry.key] = entry.value;
39099
+ }
39100
+ return readClusterStepModels(flat);
39101
+ }
38919
39102
  object({
38920
39103
  /**
38921
39104
  * Fraction of the box's own size added on EACH side before cutting.
@@ -38936,10 +39119,39 @@ object({
38936
39119
  */
38937
39120
  square: boolean()
38938
39121
  });
38939
- ({
39122
+ /**
39123
+ * The convention in force when nobody has configured one — byte-for-byte the
39124
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
39125
+ */
39126
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
38940
39127
  paddingRatio: .15,
38941
39128
  square: false
38942
- }).paddingRatio;
39129
+ };
39130
+ /**
39131
+ * The normalised box that means "the image IS the subject".
39132
+ *
39133
+ * Used when the stored pixels are ALREADY the model's input — the 112×112
39134
+ * aligned face template a gallery sample carries. There is no rectangle left to
39135
+ * choose, and the convention must not choose one: re-detecting and re-aligning
39136
+ * over a template applies a SECOND warp, passes every gate, and produces a
39137
+ * plausible useless vector (session 2026-08-20 §3).
39138
+ *
39139
+ * Sending it as the whole frame with this box is exact rather than merely
39140
+ * convenient, and the reason is a property of {@link deriveDetailCropRect}, not
39141
+ * a coincidence: padding a full-frame box can only push OUTSIDE the frame, and
39142
+ * both edge modes bring it straight back — unsquared truncates, squared cannot
39143
+ * exceed the frame's short side and then slides inside. So for a SQUARE image
39144
+ * the derivation is the identity under every convention value, and an operator
39145
+ * changing the crop margin cannot silently re-cut an enrolled template.
39146
+ * `face-reembed-pass.spec.ts` asserts that identity against the real function.
39147
+ */
39148
+ var FULL_IMAGE_BBOX = Object.freeze({
39149
+ x: 0,
39150
+ y: 0,
39151
+ w: 1,
39152
+ h: 1
39153
+ });
39154
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
38943
39155
  /**
38944
39156
  * WHICH delivered frames the decode worker retains a native copy of.
38945
39157
  *
@@ -39407,6 +39619,12 @@ Object.defineProperty(exports, "BaseDevice", {
39407
39619
  return BaseDevice;
39408
39620
  }
39409
39621
  });
39622
+ Object.defineProperty(exports, "CLUSTER_MODEL_SCOPED_STEPS", {
39623
+ enumerable: true,
39624
+ get: function() {
39625
+ return CLUSTER_MODEL_SCOPED_STEPS;
39626
+ }
39627
+ });
39410
39628
  Object.defineProperty(exports, "DEFAULT_EVENT_COLOR", {
39411
39629
  enumerable: true,
39412
39630
  get: function() {
@@ -39461,6 +39679,12 @@ Object.defineProperty(exports, "EventCategory", {
39461
39679
  return EventCategory;
39462
39680
  }
39463
39681
  });
39682
+ Object.defineProperty(exports, "FULL_IMAGE_BBOX", {
39683
+ enumerable: true,
39684
+ get: function() {
39685
+ return FULL_IMAGE_BBOX;
39686
+ }
39687
+ });
39464
39688
  Object.defineProperty(exports, "LabelAttributionSchema", {
39465
39689
  enumerable: true,
39466
39690
  get: function() {
@@ -39863,6 +40087,12 @@ Object.defineProperty(exports, "partialRecord", {
39863
40087
  return partialRecord;
39864
40088
  }
39865
40089
  });
40090
+ Object.defineProperty(exports, "pickClusterStepModels", {
40091
+ enumerable: true,
40092
+ get: function() {
40093
+ return pickClusterStepModels;
40094
+ }
40095
+ });
39866
40096
  Object.defineProperty(exports, "pipelineAnalyticsCapability", {
39867
40097
  enumerable: true,
39868
40098
  get: function() {