@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
package/README.md CHANGED
@@ -16,11 +16,11 @@ The Edge SDK runs on digital signage hardware (kiosks, vending machines, billboa
16
16
 
17
17
  - **Audience sensing** — on-device face detection, gaze tracking, emotion analysis, foot traffic estimation via ONNX models (no cloud dependency)
18
18
  - **Ad delivery** — Chromium browser lifecycle, display management, VAST/VPAID ad rendering
19
- - **Federated learning** — privacy-preserving on-device model training with sparse gradient uploads
19
+ - **Federated learning** — on-device model training with sparse gradient uploads
20
20
  - **Cloud inference** — Gemini Vision scene analysis, content moderation, contextual targeting
21
21
  - **Device management** — heartbeat, health monitoring, auto-recovery, signal buffering for offline resilience
22
22
 
23
- All audience data stays on-device. Only aggregated, anonymized metrics are transmitted.
23
+ Detection runs on the device, and the agent transmits the audience metrics. In local-sensing mode (the Sense Anything connector) it also sends Trillboards a still frame with each alert-worthy moment it analyses, and in cloud-sensing mode every frame it asks the cloud model to read; Trillboards keeps those frames for 400 days.
24
24
 
25
25
  ## Configuration
26
26
 
@@ -10,7 +10,7 @@ name = "trillboards-detector"
10
10
  # constant, a `labels.py`-only change survives `uv tool install` AND
11
11
  # `uv tool install --force`; move the version and a bare `uv tool install`
12
12
  # reinstalls. See `stamp_version.py`.
13
- version = "0.2.0+src.2c3db7881945"
13
+ version = "0.2.0+src.6d43cf061068"
14
14
  description = "On-device object-detector service for the Trillboards BYO-CCTV connector (loopback HTTP, ONNX Runtime)."
15
15
  requires-python = ">=3.10"
16
16
 
@@ -29,11 +29,29 @@ requires-python = ">=3.10"
29
29
  # `docs/runbooks/cctv-detector-tier.md` §Runtime. A bump is a re-measurement,
30
30
  # not a version-number edit.
31
31
  dependencies = [
32
- "onnxruntime==1.29.0",
33
32
  "numpy==2.5.2",
34
33
  "pillow==12.3.0",
35
34
  ]
36
35
 
36
+ # Exactly one runtime per managed tool environment. Both wheels export the
37
+ # same Python module; installing them together is not a CPU fallback strategy.
38
+ # Normal provisioners select [cpu], or [cuda] on a detected NVIDIA Windows host.
39
+ [project.optional-dependencies]
40
+ cpu = ["onnxruntime==1.29.0"]
41
+ cuda = [
42
+ "onnxruntime-gpu[cuda,cudnn]==1.29.0",
43
+ "nvidia-cublas==13.8.0.4",
44
+ "nvidia-cuda-nvrtc==13.4.92",
45
+ "nvidia-cuda-runtime==13.4.92",
46
+ "nvidia-cudnn-cu13==9.27.0.42",
47
+ "nvidia-cufft==12.4.0.43",
48
+ "nvidia-curand==10.4.4.72",
49
+ "nvidia-nvjitlink==13.4.92",
50
+ ]
51
+
52
+ [tool.uv]
53
+ conflicts = [[{extra = "cpu"}, {extra = "cuda"}]]
54
+
37
55
  [project.scripts]
38
56
  trillboards-detector = "trillboards_detector.cli:main"
39
57
 
@@ -0,0 +1,124 @@
1
+ """Provider-policy tests, not detector accuracy or simulated model evidence."""
2
+ import unittest
3
+
4
+ from trillboards_detector.execution import CPU, CUDA, LocalOrtSession
5
+
6
+
7
+ class Session:
8
+ def __init__(self, providers, failure=None, demote=False):
9
+ self.providers, self.failure, self.demote = providers, failure, demote
10
+ self.inputs = []
11
+
12
+ def get_providers(self):
13
+ return self.providers
14
+
15
+ def run(self, outputs, inputs):
16
+ self.inputs.append(inputs)
17
+ if self.failure:
18
+ raise RuntimeError(self.failure)
19
+ if self.demote:
20
+ self.providers = [CPU]
21
+ return inputs
22
+
23
+
24
+ class Runtime:
25
+ def __init__(self, available=(CUDA, CPU), *, init_error=False,
26
+ gpu_failure=None, cpu_failure=None, silent_cpu=False, demote=False):
27
+ self.available = list(available)
28
+ self.init_error, self.gpu_failure, self.cpu_failure = init_error, gpu_failure, cpu_failure
29
+ self.silent_cpu, self.demote = silent_cpu, demote
30
+ self.calls, self.preloads = [], 0
31
+
32
+ def get_available_providers(self):
33
+ return self.available
34
+
35
+ def preload_dlls(self, directory):
36
+ assert directory == ""
37
+ self.preloads += 1
38
+
39
+ def InferenceSession(self, model, *, sess_options, providers):
40
+ self.calls.append((model, sess_options, providers))
41
+ if CUDA in providers and self.init_error:
42
+ raise RuntimeError("driver unavailable")
43
+ gpu = CUDA in providers
44
+ return Session([CPU] if gpu and self.silent_cpu else providers,
45
+ self.gpu_failure if gpu else self.cpu_failure,
46
+ self.demote if gpu else False)
47
+
48
+
49
+ class TestLocalExecution(unittest.TestCase):
50
+ def test_prefers_available_cuda_and_keeps_cpu_kernels(self):
51
+ r = Runtime()
52
+ s = LocalOrtSession(r, b"immutable model", object())
53
+ self.assertEqual(r.calls[0][2], [CUDA, CPU])
54
+ self.assertEqual(r.preloads, 1)
55
+ self.assertEqual(s.acceleration["backend"], "cuda")
56
+ self.assertIsNone(s.acceleration["fallback_reason"])
57
+
58
+ def test_cpu_only_host_never_attempts_cuda(self):
59
+ r = Runtime((CPU,))
60
+ s = LocalOrtSession(r, b"model", None)
61
+ self.assertEqual([c[2] for c in r.calls], [[CPU]])
62
+ self.assertEqual(s.acceleration["backend"], "cpu")
63
+ self.assertIn("unavailable", s.acceleration["fallback_reason"])
64
+ self.assertIsNone(s._model_bytes)
65
+
66
+ def test_explicit_cpu_is_the_rollback_even_on_nvidia(self):
67
+ r = Runtime()
68
+ s = LocalOrtSession(r, b"model", None, "cpu")
69
+ self.assertEqual([c[2] for c in r.calls], [[CPU]])
70
+ self.assertEqual(s.acceleration["fallback_reason"], "CPU explicitly selected")
71
+
72
+ def test_missing_driver_is_a_visible_cpu_fallback(self):
73
+ r = Runtime(init_error=True)
74
+ s = LocalOrtSession(r, b"same weights", None)
75
+ self.assertEqual([c[2] for c in r.calls], [[CUDA, CPU], [CPU]])
76
+ self.assertIn("driver unavailable", s.acceleration["fallback_reason"])
77
+
78
+ def test_ort_silent_cpu_initialization_is_not_reported_as_gpu(self):
79
+ s = LocalOrtSession(Runtime(silent_cpu=True), b"model", None)
80
+ self.assertEqual(s.acceleration["backend"], "cpu")
81
+ self.assertIn("CPU-only", s.acceleration["fallback_reason"])
82
+
83
+ def test_gpu_failure_retries_identical_captured_input_and_model_once(self):
84
+ model = b"exact original immutable weights"
85
+ r = Runtime(gpu_failure="out of GPU memory")
86
+ s = LocalOrtSession(r, model, None)
87
+ original = {"frame": object()}
88
+ self.assertIs(s.run(["detections"], original), original)
89
+ self.assertEqual(len(r.calls), 2)
90
+ self.assertIs(r.calls[1][0], model)
91
+ self.assertEqual(s.acceleration["backend"], "cpu")
92
+ self.assertIn("out of GPU memory", s.acceleration["fallback_reason"])
93
+ s.run(["detections"], original)
94
+ self.assertEqual(len(r.calls), 2)
95
+ self.assertIsNone(s._model_bytes)
96
+
97
+ def test_cpu_failure_propagates_not_a_false_negative(self):
98
+ s = LocalOrtSession(Runtime(gpu_failure="gpu failed", cpu_failure="bad input"), b"model", None)
99
+ with self.assertRaisesRegex(RuntimeError, "bad input"):
100
+ s.run([], {})
101
+
102
+ def test_ort_internal_runtime_demotion_updates_actual_backend(self):
103
+ s = LocalOrtSession(Runtime(demote=True), b"model", None)
104
+ s.run([], {})
105
+ self.assertEqual(s.acceleration["backend"], "cpu")
106
+ self.assertIn("demoted", s.acceleration["fallback_reason"])
107
+
108
+ def test_unknown_backend_is_rejected(self):
109
+ with self.assertRaises(ValueError):
110
+ LocalOrtSession(Runtime(), b"model", None, "intel")
111
+
112
+ def test_health_snapshot_is_safe_during_cpu_session_replacement(self):
113
+ s = LocalOrtSession(Runtime(), b"model", None)
114
+ s._session = None
115
+ s._acceleration = {"requested": "auto", "backend": "initializing_cpu",
116
+ "providers": [], "fallback_reason": "GPU failed"}
117
+ self.assertEqual(s.acceleration["backend"], "initializing_cpu")
118
+ external = s.acceleration
119
+ external["providers"].append("invented")
120
+ self.assertEqual(s.acceleration["providers"], [])
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -0,0 +1,80 @@
1
+ """Selected model identity, with real registry logic and tiny fixture artifacts."""
2
+ import hashlib
3
+ import json
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from trillboards_detector.errors import ModelVerificationError
9
+ from trillboards_detector.registry import ModelEntry, ModelRegistry
10
+ from trillboards_detector.server import WireError, parse_expected_model, build_detect_response
11
+ # CI discovers this as tests.test_model_identity; the narrow detector lane
12
+ # discovers it as a top-level module. Resolve the sibling in either context.
13
+ if __package__:
14
+ from .test_registry import GENERAL, PPE, FakeSession, MB
15
+ else:
16
+ from test_registry import GENERAL, PPE, FakeSession, MB
17
+
18
+
19
+ class ModelIdentityTests(unittest.TestCase):
20
+ def setUp(self):
21
+ self.tmp = tempfile.TemporaryDirectory()
22
+ self.addCleanup(self.tmp.cleanup)
23
+ self.paths = {v.model_name: Path(self.tmp.name) / (v.model_name + '.onnx') for v in (GENERAL, PPE)}
24
+ for name, path in self.paths.items():
25
+ path.write_bytes(('explicit fixture artifact ' + name).encode())
26
+ self.sessions = []
27
+ def factory(entry):
28
+ session = FakeSession(entry.vocabulary)
29
+ session.model_sha256 = hashlib.sha256(entry.path.read_bytes()).hexdigest()
30
+ self.sessions.append(session)
31
+ original = session.detect
32
+ def detect(*args):
33
+ result = original(*args)
34
+ result.model_hashes = {session.model_name: session.model_sha256}
35
+ return result
36
+ session.detect = detect
37
+ return session
38
+ self.registry = ModelRegistry([ModelEntry(GENERAL, self.paths['general'], pinned=True), ModelEntry(PPE, self.paths['ppe'])],
39
+ factory, lambda _session: None, 2048 * MB, log=lambda _line: None)
40
+ self.registry.refresh()
41
+
42
+ def pin(self, name):
43
+ return {'modelId': name, 'sha256': hashlib.sha256(self.paths[name].read_bytes()).hexdigest()}
44
+
45
+ def test_matching_pin_reports_session_hash(self):
46
+ batch = self.registry.detect(b'fixture image', .5, {'person'}, expected_model=self.pin('general'))
47
+ self.assertEqual(build_detect_response(batch)['model_hashes'], {'general': self.pin('general')['sha256']})
48
+
49
+ def test_wrong_hash_refused_before_requested_image_inference(self):
50
+ pin = {**self.pin('general'), 'sha256': 'f' * 64}
51
+ with self.assertRaises(ModelVerificationError):
52
+ self.registry.detect(b'fixture image', .5, {'person'}, expected_model=pin)
53
+ self.assertEqual(self.sessions[0].calls, [])
54
+
55
+ def test_explicit_specialist_is_not_replaced_by_general_shared_class(self):
56
+ batch = self.registry.detect(b'fixture image', .5, {'person'}, expected_model=self.pin('ppe'))
57
+ self.assertEqual(batch.models, ['ppe'])
58
+ self.assertEqual(self.sessions[0].calls, [])
59
+
60
+ def test_unknown_model_or_classes_are_refused(self):
61
+ for pin, classes in [({'modelId': 'missing', 'sha256': 'a' * 64}, {'person'}), (self.pin('general'), {'glove'})]:
62
+ with self.assertRaises(ModelVerificationError):
63
+ self.registry.detect(b'fixture image', .5, classes, expected_model=pin)
64
+
65
+ def test_disk_replacement_cannot_be_attributed_to_old_loaded_pin(self):
66
+ old = self.pin('general')
67
+ self.paths['general'].write_bytes(b'changed artifact with a different size')
68
+ with self.assertRaises(ModelVerificationError):
69
+ self.registry.detect(b'fixture image', .5, {'person'}, expected_model=old)
70
+
71
+ def test_wire_pin_must_be_explicit_and_complete(self):
72
+ self.assertIsNone(parse_expected_model(b'{}'))
73
+ self.assertEqual(parse_expected_model(json.dumps({'expected_model': self.pin('general')}).encode()), self.pin('general'))
74
+ for value in [None, {}, {'modelId': 'general', 'sha256': 'A' * 64}, {**self.pin('general'), 'extra': True}]:
75
+ with self.assertRaises(WireError):
76
+ parse_expected_model(json.dumps({'expected_model': value}).encode())
77
+
78
+
79
+ if __name__ == '__main__':
80
+ unittest.main()
@@ -654,6 +654,24 @@ class TestDownIsNeverEmpty(RegistryCase):
654
654
  class TestSnapshotIsCheapAndHonest(RegistryCase):
655
655
  """`/health` reports which models are resident and must not load one."""
656
656
 
657
+ def test_health_reports_actual_backend_and_cpu_fallback_reason(self):
658
+ self.write_artifact("general")
659
+ reg = self.registry([GENERAL], pinned="general")
660
+ reg.refresh()
661
+ session = self.sessions["general"]
662
+ session.acceleration = {"requested": "auto", "backend": "cuda",
663
+ "providers": ["CUDAExecutionProvider", "CPUExecutionProvider"],
664
+ "fallback_reason": None}
665
+ first = reg.snapshot()["models"]["general"]
666
+ self.assertEqual(first["providers"][0], "CUDAExecutionProvider")
667
+ session.acceleration = {"requested": "auto", "backend": "cpu",
668
+ "providers": ["CPUExecutionProvider"],
669
+ "fallback_reason": "GPU inference failed"}
670
+ second = reg.snapshot()["models"]["general"]
671
+ self.assertEqual(second["providers"], ["CPUExecutionProvider"])
672
+ self.assertEqual(second["acceleration"]["fallback_reason"], "GPU inference failed")
673
+ self.assertEqual(self.sessions["general"].calls, [])
674
+
657
675
  def test_the_snapshot_loads_nothing_and_infers_nothing(self):
658
676
  self.write_artifact("general")
659
677
  self.write_artifact("ppe")
@@ -114,8 +114,10 @@ class ServerCase(unittest.TestCase):
114
114
  # 20 ms poll: `shutdown()` blocks until the loop notices, and the
115
115
  # 500 ms default turns a 22-test suite into 11 s of pure waiting.
116
116
  threading.Thread(target=lambda: server.serve_forever(poll_interval=0.02), daemon=True).start()
117
- self.addCleanup(server.shutdown)
117
+ # Cleanup runs last-in-first-out. Stop the selector thread before
118
+ # closing its socket, otherwise Windows emits WinError 10038.
118
119
  self.addCleanup(server.server_close)
120
+ self.addCleanup(server.shutdown)
119
121
  host, port = server.server_address[:2]
120
122
  return f"http://{host}:{port}"
121
123
 
@@ -135,7 +135,10 @@ class TestVersionStamp(unittest.TestCase):
135
135
  )
