@trillboards/edge-sdk 0.2.138 → 0.2.150

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.
Files changed (191) hide show
  1. package/README.md +2 -2
  2. package/deploy/detector/pyproject.toml +20 -2
  3. package/deploy/detector/tests/test_execution.py +124 -0
  4. package/deploy/detector/tests/test_model_identity.py +80 -0
  5. package/deploy/detector/tests/test_registry.py +18 -0
  6. package/deploy/detector/tests/test_server.py +3 -1
  7. package/deploy/detector/tests/test_version_stamp.py +4 -1
  8. package/deploy/detector/trillboards_detector/_version.py +2 -2
  9. package/deploy/detector/trillboards_detector/cli.py +3 -0
  10. package/deploy/detector/trillboards_detector/detections.py +7 -0
  11. package/deploy/detector/trillboards_detector/errors.py +4 -0
  12. package/deploy/detector/trillboards_detector/execution.py +109 -0
  13. package/deploy/detector/trillboards_detector/loader.py +6 -3
  14. package/deploy/detector/trillboards_detector/registry.py +27 -5
  15. package/deploy/detector/trillboards_detector/server.py +29 -2
  16. package/deploy/detector/trillboards_detector/session.py +15 -3
  17. package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
  18. package/deploy/litertlm/test_verify_model_proof.py +30 -0
  19. package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
  20. package/deploy/litertlm/verify_model_proof.py +30 -0
  21. package/deploy/macos/TrillboardsSetupApp.m +131 -3
  22. package/deploy/macos/build-macos-pkg.sh +13 -10
  23. package/deploy/macos/scripts/setup-detector.sh +2 -2
  24. package/deploy/macos/scripts/setup-litertlm.sh +66 -30
  25. package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
  26. package/deploy/setup-guidance.json +112 -0
  27. package/deploy/systemd/setup-detector.sh +2 -2
  28. package/deploy/systemd/setup-litertlm.sh +51 -27
  29. package/deploy/windows/build-windows.ps1 +11 -6
  30. package/deploy/windows/bundle-windows-installer.ps1 +3 -3
  31. package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
  32. package/deploy/windows/install-update.ps1 +52 -9
  33. package/deploy/windows/set-executable-icon.ps1 +24 -5
  34. package/deploy/windows/setup-detector.ps1 +142 -4
  35. package/deploy/windows/setup-litertlm.ps1 +53 -10
  36. package/deploy/windows/trillboards-setup.ps1 +101 -2
  37. package/dist/AudienceAnalyzeClient.d.ts +10 -0
  38. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  39. package/dist/AudienceAnalyzeClient.js +5 -4
  40. package/dist/AudienceAnalyzeClient.js.map +1 -1
  41. package/dist/CloudSensingManager.d.ts +38 -1
  42. package/dist/CloudSensingManager.d.ts.map +1 -1
  43. package/dist/CloudSensingManager.js +403 -25
  44. package/dist/CloudSensingManager.js.map +1 -1
  45. package/dist/DetectorFieldProducer.d.ts +5 -1
  46. package/dist/DetectorFieldProducer.d.ts.map +1 -1
  47. package/dist/DetectorFieldProducer.js +8 -1
  48. package/dist/DetectorFieldProducer.js.map +1 -1
  49. package/dist/DurableVlmCandidateSpool.d.ts +83 -2
  50. package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
  51. package/dist/DurableVlmCandidateSpool.js +596 -30
  52. package/dist/DurableVlmCandidateSpool.js.map +1 -1
  53. package/dist/EdgeAgent.js +1 -1
  54. package/dist/FrameGroupingShadow.d.ts +51 -0
  55. package/dist/FrameGroupingShadow.d.ts.map +1 -0
  56. package/dist/FrameGroupingShadow.js +158 -0
  57. package/dist/FrameGroupingShadow.js.map +1 -0
  58. package/dist/ImageClassifierFieldProducer.d.ts +36 -0
  59. package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
  60. package/dist/ImageClassifierFieldProducer.js +150 -0
  61. package/dist/ImageClassifierFieldProducer.js.map +1 -0
  62. package/dist/LiteRtVlmProvider.d.ts +5 -0
  63. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  64. package/dist/LiteRtVlmProvider.js +72 -7
  65. package/dist/LiteRtVlmProvider.js.map +1 -1
  66. package/dist/LocalDetectorClient.d.ts +6 -0
  67. package/dist/LocalDetectorClient.d.ts.map +1 -1
  68. package/dist/LocalDetectorClient.js +8 -0
  69. package/dist/LocalDetectorClient.js.map +1 -1
  70. package/dist/OrderedFrameReview.d.ts +57 -0
  71. package/dist/OrderedFrameReview.d.ts.map +1 -0
  72. package/dist/OrderedFrameReview.js +121 -0
  73. package/dist/OrderedFrameReview.js.map +1 -0
  74. package/dist/OrderedReviewClient.d.ts +38 -0
  75. package/dist/OrderedReviewClient.d.ts.map +1 -0
  76. package/dist/OrderedReviewClient.js +121 -0
  77. package/dist/OrderedReviewClient.js.map +1 -0
  78. package/dist/OrderedSequenceStore.d.ts +69 -0
  79. package/dist/OrderedSequenceStore.d.ts.map +1 -0
  80. package/dist/OrderedSequenceStore.js +257 -0
  81. package/dist/OrderedSequenceStore.js.map +1 -0
  82. package/dist/ProfileReviewWork.d.ts +68 -0
  83. package/dist/ProfileReviewWork.d.ts.map +1 -0
  84. package/dist/ProfileReviewWork.js +150 -0
  85. package/dist/ProfileReviewWork.js.map +1 -0
  86. package/dist/RetainedFieldContract.d.ts +21 -0
  87. package/dist/RetainedFieldContract.d.ts.map +1 -0
  88. package/dist/RetainedFieldContract.js +92 -0
  89. package/dist/RetainedFieldContract.js.map +1 -0
  90. package/dist/ReviewModelUse.d.ts +7 -0
  91. package/dist/ReviewModelUse.d.ts.map +1 -0
  92. package/dist/ReviewModelUse.js +30 -0
  93. package/dist/ReviewModelUse.js.map +1 -0
  94. package/dist/ReviewPriority.d.ts +12 -0
  95. package/dist/ReviewPriority.d.ts.map +1 -0
  96. package/dist/ReviewPriority.js +45 -0
  97. package/dist/ReviewPriority.js.map +1 -0
  98. package/dist/RuntimeModelSelection.d.ts +10 -0
  99. package/dist/RuntimeModelSelection.d.ts.map +1 -0
  100. package/dist/RuntimeModelSelection.js +58 -0
  101. package/dist/RuntimeModelSelection.js.map +1 -0
  102. package/dist/SceneChangeDetector.d.ts +1 -1
  103. package/dist/SceneChangeDetector.d.ts.map +1 -1
  104. package/dist/SceneChangeDetector.js +18 -6
  105. package/dist/SceneChangeDetector.js.map +1 -1
  106. package/dist/ScopedVlmReview.d.ts +76 -0
  107. package/dist/ScopedVlmReview.d.ts.map +1 -0
  108. package/dist/ScopedVlmReview.js +267 -0
  109. package/dist/ScopedVlmReview.js.map +1 -0
  110. package/dist/SensingProfileClient.d.ts +50 -0
  111. package/dist/SensingProfileClient.d.ts.map +1 -1
  112. package/dist/SensingProfileClient.js +58 -2
  113. package/dist/SensingProfileClient.js.map +1 -1
  114. package/dist/TemporalReviewClient.d.ts +43 -0
  115. package/dist/TemporalReviewClient.d.ts.map +1 -0
  116. package/dist/TemporalReviewClient.js +117 -0
  117. package/dist/TemporalReviewClient.js.map +1 -0
  118. package/dist/TemporalReviewJournal.d.ts +91 -0
  119. package/dist/TemporalReviewJournal.d.ts.map +1 -0
  120. package/dist/TemporalReviewJournal.js +337 -0
  121. package/dist/TemporalReviewJournal.js.map +1 -0
  122. package/dist/UrgentReviewTriage.d.ts +35 -0
  123. package/dist/UrgentReviewTriage.d.ts.map +1 -0
  124. package/dist/UrgentReviewTriage.js +52 -0
  125. package/dist/UrgentReviewTriage.js.map +1 -0
  126. package/dist/VideoSegmentUploader.d.ts +293 -0
  127. package/dist/VideoSegmentUploader.d.ts.map +1 -0
  128. package/dist/VideoSegmentUploader.js +1002 -0
  129. package/dist/VideoSegmentUploader.js.map +1 -0
  130. package/dist/VlmCandidateBuffer.d.ts +19 -0
  131. package/dist/VlmCandidateBuffer.d.ts.map +1 -1
  132. package/dist/VlmCandidateBuffer.js.map +1 -1
  133. package/dist/autoSense.d.ts +2 -2
  134. package/dist/autoSense.d.ts.map +1 -1
  135. package/dist/autoSense.js +16 -11
  136. package/dist/autoSense.js.map +1 -1
  137. package/dist/cli.js +1 -1
  138. package/dist/cli.js.map +1 -1
  139. package/dist/config.d.ts +2 -0
  140. package/dist/config.d.ts.map +1 -1
  141. package/dist/config.js.map +1 -1
  142. package/dist/continuousReview.d.ts +71 -0
  143. package/dist/continuousReview.d.ts.map +1 -0
  144. package/dist/continuousReview.js +53 -0
  145. package/dist/continuousReview.js.map +1 -0
  146. package/dist/detectorCountReview.d.ts +9 -0
  147. package/dist/detectorCountReview.d.ts.map +1 -0
  148. package/dist/detectorCountReview.js +79 -0
  149. package/dist/detectorCountReview.js.map +1 -0
  150. package/dist/diagnose.d.ts +1 -1
  151. package/dist/diagnose.js +1 -1
  152. package/dist/installerOnboardingMode.d.ts.map +1 -1
  153. package/dist/installerOnboardingMode.js +5 -3
  154. package/dist/installerOnboardingMode.js.map +1 -1
  155. package/dist/localInferenceSchema.d.ts +8 -0
  156. package/dist/localInferenceSchema.d.ts.map +1 -1
  157. package/dist/localInferenceSchema.js +3 -3
  158. package/dist/localInferenceSchema.js.map +1 -1
  159. package/dist/localSenseE2eHarness.d.ts +12 -0
  160. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  161. package/dist/localSenseE2eHarness.js +77 -4
  162. package/dist/localSenseE2eHarness.js.map +1 -1
  163. package/dist/localSensing.d.ts +91 -18
  164. package/dist/localSensing.d.ts.map +1 -1
  165. package/dist/localSensing.js +678 -153
  166. package/dist/localSensing.js.map +1 -1
  167. package/dist/mixedScopedCounts.d.ts +17 -0
  168. package/dist/mixedScopedCounts.d.ts.map +1 -0
  169. package/dist/mixedScopedCounts.js +90 -0
  170. package/dist/mixedScopedCounts.js.map +1 -0
  171. package/dist/sensingCatchRate.d.ts +9 -5
  172. package/dist/sensingCatchRate.d.ts.map +1 -1
  173. package/dist/sensingCatchRate.js +10 -6
  174. package/dist/sensingCatchRate.js.map +1 -1
  175. package/dist/sensingRunMode.d.ts +5 -0
  176. package/dist/sensingRunMode.d.ts.map +1 -1
  177. package/dist/sensingRunMode.js +56 -0
  178. package/dist/sensingRunMode.js.map +1 -1
  179. package/package.json +7 -4
  180. package/retained/ArchiveLifecycle.cjs +79 -0
  181. package/retained/ModelStageScheduler.cjs +66 -0
  182. package/retained/ProfileAuthorityClient.cjs +88 -0
  183. package/retained/RetainedCaptureClient.cjs +164 -0
  184. package/retained/WaitRecheckCaptureClient.cjs +109 -0
  185. package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
  186. package/retained/__tests__/runtime.test.cjs +384 -0
  187. package/retained/alert-rule-policy.cjs +37 -0
  188. package/retained/manager.cjs +133 -0
  189. package/retained/pilot-profile-client.cjs +82 -0
  190. package/retained/temporal-grouping.cjs +78 -0
  191. package/retained/website-api-capability.cjs +122 -0
