@camstack/addon-pipeline 1.1.24 → 1.1.26

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 (45) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/audio-codec-ffmpeg/index.js +1 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/decoder-ffmpeg/index.js +84 -12
  6. package/dist/decoder-ffmpeg/index.mjs +84 -12
  7. package/dist/detection-pipeline/index.js +149 -27
  8. package/dist/detection-pipeline/index.mjs +149 -27
  9. package/dist/{dist-pd_-3C0T.mjs → dist-CgEP_0OL.mjs} +794 -16
  10. package/dist/{dist-CE3a05qT.js → dist-DAIlCdAx.js} +799 -15
  11. package/dist/frame-handle-plane-Dq20KtKL.mjs +636 -0
  12. package/dist/frame-handle-plane-DtTRX_0n.js +647 -0
  13. package/dist/hub-hostname-DAJXlOgV.js +54 -0
  14. package/dist/hub-hostname-cCknRYKj.mjs +49 -0
  15. package/dist/{model-download-service-C-IHWnXx-BnQ_awK4.js → model-download-service-C-IHWnXx-DxM2DSns.js} +1 -2
  16. package/dist/motion-wasm/index.js +1 -1
  17. package/dist/motion-wasm/index.mjs +1 -1
  18. package/dist/pipeline-runner/index.js +341 -17
  19. package/dist/pipeline-runner/index.mjs +341 -17
  20. package/dist/recorder/index.js +267 -65
  21. package/dist/recorder/index.mjs +267 -65
  22. package/dist/stream-broker/_stub.js +39 -39
  23. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BVFAEkIB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs} +2 -2
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +26 -0
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs +26 -0
  26. package/dist/stream-broker/{hostInit-DHej3Ep9.mjs → hostInit-DyLqyJaS.mjs} +2 -2
  27. package/dist/stream-broker/index.js +7 -639
  28. package/dist/stream-broker/index.mjs +5 -637
  29. package/dist/stream-broker/remoteEntry.js +1 -1
  30. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BqXU75it.js → MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js} +1 -1
  31. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-Ct2fpgCd.js → MotionZonesSettings-NcxxQN8r-CoLjNiUN.js} +1 -1
  32. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-a9eFVPPl.js → PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js} +1 -1
  33. package/embed-dist/assets/index-C-pL8ETk.js +81 -0
  34. package/embed-dist/assets/index-DrJ0ee3f.css +2 -0
  35. package/embed-dist/index.html +2 -2
  36. package/package.json +1 -1
  37. package/python/inference_pool.py +522 -27
  38. package/python/test_inference_pool_backpressure.py +121 -0
  39. package/python/test_inference_pool_coreml_cache.py +416 -0
  40. package/python/test_inference_pool_device_selection.py +256 -0
  41. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BFeBlYRQ.mjs +0 -26
  42. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BC1Jt6G8.mjs +0 -26
  43. package/embed-dist/assets/index-C7SSikPl.css +0 -2
  44. package/embed-dist/assets/index-DhdVxWXL.js +0 -80
  45. package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-C-IHWnXx-BPy6aoAx.mjs} +1 -1
