@trillboards/edge-sdk 0.2.140 → 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 (188) 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/build-macos-pkg.sh +10 -10
  22. package/deploy/macos/scripts/setup-detector.sh +2 -2
  23. package/deploy/macos/scripts/setup-litertlm.sh +66 -30
  24. package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
  25. package/deploy/systemd/setup-detector.sh +2 -2
  26. package/deploy/systemd/setup-litertlm.sh +51 -27
  27. package/deploy/windows/build-windows.ps1 +10 -6
  28. package/deploy/windows/bundle-windows-installer.ps1 +3 -3
  29. package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
  30. package/deploy/windows/install-update.ps1 +52 -9
  31. package/deploy/windows/set-executable-icon.ps1 +24 -5
  32. package/deploy/windows/setup-detector.ps1 +142 -4
  33. package/deploy/windows/setup-litertlm.ps1 +53 -10
  34. package/dist/AudienceAnalyzeClient.d.ts +10 -0
  35. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  36. package/dist/AudienceAnalyzeClient.js +5 -4
  37. package/dist/AudienceAnalyzeClient.js.map +1 -1
  38. package/dist/CloudSensingManager.d.ts +38 -1
  39. package/dist/CloudSensingManager.d.ts.map +1 -1
  40. package/dist/CloudSensingManager.js +403 -25
  41. package/dist/CloudSensingManager.js.map +1 -1
  42. package/dist/DetectorFieldProducer.d.ts +5 -1
  43. package/dist/DetectorFieldProducer.d.ts.map +1 -1
  44. package/dist/DetectorFieldProducer.js +8 -1
  45. package/dist/DetectorFieldProducer.js.map +1 -1
  46. package/dist/DurableVlmCandidateSpool.d.ts +83 -2
  47. package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
  48. package/dist/DurableVlmCandidateSpool.js +596 -30
  49. package/dist/DurableVlmCandidateSpool.js.map +1 -1
  50. package/dist/EdgeAgent.js +1 -1
  51. package/dist/FrameGroupingShadow.d.ts +51 -0
  52. package/dist/FrameGroupingShadow.d.ts.map +1 -0
  53. package/dist/FrameGroupingShadow.js +158 -0
  54. package/dist/FrameGroupingShadow.js.map +1 -0
  55. package/dist/ImageClassifierFieldProducer.d.ts +36 -0
  56. package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
  57. package/dist/ImageClassifierFieldProducer.js +150 -0
  58. package/dist/ImageClassifierFieldProducer.js.map +1 -0
  59. package/dist/LiteRtVlmProvider.d.ts +5 -0
  60. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  61. package/dist/LiteRtVlmProvider.js +72 -7
  62. package/dist/LiteRtVlmProvider.js.map +1 -1
  63. package/dist/LocalDetectorClient.d.ts +6 -0
  64. package/dist/LocalDetectorClient.d.ts.map +1 -1
  65. package/dist/LocalDetectorClient.js +8 -0
  66. package/dist/LocalDetectorClient.js.map +1 -1
  67. package/dist/OrderedFrameReview.d.ts +57 -0
  68. package/dist/OrderedFrameReview.d.ts.map +1 -0
  69. package/dist/OrderedFrameReview.js +121 -0
  70. package/dist/OrderedFrameReview.js.map +1 -0
  71. package/dist/OrderedReviewClient.d.ts +38 -0
  72. package/dist/OrderedReviewClient.d.ts.map +1 -0
  73. package/dist/OrderedReviewClient.js +121 -0
  74. package/dist/OrderedReviewClient.js.map +1 -0
  75. package/dist/OrderedSequenceStore.d.ts +69 -0
  76. package/dist/OrderedSequenceStore.d.ts.map +1 -0
  77. package/dist/OrderedSequenceStore.js +257 -0
  78. package/dist/OrderedSequenceStore.js.map +1 -0
  79. package/dist/ProfileReviewWork.d.ts +68 -0
  80. package/dist/ProfileReviewWork.d.ts.map +1 -0
  81. package/dist/ProfileReviewWork.js +150 -0
  82. package/dist/ProfileReviewWork.js.map +1 -0
  83. package/dist/RetainedFieldContract.d.ts +21 -0
  84. package/dist/RetainedFieldContract.d.ts.map +1 -0
  85. package/dist/RetainedFieldContract.js +92 -0
  86. package/dist/RetainedFieldContract.js.map +1 -0
  87. package/dist/ReviewModelUse.d.ts +7 -0
  88. package/dist/ReviewModelUse.d.ts.map +1 -0
  89. package/dist/ReviewModelUse.js +30 -0
  90. package/dist/ReviewModelUse.js.map +1 -0
  91. package/dist/ReviewPriority.d.ts +12 -0
  92. package/dist/ReviewPriority.d.ts.map +1 -0
  93. package/dist/ReviewPriority.js +45 -0
  94. package/dist/ReviewPriority.js.map +1 -0
  95. package/dist/RuntimeModelSelection.d.ts +10 -0
  96. package/dist/RuntimeModelSelection.d.ts.map +1 -0
  97. package/dist/RuntimeModelSelection.js +58 -0
  98. package/dist/RuntimeModelSelection.js.map +1 -0
  99. package/dist/SceneChangeDetector.d.ts +1 -1
  100. package/dist/SceneChangeDetector.d.ts.map +1 -1
  101. package/dist/SceneChangeDetector.js +18 -6
  102. package/dist/SceneChangeDetector.js.map +1 -1
  103. package/dist/ScopedVlmReview.d.ts +76 -0
  104. package/dist/ScopedVlmReview.d.ts.map +1 -0
  105. package/dist/ScopedVlmReview.js +267 -0
  106. package/dist/ScopedVlmReview.js.map +1 -0
  107. package/dist/SensingProfileClient.d.ts +50 -0
  108. package/dist/SensingProfileClient.d.ts.map +1 -1
  109. package/dist/SensingProfileClient.js +58 -2
  110. package/dist/SensingProfileClient.js.map +1 -1
  111. package/dist/TemporalReviewClient.d.ts +43 -0
  112. package/dist/TemporalReviewClient.d.ts.map +1 -0
  113. package/dist/TemporalReviewClient.js +117 -0
  114. package/dist/TemporalReviewClient.js.map +1 -0
  115. package/dist/TemporalReviewJournal.d.ts +91 -0
  116. package/dist/TemporalReviewJournal.d.ts.map +1 -0
  117. package/dist/TemporalReviewJournal.js +337 -0
  118. package/dist/TemporalReviewJournal.js.map +1 -0
  119. package/dist/UrgentReviewTriage.d.ts +35 -0
  120. package/dist/UrgentReviewTriage.d.ts.map +1 -0
  121. package/dist/UrgentReviewTriage.js +52 -0
  122. package/dist/UrgentReviewTriage.js.map +1 -0
  123. package/dist/VideoSegmentUploader.d.ts +293 -0
  124. package/dist/VideoSegmentUploader.d.ts.map +1 -0
  125. package/dist/VideoSegmentUploader.js +1002 -0
  126. package/dist/VideoSegmentUploader.js.map +1 -0
  127. package/dist/VlmCandidateBuffer.d.ts +19 -0
  128. package/dist/VlmCandidateBuffer.d.ts.map +1 -1
  129. package/dist/VlmCandidateBuffer.js.map +1 -1
  130. package/dist/autoSense.d.ts +2 -2
  131. package/dist/autoSense.d.ts.map +1 -1
  132. package/dist/autoSense.js +16 -11
  133. package/dist/autoSense.js.map +1 -1
  134. package/dist/cli.js +1 -1
  135. package/dist/cli.js.map +1 -1
  136. package/dist/config.d.ts +2 -0
  137. package/dist/config.d.ts.map +1 -1
  138. package/dist/config.js.map +1 -1
  139. package/dist/continuousReview.d.ts +71 -0
  140. package/dist/continuousReview.d.ts.map +1 -0
  141. package/dist/continuousReview.js +53 -0
  142. package/dist/continuousReview.js.map +1 -0
  143. package/dist/detectorCountReview.d.ts +9 -0
  144. package/dist/detectorCountReview.d.ts.map +1 -0
  145. package/dist/detectorCountReview.js +79 -0
  146. package/dist/detectorCountReview.js.map +1 -0
  147. package/dist/diagnose.d.ts +1 -1
  148. package/dist/diagnose.js +1 -1
  149. package/dist/installerOnboardingMode.d.ts.map +1 -1
  150. package/dist/installerOnboardingMode.js +5 -3
  151. package/dist/installerOnboardingMode.js.map +1 -1
  152. package/dist/localInferenceSchema.d.ts +8 -0
  153. package/dist/localInferenceSchema.d.ts.map +1 -1
  154. package/dist/localInferenceSchema.js +3 -3
  155. package/dist/localInferenceSchema.js.map +1 -1
  156. package/dist/localSenseE2eHarness.d.ts +12 -0
  157. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  158. package/dist/localSenseE2eHarness.js +77 -4
  159. package/dist/localSenseE2eHarness.js.map +1 -1
  160. package/dist/localSensing.d.ts +91 -18
  161. package/dist/localSensing.d.ts.map +1 -1
  162. package/dist/localSensing.js +678 -153
  163. package/dist/localSensing.js.map +1 -1
  164. package/dist/mixedScopedCounts.d.ts +17 -0
  165. package/dist/mixedScopedCounts.d.ts.map +1 -0
  166. package/dist/mixedScopedCounts.js +90 -0
  167. package/dist/mixedScopedCounts.js.map +1 -0
  168. package/dist/sensingCatchRate.d.ts +9 -5
  169. package/dist/sensingCatchRate.d.ts.map +1 -1
  170. package/dist/sensingCatchRate.js +10 -6
  171. package/dist/sensingCatchRate.js.map +1 -1
  172. package/dist/sensingRunMode.d.ts +5 -0
  173. package/dist/sensingRunMode.d.ts.map +1 -1
  174. package/dist/sensingRunMode.js +56 -0
  175. package/dist/sensingRunMode.js.map +1 -1
  176. package/package.json +7 -4
  177. package/retained/ArchiveLifecycle.cjs +79 -0
  178. package/retained/ModelStageScheduler.cjs +66 -0
  179. package/retained/ProfileAuthorityClient.cjs +88 -0
  180. package/retained/RetainedCaptureClient.cjs +164 -0
  181. package/retained/WaitRecheckCaptureClient.cjs +109 -0
  182. package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
  183. package/retained/__tests__/runtime.test.cjs +384 -0
  184. package/retained/alert-rule-policy.cjs +37 -0
  185. package/retained/manager.cjs +133 -0
  186. package/retained/pilot-profile-client.cjs +82 -0
  187. package/retained/temporal-grouping.cjs +78 -0
  188. package/retained/website-api-capability.cjs +122 -0