136
136
  before = _load_stamper(root).expected_version()[2]
137
137
  for path in list((root / "trillboards_detector").rglob("*.py")) + [root / "pyproject.toml"]:
138
- path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n"))
138
+ # Start from normalized bytes: a checkout already using CRLF
139
+ # must not become CRCRLF, which is a different text document.
140
+ raw = path.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n")
141
+ path.write_bytes(raw.replace(b"\n", b"\r\n"))
139
142
  after = _load_stamper(root).expected_version()[2]
140
143
  self.assertEqual(before, after, "CRLF alone moved the fingerprint")
141
144
 
@@ -13,5 +13,5 @@ human sets.
13
13
  """
14
14
 
15
15
  BASE_VERSION = "0.2.0"
16
- SOURCE_FINGERPRINT = "2c3db7881945"
17
- VERSION = "0.2.0+src.2c3db7881945"
16
+ SOURCE_FINGERPRINT = "6d43cf061068"
17
+ VERSION = "0.2.0+src.6d43cf061068"
@@ -103,6 +103,8 @@ def build_parser() -> argparse.ArgumentParser:
103
103
  ),
104
104
  )
105
105
  s.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_S)
106
+ s.add_argument("--accelerator", choices=("auto", "cpu"), default="auto",
107
+ help="prefer compatible local NVIDIA CUDA, otherwise CPU; cpu is an explicit rollback")
106
108
  s.add_argument("--log-file", default=None, help="also write to this file (Windows has no task stdout)")
107
109
  s.add_argument("--verbose", action="store_true")
108
110
  return p
@@ -157,6 +159,7 @@ def main(argv: "list[str] | None" = None) -> int:
157
159
  poll_interval_s=args.poll_interval,
158
160
  ram_budget_bytes=ram_budget_bytes,
159
161
  log=LOG.info,
162
+ accelerator=args.accelerator,
160
163
  )
161
164
  loader.start()
162
165
 
@@ -57,6 +57,9 @@ class DetectionBatch:
57
57
  #: The model(s) that produced this, in the order they ran. Exactly one
58
58
  #: element for a batch off a single session; N for a merged one.
59
59
  models: list[str] = field(default_factory=list)
60
+ # Derived from the exact bytes used to create each ONNX session, not a
61
+ # request pin, a catalogue entry, or a later read of the mutable model path.
62
+ model_hashes: dict[str, str] = field(default_factory=dict)
60
63
 
61
64
 
62
65
  def merge_batches(batches: "list[DetectionBatch]") -> DetectionBatch:
@@ -92,6 +95,10 @@ def merge_batches(batches: "list[DetectionBatch]") -> DetectionBatch:
92
95
  merged.degenerate_dropped += b.degenerate_dropped
93
96
  merged.unnamed_class_dropped += b.unnamed_class_dropped
94
97
  merged.models.extend(b.models)
98
+ for model, digest in b.model_hashes.items():
99
+ if model in merged.model_hashes and merged.model_hashes[model] != digest:
100
+ raise ValueError("one frame cannot merge different artifacts under one model id")
101
+ merged.model_hashes[model] = digest
95
102
  return merged
96
103
 
97
104
 
@@ -37,6 +37,10 @@ class ModelContractError(RuntimeError):
37
37
  """