@@ -0,0 +1,121 @@
1
+ """Unit tests for the per-model in-flight bound (ModelBackpressure).
2
+
3
+ Pure-python: stubs numpy / PIL / postprocessors in sys.modules so the module
4
+ imports without the ML runtime installed (inference_pool uses
5
+ `from __future__ import annotations`, so annotations never touch the stubs).
6
+
7
+ Run: python3 -m unittest test_inference_pool_backpressure -v
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import types
13
+ import unittest
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Stub the heavy third-party imports BEFORE importing inference_pool.
17
+ # ---------------------------------------------------------------------------
18
+
19
+ if "numpy" not in sys.modules:
20
+ sys.modules["numpy"] = types.ModuleType("numpy")
21
+
22
+ if "PIL" not in sys.modules:
23
+ _pil = types.ModuleType("PIL")
24
+ _pil_image = types.ModuleType("PIL.Image")
25
+ _pil.Image = _pil_image
26
+ sys.modules["PIL"] = _pil
27
+ sys.modules["PIL.Image"] = _pil_image
28
+
29
+ if "postprocessors" not in sys.modules:
30
+ _pp = types.ModuleType("postprocessors")
31
+ _pp.POSTPROCESSORS = {}
32
+ sys.modules["postprocessors"] = _pp
33
+
34
+ from inference_pool import MAX_PENDING_PER_MODEL, ModelBackpressure
35
+
36
+
37
+ class ModelBackpressureTest(unittest.TestCase):
38
+ def test_runs_immediately_below_max_running(self) -> None:
39
+ bp = ModelBackpressure(max_running=2, max_pending=2)
40
+ self.assertEqual(bp.admit(0, "a"), (["a"], []))
41
+ self.assertEqual(bp.admit(0, "b"), (["b"], []))
42
+
43
+ def test_queues_when_running_slots_full(self) -> None:
44
+ bp = ModelBackpressure(max_running=1, max_pending=2)
45
+ self.assertEqual(bp.admit(0, "a"), (["a"], []))
46
+ # Running slot taken — next arrivals wait, nothing dropped yet.
47
+ self.assertEqual(bp.admit(0, "b"), ([], []))
48
+ self.assertEqual(bp.admit(0, "c"), ([], []))
49
+
50
+ def test_drops_oldest_beyond_max_pending(self) -> None:
51
+ bp = ModelBackpressure(max_running=1, max_pending=2)
52
+ bp.admit(0, "running")
53
+ bp.admit(0, "old")
54
+ bp.admit(0, "mid")
55
+ # Pending full (old, mid) — a new arrival sheds the OLDEST.
56
+ to_run, to_drop = bp.admit(0, "new")
57
+ self.assertEqual(to_run, [])
58
+ self.assertEqual(to_drop, ["old"])
59
+ # And again — "mid" is now the oldest.
60
+ to_run, to_drop = bp.admit(0, "newer")
61
+ self.assertEqual(to_drop, ["mid"])
62
+
63
+ def test_complete_promotes_oldest_pending(self) -> None:
64
+ bp = ModelBackpressure(max_running=1, max_pending=2)
65
+ bp.admit(0, "a")
66
+ bp.admit(0, "b")
67
+ bp.admit(0, "c")
68
+ # "a" finishes — "b" (oldest waiting) takes the freed slot.
69
+ self.assertEqual(bp.complete(0), ["b"])
70
+ self.assertEqual(bp.complete(0), ["c"])
71
+ self.assertEqual(bp.complete(0), [])
72
+
73
+ def test_slot_freed_by_complete_is_reusable(self) -> None:
74
+ bp = ModelBackpressure(max_running=1, max_pending=1)
75
+ bp.admit(0, "a")
76
+ self.assertEqual(bp.complete(0), [])
77
+ # Slot free again — the next arrival runs immediately.
78
+ self.assertEqual(bp.admit(0, "b"), (["b"], []))
79
+
80
+ def test_models_are_bounded_independently(self) -> None:
81
+ bp = ModelBackpressure(max_running=1, max_pending=0)
82
+ self.assertEqual(bp.admit(0, "m0-a"), (["m0-a"], []))
83
+ # Model 0 saturated, but model 1 has its own slots.
84
+ self.assertEqual(bp.admit(1, "m1-a"), (["m1-a"], []))
85
+ # Model 0 overload does not shed model 1 frames.
86
+ _, dropped = bp.admit(0, "m0-b")
87
+ self.assertEqual(dropped, ["m0-b"])
88
+ self.assertEqual(bp.complete(1), [])
89
+
90
+ def test_zero_pending_rejects_overload_arrival_immediately(self) -> None:
91
+ bp = ModelBackpressure(max_running=1, max_pending=0)
92
+ bp.admit(0, "a")
93
+ to_run, to_drop = bp.admit(0, "b")
94
+ self.assertEqual(to_run, [])
95
+ self.assertEqual(to_drop, ["b"])
96
+
97
+ def test_running_count_never_goes_negative(self) -> None:
98
+ bp = ModelBackpressure(max_running=1, max_pending=1)
99
+ # Spurious complete on an idle model must not corrupt the state.
100
+ self.assertEqual(bp.complete(0), [])
101
+ self.assertEqual(bp.admit(0, "a"), (["a"], []))
102
+ self.assertEqual(bp.admit(0, "b"), ([], []))
103
+
104
+ def test_minimum_bounds_are_enforced(self) -> None:
105
+ # max_running clamps to >= 1 so a model can always make progress.
106
+ bp = ModelBackpressure(max_running=0, max_pending=-3)
107
+ self.assertEqual(bp.admit(0, "a"), (["a"], []))
108
+ # max_pending clamps to >= 0 — overload arrival sheds immediately.
109
+ to_run, to_drop = bp.admit(0, "b")
110
+ self.assertEqual(to_run, [])
111
+ self.assertEqual(to_drop, ["b"])
112
+
113
+ def test_default_pending_constant_is_small(self) -> None:
114
+ # The whole point is that queue wait stays bounded to a couple of
115
+ # frames — a large buffer would re-create the invisible latency.
116
+ self.assertGreaterEqual(MAX_PENDING_PER_MODEL, 1)
117
+ self.assertLessEqual(MAX_PENDING_PER_MODEL, 4)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()
@@ -0,0 +1,416 @@
1
+ """Unit tests for the CoreML persistent compiled-model cache in inference_pool.
2
+
3
+ Covers:
4
+ - _coreml_cache_paths — `.coreml-cache/<stem>.mlmodelc` + stamp resolution.
5
+ - _coreml_model_fingerprint — file/.mlpackage-dir fingerprinting + JSON
6
+ round-trip stability.
7
+ - _coreml_stamp_matches — pure cache-validity decision.
8
+ - _persist_coreml_compiled — atomic copy + stamp-written-LAST ordering.
9
+ - _acquire_coreml_model — HIT/MISS/invalidation flow with a fake `ct`, and
10
+ the HARD SAFETY guarantee: every cache failure falls back to the plain
11
+ `ct.models.MLModel(path)` load (pre-cache behavior).
12
+
13
+ Pure-python: stubs numpy / PIL / postprocessors in sys.modules so the module
14
+ imports without the ML runtime installed (inference_pool uses
15
+ `from __future__ import annotations`, so annotations never touch the stubs).
16
+
17
+ Run: python3 -m unittest test_inference_pool_coreml_cache -v
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+ import tempfile
25
+ import types
26
+ import unittest
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Stub the heavy third-party imports BEFORE importing inference_pool.
30
+ # ---------------------------------------------------------------------------
31
+
32
+ if "numpy" not in sys.modules:
33
+ sys.modules["numpy"] = types.ModuleType("numpy")
34
+
35
+ if "PIL" not in sys.modules:
36
+ _pil = types.ModuleType("PIL")
37
+ _pil_image = types.ModuleType("PIL.Image")
38
+ _pil.Image = _pil_image
39
+ sys.modules["PIL"] = _pil
40
+ sys.modules["PIL.Image"] = _pil_image
41
+
42
+ if "postprocessors" not in sys.modules:
43
+ _pp = types.ModuleType("postprocessors")
44
+ _pp.POSTPROCESSORS = {}
45
+ sys.modules["postprocessors"] = _pp
46
+
47
+ from inference_pool import (
48
+ COREML_CACHE_DIR_NAME,
49
+ COREML_CACHE_STAMP_VERSION,
50
+ _acquire_coreml_model,
51
+ _coreml_cache_paths,
52
+ _coreml_model_fingerprint,
53
+ _coreml_stamp_matches,
54
+ _persist_coreml_compiled,
55
+ )
56
+
57
+
58
+ def _write(path: str, content: bytes = b"x") -> None:
59
+ os.makedirs(os.path.dirname(path), exist_ok=True)
60
+ with open(path, "wb") as fh:
61
+ fh.write(content)
62
+
63
+
64
+ def _make_mlpackage(root: str, name: str = "yolo.mlpackage") -> str:
65
+ pkg = os.path.join(root, name)
66
+ _write(os.path.join(pkg, "Manifest.json"), b"{}")
67
+ _write(os.path.join(pkg, "Data", "com.apple.CoreML", "model.mlmodel"), b"weights")
68
+ return pkg
69
+
70
+
71
+ class CoremlCachePathsTest(unittest.TestCase):
72
+ def test_mlpackage_maps_to_dot_cache_sibling(self) -> None:
73
+ cache, stamp = _coreml_cache_paths("/models/yolo.mlpackage")
74
+ self.assertEqual(cache, os.path.join("/models", COREML_CACHE_DIR_NAME, "yolo.mlmodelc"))
75
+ self.assertEqual(stamp, os.path.join("/models", COREML_CACHE_DIR_NAME, "yolo.stamp.json"))
76
+
77
+ def test_plain_mlmodel_file_uses_stem(self) -> None:
78
+ cache, stamp = _coreml_cache_paths("/m/face.mlmodel")
79
+ self.assertEqual(cache, os.path.join("/m", COREML_CACHE_DIR_NAME, "face.mlmodelc"))
80
+ self.assertEqual(stamp, os.path.join("/m", COREML_CACHE_DIR_NAME, "face.stamp.json"))
81
+
82
+ def test_relative_path_is_absolutized(self) -> None:
83
+ cache, _ = _coreml_cache_paths("yolo.mlpackage")
84
+ self.assertTrue(os.path.isabs(cache))
85
+
86
+ def test_different_models_get_distinct_entries(self) -> None:
87
+ a, _ = _coreml_cache_paths("/models/yolo9.mlpackage")
88
+ b, _ = _coreml_cache_paths("/models/yolo11.mlpackage")
89
+ self.assertNotEqual(a, b)
90
+
91
+
92
+ class CoremlModelFingerprintTest(unittest.TestCase):
93
+ def test_directory_fingerprint_counts_files_and_bytes(self) -> None:
94
+ with tempfile.TemporaryDirectory() as tmp:
95
+ pkg = _make_mlpackage(tmp)
96
+ fp = _coreml_model_fingerprint(pkg)
97
+ self.assertEqual(fp["kind"], "dir")
98
+ self.assertEqual(fp["files"], 2)
99
+ self.assertEqual(fp["size"], len(b"{}") + len(b"weights"))
100
+ self.assertGreater(fp["mtimeNs"], 0)
101
+
102
+ def test_plain_file_fingerprint(self) -> None:
103
+ with tempfile.TemporaryDirectory() as tmp:
104
+ path = os.path.join(tmp, "m.mlmodel")
105
+ _write(path, b"abc")
106
+ fp = _coreml_model_fingerprint(path)
107
+ self.assertEqual(fp["kind"], "file")
108
+ self.assertEqual(fp["files"], 1)
109
+ self.assertEqual(fp["size"], 3)
110
+
111
+ def test_content_change_changes_fingerprint(self) -> None:
112
+ with tempfile.TemporaryDirectory() as tmp:
113
+ pkg = _make_mlpackage(tmp)
114
+ before = _coreml_model_fingerprint(pkg)
115
+ _write(os.path.join(pkg, "Data", "com.apple.CoreML", "model.mlmodel"), b"NEW weights!")
116
+ after = _coreml_model_fingerprint(pkg)
117
+ self.assertNotEqual(before, after)
118
+
119
+ def test_fingerprint_survives_json_round_trip(self) -> None:
120
+ with tempfile.TemporaryDirectory() as tmp:
121
+ pkg = _make_mlpackage(tmp)
122
+ fp = _coreml_model_fingerprint(pkg)
123
+ self.assertEqual(json.loads(json.dumps(fp)), fp)
124
+
125
+ def test_missing_path_raises(self) -> None:
126
+ # The caller (_acquire_coreml_model) catches this and falls back.
127
+ with self.assertRaises(OSError):
128
+ _coreml_model_fingerprint("/nonexistent/model.mlpackage")
129
+
130
+
131
+ class CoremlStampMatchesTest(unittest.TestCase):
132
+ FP = {"kind": "dir", "files": 2, "size": 9, "mtimeNs": 123}
133
+
134
+ def _stamp(self) -> dict:
135
+ return {"version": COREML_CACHE_STAMP_VERSION, "fingerprint": dict(self.FP)}
136
+
137
+ def test_matching_stamp_validates(self) -> None:
138
+ self.assertTrue(_coreml_stamp_matches(self._stamp(), dict(self.FP)))
139
+
140
+ def test_none_or_non_dict_rejected(self) -> None:
141
+ self.assertFalse(_coreml_stamp_matches(None, dict(self.FP)))
142
+ self.assertFalse(_coreml_stamp_matches("stamp", dict(self.FP)))
143
+ self.assertFalse(_coreml_stamp_matches([], dict(self.FP)))
144
+
145
+ def test_wrong_version_rejected(self) -> None:
146
+ stamp = self._stamp()
147
+ stamp["version"] = COREML_CACHE_STAMP_VERSION + 1
148
+ self.assertFalse(_coreml_stamp_matches(stamp, dict(self.FP)))
149
+
150
+ def test_changed_fingerprint_rejected(self) -> None:
151
+ changed = dict(self.FP)
152
+ changed["mtimeNs"] = 999
153
+ self.assertFalse(_coreml_stamp_matches(self._stamp(), changed))
154
+
155
+ def test_missing_fingerprint_rejected(self) -> None:
156
+ self.assertFalse(
157
+ _coreml_stamp_matches({"version": COREML_CACHE_STAMP_VERSION}, dict(self.FP)),
158
+ )
159
+
160
+
161
+ class PersistCoremlCompiledTest(unittest.TestCase):
162
+ def _compiled_src(self, root: str) -> str:
163
+ src = os.path.join(root, "tmp-compiled.mlmodelc")
164
+ _write(os.path.join(src, "coremldata.bin"), b"compiled")
165
+ _write(os.path.join(src, "model.espresso.net"), b"net")
166
+ return src
167
+
168
+ def test_persists_dir_and_stamp(self) -> None:
169
+ with tempfile.TemporaryDirectory() as tmp:
170
+ src = self._compiled_src(tmp)
171
+ cache = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.mlmodelc")
172
+ stamp = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.stamp.json")
173
+ fp = {"kind": "dir", "files": 1, "size": 1, "mtimeNs": 1}
174
+ _persist_coreml_compiled(src, cache, stamp, fp)
175
+ self.assertTrue(os.path.isfile(os.path.join(cache, "coremldata.bin")))
176
+ self.assertTrue(os.path.isfile(os.path.join(cache, "model.espresso.net")))
177
+ with open(stamp, "r", encoding="utf-8") as fh:
178
+ data = json.load(fh)
179
+ self.assertTrue(_coreml_stamp_matches(data, fp))
180
+ # No tmp leftovers.
181
+ leftovers = [n for n in os.listdir(os.path.dirname(cache)) if ".tmp-" in n]
182
+ self.assertEqual(leftovers, [])
183
+
184
+ def test_overwrites_stale_cache(self) -> None:
185
+ with tempfile.TemporaryDirectory() as tmp:
186
+ src = self._compiled_src(tmp)
187
+ cache = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.mlmodelc")
188
+ stamp = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.stamp.json")
189
+ _write(os.path.join(cache, "old.bin"), b"stale")
190
+ _write(stamp, b"{}")
191
+ fp = {"kind": "dir", "files": 1, "size": 1, "mtimeNs": 2}
192
+ _persist_coreml_compiled(src, cache, stamp, fp)
193
+ self.assertFalse(os.path.exists(os.path.join(cache, "old.bin")))
194
+ self.assertTrue(os.path.isfile(os.path.join(cache, "coremldata.bin")))
195
+
196
+ def test_missing_source_raises_and_leaves_no_stamp(self) -> None:
197
+ with tempfile.TemporaryDirectory() as tmp:
198
+ cache = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.mlmodelc")
199
+ stamp = os.path.join(tmp, COREML_CACHE_DIR_NAME, "yolo.stamp.json")
200
+ with self.assertRaises(OSError):
201
+ _persist_coreml_compiled(
202
+ os.path.join(tmp, "missing"), cache, stamp,
203
+ {"kind": "dir", "files": 0, "size": 0, "mtimeNs": 0},
204
+ )
205
+ # Stamp is written LAST — a failed copy must never leave one.
206
+ self.assertFalse(os.path.exists(stamp))
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Fake coremltools for _acquire_coreml_model flow tests
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ class _FakeSpec:
215
+ """Sentinel spec object; identity is asserted in the tests."""
216
+
217
+
218
+ class _FakeMLModel:
219
+ def __init__(self, compiled_dir: str) -> None:
220
+ self._compiled_dir = compiled_dir
221
+ self._spec = _FakeSpec()
222
+
223
+ def get_spec(self) -> _FakeSpec:
224
+ return self._spec
225
+
226
+ def get_compiled_model_path(self) -> str:
227
+ return self._compiled_dir
228
+
229
+
230
+ class _FakeCompiledMLModel:
231
+ def __init__(self, path: str, compute_units: object) -> None:
232
+ self.path = path
233
+ self.compute_units = compute_units
234
+
235
+
236
+ class _FakeCt:
237
+ """Minimal stand-in for the coremltools module surface the helper uses."""
238
+
239
+ def __init__(self, tmp: str, fail_compiled: bool = False, fail_load_spec: bool = False) -> None:
240
+ self.tmp = tmp
241
+ self.mlmodel_calls: list[tuple[str, object]] = []
242
+ self.compiled_calls: list[tuple[str, object]] = []
243
+ self.load_spec_calls: list[str] = []
244
+ self.loaded_spec = _FakeSpec()
245
+
246
+ fake = self
247
+
248
+ class _MLModel:
249
+ def __new__(cls, path: str, compute_units: object = None) -> _FakeMLModel:
250
+ fake.mlmodel_calls.append((path, compute_units))
251
+ compiled = os.path.join(fake.tmp, f"compiled-{len(fake.mlmodel_calls)}.mlmodelc")
252
+ _write(os.path.join(compiled, "coremldata.bin"), b"compiled")
253
+ return _FakeMLModel(compiled)
254
+
255
+ class _CompiledMLModel:
256
+ def __new__(cls, path: str, compute_units: object = None) -> _FakeCompiledMLModel:
257
+ fake.compiled_calls.append((path, compute_units))
258
+ if fail_compiled:
259
+ raise RuntimeError("CompiledMLModel load failed")
260
+ return _FakeCompiledMLModel(path, compute_units)
261
+
262
+ def _load_spec(path: str) -> _FakeSpec:
263
+ fake.load_spec_calls.append(path)
264
+ if fail_load_spec:
265
+ raise RuntimeError("load_spec failed")
266
+ return fake.loaded_spec
267
+
268
+ self.models = types.SimpleNamespace(MLModel=_MLModel, CompiledMLModel=_CompiledMLModel)
269
+ self.utils = types.SimpleNamespace(load_spec=_load_spec)
270
+
271
+
272
+ class AcquireCoremlModelTest(unittest.TestCase):
273
+ CU = object()
274
+
275
+ def test_first_load_is_miss_that_compiles_and_persists(self) -> None:
276
+ with tempfile.TemporaryDirectory() as tmp:
277
+ pkg = _make_mlpackage(tmp)
278
+ ct = _FakeCt(tmp)
279
+ model, spec = _acquire_coreml_model(ct, pkg, self.CU)
280
+ # MISS: plain MLModel compile, spec from model.get_spec().
281
+ self.assertIsInstance(model, _FakeMLModel)
282
+ self.assertIs(spec, model.get_spec())
283
+ self.assertEqual(ct.mlmodel_calls, [(pkg, self.CU)])
284
+ self.assertEqual(ct.compiled_calls, [])
285
+ # Persisted for the next spawn.
286
+ cache, stamp = _coreml_cache_paths(pkg)
287
+ self.assertTrue(os.path.isfile(os.path.join(cache, "coremldata.bin")))
288
+ self.assertTrue(os.path.isfile(stamp))
289
+
290
+ def test_second_load_is_hit_with_no_recompile(self) -> None:
291
+ with tempfile.TemporaryDirectory() as tmp:
292
+ pkg = _make_mlpackage(tmp)
293
+ _acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed the cache
294
+ ct = _FakeCt(tmp)
295
+ model, spec = _acquire_coreml_model(ct, pkg, self.CU)
296
+ # HIT: CompiledMLModel over the cache path; spec via load_spec
297
+ # from the SOURCE .mlpackage; NO MLModel compile.
298
+ cache, _ = _coreml_cache_paths(pkg)
299
+ self.assertIsInstance(model, _FakeCompiledMLModel)
300
+ self.assertEqual(model.path, cache)
301
+ self.assertIs(model.compute_units, self.CU)
302
+ self.assertIs(spec, ct.loaded_spec)
303
+ self.assertEqual(ct.load_spec_calls, [pkg])
304
+ self.assertEqual(ct.mlmodel_calls, [])
305
+
306
+ def test_changed_model_invalidates_cache_and_recompiles(self) -> None:
307
+ with tempfile.TemporaryDirectory() as tmp:
308
+ pkg = _make_mlpackage(tmp)
309
+ _acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
310
+ _write(
311
+ os.path.join(pkg, "Data", "com.apple.CoreML", "model.mlmodel"),
312
+ b"retrained weights (longer)",
313
+ )
314
+ ct = _FakeCt(tmp)
315
+ model, spec = _acquire_coreml_model(ct, pkg, self.CU)
316
+ self.assertIsInstance(model, _FakeMLModel)
317
+ self.assertIs(spec, model.get_spec())
318
+ self.assertEqual(ct.compiled_calls, [])
319
+ self.assertEqual(len(ct.mlmodel_calls), 1)
320
+ # Cache rewritten: a third spawn hits again.
321
+ ct3 = _FakeCt(tmp)
322
+ model3, _ = _acquire_coreml_model(ct3, pkg, self.CU)
323
+ self.assertIsInstance(model3, _FakeCompiledMLModel)
324
+
325
+ def test_compiled_load_failure_falls_back_to_plain_mlmodel(self) -> None:
326
+ with tempfile.TemporaryDirectory() as tmp:
327
+ pkg = _make_mlpackage(tmp)
328
+ _acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
329
+ ct = _FakeCt(tmp, fail_compiled=True)
330
+ model, spec = _acquire_coreml_model(ct, pkg, self.CU)
331
+ # HARD SAFETY: CompiledMLModel raising → plain MLModel path.
332
+ self.assertIsInstance(model, _FakeMLModel)
333
+ self.assertIs(spec, model.get_spec())
334
+ self.assertEqual(ct.mlmodel_calls, [(pkg, self.CU)])
335
+
336
+ def test_load_spec_failure_falls_back_to_plain_mlmodel(self) -> None:
337
+ with tempfile.TemporaryDirectory() as tmp:
338
+ pkg = _make_mlpackage(tmp)
339
+ _acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
340
+ ct = _FakeCt(tmp, fail_load_spec=True)
341
+ model, spec = _acquire_coreml_model(ct, pkg, self.CU)
342
+ self.assertIsInstance(model, _FakeMLModel)
343
+ self.assertIs(spec, model.get_spec())
344
+
345
+ def test_corrupt_stamp_json_falls_back_to_plain_mlmodel(self) -> None:
346
+ with tempfile.TemporaryDirectory() as tmp:
347
+ pkg = _make_mlpackage(tmp)
348
+ _acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
349
+ _, stamp = _coreml_cache_paths(pkg)
350
+ _write(stamp, b"not json {{{")
351
+ ct = _FakeCt(tmp)
352
+ model, _ = _acquire_coreml_model(ct, pkg, self.CU)
353
+ self.assertIsInstance(model, _FakeMLModel)
354
+ self.assertEqual(ct.compiled_calls, [])
355
+
356
+ def test_stamp_without_cache_dir_falls_back(self) -> None:
357
+ with tempfile.TemporaryDirectory() as tmp:
358
+ pkg = _make_mlpackage(tmp)
359
+ _acquire_coreml_model(_FakeCt(tmp), pkg, self.CU) # seed
360
+ cache, _ = _coreml_cache_paths(pkg)
361
+ import shutil as _shutil
362
+ _shutil.rmtree(cache)
363
+ ct = _FakeCt(tmp)
364
+ model, _ = _acquire_coreml_model(ct, pkg, self.CU)
365
+ self.assertIsInstance(model, _FakeMLModel)
366
+ self.assertEqual(ct.compiled_calls, [])
367
+
368
+ def test_persist_failure_still_returns_compiled_model(self) -> None:
369
+ with tempfile.TemporaryDirectory() as tmp:
370
+ pkg = _make_mlpackage(tmp)
371
+ ct = _FakeCt(tmp)
372
+
373
+ def _boom() -> str:
374
+ raise RuntimeError("no compiled path")
375
+
376
+ # Force get_compiled_model_path to fail AFTER the compile — the
377
+ # already-loaded model must still be returned (cache is best
378
+ # effort only).
379
+ orig_new = ct.models.MLModel.__new__
380
+
381
+ class _MLModelNoCompiledPath:
382
+ def __new__(cls, path: str, compute_units: object = None) -> _FakeMLModel:
383
+ m = orig_new(ct.models.MLModel, path, compute_units)
384
+ m.get_compiled_model_path = _boom
385
+ return m
386
+
387
+ ct.models = types.SimpleNamespace(
388
+ MLModel=_MLModelNoCompiledPath, CompiledMLModel=ct.models.CompiledMLModel,
389
+ )
390
+ model, spec = _acquire_coreml_model(ct, pkg, self.CU)
391
+ self.assertIsInstance(model, _FakeMLModel)
392
+ self.assertIs(spec, model.get_spec())
393
+ _, stamp = _coreml_cache_paths(pkg)
394
+ self.assertFalse(os.path.exists(stamp))
395
+
396
+ def test_missing_model_path_raises_like_pre_cache_behavior(self) -> None:
397
+ with tempfile.TemporaryDirectory() as tmp:
398
+ missing = os.path.join(tmp, "gone.mlpackage")
399
+
400
+ class _RaisingMLModel:
401
+ def __new__(cls, path: str, compute_units: object = None) -> object:
402
+ raise FileNotFoundError(path)
403
+
404
+ ct = _FakeCt(tmp)
405
+ ct.models = types.SimpleNamespace(
406
+ MLModel=_RaisingMLModel, CompiledMLModel=ct.models.CompiledMLModel,
407
+ )
408
+ # Pre-cache, MLModel(path) raised for a missing model and the
409
+ # load/replace command handlers reported it — the cache layer
410
+ # must surface the SAME failure, not swallow it.
411
+ with self.assertRaises(FileNotFoundError):
412
+ _acquire_coreml_model(ct, missing, self.CU)
413
+
414
+
415
+ if __name__ == "__main__":
416
+ unittest.main()