@@ -38,6 +38,9 @@ const crypto = __importStar(require("crypto"));
38
38
  const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const VlmCandidateBuffer_1 = require("./VlmCandidateBuffer");
41
+ const ReviewPriority_1 = require("./ReviewPriority");
42
+ const UrgentReviewTriage_1 = require("./UrgentReviewTriage");
43
+ const ProfileReviewWork_1 = require("./ProfileReviewWork");
41
44
  const RECORD_VERSION = 1;
42
45
  const DEFAULT_MAX_FRAMES = 4096;
43
46
  const DEFAULT_MAX_PER_FEED = 128;
@@ -67,10 +70,24 @@ const DEFAULT_RETRY_MAX_MS = 5 * 60 * 1000;
67
70
  */
68
71
  class DurableVlmCandidateSpool {
69
72
  constructor(options) {
73
+ this.triageEligible = new Set();
74
+ this.triageFinished = new Set();
75
+ this.triagePositive = new Set();
76
+ this.triageOrder = new Map();
77
+ this.triageTick = 0;
78
+ this.priorityTurns = 0;
79
+ this.deliveryPriorityTurns = 0;
80
+ // At most one direct answer per camera, and 64 cameras total (under 8 MiB).
81
+ // No JPEGs, no chained reuse, no persistence or TTL renewal for this cache.
82
+ this.recentAnswers = new Map();
83
+ this.reusePrepared = 0;
84
+ this.capacityRejected = 0;
70
85
  this.feeds = new Map();
71
86
  this.inFlight = new Map();
72
87
  this.serveOrder = new Map();
73
88
  this.serveTick = 0;
89
+ this.deliveryServeTick = 0;
90
+ this.deliveryServeOrder = new Map();
74
91
  this.queued = 0;
75
92
  this.persisted = 0;
76
93
  this.persistedBytes = 0;
@@ -91,7 +108,26 @@ class DurableVlmCandidateSpool {
91
108
  this.maxPerEpisode = Math.min(this.maxPerFeed, Math.max(1, Math.trunc(options.maxPerEpisode ?? VlmCandidateBuffer_1.DEFAULT_CANDIDATES_PER_EPISODE)));
92
109
  this.episodeGapMs = Math.max(1, Math.trunc(options.episodeGapMs ?? VlmCandidateBuffer_1.DEFAULT_CANDIDATE_EPISODE_GAP_MS));
93
110
  this.maxBytes = Math.max(1, Math.trunc(options.maxBytes ?? DEFAULT_MAX_BYTES));
94
- this.retentionMs = Math.max(1, Math.trunc(options.retentionMs ?? DEFAULT_RETENTION_MS));
111
+ this.retentionMs = Math.max(1, Math.trunc(options.retentionMs ??
112
+ (options.reviewWindowMs === undefined ? DEFAULT_RETENTION_MS : 86400000)));
113
+ this.reviewWindowMs = options.reviewWindowMs ?? null;
114
+ if (options.preserveArchivedPending && this.reviewWindowMs === null)
115
+ throw Error('Archived pending protection requires continuous review');
116
+ this.preserveArchivedPending = options.preserveArchivedPending === true;
117
+ if (options.priorityScheduling && this.reviewWindowMs === null) {
118
+ throw Error('Priority scheduling requires continuous review');
119
+ }
120
+ this.priorityScheduling = options.priorityScheduling === true;
121
+ if (options.urgentTriagePolicies && !this.priorityScheduling)
122
+ throw Error('Urgent triage requires priority scheduling');
123
+ this.urgentTriagePolicies = JSON.parse(JSON.stringify(options.urgentTriagePolicies ?? {}));
124
+ this.reuseExactPendingImages = this.reviewWindowMs !== null && options.reuseExactPendingImages === true;
125
+ this.reuseExactRecentImages = this.reviewWindowMs !== null && options.reuseExactRecentImages === true;
126
+ if (this.reviewWindowMs !== null && (!Number.isFinite(this.reviewWindowMs) || this.reviewWindowMs < 1 ||
127
+ this.retentionMs < this.reviewWindowMs ||
128
+ [this.retentionMs, this.maxFrames, this.maxPerFeed, this.maxBytes]
129
+ .some(value => !Number.isFinite(value) || value < 1)))
130
+ throw new Error('Invalid continuous review storage limits');
95
131
  this.retryInitialMs = Math.max(1, Math.trunc(options.retryInitialMs ?? DEFAULT_RETRY_INITIAL_MS));
96
132
  this.retryMaxMs = Math.max(this.retryInitialMs, Math.trunc(options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS));
97
133
  this.now = options.now ?? Date.now;
@@ -119,7 +155,63 @@ class DurableVlmCandidateSpool {
119
155
  const now = nowMs ?? this.now();
120
156
  this.expire(now);
121
157
  const byteLength = candidate.jpeg.length;
158
+ if (candidate.reviewPriority !== undefined && !(0, ReviewPriority_1.isReviewPriority)(candidate.reviewPriority)) {
159
+ this.writeFailed++;
160
+ return 'rejected';
161
+ }
162
+ const contextBytes = candidate.reviewContext ? Buffer.byteLength(JSON.stringify(candidate.reviewContext)) : 0;
163
+ const groupingBytes = candidate.groupingShadow ? Buffer.byteLength(JSON.stringify(candidate.groupingShadow)) : 0;
164
+ const reservedResultBytes = this.reviewWindowMs === null ? 0 : 64 * 1024;
165
+ const profileWork = candidate.profileWork && candidate.groupingShadow
166
+ ? this.resolveProfileWork({ ...candidate, jpegSha256: crypto.createHash('sha256').update(candidate.jpeg).digest('hex'),
167
+ enqueuedAtMs: candidate.enqueuedAtMs ?? now }) : undefined;
168
+ // Separate experimental plans/results reserve 32 KiB each, without borrowing
169
+ // the normal full-result budget or duplicating the JPEG.
170
+ const profileBytes = profileWork ? 64 * 1024 : 0;
171
+ const configuredTriage = this.urgentTriagePolicies[candidate.name];
172
+ const urgentTriagePolicy = configuredTriage && candidate.reviewPriority === 'urgent'
173
+ && configuredTriage.contextSha256 === (0, UrgentReviewTriage_1.triageHash)(candidate.reviewContext) ? configuredTriage : undefined;
174
+ const admissionBytes = byteLength + contextBytes + groupingBytes + reservedResultBytes + profileBytes
175
+ + (urgentTriagePolicy ? UrgentReviewTriage_1.TRIAGE_RESERVED_BYTES : 0);
176
+ if (contextBytes > 64 * 1024) {
177
+ this.writeFailed++;
178
+ return 'rejected';
179
+ }
180
+ if (groupingBytes > 16 * 1024) {
181
+ this.writeFailed++;
182
+ return 'rejected';
183
+ }
122
184
  const feed = this.feedQueue(candidate.name);
185
+ const pending = [...feed.items, ...this.inFlight.values()].filter(e => e.name === candidate.name);
186
+ const digest = crypto.createHash('sha256').update(candidate.jpeg).digest('hex');
187
+ if (candidate.retainedId !== undefined) {
188
+ if (this.reviewWindowMs === null || !/^[a-f0-9]{32}$/.test(candidate.retainedId)
189
+ || (candidate.reviewContext?.orderedSequence?.id !== candidate.retainedId && !(candidate.reviewContext?.retainedCapture?.version === 1 && candidate.reviewContext?.retainedCapture?.kind === 'retained_single_capture' && candidate.reviewContext?.retainedCapture?.id === candidate.retainedId)))
190
+ return 'rejected';
191
+ const old = pending.find(e => e.id === candidate.retainedId);
192
+ if (old) {
193
+ if (old.jpegSha256 !== digest || old.enqueuedAtMs !== candidate.enqueuedAtMs
194
+ || JSON.stringify(old.reviewContext) !== JSON.stringify(candidate.reviewContext))
195
+ return 'rejected';
196
+ return 'coalesced';
197
+ }
198
+ }
199
+ // Temporal rehearsal retains each captured timestamp, even for identical JPEGs.
200
+ // Otherwise a still-open door loses the observations needed to measure duration.
201
+ if (this.reviewWindowMs !== null && !candidate.reviewContext?.temporalReviewContract && !candidate.reviewContext?.orderedSequence && !candidate.reviewContext?.retainedCapture && pending.some(e => e.jpegSha256 === digest &&
202
+ (0, ReviewPriority_1.reviewPriorityRank)(e.reviewPriority) === (0, ReviewPriority_1.reviewPriorityRank)(candidate.reviewPriority) &&
203
+ JSON.stringify(e.reviewContext) === JSON.stringify(candidate.reviewContext) &&
204
+ (candidate.reviewKind === 'sample' ? e.reviewKind === 'sample' : now - e.enqueuedAtMs < 1000))) {
205
+ feed.coalesced++;
206
+ this.coalesced++;
207
+ return 'coalesced';
208
+ }
209
+ // Preserve admitted evidence under pressure; count rejected new arrivals.
210
+ if (this.reviewWindowMs !== null && (pending.length >= this.maxPerFeed || this.persisted >= this.maxFrames ||
211
+ this.persistedBytes + admissionBytes > this.maxBytes)) {
212
+ this.capacityRejected++;
213
+ return 'rejected';
214
+ }
123
215
  if (byteLength > this.maxBytes) {
124
216
  feed.evicted++;
125
217
  this.evicted++;
@@ -131,9 +223,12 @@ class DurableVlmCandidateSpool {
131
223
  const episodeStartedAtMs = startsNewEpisode
132
224
  ? enqueuedAtMs
133
225
  : feed.episodeStartedAtMs ?? enqueuedAtMs;
134
- const episodeEntries = this.currentEpisodeEntries(feed, episodeStartedAtMs)
135
- .sort((a, b) => a.enqueuedAtMs - b.enqueuedAtMs || a.id.localeCompare(b.id));
136
- const episodeAtCapacity = episodeEntries.length >= this.maxPerEpisode;
226
+ const episodeEntries = this.reviewWindowMs !== null ? [] :
227
+ this.currentEpisodeEntries(feed, episodeStartedAtMs)
228
+ .sort((a, b) => a.enqueuedAtMs - b.enqueuedAtMs || a.id.localeCompare(b.id));
229
+ // A later frame can contain a new short-lived action. Only legacy mode
230
+ // coalesces a whole motion episode to its first frames.
231
+ const episodeAtCapacity = this.reviewWindowMs === null && episodeEntries.length >= this.maxPerEpisode;
137
232
  feed.episodeStartedAtMs = episodeStartedAtMs;
138
233
  feed.lastCandidateAtMs = enqueuedAtMs;
139
234
  if (episodeAtCapacity) {
@@ -149,7 +244,7 @@ class DurableVlmCandidateSpool {
149
244
  }
150
245
  const entry = {
151
246
  version: RECORD_VERSION,
152
- id: crypto.randomUUID().replace(/-/g, ''),
247
+ id: candidate.retainedId ?? crypto.randomUUID().replace(/-/g, ''),
153
248
  name: candidate.name,
154
249
  enqueuedAtMs,
155
250
  availableAtMs: now,
@@ -159,10 +254,22 @@ class DurableVlmCandidateSpool {
159
254
  imageWidth: candidate.imageWidth,
160
255
  imageHeight: candidate.imageHeight,
161
256
  byteLength,
162
- jpegSha256: crypto.createHash('sha256').update(candidate.jpeg).digest('hex'),
257
+ jpegSha256: digest,
163
258
  attempts: 0,
164
259
  ...(candidate.detectorShadow ? { detectorShadow: candidate.detectorShadow } : {}),
260
+ ...(candidate.reviewKind ? { reviewKind: candidate.reviewKind } : {}),
261
+ ...(candidate.reviewPriority ? { reviewPriority: candidate.reviewPriority } : {}),
262
+ ...(candidate.reviewContext ? { reviewContext: candidate.reviewContext, contextBytes } : {}),
263
+ ...(candidate.groupingShadow ? { groupingShadow: candidate.groupingShadow, groupingBytes } : {}),
264
+ ...(reservedResultBytes ? { reservedResultBytes } : {}),
265
+ ...(profileWork ? { profileWork } : {}),
266
+ ...(urgentTriagePolicy ? { urgentTriagePolicy } : {}),
165
267
  };
268
+ const recentAnswer = this.exactRecentAnswer(entry, now);
269
+ if (recentAnswer) {
270
+ entry.cachedAnalysis = recentAnswer;
271
+ entry.resultBytes = Buffer.byteLength(JSON.stringify(recentAnswer));
272
+ }
166
273
  // Persist first. If the disk is unavailable, existing evidence remains
167
274
  // untouched and the caller gets a counted rejection instead of a false
168
275
  // claim that the event is durable.
@@ -172,7 +279,9 @@ class DurableVlmCandidateSpool {
172
279
  return 'rejected';
173
280
  }
174
281
  this.persisted++;
175
- this.persistedBytes += byteLength;
282
+ this.persistedBytes += this.entryBytes(entry);
283
+ if (recentAnswer)
284
+ this.reusePrepared++;
176
285
  // A busy camera pays its own pressure first. Global pressure then evicts
177
286
  // from the fullest queued feed; in-flight evidence is never deleted.
178
287
  while (feed.items.length >= this.maxPerFeed)
@@ -185,29 +294,30 @@ class DurableVlmCandidateSpool {
185
294
  if (this.persisted > this.maxFrames || this.persistedBytes > this.maxBytes) {
186
295
  this.deletePair(entry);
187
296
  this.persisted--;
188
- this.persistedBytes -= byteLength;
297
+ this.persistedBytes -= this.entryBytes(entry);
189
298
  feed.evicted++;
190
299
  this.evicted++;
191
300
  return 'rejected';
192
301
  }
193
302
  feed.items.push(entry);
303
+ this.restoreTriage(entry);
194
304
  this.sortFeed(feed);
195
305
  this.queued++;
196
306
  this.enqueued++;
197
307
  feed.enqueued++;
198
308
  return 'buffered';
199
309
  }
200
- peek(nowMs) {
310
+ peek(nowMs, stage, accept) {
201
311
  const now = nowMs ?? this.now();
202
312
  this.expire(now);
203
- const selected = this.nextEligible(now);
313
+ const selected = this.nextEligible(now, stage, accept);
204
314
  return selected ? this.asCandidate(selected.entry, Buffer.alloc(0)) : null;
205
315
  }
206
- take(nowMs) {
316
+ take(nowMs, stage, accept) {
207
317
  const now = nowMs ?? this.now();
208
318
  this.expire(now);
209
319
  for (;;) {
210
- const selected = this.nextEligible(now);
320
+ const selected = this.nextEligible(now, stage, accept);
211
321
  if (!selected)
212
322
  return null;
213
323
  const { feed, index, entry } = selected;
@@ -237,7 +347,18 @@ class DurableVlmCandidateSpool {
237
347
  this.inFlight.set(entry.id, entry);
238
348
  feed.served++;
239
349
  this.served++;
240
- this.serveOrder.set(feed.name, ++this.serveTick);
350
+ if (stage === 'delivery')
351
+ this.deliveryServeOrder.set(feed.name, ++this.deliveryServeTick);
352
+ else
353
+ this.serveOrder.set(feed.name, ++this.serveTick);
354
+ // Only a successful take spends a turn. peek(), blocked feeds and retry
355
+ // backoff never advance either independent lane's fairness state.
356
+ if (this.priorityScheduling) {
357
+ if (stage === 'delivery')
358
+ this.deliveryPriorityTurns = (this.deliveryPriorityTurns + 1) % (ReviewPriority_1.PRIORITY_BURST + 1);
359
+ else
360
+ this.priorityTurns = (this.priorityTurns + 1) % (ReviewPriority_1.PRIORITY_BURST + 1);
361
+ }
241
362
  return this.asCandidate(entry, jpeg);
242
363
  }
243
364
  }
@@ -253,8 +374,247 @@ class DurableVlmCandidateSpool {
253
374
  this.removePersisted(entry);
254
375
  this.completed++;
255
376
  }
377
+ /** Commit a model result before upload, so network retries do not repeat inference. */
378
+ checkpoint(candidate, result) {
379
+ const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
380
+ if (!entry)
381
+ throw new Error('Evidence is not in flight');
382
+ const bytes = Buffer.byteLength(JSON.stringify(result));
383
+ const delta = entry.reservedResultBytes ? 0 : bytes - (entry.resultBytes ?? 0);
384
+ if (bytes > 64 * 1024 || this.persistedBytes + delta > this.maxBytes) {
385
+ this.writeFailed++;
386
+ throw new Error('Evidence result exceeds the storage budget');
387
+ }
388
+ entry.cachedAnalysis = result;
389
+ delete entry.scopedVlmProgress;
390
+ entry.resultBytes = bytes;
391
+ this.persistedBytes += delta;
392
+ if (!this.writeMetadata(entry)) {
393
+ // Keep the answer in memory while disk recovers. The client retries
394
+ // this checkpoint before uploading, without spending another model call.
395
+ throw new Error('Could not persist the model result');
396
+ }
397
+ this.prepareExactPendingAnswers(entry, result);
398
+ this.rememberDirectAnswer(entry, result);
399
+ }
400
+ /** Partial scope work uses the already reserved result budget, never upload eligibility. */
401
+ checkpointScopedReview(candidate, progress) {
402
+ const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
403
+ if (!entry || entry.cachedAnalysis || !entry.reviewContext?.scopedVlmReview || entry.reservedResultBytes !== 64 * 1024) {
404
+ throw Error('Scoped checkpoint requires unreviewed durable evidence');
405
+ }
406
+ if (Buffer.byteLength(JSON.stringify(progress)) > entry.reservedResultBytes) {
407
+ this.writeFailed++;
408
+ throw Error('Scoped checkpoint exceeds the reserved result budget');
409
+ }
410
+ entry.scopedVlmProgress = JSON.parse(JSON.stringify(progress));
411
+ if (!this.writeMetadata(entry))
412
+ throw Error('Could not persist scoped review progress');
413
+ }
414
+ /** Revalidate group references against committed evidence, then commit the job selection. */
415
+ prepareProfileWork(candidate) {
416
+ const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
417
+ if (!entry?.profileWork || !entry.reviewContext)
418
+ throw Error('No durable profile work');
419
+ const work = this.resolveProfileWork(entry);
420
+ if (!work)
421
+ throw Error('Invalid durable profile work');
422
+ entry.profileWork = work;
423
+ if (!this.writeMetadata(entry))
424
+ throw Error('Could not persist profile work selection');
425
+ return JSON.parse(JSON.stringify(work));
426
+ }
427
+ /** Rehearsal output is never cachedAnalysis and cannot become a delivery job. */
428
+ checkpointProfileWork(candidate, result) {
429
+ const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
430
+ const work = entry?.profileWork;
431
+ if (!entry || !work || !entry.reviewContext || result.version !== 1 || result.kind !== 'profile_work_rehearsal' ||
432
+ result.evidenceId !== entry.id || result.imageSha256 !== entry.jpegSha256 || result.contextSha256 !== work.contextSha256 ||
433
+ result.capturedAtMs !== entry.enqueuedAtMs || !Number.isFinite(result.reviewedAtMs) ||
434
+ JSON.stringify(result.selectedFields) !== JSON.stringify(work.items.filter(i => i.selected).map(i => i.field)) ||
435
+ JSON.stringify(result.unreviewedFields) !== JSON.stringify(work.items.filter(i => !i.selected).map(i => i.field)) ||
436
+ !result.structured || typeof result.structured !== 'object' || Array.isArray(result.structured) ||
437
+ Object.keys(result.structured).some(f => !result.selectedFields.includes(f)) ||
438
+ !Array.isArray(result.unresolvedFields) || result.unresolvedFields.some(f => !result.selectedFields.includes(f)) ||
439
+ Buffer.byteLength(JSON.stringify(result)) > 32 * 1024)
440
+ throw Error('Invalid profile work result');
441
+ entry.profileWorkResult = JSON.parse(JSON.stringify(result));
442
+ if (!this.writeMetadata(entry))
443
+ throw Error('Could not persist profile work result');
444
+ }
445
+ resolveProfileWork(entry) {
446
+ if (!entry.reviewContext || !entry.groupingShadow || !entry.jpegSha256)
447
+ return undefined;
448
+ const work = (0, ProfileReviewWork_1.planProfileReview)(entry.reviewContext, entry.groupingShadow);
449
+ if (!work)
450
+ return undefined;
451
+ try {
452
+ (0, ProfileReviewWork_1.validateProfileWork)(work, entry.reviewContext, entry.jpegSha256, entry.enqueuedAtMs);
453
+ }
454
+ catch {
455
+ return undefined;
456
+ }
457
+ const pending = [...(this.feeds.get(entry.name)?.items ?? []), ...this.inFlight.values()];
458
+ for (const item of work.items.filter(i => !i.selected)) {
459
+ const source = pending.find(e => e.name === entry.name && e.enqueuedAtMs === item.referenceCapturedAtMs &&
460
+ e.enqueuedAtMs < entry.enqueuedAtMs && e.jpegSha256 === item.referenceSha256 &&
461
+ e.profileWork?.contextSha256 === work.contextSha256 && Array.isArray(e.profileWork.items) &&
462
+ e.profileWork.items.some(i => i.field === item.field && i.selected) &&
463
+ fs.existsSync(this.metadataPath(e.id)) && fs.existsSync(this.jpegPath(e.id)));
464
+ if (source)
465
+ item.referenceEvidenceId = source.id;
466
+ else {
467
+ item.selected = true;
468
+ item.reason = 'missing_durable_reference';
469
+ delete item.referenceSha256;
470
+ delete item.referenceCapturedAtMs;
471
+ }
472
+ }
473
+ (0, ProfileReviewWork_1.promoteProfileDependencies)(work);
474
+ return Buffer.byteLength(JSON.stringify(work)) <= 32 * 1024 ? work : undefined;
475
+ }
476
+ /** Read-only test/audit surface; no reference is exposed before writePair commits. */
477
+ inspectProfileWork(name, capturedAtMs, sha256) {
478
+ const entry = [...(this.feeds.get(name)?.items ?? []), ...this.inFlight.values()].find(e => e.name === name && e.enqueuedAtMs === capturedAtMs && e.jpegSha256 === sha256 && e.profileWork);
479
+ return entry ? { evidenceId: entry.id, work: JSON.parse(JSON.stringify(entry.profileWork)) } : null;
480
+ }
481
+ /** A raw archive ID names this receipt, not a different visual question.
482
+ * Keep all selected IDs and original timestamps; share only the exact answer.
483
+ * Temporal/ordered observations still have no answer-reuse permission.
484
+ */
485
+ exactAnswerContext(entry) {
486
+ const context = entry.reviewContext;
487
+ if (!context || context.temporalReviewContract || context.orderedSequence || context.orderedCaptureContract)
488
+ return undefined;
489
+ if (context.retainedCapture === undefined)
490
+ return context;
491
+ const ref = context.retainedCapture;
492
+ if (ref?.version !== 1 || ref.kind !== 'retained_single_capture' || ref.id !== entry.id ||
493
+ Object.keys(ref).sort().join(',') !== 'id,kind,version')
494
+ return undefined;
495
+ const normalized = { ...context };
496
+ delete normalized.retainedCapture;
497
+ return normalized;
498
+ }
499
+ rememberDirectAnswer(source, result) {
500
+ const sourceContext = this.exactAnswerContext(source);
501
+ if (!sourceContext)
502
+ return;
503
+ if (!this.reuseExactRecentImages || !source.reviewContext || !source.jpegSha256 ||
504
+ result.version !== 1 || result.evidenceSha256 !== source.jpegSha256 ||
505
+ result.reusedFromEvidenceId !== undefined || result.reuseMethod !== undefined ||
506
+ typeof result.reviewedAtMs !== 'number' || !Number.isFinite(result.reviewedAtMs) ||
507
+ result.reviewedAtMs < source.enqueuedAtMs || result.reviewedAtMs > this.now() ||
508
+ this.now() - source.enqueuedAtMs > this.reviewWindowMs)
509
+ return;
510
+ this.recentAnswers.delete(source.name);
511
+ this.recentAnswers.set(source.name, {
512
+ id: source.id, capturedAtMs: source.enqueuedAtMs, reviewedAtMs: result.reviewedAtMs,
513
+ jpegSha256: source.jpegSha256, imageWidth: source.imageWidth, imageHeight: source.imageHeight,
514
+ context: JSON.stringify(sourceContext), result,
515
+ });
516
+ if (this.recentAnswers.size > 64)
517
+ this.recentAnswers.delete(this.recentAnswers.keys().next().value);
518
+ }
519
+ exactRecentAnswer(entry, now) {
520
+ const entryContext = this.exactAnswerContext(entry);
521
+ if (!entryContext)
522
+ return undefined;
523
+ if (!this.reuseExactRecentImages || !entry.reviewContext)
524
+ return undefined;
525
+ for (const [name, answer] of this.recentAnswers) {
526
+ if (now - answer.capturedAtMs > this.reviewWindowMs)
527
+ this.recentAnswers.delete(name);
528
+ }
529
+ const source = this.recentAnswers.get(entry.name);
530
+ if (!source || entry.enqueuedAtMs < source.reviewedAtMs || entry.enqueuedAtMs > now ||
531
+ now < source.reviewedAtMs || entry.enqueuedAtMs - source.capturedAtMs > this.reviewWindowMs ||
532
+ entry.jpegSha256 !== source.jpegSha256 || entry.imageWidth !== source.imageWidth ||
533
+ entry.imageHeight !== source.imageHeight || JSON.stringify(entryContext) !== source.context)
534
+ return undefined;
535
+ const answer = { ...source.result, analyzeMs: 0, reviewedAtMs: now,
536
+ reusedFromEvidenceId: source.id, reuseMethod: 'exact_recent',
537
+ sourceCapturedAtMs: source.capturedAtMs, sourceReviewedAtMs: source.reviewedAtMs };
538
+ return Buffer.byteLength(JSON.stringify(answer)) <= 64 * 1024 ? answer : undefined;
539
+ }
540
+ /**
541
+ * Share a completed answer, never discard a visually similar frame. Every
542
+ * recipient remains a separate durable candidate until its own receiver ack.
543
+ * This pending-only path excludes future captures, and profile/camera boundaries
544
+ * are never crossed. A changed JPEG, however small the change, is ineligible.
545
+ */
546
+ prepareExactPendingAnswers(source, result) {
547
+ const sourceContext = this.exactAnswerContext(source);
548
+ if (!sourceContext)
549
+ return;
550
+ if (!this.reuseExactPendingImages || !source.reviewContext || !source.jpegSha256 ||
551
+ result.version !== 1 || result.evidenceSha256 !== source.jpegSha256 ||
552
+ result.reusedFromEvidenceId !== undefined || typeof result.reviewedAtMs !== 'number' ||
553
+ !Number.isFinite(result.reviewedAtMs) || result.reviewedAtMs < source.enqueuedAtMs)
554
+ return;
555
+ const context = JSON.stringify(sourceContext);
556
+ const answer = { ...result, reusedFromEvidenceId: source.id };
557
+ const bytes = Buffer.byteLength(JSON.stringify(answer));
558
+ if (bytes > 64 * 1024)
559
+ return; // The original result still remains usable.
560
+ for (const pending of this.feeds.get(source.name)?.items ?? []) {
561
+ if (pending.cachedAnalysis || pending.jpegSha256 !== source.jpegSha256 ||
562
+ pending.enqueuedAtMs > result.reviewedAtMs ||
563
+ Math.abs(pending.enqueuedAtMs - source.enqueuedAtMs) > this.reviewWindowMs ||
564
+ pending.imageWidth !== source.imageWidth || pending.imageHeight !== source.imageHeight ||
565
+ JSON.stringify(this.exactAnswerContext(pending)) !== context)
566
+ continue;
567
+ const delta = pending.reservedResultBytes ? 0 : bytes - (pending.resultBytes ?? 0);
568
+ if (this.persistedBytes + delta > this.maxBytes)
569
+ continue;
570
+ pending.cachedAnalysis = answer;
571
+ delete pending.scopedVlmProgress;
572
+ pending.resultBytes = bytes;
573
+ this.persistedBytes += delta;
574
+ // If disk fails, keep the answer in memory. Serving this candidate retries
575
+ // its own checkpoint before upload; recovery can safely recompute it.
576
+ if (this.writeMetadata(pending))
577
+ this.reusePrepared++;
578
+ }
579
+ }
580
+ /** Move a committed answer to the independent delivery lane. */
581
+ readyForDelivery(candidate) {
582
+ const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
583
+ if (!entry?.cachedAnalysis)
584
+ throw new Error('Delivery requires a saved model result');
585
+ if (!this.reviewComplete(entry))
586
+ throw new Error('Temporal decision must be committed before delivery');
587
+ // Commit before changing ownership. A failed write leaves this in flight so
588
+ // the caller can retry the same result; it never becomes an acknowledged alert.
589
+ entry.availableAtMs = this.now();
590
+ entry.attempts = 0;
591
+ if (!this.writeMetadata(entry))
592
+ throw new Error('Could not persist delivery handoff');
593
+ this.inFlight.delete(entry.id);
594
+ const feed = this.feedQueue(entry.name);
595
+ feed.items.push(entry);
596
+ this.sortFeed(feed);
597
+ this.queued++;
598
+ }
256
599
  /** Keep the same evidence and retry it after bounded exponential backoff. */
257
600
  retry(candidate, nowMs) {
601
+ this.requeue(candidate, nowMs, false);
602
+ }
603
+ /**
604
+ * Retry a saved answer after a short connectivity probe interval. The manager
605
+ * shares this pause across its single upload lane, so a large offline queue
606
+ * cannot turn one connection failure into one request per saved image.
607
+ */
608
+ retryDelivery(candidate, nowMs) {
609
+ const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
610
+ if (this.reviewWindowMs === null || !entry?.cachedAnalysis) {
611
+ throw new Error('Delivery retry requires a saved continuous-review answer');
612
+ }
613
+ const now = nowMs ?? this.now();
614
+ this.requeue(candidate, now, true);
615
+ return now + this.retryInitialMs;
616
+ }
617
+ requeue(candidate, nowMs, delivery) {
258
618
  const id = candidate.spoolId;
259
619
  if (!id)
260
620
  return;
@@ -263,7 +623,7 @@ class DurableVlmCandidateSpool {
263
623
  return;
264
624
  this.inFlight.delete(id);
265
625
  entry.attempts += 1;
266
- const delay = Math.min(this.retryMaxMs, this.retryInitialMs * Math.pow(2, Math.min(16, entry.attempts - 1)));
626
+ const delay = delivery ? this.retryInitialMs : Math.min(this.retryMaxMs, this.retryInitialMs * Math.pow(2, Math.min(16, entry.attempts - 1)));
267
627
  entry.availableAtMs = (nowMs ?? this.now()) + delay;
268
628
  this.writeMetadata(entry);
269
629
  const feed = this.feedQueue(entry.name);
@@ -278,6 +638,15 @@ class DurableVlmCandidateSpool {
278
638
  for (const feed of this.feeds.values()) {
279
639
  for (let i = feed.items.length - 1; i >= 0; i--) {
280
640
  const entry = feed.items[i];
641
+ // A completed review is pending delivery, even after the evidence age
642
+ // target. Refuse new work at capacity rather than erase an unsent result.
643
+ if (this.reviewWindowMs !== null && entry.cachedAnalysis)
644
+ continue;
645
+ const archiveRef = entry.reviewContext?.retainedCapture;
646
+ if (this.preserveArchivedPending && archiveRef?.version === 1 &&
647
+ archiveRef.kind === 'retained_single_capture' && archiveRef.id === entry.id &&
648
+ Object.keys(archiveRef).sort().join(',') === 'id,kind,version')
649
+ continue;
281
650
  if (now - entry.enqueuedAtMs <= this.retentionMs)
282
651
  continue;
283
652
  feed.items.splice(i, 1);
@@ -337,15 +706,93 @@ class DurableVlmCandidateSpool {
337
706
  writeFailed: this.writeFailed,
338
707
  readFailed: this.readFailed,
339
708
  deleteFailed: this.deleteFailed,
709
+ reusePrepared: this.reusePrepared,
710
+ ...(this.reviewWindowMs === null ? {} : {
711
+ reviewWindowMs: this.reviewWindowMs,
712
+ delayed: [...this.feeds.values()].reduce((n, f) => n + f.items.filter(e => now - e.enqueuedAtMs > this.reviewWindowMs).length, 0),
713
+ capacityRejected: this.capacityRejected,
714
+ }),
340
715
  };
341
716
  }
717
+ /** One read-only advisory look. It never takes/removes a review or reserves a GPU slot. */
718
+ nextTriage() {
719
+ let selected;
720
+ let order = Infinity;
721
+ for (const feed of this.feeds.values()) {
722
+ let item;
723
+ for (let i = feed.items.length - 1; i >= 0; i--) {
724
+ const entry = feed.items[i];
725
+ if (!entry.cachedAnalysis && this.triageEligible.has(entry.id) && !this.triageFinished.has(entry.id)) {
726
+ item = entry;
727
+ break;
728
+ }
729
+ }
730
+ const feedOrder = this.triageOrder.get(feed.name) ?? 0;
731
+ if (item && feedOrder < order) {
732
+ selected = item;
733
+ order = feedOrder;
734
+ }
735
+ }
736
+ if (!selected)
737
+ return null;
738
+ this.triageOrder.set(selected.name, ++this.triageTick);
739
+ // Corrupt/unreadable bytes are a visible error, never an advisory-negative deletion.
740
+ const jpeg = fs.readFileSync(this.jpegPath(selected.id));
741
+ if (jpeg.length !== selected.byteLength || crypto.createHash('sha256').update(jpeg).digest('hex') !== selected.jpegSha256) {
742
+ throw Error('Urgent triage evidence hash mismatch');
743
+ }
744
+ return { candidate: this.asCandidate(selected, jpeg), policy: { ...selected.urgentTriagePolicy } };
745
+ }
746
+ checkpointTriage(result) {
747
+ const entry = this.inFlight.get(result.evidenceId) ?? [...this.feeds.values()]
748
+ .flatMap(feed => feed.items).find(item => item.id === result.evidenceId);
749
+ if (!entry || entry.cachedAnalysis || !this.triageEligible.has(entry.id)
750
+ || this.triageFinished.has(entry.id) || !(0, UrgentReviewTriage_1.validTriageResult)(result, entry, entry.urgentTriagePolicy))
751
+ return false;
752
+ const previous = entry.urgentTriageResult;
753
+ const previousBytes = this.entryBytes(entry);
754
+ entry.urgentTriageResult = JSON.parse(JSON.stringify(result));
755
+ if (!this.writeMetadata(entry)) {
756
+ entry.urgentTriageResult = previous;
757
+ return false;
758
+ }
759
+ this.persistedBytes += this.entryBytes(entry) - previousBytes;
760
+ this.restoreTriage(entry);
761
+ return true;
762
+ }
763
+ restoreTriage(entry) {
764
+ const policy = this.urgentTriagePolicies[entry.name];
765
+ if (!policy || entry.reviewPriority !== 'urgent' || policy.contextSha256 !== (0, UrgentReviewTriage_1.triageHash)(entry.reviewContext)
766
+ || (0, UrgentReviewTriage_1.triageHash)(policy) !== (0, UrgentReviewTriage_1.triageHash)(entry.urgentTriagePolicy))
767
+ return;
768
+ this.triageEligible.add(entry.id);
769
+ // Damaged optional hints do not invalidate the underlying evidence record.
770
+ if ((0, UrgentReviewTriage_1.validTriageResult)(entry.urgentTriageResult, entry, policy)) {
771
+ this.triageFinished.add(entry.id);
772
+ if (entry.urgentTriageResult.status === 'positive')
773
+ this.triagePositive.add(entry.id);
774
+ }
775
+ }
342
776
  clearQueuedState() {
777
+ this.triageEligible.clear();
778
+ this.triageFinished.clear();
779
+ this.triagePositive.clear();
780
+ this.triageOrder.clear();
781
+ this.triageTick = 0;
782
+ for (const entry of this.inFlight.values())
783
+ this.restoreTriage(entry);
784
+ this.recentAnswers.clear();
343
785
  this.feeds.clear();
344
786
  this.serveOrder.clear();
345
787
  this.serveTick = 0;
788
+ this.deliveryServeOrder.clear();
789
+ this.deliveryServeTick = 0;
790
+ this.priorityTurns = 0;
791
+ this.deliveryPriorityTurns = 0;
346
792
  this.queued = 0;
347
793
  this.persisted = this.inFlight.size;
348
- this.persistedBytes = [...this.inFlight.values()].reduce((n, e) => n + e.byteLength, 0);
794
+ this.persistedBytes = [...this.inFlight.values()].reduce((n, e) => n + this.entryBytes(e), 0);
795
+ this.capacityRejected = 0;
349
796
  this.enqueued = 0;
350
797
  this.served = 0;
351
798
  this.coalesced = 0;
@@ -357,6 +804,7 @@ class DurableVlmCandidateSpool {
357
804
  this.writeFailed = 0;
358
805
  this.readFailed = 0;
359
806
  this.deleteFailed = 0;
807
+ this.reusePrepared = 0;
360
808
  }
361
809
  ensureDirectory() {
362
810
  fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
@@ -386,7 +834,7 @@ class DurableVlmCandidateSpool {
386
834
  let entry = null;
387
835
  try {
388
836
  const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
389
- if (!this.validRecord(raw))
837
+ if (!this.validRecord(raw) || raw.id !== name.slice(0, -5))
390
838
  throw new Error('invalid record');
391
839
  const jpegStat = fs.statSync(this.jpegPath(raw.id));
392
840
  if (!jpegStat.isFile() || jpegStat.size !== raw.byteLength)
@@ -394,29 +842,34 @@ class DurableVlmCandidateSpool {
394
842
  entry = raw;
395
843
  }
396
844
  catch {
845
+ this.readFailed++;
397
846
  const id = name.slice(0, -5);
398
847
  this.deletePairById(id);
399
848
  continue;
400
849
  }
401
850
  if (this.inFlight.has(entry.id))
402
851
  continue;
852
+ this.restoreTriage(entry);
403
853
  const feed = this.feedQueue(entry.name);
404
854
  feed.items.push(entry);
405
855
  feed.enqueued++;
406
856
  this.queued++;
407
857
  this.persisted++;
408
- this.persistedBytes += entry.byteLength;
858
+ this.persistedBytes += this.entryBytes(entry);
409
859
  this.enqueued++;
410
860
  this.recovered++;
411
861
  }
412
862
  for (const feed of this.feeds.values()) {
413
863
  this.sortFeed(feed);
414
- this.coalesceRecoveredEpisodes(feed);
415
- while (feed.items.length > this.maxPerFeed)
416
- this.evictOldestOf(feed);
864
+ if (this.reviewWindowMs === null)
865
+ this.coalesceRecoveredEpisodes(feed);
866
+ if (this.reviewWindowMs === null) {
867
+ while (feed.items.length > this.maxPerFeed)
868
+ this.evictOldestOf(feed);
869
+ }
417
870
  }
418
871
  this.expire(this.now());
419
- while (this.persisted > this.maxFrames || this.persistedBytes > this.maxBytes) {
872
+ while (this.reviewWindowMs === null && (this.persisted > this.maxFrames || this.persistedBytes > this.maxBytes)) {
420
873
  if (!this.evictFromFullestFeed())
421
874
  break;
422
875
  }
@@ -443,15 +896,25 @@ class DurableVlmCandidateSpool {
443
896
  typeof value.reason === 'string' &&
444
897
  Number.isFinite(value.byteLength) && value.byteLength > 0 &&
445
898
  (value.jpegSha256 === undefined || /^[a-f0-9]{64}$/.test(value.jpegSha256)) &&
899
+ (value.reviewKind === undefined || value.reviewKind === 'event' || value.reviewKind === 'sample') &&
900
+ (value.reservedResultBytes === undefined || value.reservedResultBytes === 64 * 1024) &&
901
+ (value.reviewContext === undefined ? value.contextBytes === undefined : (value.reviewContext !== null && typeof value.reviewContext === 'object' &&
902
+ !Array.isArray(value.reviewContext) &&
903
+ value.contextBytes === Buffer.byteLength(JSON.stringify(value.reviewContext)) &&
904
+ value.contextBytes <= 64 * 1024)) &&
905
+ (value.cachedAnalysis === undefined ? value.resultBytes === undefined : (value.cachedAnalysis !== null && typeof value.cachedAnalysis === 'object' &&
906
+ !Array.isArray(value.cachedAnalysis) &&
907
+ value.resultBytes === Buffer.byteLength(JSON.stringify(value.cachedAnalysis)) &&
908
+ value.resultBytes <= 64 * 1024)) &&
446
909
  Number.isFinite(value.attempts) && value.attempts >= 0);
447
910
  }
448
911
  writePair(entry, jpeg) {
449
912
  const jpegPath = this.jpegPath(entry.id);
450
913
  const metadataPath = this.metadataPath(entry.id);
451
914
  try {
452
- fs.writeFileSync(`${jpegPath}.tmp`, jpeg, { flag: 'wx', mode: 0o600 });
915
+ this.writeFile(`${jpegPath}.tmp`, jpeg, 'wx');
453
916
  fs.renameSync(`${jpegPath}.tmp`, jpegPath);
454
- fs.writeFileSync(`${metadataPath}.tmp`, JSON.stringify(entry), { flag: 'wx', mode: 0o600 });
917
+ this.writeFile(`${metadataPath}.tmp`, JSON.stringify(entry), 'wx');
455
918
  fs.renameSync(`${metadataPath}.tmp`, metadataPath);
456
919
  return true;
457
920
  }
@@ -461,6 +924,32 @@ class DurableVlmCandidateSpool {
461
924
  return false;
462
925
  }
463
926
  }
927
+ entryBytes(entry) {
928
+ // Recompute diagnostic bytes on recovery; bad optional audit metadata must
929
+ // neither discard the JPEG nor evade the admission byte budget.
930
+ return entry.byteLength + (entry.contextBytes ?? 0) +
931
+ Math.max(entry.urgentTriagePolicy ? UrgentReviewTriage_1.TRIAGE_RESERVED_BYTES : 0, (entry.urgentTriagePolicy ? Buffer.byteLength(JSON.stringify(entry.urgentTriagePolicy)) : 0)
932
+ + (entry.urgentTriageResult ? Buffer.byteLength(JSON.stringify(entry.urgentTriageResult)) : 0)) +
933
+ (entry.groupingShadow ? Buffer.byteLength(JSON.stringify(entry.groupingShadow)) : 0) +
934
+ (entry.profileWork ? Math.max(64 * 1024, Buffer.byteLength(JSON.stringify(entry.profileWork)) +
935
+ (entry.profileWorkResult ? Buffer.byteLength(JSON.stringify(entry.profileWorkResult)) : 0)) :
936
+ entry.profileWorkResult ? Buffer.byteLength(JSON.stringify(entry.profileWorkResult)) : 0) +
937
+ Math.max(entry.reservedResultBytes ?? entry.resultBytes ?? 0, entry.scopedVlmProgress ? Buffer.byteLength(JSON.stringify(entry.scopedVlmProgress)) : 0);
938
+ }
939
+ writeFile(file, data, flag = 'w') {
940
+ if (this.reviewWindowMs === null) {
941
+ fs.writeFileSync(file, data, { flag, mode: 0o600 });
942
+ return;
943
+ }
944
+ const fd = fs.openSync(file, flag, 0o600);
945
+ try {
946
+ fs.writeFileSync(fd, data);
947
+ fs.fsyncSync(fd);
948
+ }
949
+ finally {
950
+ fs.closeSync(fd);
951
+ }
952
+ }
464
953
  writeMetadata(entry) {
465
954
  const metadataPath = this.metadataPath(entry.id);
466
955
  const tmp = `${metadataPath}.tmp`;
@@ -469,8 +958,9 @@ class DurableVlmCandidateSpool {
469
958
  fs.unlinkSync(tmp);
470
959
  }
471
960
  catch { /* absent is normal */ }
472
- fs.writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 });
961
+ this.writeFile(tmp, JSON.stringify(entry));
473
962
  fs.renameSync(tmp, metadataPath);
963
+ return true;
474
964
  }
475
965
  catch {
476
966
  this.writeFailed++;
@@ -478,6 +968,7 @@ class DurableVlmCandidateSpool {
478
968
  fs.unlinkSync(tmp);
479
969
  }
480
970
  catch { /* best-effort */ }
971
+ return false;
481
972
  }
482
973
  }
483
974
  asCandidate(entry, jpeg) {
@@ -492,6 +983,15 @@ class DurableVlmCandidateSpool {
492
983
  imageHeight: entry.imageHeight,
493
984
  spoolId: entry.id,
494
985
  jpegSha256: entry.jpegSha256,
986
+ ...(entry.reviewKind ? { reviewKind: entry.reviewKind } : {}),
987
+ ...((0, ReviewPriority_1.isReviewPriority)(entry.reviewPriority) ? { reviewPriority: entry.reviewPriority } : {}),
988
+ ...(entry.cachedAnalysis ? { cachedAnalysis: entry.cachedAnalysis } : {}),
989
+ ...(entry.scopedVlmProgress ? { scopedVlmProgress: JSON.parse(JSON.stringify(entry.scopedVlmProgress)) } : {}),
990
+ ...(entry.reviewContext ? { reviewContext: entry.reviewContext } : {}),
991
+ ...(entry.profileWork ? { profileWork: JSON.parse(JSON.stringify(entry.profileWork)) } : {}),
992
+ ...(entry.profileWorkResult ? { profileWorkResult: JSON.parse(JSON.stringify(entry.profileWorkResult)) } : {}),
993
+ ...(entry.groupingShadow?.mode === 'shadow' && entry.groupingShadow?.version === 1 &&
994
+ Array.isArray(entry.groupingShadow.scopes) ? { groupingShadow: entry.groupingShadow } : {}),
495
995
  ...(entry.detectorShadow ? { detectorShadow: entry.detectorShadow } : {}),
496
996
  };
497
997
  }
@@ -516,20 +1016,83 @@ class DurableVlmCandidateSpool {
516
1016
  sortFeed(feed) {
517
1017
  feed.items.sort((a, b) => a.enqueuedAtMs - b.enqueuedAtMs || a.id.localeCompare(b.id));
518
1018
  }
519
- nextEligible(now) {
1019
+ reviewComplete(entry) {
1020
+ if (!entry.cachedAnalysis)
1021
+ return false;
1022
+ if (entry.reviewContext?.orderedSequence) {
1023
+ const decision = entry.cachedAnalysis.orderedReview;
1024
+ return decision?.version === 1 && decision.kind === 'ordered_frame_rehearsal'
1025
+ && decision.sequenceId === entry.id && decision.anchorSha256 === entry.jpegSha256
1026
+ && decision.capturedAtMs === entry.enqueuedAtMs;
1027
+ }
1028
+ if (!entry.reviewContext?.temporalReviewContract)
1029
+ return true;
1030
+ const decision = entry.cachedAnalysis.temporalReview;
1031
+ return decision?.version === 1 && decision.kind === 'temporal_rehearsal'
1032
+ && decision.evidenceId === entry.id && decision.imageSha256 === entry.jpegSha256
1033
+ && decision.capturedAtMs === entry.enqueuedAtMs;
1034
+ }
1035
+ nextEligible(now, stage, accept) {
1036
+ // Crossing the fresh/delayed boundary never changes order. The opt-in
1037
+ // capture-time priority gives urgent work preference, with every fifth
1038
+ // successful take using ordinary fair-camera/FIFO order. Thus a sustained
1039
+ // urgent stream cannot indefinitely bypass a ready ordinary camera/item.
1040
+ // A fairness turn is work-conserving: it never waits for an empty class.
1041
+ const orderByFeed = stage === 'delivery' ? this.deliveryServeOrder : this.serveOrder;
1042
+ const preference = this.priorityScheduling &&
1043
+ (stage === 'delivery' ? this.deliveryPriorityTurns : this.priorityTurns) < ReviewPriority_1.PRIORITY_BURST;
1044
+ const inStage = (entry) => stage === undefined ||
1045
+ (stage === 'delivery' ? this.reviewComplete(entry) : !this.reviewComplete(entry));
1046
+ const eligible = (entry) => inStage(entry) && entry.availableAtMs <= now;
1047
+ const priorityRank = (entry) => (0, ReviewPriority_1.reviewPriorityRank)(entry.reviewPriority) * 2
1048
+ + (stage === 'review' && this.triagePositive.has(entry.id) ? 1 : 0);
1049
+ // One upload and one review may work on different images from the same
1050
+ // camera. A blocked network must not block that camera's next model call.
1051
+ const busy = new Set([...this.inFlight.values()].filter(inStage).map(e => e.name));
520
1052
  let best = null;
521
1053
  let bestOrder = Infinity;
1054
+ let bestRank = -1;
522
1055
  for (const feed of this.feeds.values()) {
523
- const index = feed.items.findIndex((entry) => entry.availableAtMs <= now);
1056
+ if (this.reviewWindowMs !== null &&
1057
+ busy.has(feed.name))
1058
+ continue;
1059
+ // Keep FIFO within each class. A newly urgent item on the same feed can
1060
+ // pass older normal work; the fairness turn still serves that older work.
1061
+ let index = -1;
1062
+ // Temporal comparisons need capture order within a camera. An older retry
1063
+ // must finish before a later observation, even when the latter is urgent.
1064
+ // Other cameras and already committed upload work remain independent.
1065
+ const temporalFeed = stage === 'review' && feed.items.some(e => e.reviewContext?.temporalReviewContract);
1066
+ for (let i = 0; i < feed.items.length; i++) {
1067
+ if (temporalFeed && inStage(feed.items[i])) {
1068
+ if (eligible(feed.items[i]))
1069
+ index = i;
1070
+ break;
1071
+ }
1072
+ if (!eligible(feed.items[i]))
1073
+ continue;
1074
+ if (index < 0 || preference &&
1075
+ priorityRank(feed.items[i]) > priorityRank(feed.items[index]))
1076
+ index = i;
1077
+ if (!preference)
1078
+ break;
1079
+ }
524
1080
  if (index < 0)
525
1081
  continue;
526
1082
  const entry = feed.items[index];
527
- const order = this.serveOrder.get(feed.name) ?? 0;
1083
+ // Check the feed's already-selected head, not every later item: a new
1084
+ // profile cannot jump older work merely because its model is free.
1085
+ if (accept && !accept(this.asCandidate(entry, Buffer.alloc(0))))
1086
+ continue;
1087
+ const order = orderByFeed.get(feed.name) ?? 0;
1088
+ const rank = preference ? priorityRank(entry) : 0;
528
1089
  if (best === null ||
529
- order < bestOrder ||
530
- (order === bestOrder && entry.enqueuedAtMs < best.entry.enqueuedAtMs)) {
1090
+ rank > bestRank ||
1091
+ (rank === bestRank && (order < bestOrder ||
1092
+ (order === bestOrder && entry.enqueuedAtMs < best.entry.enqueuedAtMs)))) {
531
1093
  best = { feed, index, entry };
532
1094
  bestOrder = order;
1095
+ bestRank = rank;
533
1096
  }
534
1097
  }
535
1098
  return best;
@@ -611,9 +1174,12 @@ class DurableVlmCandidateSpool {
611
1174
  this.removePersisted(entry);
612
1175
  }
613
1176
  removePersisted(entry) {
1177
+ this.triageEligible.delete(entry.id);
1178
+ this.triageFinished.delete(entry.id);
1179
+ this.triagePositive.delete(entry.id);
614
1180
  this.deletePair(entry);
615
1181
  this.persisted = Math.max(0, this.persisted - 1);
616
- this.persistedBytes = Math.max(0, this.persistedBytes - entry.byteLength);
1182
+ this.persistedBytes = Math.max(0, this.persistedBytes - this.entryBytes(entry));
617
1183
  }
618
1184
  deletePair(entry) {
619
1185
  this.deletePairById(entry.id);