38
38
 
39
39
 
40
+ class ModelVerificationError(RuntimeError):
41
+ """A selected artifact is not the one in the loaded inference session."""
42
+
43
+
40
44
  class UnknownClasses(ValueError):
41
45
  """The caller named classes no resident-or-present model can emit.
42
46
 
@@ -0,0 +1,109 @@
1
+ """Select one local ORT backend and expose every CPU fallback.
2
+
3
+ The model bytes and outputs are unchanged. This module has no runtime imports,
4
+ so provider policy can be tested without downloading models or CUDA libraries.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ LOG = logging.getLogger(__name__)
11
+ CPU = "CPUExecutionProvider"
12
+ CUDA = "CUDAExecutionProvider"
13
+
14
+
15
+ class LocalOrtSession:
16
+ """One ORT session; at most one CPU retry after a GPU failure.
17
+
18
+ Keep the original immutable bytes only while GPU fallback is possible. An
19
+ OTA replacing the file must never change the model used for the retry.
20
+ """
21
+
22
+ def __init__(self, runtime, model_bytes: bytes, options, accelerator="auto"):
23
+ if accelerator not in ("auto", "cpu"):
24
+ raise ValueError("accelerator must be auto or cpu")
25
+ self._runtime, self._options = runtime, options
26
+ self._model_bytes = model_bytes
27
+ self.requested = accelerator
28
+ self.fallback_reason = None
29
+ self._cuda_active = False
30
+ self._acceleration = {"requested": accelerator, "backend": "starting",
31
+ "providers": [], "fallback_reason": None}
32
+ available = runtime.get_available_providers()
33
+ if accelerator == "auto" and CUDA in available:
34
+ try:
35
+ preload = getattr(runtime, "preload_dlls", None)
36
+ if preload is not None:
37
+ preload(directory="")
38
+ self._session = runtime.InferenceSession(
39
+ model_bytes, sess_options=options, providers=[CUDA, CPU]
40
+ )
41
+ self._cuda_active = CUDA in self._session.get_providers()
42
+ except Exception as error:
43
+ self._cpu("CUDA initialization failed: " + str(error))
44
+ else:
45
+ if not self._cuda_active:
46
+ self._cpu("CUDA initialization returned a CPU-only session")
47
+ else:
48
+ self._session = runtime.InferenceSession(
49
+ model_bytes, sess_options=options, providers=[CPU]
50
+ )
51
+ self._model_bytes = None
52
+ self.fallback_reason = (
53
+ "CPU explicitly selected" if accelerator == "cpu"
54
+ else "CUDA provider unavailable; using the CPU runtime"
55
+ )
56
+ self._publish_acceleration()
57
+ LOG.info("[detector] local execution: %s", self.acceleration)
58
+
59
+ def _cpu(self, reason):
60
+ # A failed GPU attempt must not keep its allocation while CPU starts.
61
+ self._session = None
62
+ self._cuda_active = False
63
+ self.fallback_reason = reason[:1000]
64
+ self._acceleration = {"requested": self.requested, "backend": "initializing_cpu",
65
+ "providers": [], "fallback_reason": self.fallback_reason}
66
+ LOG.warning("[detector] GPU unavailable; CPU fallback: %s", self.fallback_reason)
67
+ self._session = self._runtime.InferenceSession(
68
+ self._model_bytes, sess_options=self._options, providers=[CPU]
69
+ )
70
+ self._model_bytes = None
71
+ self._publish_acceleration()
72
+
73
+ def _publish_acceleration(self):
74
+ providers = list(self._session.get_providers())
75
+ self._acceleration = {
76
+ "requested": self.requested,
77
+ "backend": "cuda" if CUDA in providers else "cpu",
78
+ "providers": providers,
79
+ "fallback_reason": self.fallback_reason,
80
+ }
81
+
82
+ @property
83
+ def acceleration(self):
84
+ # Health must not dereference the transient None session while another
85
+ # thread is constructing CPU fallback. Publish one immutable snapshot.
86
+ return {**self._acceleration, "providers": list(self._acceleration["providers"])}
87
+
88
+ def run(self, outputs, inputs):
89
+ try:
90
+ result = self._session.run(outputs, inputs)
91
+ # ORT may demote internally without raising. Do not keep reporting
92
+ # CUDA just because construction originally requested it.
93
+ if self._cuda_active and CUDA not in self._session.get_providers():
94
+ self._cuda_active = False
95
+ self._model_bytes = None
96
+ self.fallback_reason = "ORT demoted GPU inference to CPU"
97
+ LOG.warning("[detector] %s", self.fallback_reason)
98
+ self._publish_acceleration()
99
+ return result
100
+ except Exception as error:
101
+ if not self._cuda_active:
102
+ raise
103
+ self._cpu("GPU inference failed: " + str(error))
104
+ # Same captured input, once. If CPU also fails, propagate the error;
105
+ # never turn a failed inference into a negative observation.
106
+ return self._session.run(outputs, inputs)
107
+
108
+ def __getattr__(self, name):
109
+ return getattr(self._session, name)
@@ -128,6 +128,7 @@ class DetectorLoader:
128
128
  ram_budget_bytes: "int | None" = None,