@@ -1,20 +1,68 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LocalSensingClient = void 0;
36
+ exports.VIDEO_SEGMENTS_DIRNAME = exports.LocalSensingClient = void 0;
4
37
  exports.fieldSetForProfileType = fieldSetForProfileType;
5
38
  exports.repairLowConfidencePresenceContradiction = repairLowConfidencePresenceContradiction;
6
39
  exports.isAlertWorthy = isAlertWorthy;
7
40
  exports.resolveMaxStreamSessionsPerSource = resolveMaxStreamSessionsPerSource;
41
+ exports.recordingUrlKey = recordingUrlKey;
42
+ exports.videoFeedKey = videoFeedKey;
43
+ exports.getVideoRecordingPlan = getVideoRecordingPlan;
8
44
  exports.buildLocalSensingLoops = buildLocalSensingLoops;
9
45
  const crypto_1 = require("crypto");
46
+ const path = __importStar(require("path"));
47
+ const dataDir_1 = require("./dataDir");
48
+ const VideoSegmentUploader_1 = require("./VideoSegmentUploader");
10
49
  const AudienceAnalyzeClient_1 = require("./AudienceAnalyzeClient");
11
50
  const LiteRtVlmProvider_1 = require("./LiteRtVlmProvider");
12
51
  const analyzeCostObserver_1 = require("./analyzeCostObserver");
52
+ const continuousReview_1 = require("./continuousReview");
53
+ const ProfileReviewWork_1 = require("./ProfileReviewWork");
13
54
  const DetectorFieldProducer_1 = require("./DetectorFieldProducer");
14
55
  const SensingProfileClient_1 = require("./SensingProfileClient");
56
+ const detectorCountReview_1 = require("./detectorCountReview");
57
+ const jpegFrameSize_1 = require("./jpegFrameSize");
58
+ const ScopedVlmReview_1 = require("./ScopedVlmReview");
15
59
  const CloudSensingManager_1 = require("./CloudSensingManager");
16
60
  const localInferenceSchema_1 = require("./localInferenceSchema");
17
61
  const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
62
+ const ReviewPriority_1 = require("./ReviewPriority");
63
+ const RetainedFieldContract_1 = require("./RetainedFieldContract");
64
+ const RuntimeModelSelection_1 = require("./RuntimeModelSelection");
65
+ const ReviewModelUse_1 = require("./ReviewModelUse");
18
66
  const connectorCadence_1 = require("./connectorCadence");
19
67
  /** How long a fetched profile is cached before the connector re-checks for a new alert. */
20
68
  const DEFAULT_PROFILE_TTL_MS = 5 * 60 * 1000;
@@ -302,6 +350,15 @@ class LocalSensingClient {
302
350
  constructor(localProvider, uploadClient, options = {}) {
303
351
  this.localProvider = localProvider;
304
352
  this.uploadClient = uploadClient;
353
+ this.cachedDetectorCounts = false;
354
+ this.cachedMixedScopedCounts = false;
355
+ this.cachedMixedFrameCounts = false;
356
+ this.cachedReviewPolicy = null;
357
+ this.cachedFieldContract = null;
358
+ this.cachedModelSelection = null;
359
+ this.cachedDeclaredProfileReview = false;
360
+ this.cachedVlmFields = [];
361
+ this.cachedFocusRegionsValid = true;
305
362
  /**
306
363
  * Fields whose ABSENT reading an operator route on this screen fires on, from the
307
364
  * deployed profile (see SensingProfileClient.absenceWatchedFields). Refreshed on the
@@ -411,6 +468,9 @@ class LocalSensingClient {
411
468
  * profile. See fieldSetForProfileType.
412
469
  */
413
470
  this.activeFieldSet = 'audience';
471
+ this.retainedApiBaseUrl = options.retainedApiBaseUrl;
472
+ if (this.retainedApiBaseUrl !== undefined)
473
+ (0, continuousReview_1.assertRetainedReviewReceiver)(this.getEndpoint(), this.retainedApiBaseUrl);
414
474
  this.profileClient = options.profileClient;
415
475
  this.profileTtlMs = options.profileTtlMs ?? DEFAULT_PROFILE_TTL_MS;
416
476
  this.baseSchema = options.schema ?? (0, localInferenceSchema_1.buildLocalSensingSchema)();
@@ -418,7 +478,34 @@ class LocalSensingClient {
418
478
  this.cachedSchema = this.baseSchema;
419
479
  this.cachedPrompt = this.basePrompt;
420
480
  this.attachEvidenceFrame = options.attachEvidenceFrame !== false;
481
+ this.detectorCountsEnabled = options.detectorCounts === true;
482
+ if (this.detectorCountsEnabled)
483
+ this.assertLocalCountReceiver();
484
+ this.scopedVlmReviewEnabled = options.scopedVlmReview === true;
485
+ if (this.scopedVlmReviewEnabled)
486
+ this.assertLocalScopedReceiver();
487
+ this.overlapMixedReviewEnabled = options.overlapMixedReview === true;
488
+ if (this.overlapMixedReviewEnabled)
489
+ this.assertLocalScopedReceiver();
490
+ this.declaredProfileReviewEnabled = options.declaredProfileReview === true;
491
+ this.websiteManagedProfiles = options.websiteManagedProfiles === true;
492
+ if (this.declaredProfileReviewEnabled) {
493
+ this.assertLocalScopedReceiver();
494
+ if (!this.scopedVlmReviewEnabled)
495
+ throw Error('Declared profile review requires scoped review');
496
+ }
421
497
  this.producerOptions = options.detectorFieldProducerOptions ?? {};
498
+ this.imageClassifierProducer = options.imageClassifierFieldProducer;
499
+ if (this.imageClassifierProducer)
500
+ this.assertLocalScopedReceiver();
501
+ if (options.rfDetrSourceRuntime) {
502
+ this.assertLocalScopedReceiver();
503
+ const pin = options.rfDetrSourceRuntime;
504
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(pin.modelId) || !/^[a-f0-9]{64}$/.test(pin.sha256)) {
505
+ throw Error('Invalid RF-DETR source-runtime binding');
506
+ }
507
+ this.rfDetrSourceRuntime = Object.freeze({ modelId: pin.modelId, sha256: pin.sha256 });
508
+ }
422
509
  // `null` is an explicit OFF and must not be confused with `undefined`,
423
510
  // which is "build the default one". A deployment without the detector
424
511
  // service is the former; every shipping connector is the latter.
@@ -462,7 +549,11 @@ class LocalSensingClient {
462
549
  const profile = await this.profileClient.fetchProfile();
463
550
  if (profile === null)
464
551
  return; // transient error — keep last-good schema
552
+ this.reviewProfileState = profile.profileState;
465
553
  this.activeProfileName = profile.profileName;
554
+ this.cachedReviewPolicy = (0, ReviewPriority_1.parseReviewPolicy)(profile.reviewPolicy);
555
+ this.cachedFieldContract = profile.retainedFieldContract ?? null;
556
+ this.cachedModelSelection = (0, RuntimeModelSelection_1.parseModelSelection)(profile.modelSelection);
466
557
  this.cachedAbsenceWatchedFields = new Set(profile.absenceWatchedFields || []);
467
558
  const fieldSet = fieldSetForProfileType(profile.profileType);
468
559
  this.activeFieldSet = fieldSet;
@@ -472,11 +563,22 @@ class LocalSensingClient {
472
563
  // TypeError on the path to every alert. A field with no consumer is not
473
564
  // known to be safe — it is only known to be unread.
474
565
  this.cachedProducerFields = profile.producerFields ?? [];
566
+ this.cachedDetectorCounts = this.detectorCountsEnabled && profile.detectorCountEligible === true;
567
+ this.cachedMixedFrameCounts = this.detectorCountsEnabled && this.scopedVlmReviewEnabled
568
+ && profile.mixedFrameCountEligible === true && profile.mixedScopedCountEligible !== true;
569
+ this.cachedMixedScopedCounts = this.detectorCountsEnabled && this.scopedVlmReviewEnabled
570
+ && (profile.mixedScopedCountEligible === true || this.cachedMixedFrameCounts);
475
571
  // The geometry half of the same partition. `?? new Map()` for the reason the
476
572
  // line above carries `?? []`: this assignment can outlive a profile object
477
573
  // that predates the key, and an undefined map is a TypeError on the path to
478
574
  // every alert the first time anything reads it.
479
575
  this.cachedAlertFocusRegions = profile.alertFocusRegions ?? new Map();
576
+ this.cachedFocusRegionsValid = profile.alertFocusRegionsValid !== false;
577
+ this.cachedVlmFields = this.usesWebsiteProfile()
578
+ ? profile.vlmFields.filter(field => field.type !== 'hidden') : profile.vlmFields;
579
+ this.cachedDeclaredProfileReview = this.declaredProfileReviewEnabled && fieldSet === 'operator'
580
+ && !this.cachedProducerFields.length && profile.vlmFields.length > 0
581
+ && this.cachedModelSelection !== null;
480
582
  // Rebuilt only on a real change, and NEVER when the producer was injected —
481
583
  // a test's stub must not be silently replaced by a real one the moment a
482
584
  // profile arrives.
@@ -516,6 +618,45 @@ class LocalSensingClient {
516
618
  this.cachedSchema = this.baseSchema;
517
619
  this.cachedPrompt = this.basePrompt;
518
620
  }
621
+ if (this.cachedDetectorCounts) {
622
+ // A complete counting observation has no VLM certainty score. Do not invent
623
+ // confidence from a box score, especially when the detector sees zero boxes.
624
+ this.cachedSchema = {
625
+ type: 'object', additionalProperties: false,
626
+ properties: Object.fromEntries([
627
+ ...this.cachedProducerFields.map(f => [f.field, { type: f.type === 'boolean' ? 'boolean' : 'integer' }]),
628
+ ['people_present', { type: 'boolean' }],
629
+ ]),
630
+ required: [...this.cachedProducerFields.map(f => f.field), 'people_present'],
631
+ };
632
+ this.cachedPrompt = 'RF-DETR person counts; people_present is derived from whole-frame person_count > 0. VLM confidence is not measured.';
633
+ }
634
+ if (this.cachedMixedScopedCounts) {
635
+ // Only the explicitly declared activity questions reach Gemma. RF-DETR owns
636
+ // person_count, people_present is derived, and global confidence is unmeasured.
637
+ const properties = this.cachedSchema.properties;
638
+ this.cachedSchema = { type: 'object', additionalProperties: false,
639
+ properties: Object.fromEntries(profile.vlmFields.map(field => [field.field, properties[field.field]])),
640
+ required: profile.vlmFields.map(field => field.field) };
641
+ }
642
+ if (this.cachedDeclaredProfileReview) {
643
+ // Preserve every declared question (including explicitly requested presence).
644
+ // This is a separate captured method, not permission to shorten old evidence.
645
+ const properties = this.cachedSchema.properties;
646
+ this.cachedSchema = { type: 'object', additionalProperties: false,
647
+ properties: Object.fromEntries(profile.vlmFields.map(field => [field.field, properties[field.field]])),
648
+ required: profile.vlmFields.map(field => field.field) };
649
+ this.cachedPrompt = 'Review only the captured operator fields in their assigned camera areas.';
650
+ }
651
+ if (this.usesWebsiteProfile()) {
652
+ // Strict website selection already owns the entire partition. Do not add
653
+ // generic presence/count/confidence questions back into its VLM half.
654
+ const properties = this.cachedSchema.properties;
655
+ this.cachedSchema = { type: 'object', additionalProperties: false,
656
+ properties: Object.fromEntries(this.cachedVlmFields.map(field => [field.field, properties[field.field]])),
657
+ required: this.cachedVlmFields.map(field => field.field) };
658
+ this.cachedPrompt = 'Review only the captured website fields in their assigned camera areas.';
659
+ }
519
660
  }
520
661
  /**
521
662
  * What the on-device runtime resolved about this host's accelerator (which
@@ -565,168 +706,430 @@ class LocalSensingClient {
565
706
  getObservedAnalyzeMs() {
566
707
  return this.analyzeCost.medianMs();
567
708
  }
568
- async analyze(request) {
709
+ /** Resolve once before LOOK starts; later refreshes never hold up capture. */
710
+ async prepareReview() {
569
711
  await this.refreshProfileIfStale();
570
- // The measured dimensions of THIS frame, forwarded to every upload path
571
- // below. This client sits between the cadence loop and the upload client,
572
- // so anything it does not forward is dropped — and the connector is the
573
- // multi-channel DVR/NVR product, i.e. precisely the topology whose
574
- // configured width is absent. Undefined here simply falls back to the
575
- // upload client's constructor value.
576
- const frameDimensions = {
577
- imageWidth: request.imageWidth,
578
- imageHeight: request.imageHeight,
579
- };
580
- // Mint the evidence identity BEFORE inference. This digest therefore names
581
- // the model input, rather than a later recapture or re-encoded thumbnail.
712
+ }
713
+ /** Only an explicitly verified inactive profile pauses new model work. */
714
+ isReviewActive() {
715
+ return this.reviewProfileState !== 'inactive';
716
+ }
717
+ usesWebsiteProfile() {
718
+ return this.websiteManagedProfiles && this.reviewProfileState === 'active';
719
+ }
720
+ captureReviewContext() {
721
+ void this.prepareReview().catch(() => { });
722
+ const context = JSON.parse(JSON.stringify({
723
+ version: 1,
724
+ ...(this.reviewProfileState ? { profileState: this.reviewProfileState } : {}),
725
+ schema: this.cachedSchema,
726
+ ...(this.cachedReviewPolicy ? { reviewPolicy: this.cachedReviewPolicy } : {}),
727
+ ...(this.cachedFieldContract ? { retainedFieldContract: this.cachedFieldContract } : {}),
728
+ ...(this.cachedModelSelection ? { modelSelection: this.cachedModelSelection } : {}),
729
+ prompt: this.cachedPrompt,
730
+ absenceWatchedFields: [...this.cachedAbsenceWatchedFields],
731
+ producerFields: this.cachedProducerFields,
732
+ regions: [...this.cachedAlertFocusRegions],
733
+ // The API advertises operating points from every executable grant. This
734
+ // complete mixed contract owns only person_count; retain exactly its point.
735
+ operatingPoints: this.cachedMixedScopedCounts
736
+ ? [['person', this.cachedProducerOperatingPoints.get('person')]]
737
+ : [...this.cachedProducerOperatingPoints],
738
+ ...(this.cachedDetectorCounts ? { detectorCounts: true } : {}),
739
+ ...((this.scopedVlmReviewEnabled || this.usesWebsiteProfile()) && !this.cachedDetectorCounts && this.activeFieldSet === 'operator'
740
+ && (this.usesWebsiteProfile() && this.cachedVlmFields.length || this.cachedDeclaredProfileReview
741
+ || this.cachedMixedFrameCounts || this.cachedAlertFocusRegions.size || !this.cachedFocusRegionsValid) ? { scopedVlmReview: {
742
+ version: 1, fieldSet: 'operator', fields: this.cachedVlmFields, regionsValid: this.cachedFocusRegionsValid,
743
+ ...(this.usesWebsiteProfile() ? { declaredVlmFieldsOnly: true }
744
+ : this.cachedDeclaredProfileReview ? { declaredFieldsOnly: true } : {}),
745
+ ...(this.cachedMixedScopedCounts ? { personCounts: {
746
+ version: 1, modelId: 'rfdetr_medium', minConfidence: this.cachedProducerOperatingPoints.get('person'),
747
+ ...(this.cachedMixedFrameCounts ? { scopePolicy: 'declared_regions_v2' } : {}),
748
+ }, ...(this.overlapMixedReviewEnabled ? { countExecution: 'overlap_v1' } : {}) } : {}),
749
+ } } : {}),
750
+ }));
751
+ if (this.overlapMixedReviewEnabled && !context.detectorCounts && !context.scopedVlmReview
752
+ && context.producerFields.length && context.modelSelection && !('invalid' in context.modelSelection)
753
+ && context.modelSelection.models.some(m => m.format === 'onnx')
754
+ && context.modelSelection.models.some(m => m.format === 'litert-lm')) {
755
+ context.mixedModelExecution = 'overlap_v1';
756
+ }
757
+ return context;
758
+ }
759
+ /** Admission follows the saved profile, never the camera's current setting. */
760
+ getReviewModelUse(context) {
761
+ return (0, ReviewModelUse_1.retainedReviewModelUse)(context);
762
+ }
763
+ /** Finish and durably checkpoint inference without starting a network upload. */
764
+ async reviewCandidate(request) {
765
+ if (!request.checkpointAnalysis)
766
+ throw new Error('Independent review requires a durable checkpoint');
767
+ await this.prepareAnalysis(request);
768
+ }
769
+ /** Isolated review-only rehearsal. Sparse results never enter the normal upload envelope. */
770
+ async reviewProfileWork(request, work, checkpoint) {
771
+ const url = new URL(this.getEndpoint());
772
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) ||
773
+ !request.evidenceId || request.capturedAt === undefined || !request.reviewContext || typeof checkpoint !== 'function') {
774
+ throw Error('Profile work requires isolated durable evidence');
775
+ }
776
+ const context = request.reviewContext;
777
+ const selection = (0, RuntimeModelSelection_1.retainedModelSelection)(context, !!this.imageClassifierProducer, !!this.rfDetrSourceRuntime);
778
+ const sourcePin = selection?.models.find(model => model.format === 'safetensors-rfdetr');
779
+ if (sourcePin && (!this.rfDetrSourceRuntime || selection.models.length !== 1
780
+ || sourcePin.modelId !== this.rfDetrSourceRuntime.modelId || sourcePin.sha256 !== this.rfDetrSourceRuntime.sha256)) {
781
+ throw Error('RF-DETR source comparison requires its own exactly bound arm');
782
+ }
783
+ const classifierPin = selection?.models.find(model => model.format === 'tflite-image-classifier');
784
+ if (classifierPin) {
785
+ if (!this.imageClassifierProducer || selection.models.length !== 1) {
786
+ throw Error('Image classifier comparison requires its own explicitly injected arm');
787
+ }
788
+ this.imageClassifierProducer.assertContext(context, classifierPin);
789
+ }
790
+ const vlmPin = selection?.models.find(model => model.format === 'litert-lm');
791
+ const detectorPins = selection ? new Map(selection.models.filter(model => model.format === 'onnx' || model.format === 'safetensors-rfdetr')
792
+ .map(model => [model.modelId, model.sha256])) : undefined;
793
+ const regions = new Map(context.regions ?? []);
794
+ const producer = !this.producerInjected ? new DetectorFieldProducer_1.DetectorFieldProducer({ ...this.producerOptions,
795
+ classThresholds: new Map(context.operatingPoints ?? []), regionForField: field => regions.get(field) ?? null }) : this.producer;
796
+ const result = await (0, ProfileReviewWork_1.reviewProfileBatch)({ jpeg: request.jpeg, evidenceId: request.evidenceId,
797
+ capturedAtMs: request.capturedAt, context, work,
798
+ analyze: async (r) => {
799
+ if (selection && !vlmPin)
800
+ throw Error('Retained work has no selected language model');
801
+ return repairLowConfidencePresenceContradiction(await this.localProvider.analyze({ ...r,
802
+ ...(vlmPin ? { expectedModel: vlmPin } : {}) }));
803
+ },
804
+ produce: fields => classifierPin
805
+ ? this.imageClassifierProducer.produce(request.jpeg, fields, classifierPin)
806
+ : producer ? producer.produce(request.jpeg, fields, detectorPins) : Promise.resolve({ produced: [], withheld: [] }) });
807
+ checkpoint(result);
808
+ return result;
809
+ }
810
+ async prepareAnalysis(request) {
811
+ if (!request.reviewContext && !request.cachedAnalysis)
812
+ await this.refreshProfileIfStale();
813
+ const context = request.reviewContext;
814
+ if (!context && this.cachedModelSelection)
815
+ throw Error('Selected model review requires retained evidence');
816
+ if (context && (context.version !== 1 || !context.schema || typeof context.prompt !== 'string' ||
817
+ !Array.isArray(context.producerFields) || !Array.isArray(context.regions) ||
818
+ !Array.isArray(context.operatingPoints) || !Array.isArray(context.absenceWatchedFields)
819
+ || (context.detectorCounts !== undefined && context.detectorCounts !== true))) {
820
+ throw new Error('Invalid retained sensing profile');
821
+ }
582
822
  const capturedAtMs = request.capturedAt ?? Date.now();
583
- const evidenceId = request.evidenceId || `ev_${(0, crypto_1.randomUUID)()}`;
584
823
  const evidenceSha256 = (0, crypto_1.createHash)('sha256').update(request.jpeg).digest('hex');
585
- let structured;
586
- // Bracket the ONE call whose duration is the quantity the whole capacity
587
- // model is built on. Started before the await and read after it, so it is
588
- // the frame's wall-clock cost and not a stage of it.
589
- const analyzeStartedAt = Date.now();
590
- try {
591
- structured = await this.localProvider.analyze({
592
- jpeg: request.jpeg,
593
- schema: this.cachedSchema,
594
- prompt: this.cachedPrompt,
595
- });
824
+ let saved = request.cachedAnalysis;
825
+ const detectorCounts = context?.detectorCounts === true;
826
+ const scopedReview = context?.scopedVlmReview;
827
+ const capturedPolicy = (0, ReviewPriority_1.parseReviewPolicy)(context?.reviewPolicy);
828
+ if (capturedPolicy && 'invalid' in capturedPolicy) {
829
+ throw Error('Invalid retained profile review policy; evidence retained for diagnosis');
596
830
  }
597
- catch (err) {
598
- // LOCAL-INFERENCE ONLY: a frame the on-device runtime could not analyze is
599
- // DROPPED, not metered. This branch used to POST the frame to the cloud
600
- // VLM (`cloudFallbackOnError`) at $0.007008 each — the per-frame cost this
601
- // whole change exists to remove. The capture loop's own cadence is the
602
- // resilience: the next frame is seconds away, and a runtime that keeps
603
- // throwing is a fault to surface, not a bill to pay.
604
- throw err;
605
- }
606
- structured = repairLowConfidencePresenceContradiction(structured);
607
- // THE PRODUCER HALF. Fields a granted specialist owns are answered by the
608
- // detector, never by the prompt — this is the consumer that closes the
609
- // partition the server has been shipping since `fieldRouting` landed, and
610
- // without it `producer[]` was a diagnostic and the specialists answered
611
- // nothing.
612
- //
613
- // AFTER the VLM, not before: the two runtimes are separate processes but
614
- // share the box's compute, and issuing them together would make each
615
- // frame's cost the sum under contention rather than the max. The detector's
616
- // measured p50 is ~61 ms against a 7.3 s analyze, so the ordering costs
617
- // nothing that the budget below does not already bound.
618
- //
619
- // `produce` NEVER THROWS by contract, so no try/catch: adding one would
620
- // imply it can, and would swallow a genuine programming error in it.
621
- let producedSet = null;
622
- /** field -> the specialist that answered it, or null when none did. */
623
- let detectorProduced = null;
624
- if (this.producer && this.cachedProducerFields.length > 0) {
625
- producedSet = await this.producer.produce(request.jpeg,
626
- // `derivation` is carried, not dropped. `classifyProducerField` reads the
627
- // relation kind from THIS object and from nowhere else, so a map that
628
- // rebuilt the entry without it sent every relation field down the suffix
629
- // rule — which cannot express one. That is what made `zone_count`
630
- // unreachable and the geometric region inert.
631
- this.cachedProducerFields.map((f) => ({
632
- field: f.field,
633
- modelId: f.modelId,
634
- type: f.type ?? null,
635
- derivation: f.derivation ?? null,
636
- })));
637
- this.producerProduced += producedSet.produced.length;
638
- for (const w of producedSet.withheld) {
639
- this.producerWithheldByReason[w.reason] = (this.producerWithheldByReason[w.reason] || 0) + 1;
831
+ const requiredFields = (0, RetainedFieldContract_1.readFieldContract)(context?.retainedFieldContract);
832
+ const selection = (0, RuntimeModelSelection_1.retainedModelSelection)(context);
833
+ if (saved?.runtimeSelectionSha256 !== undefined && !selection)
834
+ throw Error('Saved model-verified result requires its retained selection');
835
+ const vlmPin = selection?.models.find(model => model.format === 'litert-lm');
836
+ const detectorPins = selection ? new Map(selection.models.filter(model => model.format === 'onnx')
837
+ .map(model => [model.modelId, model.sha256])) : undefined;
838
+ if (context?.mixedModelExecution !== undefined) {
839
+ this.assertLocalScopedReceiver();
840
+ if (context.mixedModelExecution !== 'overlap_v1' || detectorCounts || scopedReview
841
+ || !vlmPin || !detectorPins?.size || !context.producerFields.length
842
+ || !request.evidenceId || !Number.isFinite(request.capturedAt)
843
+ || !Number.isFinite(request.reviewWindowMs) || !request.checkpointAnalysis) {
844
+ throw Error('Overlapping paired review requires its complete retained contract');
845
+ }
846
+ }
847
+ if (saved && selection && saved.runtimeSelectionSha256 !== (0, ProfileReviewWork_1.reviewContextHash)(context)) {
848
+ throw Error('Saved result does not match retained model selection');
849
+ }
850
+ const mixedCounts = scopedReview?.personCounts !== undefined;
851
+ if (scopedReview?.declaredVlmFieldsOnly !== undefined) {
852
+ const vlmFields = requiredFields?.filter(field => field.modelId === undefined);
853
+ if (context?.profileState !== 'active' || !selection || !vlmPin || !vlmFields
854
+ || !Array.isArray(scopedReview.fields) || scopedReview.fields.length !== vlmFields.length
855
+ || vlmFields.some(field => !scopedReview.fields.some(f => f.field === field.field && f.type === field.type))
856
+ || context.absenceWatchedFields.some(field => !requiredFields.some(f => f.field === field))) {
857
+ throw Error('Website review requires complete captured ownership and absence conditions');
858
+ }
859
+ (0, ScopedVlmReview_1.planScopedVlmReview)(context.schema, scopedReview, context.regions, context.producerFields);
860
+ }
861
+ if (scopedReview?.declaredFieldsOnly !== undefined) {
862
+ if (!selection || !vlmPin || selection.models.length !== 1
863
+ || !Array.isArray(scopedReview.fields) || !requiredFields
864
+ || scopedReview.fields.length !== requiredFields.length
865
+ || requiredFields.some(field => !scopedReview.fields.some(f => f.field === field.field && f.type === field.type))
866
+ || context.absenceWatchedFields.some(field => !scopedReview.fields.some(f => f.field === field))) {
867
+ throw Error('Declared review requires complete pinned VLM ownership and absence conditions');
868
+ }
869
+ // Revalidate on cached delivery too; an edited snapshot must never drop a question.
870
+ (0, ScopedVlmReview_1.planScopedVlmReview)(context.schema, scopedReview, context.regions, context.producerFields);
871
+ }
872
+ const scopedMethod = mixedCounts ? 'scoped_detector_counts_v1' : 'scoped_vlm_v1';
873
+ if (scopedReview || saved?.analysisMethod === 'scoped_vlm_v1' || saved?.analysisMethod === 'scoped_detector_counts_v1') {
874
+ this.assertLocalScopedReceiver();
875
+ if (!scopedReview || detectorCounts || !request.evidenceId || !Number.isFinite(request.capturedAt)
876
+ || !Number.isFinite(request.reviewWindowMs) || !request.checkpointAnalysis
877
+ || !saved && !request.checkpointScopedReview) {
878
+ throw Error('Scoped review requires its retained profile and durable evidence');
879
+ }
880
+ if (mixedCounts) {
881
+ (0, ScopedVlmReview_1.planScopedVlmReview)(context.schema, scopedReview, context.regions, context.producerFields);
882
+ if (context.operatingPoints.length !== 1 || context.operatingPoints[0][0] !== 'person'
883
+ || context.operatingPoints[0][1] !== scopedReview.personCounts.minConfidence
884
+ || context.absenceWatchedFields.some(field => !['person_count', 'people_present',
885
+ ...scopedReview.fields.map(f => f.field)].includes(field))) {
886
+ throw Error('Mixed review requires the retained threshold and complete field ownership');
887
+ }
888
+ }
889
+ }
890
+ if (!context && this.scopedVlmReviewEnabled)
891
+ throw Error('Scoped review requires a retained profile');
892
+ if (!context && this.cachedDetectorCounts)
893
+ throw Error('Detector counts require a retained profile and durable evidence');
894
+ // Pin the captured choice across profile changes/restarts. Neither a new
895
+ // profile nor a saved answer can send this experimental contract to production.
896
+ if (detectorCounts || saved?.analysisMethod === 'detector_counts_v1') {
897
+ this.assertLocalCountReceiver();
898
+ if (!context || !detectorCounts || !request.evidenceId || !Number.isFinite(request.capturedAt)
899
+ || !Number.isFinite(request.reviewWindowMs) || !request.checkpointAnalysis
900
+ || !(0, detectorCountReview_1.isPersonCountContract)(context.producerFields, new Map(context.regions))) {
901
+ throw Error('Detector counts require a complete retained profile and durable evidence');
902
+ }
903
+ const properties = context.schema.properties;
904
+ const required = context.schema.required;
905
+ const keys = [...context.producerFields.map(f => f.field), 'people_present'];
906
+ if (!properties || Object.keys(properties).length !== keys.length
907
+ || !Array.isArray(required) || required.length !== keys.length || new Set(required).size !== keys.length
908
+ || keys.some(key => !required.includes(key)) || properties.people_present?.type !== 'boolean'
909
+ || context.producerFields.some(f => properties[f.field]?.type !== (f.type === 'boolean' ? 'boolean' : 'integer'))) {
910
+ throw Error('Detector counts require a complete retained count schema');
640
911
  }
641
- const { merged, collisions, producedBy } = mergeProducedFields(structured, producedSet);
642
- structured = merged;
643
- detectorProduced = Object.keys(producedBy).length > 0 ? producedBy : null;
644
- if (collisions.length > 0) {
645
- // The prompt was never given these fields, so the model answering them
646
- // means the schema and the partition disagree about who owns them.
912
+ if (context.regions.length && !(0, jpegFrameSize_1.readJpegFrameSize)(request.jpeg)) {
913
+ throw Error('Detector count lasso requires readable image dimensions; retain for retry');
914
+ }
915
+ }
916
+ if (saved && (saved.version !== 1 || saved.evidenceSha256 !== evidenceSha256 ||
917
+ !saved.structured || Array.isArray(saved.structured) || typeof saved.structured !== 'object' ||
918
+ typeof saved.promptText !== 'string' || typeof saved.hasSignal !== 'boolean' ||
919
+ !Number.isFinite(saved.analyzeMs) || !Number.isFinite(saved.reviewedAtMs)
920
+ || (saved.analysisMethod !== undefined && saved.analysisMethod !== 'detector_counts_v1'
921
+ && saved.analysisMethod !== 'scoped_vlm_v1' && saved.analysisMethod !== 'scoped_detector_counts_v1'))) {
922
+ throw new Error('Invalid saved model result');
923
+ }
924
+ if (saved && (detectorCounts !== (saved.analysisMethod === 'detector_counts_v1'))) {
925
+ throw Error('Saved result does not match retained review method');
926
+ }
927
+ if (saved && (!!scopedReview !== (saved.analysisMethod === 'scoped_vlm_v1' || saved.analysisMethod === 'scoped_detector_counts_v1')
928
+ || scopedReview && saved.analysisMethod !== scopedMethod
929
+ || scopedReview && (!Array.isArray(saved.scopeReviews) || !saved.scopeReviews.length
930
+ || saved.scopeContextSha256 !== (0, ProfileReviewWork_1.reviewContextHash)(context)))) {
931
+ throw Error('Saved result does not match retained scoped review method');
932
+ }
933
+ if (saved && mixedCounts && (!saved.hasSignal || !saved.detectorProduced
934
+ || Object.keys(saved.detectorProduced).length !== 1 || saved.detectorProduced.person_count !== 'rfdetr_medium'
935
+ || Object.keys(saved.structured).length !== scopedReview.fields.length + 2
936
+ || !Number.isSafeInteger(saved.structured.person_count) || saved.structured.person_count < 0
937
+ || saved.structured.people_present !== (saved.structured.person_count > 0)
938
+ || scopedReview.fields.some(field => typeof saved.structured[field.field] !== 'boolean'))) {
939
+ throw Error('Invalid saved mixed count and behavior result');
940
+ }
941
+ if (saved && detectorCounts) {
942
+ const fields = context.producerFields;
943
+ if (!saved.hasSignal || !saved.detectorProduced || Object.keys(saved.detectorProduced).length !== fields.length
944
+ || Object.keys(saved.structured).length !== fields.length + 1
945
+ || saved.structured.people_present !== (saved.structured.person_count > 0)
946
+ || fields.some(f => saved.detectorProduced[f.field] !== f.modelId
947
+ || (f.type === 'boolean' ? typeof saved.structured[f.field] !== 'boolean'
948
+ : !Number.isSafeInteger(saved.structured[f.field]) || saved.structured[f.field] < 0))) {
949
+ throw Error('Invalid saved detector count result');
950
+ }
951
+ }
952
+ if (saved?.reuseMethod === 'exact_recent' && (typeof saved.reusedFromEvidenceId !== 'string' || !saved.reusedFromEvidenceId ||
953
+ typeof saved.sourceCapturedAtMs !== 'number' || !Number.isFinite(saved.sourceCapturedAtMs) ||
954
+ typeof saved.sourceReviewedAtMs !== 'number' || !Number.isFinite(saved.sourceReviewedAtMs) ||
955
+ saved.sourceCapturedAtMs > saved.sourceReviewedAtMs || saved.sourceReviewedAtMs > capturedAtMs ||
956
+ capturedAtMs > saved.reviewedAtMs || request.reviewWindowMs === undefined ||
957
+ saved.reviewedAtMs - saved.sourceCapturedAtMs > request.reviewWindowMs)) {
958
+ throw new Error('Invalid recent answer provenance');
959
+ }
960
+ if (!saved) {
961
+ const promptText = context?.prompt ?? this.cachedPrompt;
962
+ const schema = context?.schema ?? this.cachedSchema;
963
+ const fields = context?.producerFields ?? this.cachedProducerFields;
964
+ const absence = context ? new Set(context.absenceWatchedFields) : this.cachedAbsenceWatchedFields;
965
+ const regions = new Map(context?.regions ?? []);
966
+ const producer = context && !this.producerInjected
967
+ ? new DetectorFieldProducer_1.DetectorFieldProducer({
968
+ ...this.producerOptions,
969
+ classThresholds: new Map(context.operatingPoints),
970
+ regionForField: field => regions.get(field) ?? null,
971
+ })
972
+ : this.producer;
973
+ const startedAt = Date.now();
974
+ let structured = {};
975
+ let scopeReviews;
976
+ let detectorProduced = null;
977
+ const reviewVlm = async () => {
978
+ if (scopedReview) {
979
+ const scoped = await (0, ScopedVlmReview_1.reviewScopedVlm)({ jpeg: request.jpeg, schema, contract: scopedReview,
980
+ ...(selection ? { modelSelectionSha256: (0, ProfileReviewWork_1.reviewContextHash)(context) } : {}),
981
+ regions: context.regions, producerFields: fields,
982
+ progress: request.scopedVlmProgress, checkpoint: request.checkpointScopedReview,
983
+ ...(mixedCounts ? { produceCounts: async () => {
984
+ if (!producer)
985
+ throw Error('Mixed count producer unavailable; retain for retry');
986
+ const produced = await producer.produce(request.jpeg, [...fields], detectorPins);
987
+ this.producerProduced += produced.produced.length;
988
+ for (const withheld of produced.withheld) {
989
+ this.producerWithheldByReason[withheld.reason] = (this.producerWithheldByReason[withheld.reason] || 0) + 1;
990
+ }
991
+ return produced;
992
+ } } : {}),
993
+ analyze: async (input) => {
994
+ if (selection && !vlmPin)
995
+ throw Error('Retained scope has no selected language model');
996
+ const result = await this.localProvider.analyze({ ...input, ...(vlmPin ? { expectedModel: vlmPin } : {}) });
997
+ this.localInferencesCompleted++;
998
+ return result;
999
+ } });
1000
+ structured = mixedCounts ? scoped.structured : repairLowConfidencePresenceContradiction(scoped.structured);
1001
+ scopeReviews = scoped.traces;
1002
+ if (mixedCounts)
1003
+ detectorProduced = { person_count: 'rfdetr_medium' };
1004
+ }
1005
+ else if (!detectorCounts && (!selection || vlmPin)) {
1006
+ structured = repairLowConfidencePresenceContradiction(await this.localProvider.analyze({
1007
+ jpeg: request.jpeg, schema, prompt: promptText,
1008
+ ...(vlmPin ? { expectedModel: vlmPin } : {}),
1009
+ }));
1010
+ this.localInferencesCompleted++;
1011
+ }
1012
+ else if (!producer) {
1013
+ throw Error('Detector count producer unavailable; retain for retry');
1014
+ }
1015
+ };
1016
+ const reviewDetector = async () => {
1017
+ if (!producer || !fields.length || mixedCounts)
1018
+ return undefined;
1019
+ return producer.produce(request.jpeg, fields.map(f => ({
1020
+ field: f.field, modelId: f.modelId,
1021
+ type: f.type ?? null, derivation: f.derivation ?? null,
1022
+ })), detectorPins);
1023
+ };
1024
+ let produced;
1025
+ if (context?.mixedModelExecution === 'overlap_v1') {
1026
+ // Keep both scheduler reservations until both calls settle, even on failure.
1027
+ // An early rejection must not admit another candidate onto a still-busy model.
1028
+ const [vlmResult, detectorResult] = await Promise.allSettled([reviewVlm(), reviewDetector()]);
1029
+ if (vlmResult.status === 'rejected')
1030
+ throw vlmResult.reason;
1031
+ if (detectorResult.status === 'rejected')
1032
+ throw detectorResult.reason;
1033
+ produced = detectorResult.value;
1034
+ }
1035
+ else {
1036
+ await reviewVlm();
1037
+ produced = await reviewDetector();
1038
+ }
1039
+ if (produced) {
1040
+ this.producerProduced += produced.produced.length;
1041
+ for (const withheld of produced.withheld) {
1042
+ this.producerWithheldByReason[withheld.reason] =
1043
+ (this.producerWithheldByReason[withheld.reason] || 0) + 1;
1044
+ }
1045
+ const { merged, collisions, producedBy } = mergeProducedFields(structured, produced);
1046
+ structured = merged;
1047
+ if (detectorCounts)
1048
+ structured = (0, detectorCountReview_1.completePersonCounts)(produced, fields);
1049
+ detectorProduced = Object.keys(producedBy).length ? producedBy : null;
647
1050
  this.producerFieldCollisions += collisions.length;
648
1051
  }
1052
+ const analyzeMs = Date.now() - startedAt;
1053
+ this.analyzeCost.record(analyzeMs);
1054
+ saved = {
1055
+ version: 1, structured, detectorProduced, analyzeMs, promptText,
1056
+ reviewedAtMs: Date.now(), evidenceSha256,
1057
+ ...(selection ? { runtimeSelectionSha256: (0, ProfileReviewWork_1.reviewContextHash)(context) } : {}),
1058
+ // Zero can satisfy a below-count/absence rule too. Keep its evidence.
1059
+ hasSignal: detectorCounts || mixedCounts || isAlertWorthy(structured, absence),
1060
+ ...(detectorCounts ? { analysisMethod: 'detector_counts_v1' } : {}),
1061
+ ...(scopedReview ? { analysisMethod: scopedMethod, scopeReviews,
1062
+ scopeContextSha256: (0, ProfileReviewWork_1.reviewContextHash)(context) } : {}),
1063
+ };
649
1064
  }
650
- // The on-device runtime returned a result: the ONLY event that proves it is
651
- // alive. Counted here, past every throw, so nothing but a real on-device
652
- // inference can advance it.
653
- this.localInferencesCompleted += 1;
654
- // Recorded HERE, past every throw, for the same reason the counter above is:
655
- // a request that failed is not a frame cost. A fast failure recorded as a
656
- // fast frame is how a box talks itself into a cadence it cannot serve.
657
- const analyzeMs = Date.now() - analyzeStartedAt;
658
- this.analyzeCost.record(analyzeMs);
659
- // Connector-controlled provenance + time (don't trust the model for these).
660
- // PRIVACY-CONSCIOUS evidence frame: attach the SAME JPEG the VLM analyzed
661
- // (no re-capture) only when enabled AND the observation is potentially
662
- // alert-worthy. Empty/uninteresting scenes send results only — no frame.
663
- // Evaluate over the raw model output (not the provenance-stamped `result`,
664
- // though both yield the same gate since source/captured_at are strings).
665
- const hasSignal = isAlertWorthy(structured, this.cachedAbsenceWatchedFields);
1065
+ // Also retry a previously failed disk commit before any upload. The spool
1066
+ // keeps that answer in memory while disk recovers; inference is not repeated.
1067
+ (0, RetainedFieldContract_1.assertCompleteFields)(requiredFields, saved.structured, saved.detectorProduced);
1068
+ request.checkpointAnalysis?.(saved);
1069
+ return saved;
1070
+ }
1071
+ assertLocalCountReceiver() {
1072
+ (0, continuousReview_1.assertRetainedReviewReceiver)(this.getEndpoint(), this.retainedApiBaseUrl);
1073
+ }
1074
+ assertLocalScopedReceiver() {
1075
+ (0, continuousReview_1.assertRetainedReviewReceiver)(this.getEndpoint(), this.retainedApiBaseUrl);
1076
+ }
1077
+ async analyze(request) {
1078
+ const capturedAtMs = request.capturedAt ?? Date.now();
1079
+ const saved = await this.prepareAnalysis({ ...request, capturedAt: capturedAtMs });
1080
+ const evidenceId = request.evidenceId || `ev_${(0, crypto_1.randomUUID)()}`;
1081
+ const evidenceSha256 = saved.evidenceSha256;
1082
+ const review = request.reviewWindowMs === undefined ? undefined :
1083
+ (0, continuousReview_1.evidenceReview)(capturedAtMs, saved.reviewedAtMs, request.reviewWindowMs);
1084
+ if (review && typeof saved.reusedFromEvidenceId === 'string') {
1085
+ review.reusedFromEvidenceId = saved.reusedFromEvidenceId;
1086
+ }
1087
+ if (review && saved.reuseMethod === 'exact_recent') {
1088
+ review.reuseMethod = 'exact_recent';
1089
+ review.sourceCapturedAtMs = saved.sourceCapturedAtMs;
1090
+ review.sourceReviewedAtMs = saved.sourceReviewedAtMs;
1091
+ }
1092
+ const selection = (0, RuntimeModelSelection_1.retainedModelSelection)(request.reviewContext);
1093
+ const selectedDetectorOnly = selection && selection.models.every(model => model.format === 'onnx')
1094
+ && saved.analysisMethod === undefined;
666
1095
  const result = {
667
- ...structured,
668
- source: 'vlm_edge',
1096
+ ...saved.structured,
1097
+ source: selectedDetectorOnly || saved.analysisMethod === 'detector_counts_v1' ? 'detector_edge'
1098
+ : saved.analysisMethod === 'scoped_detector_counts_v1' ? 'mixed_edge' : 'vlm_edge',
669
1099
  captured_at: new Date(capturedAtMs).toISOString(),
670
1100
  };
671
- const includeFrame = this.attachEvidenceFrame && hasSignal;
672
1101
  return this.uploadClient.analyzePreAnalyzed(result, {
673
1102
  capturedAt: capturedAtMs,
674
1103
  captureMode: request.captureMode,
675
- // Reported even when no evidence frame is attached: the observation was
676
- // still DERIVED from a frame of this size, and the privacy gate decides
677
- // whether the pixels leave, not whether their dimensions do.
678
- ...frameDimensions,
679
- // Reported on EVERY analysis, not only the alert-worthy ones that carry a
680
- // frame. This is the number the entire capacity model is built on — it is
681
- // what `MEASURED_LOCAL_ANALYZE_MS` is a stand-in for — and until now it
682
- // was measured here, spent on the local cadence, and then dropped, so
683
- // vision_call_log.latency_ms was NULL on 100% of local-vlm rows while the
684
- // cloud path was 100% populated. A quantity every box computes and no box
685
- // reports is the telemetry-producer-substrate defect in
686
- // docs/runbooks/telemetry-producer-substrate.md.
687
- //
688
- // Deliberately OUTSIDE the `includeFrame` branch below: the frame only
689
- // rides along on alert-worthy analyses, and sampling the fleet's per-frame
690
- // cost on busy scenes alone would bias the very number the cadence is
691
- // derived from.
692
- analyzeMs,
693
- // The prompt this analysis actually used. `cachedPrompt` is rebuilt
694
- // whenever the deployed profile changes, so this reports the question as
695
- // it was asked for THIS frame rather than as the current source would
696
- // rebuild it — which is the distinction that matters when a fleet is
697
- // mid-rollout across two connector versions.
698
- promptText: this.cachedPrompt,
699
- // WHICH FIELDS A SPECIALIST ANSWERED. This used to be dropped here, with a
700
- // comment saying it would ship "when it has a column" — because an ingest
701
- // that destructures its accepted keys by name silently discards an unnamed
702
- // one and still answers 2xx, and a producer whose output nothing reads must
703
- // not report success.
704
- //
705
- // IT NOW HAS ONE. `earner-misc.js` names `detectorProduced`, and
706
- // `ingestPreAnalyzedFrame` stamps those fields `local_detector` in
707
- // `_provenance_map` — which `chipPersistence.fanOutProfileFields` already
708
- // writes, unchanged, into `observation_field_values.provenance`. So the
709
- // fact lands at the grain it belongs to, per field per observation, in the
710
- // per-field provenance column that has existed all along.
711
- //
712
- // WHY IT HAD TO SHIP: a merged `forklift_present: true` is byte-identical
713
- // whether `gh_forklift_combine` counted a detection at its fitted 0.7435 or
714
- // the VLM wrote it in prose. Without this key, "the false-alarm rate WITH
715
- // specialists serving" is not a hard measurement — it is an unanswerable
716
- // one, and a run whose detectors all withheld would report VLM numbers
717
- // under a specialist label with nothing able to contradict it.
718
- //
719
- // OMITTED, never `{}`, when no field was produced: absent means "no
720
- // specialist answered anything on this frame", and an empty object would
721
- // make a box with no granted specialist indistinguishable from one whose
722
- // detector was down — the same absent-vs-negative distinction the withhold
723
- // rule exists for, one layer up.
724
- ...(detectorProduced ? { detectorProduced } : {}),
725
- // Forwarded verbatim. `analyzePreAnalyzed` spreads it last with the same
726
- // override semantics `analyze` documents, so this is one channel end to
727
- // end rather than a second one shaped like the first.
728
- ...(request.extra ? { extra: request.extra } : {}),
729
- ...(includeFrame ? { jpeg: request.jpeg, evidenceId, evidenceSha256 } : {}),
1104
+ imageWidth: request.imageWidth,
1105
+ imageHeight: request.imageHeight,
1106
+ analyzeMs: saved.analyzeMs,
1107
+ promptText: saved.promptText,
1108
+ ...(saved.detectorProduced ? { detectorProduced: saved.detectorProduced } : {}),
1109
+ ...((request.extra || review) ? {
1110
+ extra: { ...request.extra, ...(review ? { evidenceReview: review } : {}),
1111
+ ...(selectedDetectorOnly ? { selectedDetectorReview: {
1112
+ version: 1, selectionSha256: saved.runtimeSelectionSha256,
1113
+ fields: selection.fieldOwners.map(owner => owner.field), confidence: 'not_measured',
1114
+ } } : {}),
1115
+ ...(saved.analysisMethod === 'detector_counts_v1' ? { detectorCountReview: {
1116
+ version: 1, fields: Object.keys(saved.detectorProduced ?? {}),
1117
+ derivedPresenceFrom: 'person_count', confidence: 'not_measured',
1118
+ } } : {}),
1119
+ ...(saved.analysisMethod === 'scoped_detector_counts_v1' ? { mixedCountReview: {
1120
+ version: 1, fields: request.reviewContext.scopedVlmReview.fields.map(f => f.field),
1121
+ countModel: 'rfdetr_medium',
1122
+ countMinConfidence: request.reviewContext.scopedVlmReview.personCounts.minConfidence,
1123
+ confidence: 'not_measured', scopeContextSha256: saved.scopeContextSha256,
1124
+ } } : {}),
1125
+ },
1126
+ } : {}),
1127
+ // Retained ingest accepts ownership of the original evidence, including a
1128
+ // completed negative observation. Omitting its JPEG causes endless 400 retries.
1129
+ ...(review ? { evidenceId, evidenceSha256 } : {}),
1130
+ ...(this.attachEvidenceFrame && (saved.hasSignal || review) ? {
1131
+ jpeg: request.jpeg, evidenceId, evidenceSha256,
1132
+ } : {}),
730
1133
  });
731
1134
  }
732
1135
  }
@@ -778,6 +1181,60 @@ function resolveMaxStreamSessionsPerSource(requested, warn = (m) => console.warn
778
1181
  }
779
1182
  return effective;
780
1183
  }
1184
+ /** Under the connector's data root: one directory per recording feed. */
1185
+ exports.VIDEO_SEGMENTS_DIRNAME = 'video-segments';
1186
+ /**
1187
+ * One stream, one key: scheme, host, port, path and query of an RTSP URL,
1188
+ * WITHOUT its credentials — two feeds pointing at the same channel with
1189
+ * different logins are one camera, and record once.
1190
+ */
1191
+ function recordingUrlKey(rtspUrl) {
1192
+ if (!rtspUrl)
1193
+ return null;
1194
+ try {
1195
+ const u = new URL(rtspUrl);
1196
+ const port = u.port || (u.protocol === 'rtsps:' ? '322' : u.protocol === 'rtsp:' ? '554' : '');
1197
+ return `${u.protocol}//${u.hostname.toLowerCase()}:${port}${u.pathname}${u.search}`;
1198
+ }
1199
+ catch {
1200
+ return rtspUrl;
1201
+ }
1202
+ }
1203
+ /**
1204
+ * A feed's video directory name: its screen id (stable across restarts and
1205
+ * re-pairs), else a hash of its token — never the token itself.
1206
+ */
1207
+ function videoFeedKey(feed) {
1208
+ const screen = feed.screenId ? String(feed.screenId).replace(/[^A-Za-z0-9._-]/g, '_') : '';
1209
+ if (screen)
1210
+ return screen;
1211
+ return `token-${(0, crypto_1.createHash)('sha256').update(String(feed.deviceToken)).digest('hex').slice(0, 16)}`;
1212
+ }
1213
+ let lastVideoRecordingPlan = { recordingFeeds: [], sharedUrlFeeds: [], croppedFeeds: [] };
1214
+ /** The plan `buildLocalSensingLoops` last built, for diagnostics. */
1215
+ function getVideoRecordingPlan() {
1216
+ return {
1217
+ recordingFeeds: [...lastVideoRecordingPlan.recordingFeeds],
1218
+ sharedUrlFeeds: [...lastVideoRecordingPlan.sharedUrlFeeds],
1219
+ croppedFeeds: [...lastVideoRecordingPlan.croppedFeeds],
1220
+ };
1221
+ }
1222
+ function summarizeVideoRecordingPlan(feeds, recordingFeedByUrl) {
1223
+ const recordingFeeds = [...recordingFeedByUrl.values()];
1224
+ const recording = new Set(recordingFeeds);
1225
+ const sharedUrlFeeds = [];
1226
+ const croppedFeeds = [];
1227
+ for (const feed of feeds) {
1228
+ if (recording.has(feed.name))
1229
+ continue;
1230
+ const key = recordingUrlKey(feed.rtspUrl);
1231
+ if (key !== null && recordingFeedByUrl.has(key))
1232
+ sharedUrlFeeds.push(feed.name);
1233
+ else if (feed.crop)
1234
+ croppedFeeds.push(feed.name);
1235
+ }
1236
+ return { recordingFeeds, sharedUrlFeeds, croppedFeeds };
1237
+ }
781
1238
  /**
782
1239
  * Build per-feed capture loops for LOCAL sensing — one RTSP provider + one
783
1240
  * LocalSensingClient (local VLM + preAnalyzed upload) per feed, each under its
@@ -862,12 +1319,32 @@ function buildLocalSensingLoops(config, deps) {
862
1319
  * silent clamp — the same standard `formatDeployedCadence` is held to.
863
1320
  */
864
1321
  const maxStreamSessionsPerSource = resolveMaxStreamSessionsPerSource(config.cloudSensing?.maxStreamSessionsPerSource);
1322
+ /**
1323
+ * VIDEO — the same persistent session records the camera's own bitstream as
1324
+ * MP4 segments (a stream copy: no decode, no second RTSP session), and a
1325
+ * recorder per feed delivers them into `video_segment`.
1326
+ *
1327
+ * ONE recording per distinct RTSP URL. Mosaic tiles share their source's
1328
+ * URL: the source feed's provider (built crop-less, under the source feed's
1329
+ * own token) records, and every other feed on that URL is skipped and
1330
+ * counted in `getVideoRecordingPlan()`. A cropped per-tile pull never
1331
+ * records — its crop belongs to the look output, and a copy would file the
1332
+ * whole composite under one tile's screen.
1333
+ *
1334
+ * Recording itself is the SERVER's call: the recorder applies the `video`
1335
+ * knobs from this feed's sensing profile, and records nothing until the
1336
+ * server has answered once. Segments live under the connector's data root,
1337
+ * the one directory the Linux unit grants write access to.
1338
+ */
1339
+ const videoRoot = path.join((0, dataDir_1.resolveDataDir)(config.dataDir), exports.VIDEO_SEGMENTS_DIRNAME);
1340
+ const recordingFeedByUrl = new Map();
1341
+ const attachVideo = deps?.videoRecorderFactory === undefined ? VideoSegmentUploader_1.attachCctvVideoRecorder : deps.videoRecorderFactory;
865
1342
  const rtspFactory = deps?.rtspProviderFactory ??
866
1343
  ((feed) => {
867
1344
  // eslint-disable-next-line @typescript-eslint/no-var-requires
868
1345
  const { RTSPStreamCameraProvider } = require('@trillboards/edge-platform-rtsp');
869
1346
  const legacy = (0, legacyRecorderProfile_1.legacyRecorderRtspOptions)(feed.sourceType);
870
- return new RTSPStreamCameraProvider({
1347
+ const provider = new RTSPStreamCameraProvider({
871
1348
  rtspUrl: feed.rtspUrl,
872
1349
  width: feed.width,
873
1350
  height: feed.height,
@@ -903,12 +1380,38 @@ function buildLocalSensingLoops(config, deps) {
903
1380
  outputFormat: feed.outputFormat ?? 'jpeg',
904
1381
  },
905
1382
  });
1383
+ const urlKey = recordingUrlKey(feed.rtspUrl);
1384
+ if (attachVideo && !feed.crop && urlKey !== null && !recordingFeedByUrl.has(urlKey)) {
1385
+ // Recording is an addition to a feed, never a condition of it: a
1386
+ // recorder that cannot be built leaves the feed sensing exactly as before.
1387
+ try {
1388
+ attachVideo({
1389
+ provider,
1390
+ dir: path.join(videoRoot, videoFeedKey(feed)),
1391
+ feedName: feed.name,
1392
+ apiBaseUrl: config.apiBaseUrl,
1393
+ deviceToken: feed.deviceToken,
1394
+ // The same cadence the local profile cache refetches on.
1395
+ pollMs: DEFAULT_PROFILE_TTL_MS,
1396
+ ...(deps?.httpPoster ? { httpPoster: deps.httpPoster } : {}),
1397
+ });
1398
+ recordingFeedByUrl.set(urlKey, feed.name);
1399
+ }
1400
+ catch (err) {
1401
+ console.warn(`[video] ${feed.name}: recording not attached: ${err instanceof Error ? err.message : String(err)}`);
1402
+ }
1403
+ }
1404
+ return provider;
906
1405
  });
907
1406
  const localProviderFactory = deps?.localProviderFactory ??
908
1407
  (() => new LiteRtVlmProvider_1.LiteRtVlmProvider({ litertLmBaseUrl, model, accelerator }));
1408
+ const reviewOptions = config.cloudSensing?.candidateBuffer?.continuousReview;
1409
+ const retainedApiBaseUrl = (0, continuousReview_1.retainedReviewApiBase)(reviewOptions, config.apiBaseUrl);
909
1410
  const uploadClientFactory = deps?.uploadClientFactory ??
910
1411
  ((feed) => new AudienceAnalyzeClient_1.AudienceAnalyzeClient({
911
- apiBaseUrl: config.apiBaseUrl,
1412
+ apiBaseUrl: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled
1413
+ ? retainedApiBaseUrl ?? config.cloudSensing.candidateBuffer.continuousReview.receiverUrl ?? continuousReview_1.DEFAULT_LOCAL_REVIEW_RECEIVER_URL
1414
+ : config.apiBaseUrl,
912
1415
  deviceToken: feed.deviceToken,
913
1416
  screenId: feed.screenId,
914
1417
  imageWidth: feed.width,
@@ -922,7 +1425,7 @@ function buildLocalSensingLoops(config, deps) {
922
1425
  // a single shared pull + crops each tile, so local-sense honors mosaic configs
923
1426
  // identically to cloud-sense. `sharedSources` lets the VLM attach to the SAME
924
1427
  // pull the on-device zoo uses (combined-sense), so one RTSP pull feeds both.
925
- return (0, CloudSensingManager_1.buildTiledCameras)(feeds, rtspFactory, {
1428
+ const tiled = (0, CloudSensingManager_1.buildTiledCameras)(feeds, rtspFactory, {
926
1429
  sharedSources: deps?.sharedSources,
927
1430
  // A MOSAIC TILE MUST NOT SILENTLY KEEP THE OLD CADENCE.
928
1431
  //
@@ -944,12 +1447,34 @@ function buildLocalSensingLoops(config, deps) {
944
1447
  // pulls remain bounded by `CaptureAdmission` (2 per physical recorder) —
945
1448
  // a recorder-scoped bound, which is the one that was doing the protecting.
946
1449
  sharedFrameMaxAgeMs: (0, connectorCadence_1.lookIntervalMsForFeedCount)(feeds.length, 'persistent') ?? connectorCadence_1.LOOK_INTERVAL_MS,
947
- }).map(({ feed, camera }) => ({
1450
+ });
1451
+ // Only the default factory records, so only its build has a plan to report.
1452
+ if (!deps?.rtspProviderFactory) {
1453
+ lastVideoRecordingPlan = summarizeVideoRecordingPlan(feeds, recordingFeedByUrl);
1454
+ if (attachVideo) {
1455
+ const plan = lastVideoRecordingPlan;
1456
+ console.log(`[video] ${plan.recordingFeeds.length} feed(s) can record (one per RTSP URL); ` +
1457
+ `${plan.sharedUrlFeeds.length} share a recorded URL; ${plan.croppedFeeds.length} cropped tile(s) without one. ` +
1458
+ 'Recording follows the server\'s video knobs.');
1459
+ }
1460
+ }
1461
+ return tiled.map(({ feed, camera }) => ({
948
1462
  name: feed.name,
949
1463
  camera,
950
1464
  client: new LocalSensingClient(localProviderFactory(feed), uploadClientFactory(feed), {
1465
+ websiteManagedProfiles: deps?.websiteManagedProfiles,
1466
+ retainedApiBaseUrl,
951
1467
  profileClient: profileClientFactory(feed),
952
1468
  attachEvidenceFrame,
1469
+ detectorFieldProducerOptions: deps?.detectorFieldProducerOptions,
1470
+ detectorCounts: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
1471
+ && config.cloudSensing.candidateBuffer.continuousReview.detectorCounts === true,
1472
+ scopedVlmReview: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
1473
+ && config.cloudSensing.candidateBuffer.continuousReview.scopedVlmReview === true,
1474
+ overlapMixedReview: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
1475
+ && config.cloudSensing.candidateBuffer.continuousReview.overlapMixedReview === true,
1476
+ declaredProfileReview: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
1477
+ && config.cloudSensing.candidateBuffer.continuousReview.declaredProfileReview === true,
953
1478
  }),
954
1479
  }));
955
1480
  }