129
129
  vocabularies: "tuple[ModelVocabulary, ...]" = BUILTIN_VOCABULARIES,
130
130
  log=print,
131
+ accelerator: str = "auto",
131
132
  ):
132
133
  # `ram_budget_bytes=None` is NOT a default policy — it is the absence of
133
134
  # one, and the registry refuses every portfolio decision in that state
@@ -137,6 +138,7 @@ class DetectorLoader:
137
138
  self.model_path = model_path
138
139
  self.model_name = model_name
139
140
  self._intra_op_threads = intra_op_threads
141
+ self._accelerator = accelerator
140
142
  self._poll_interval_s = poll_interval_s
141
143
  self._log = log
142
144
 
@@ -154,7 +156,7 @@ class DetectorLoader:
154
156
  # ── the two halves that need a real runtime ──────────────────────────────
155
157
 
156
158
  def _build_session(self, entry: ModelEntry) -> OnnxDetector:
157
- return OnnxDetector(entry.path, entry.vocabulary, self._intra_op_threads)
159
+ return OnnxDetector(entry.path, entry.vocabulary, self._intra_op_threads, self._accelerator)
158
160
 
159
161
  def _probe(self, session: OnnxDetector) -> None:
160
162
  """PROVE IT. A session that constructs is not a session that infers.
@@ -276,6 +278,7 @@ class DetectorLoader:
276
278
  "input",
277
279
  "classes",
278
280
  "providers",
281
+ "acceleration",
279
282
  "output_layout",
280
283
  "preprocessing",
281
284
  ):
@@ -283,8 +286,8 @@ class DetectorLoader:
283
286
  payload[key] = pinned[key]
284
287
  return payload
285
288
 
286
- def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None") -> DetectionBatch:
287
- return self.registry.detect(jpeg, min_confidence, wanted)
289
+ def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None", expected_model=None) -> DetectionBatch:
290
+ return self.registry.detect(jpeg, min_confidence, wanted, expected_model=expected_model)
288
291
 
289
292
  # ── the loop ─────────────────────────────────────────────────────────────
290
293
 
@@ -62,7 +62,7 @@ import time
62
62
  from pathlib import Path
63
63
 
64
64
  from .detections import DetectionBatch, merge_batches
65
- from .errors import DetectorNotReady, ModelContractError, UnknownClasses
65
+ from .errors import DetectorNotReady, ModelContractError, ModelVerificationError, UnknownClasses
66
66
  from .labels import ClassIndex, ModelVocabulary, normalize_class_name
67
67
 
68
68
  #: Per-model residency states. `absent` and `idle` are new and they are
@@ -379,7 +379,7 @@ class ModelRegistry:
379
379
 
380
380
  # ── the request path ─────────────────────────────────────────────────────
381
381
 
382
- def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None") -> DetectionBatch:
382
+ def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None", expected_model=None) -> DetectionBatch:
383
383
  """Run every model this request needs, in catalogue order, and merge.
384
384
 
385
385
  ALL OR NOTHING. If any model in the plan cannot be made resident — the
@@ -391,7 +391,16 @@ class ModelRegistry:
391
391
  quieter venue, which is the single failure this component exists to
392
392
  prevent.
393
393
  """
394
- plan = self.class_index().assign(wanted)
394
+ if expected_model is None:
395
+ plan = self.class_index().assign(wanted)
396
+ else:
397
+ selected = self._states.get(expected_model["modelId"])
398
+ if selected is None:
399
+ raise ModelVerificationError("selected model is not in this box's catalogue")
400
+ vocabulary = selected.entry.vocabulary
401
+ if wanted is not None and not wanted.issubset(vocabulary.class_names):
402
+ raise ModelVerificationError("selected model cannot answer all requested classes")
403
+ plan = [(vocabulary, wanted)]
395
404
  if not plan:
396
405
  raise DetectorNotReady(
397
406
  "no model on this box can answer that request; "
@@ -400,6 +409,10 @@ class ModelRegistry:
400
409
 
401
410
  acquired = self._acquire([vocabulary.model_name for vocabulary, _ in plan])
402
411
  try:
412
+ if expected_model is not None and (
413
+ len(acquired) != 1 or getattr(acquired[0], "model_sha256", None) != expected_model["sha256"]
414
+ ):
415
+ raise ModelVerificationError("loaded session does not match the selected artifact")
403
416
  batches = []
404
417
  for (vocabulary, classes), session in zip(plan, acquired):
405
418
  state = self._states[vocabulary.model_name]
@@ -707,6 +720,10 @@ class ModelRegistry:
707
720
  row["input"] = f"{spec.width}x{spec.height}"
708
721
  row["classes"] = spec.num_classes
709
722
  row["providers"] = list(spec.providers)
723
+ acceleration = getattr(s.session, "acceleration", None)
724
+ if isinstance(acceleration, dict):
725
+ row["acceleration"] = acceleration
726
+ row["providers"] = acceleration["providers"]
710
727
  # WHICH DECODE AND WHICH PREPROCESSING PRODUCED THIS MODEL'S
711
728
  # NUMBERS. Three layouts now reach this service and their
712
729
  # preprocessors are not interchangeable, so a confidence with
@@ -805,8 +822,13 @@ def read_process_rss() -> "tuple[int, str] | None":
805
822
 
806
823
  counters = _Counters()
807
824
  counters.cb = ctypes.sizeof(_Counters)
808
- handle = ctypes.windll.kernel32.GetCurrentProcess()
809
- if ctypes.windll.psapi.GetProcessMemoryInfo(
825
+ current_process = ctypes.windll.kernel32.GetCurrentProcess
826
+ current_process.restype = wintypes.HANDLE
827
+ memory_info = ctypes.windll.psapi.GetProcessMemoryInfo
828
+ memory_info.argtypes = [wintypes.HANDLE, ctypes.POINTER(_Counters), wintypes.DWORD]
829
+ memory_info.restype = wintypes.BOOL
830
+ handle = current_process()
831
+ if memory_info(
810
832
  handle, ctypes.byref(counters), counters.cb
811
833
  ):
812
834
  return int(counters.WorkingSetSize), "psapi-workingset"