@trillboards/edge-sdk 0.2.131 → 0.2.132

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.
@@ -1,6 +1,16 @@
1
1
  [project]
2
2
  name = "trillboards-detector"
3
- version = "0.2.0"
3
+ # GENERATED — `stamp_version.py --write` owns this line; `--check` fails CI when
4
+ # it is stale. The `+src.<hex>` local segment is a fingerprint of the bytes this
5
+ # package ships, and it lives HERE rather than in a hatch dynamic-version hook
6
+ # because uv reads the version out of this file to decide whether its cached
7
+ # wheel still describes the directory. A dynamic version would be read only by
8
+ # BUILDING, which is the step a cache hit skips, so it could never invalidate
9
+ # its own cache. Measured on uv 0.9.2 (2026-09-04): with the version held
10
+ # constant, a `labels.py`-only change survives `uv tool install` AND
11
+ # `uv tool install --force`; move the version and a bare `uv tool install`
12
+ # reinstalls. See `stamp_version.py`.
13
+ version = "0.2.0+src.2c3db7881945"
4
14
  description = "On-device object-detector service for the Trillboards BYO-CCTV connector (loopback HTTP, ONNX Runtime)."
5
15
  requires-python = ">=3.10"
6
16
 
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """Stamp `trillboards-detector`'s version from the bytes it ships.
3
+
4
+ WHY THIS EXISTS — A VERSION THAT DOES NOT MOVE IS A LIE THE INSTALLER BELIEVES
5
+ =============================================================================
6
+
7
+ `pyproject.toml` said `version = "0.2.0"` for the whole life of this package.
8
+ Measured on 2026-09-04 across the seven commits that touched
9
+ `trillboards_detector/labels.py`, the model vocabulary went from **16,129 bytes
10
+ to 37,321** — one model id to eight — and the version string never moved once.
11
+
12
+ That is not cosmetic, because uv decides whether to rebuild a LOCAL directory
13
+ from its cached wheel, and the cache is not keyed on the package sources.
14
+ Measured on uv 0.9.2, changing ONLY `trillboards_detector/labels.py` while
15
+ `pyproject.toml` stays untouched:
16
+
17
+ uv tool install <dir> -> "Audited 1 package" STALE
18
+ uv tool install --force <dir> -> venv rebuilt, STALE
19
+ uv tool install --force --refresh-package <name> -> fresh
20
+ (version string moved in pyproject.toml) -> fresh, with no flags
21
+
22
+ **So `--force` is not enough, and all three shipped installers used exactly
23
+ `--force`.** A box therefore keeps an obsolete CLASS VOCABULARY for ever: a
24
+ granted specialist downloads, sits on disk, and `POST /detect` answers
25
+ 400 "requested classes not in this box's vocabulary" while `/health` says
26
+ `ready` — the failure written up in
27
+ `.claude/lessons/2026-09-02-a-granted-model-is-not-a-served-model.md`, where the
28
+ run measured the VLM under a specialist's label.
29
+
30
+ WHY DERIVED AND NOT HAND-BUMPED
31
+ ===============================
32
+
33
+ A hand-bumped version is the same trap one release later: the seven commits
34
+ above each had an author who could have bumped it and none did, because nothing
35
+ asked them to and nothing checked. So the version's local segment is a
36
+ FINGERPRINT OF THE SHIPPED BYTES. Editing any shipped file moves it; forgetting
37
+ is not an available move, because CI recomputes it (`--check`).
38
+
39
+ The base version stays hand-editable in `_version.py` — it is the human release
40
+ line, and a release IS a judgement. What is mechanical is only the claim "these
41
+ are different bytes", which is not a judgement at all.
42
+
43
+ WHAT IS IN THE FINGERPRINT, AND WHY EACH PART
44
+ =============================================
45
+
46
+ * every `*.py` under `trillboards_detector/`, EXCEPT `_version.py` — the code
47
+ that is actually installed. `_version.py` is excluded because including the
48
+ stamp in its own input has no fixed point.
49
+ * `pyproject.toml` with the `version = "..."` line normalised out, for the same
50
+ reason, and INCLUDED at all because the pinned dependency set
51
+ (`onnxruntime==1.29.0`) is part of what this tool IS: a box on a different ORT
52
+ is a box with different kernels, which is the whole reason those pins carry
53
+ the docblock they carry.
54
+
55
+ Line endings are normalised to `\n` before hashing. A Windows checkout with
56
+ `core.autocrlf=true` would otherwise fingerprint differently from the Linux one
57
+ that built it, and the stamp would flap per platform rather than per change.
58
+
59
+ USAGE
60
+ python3 stamp_version.py --check # exit 1 on drift (CI)
61
+ python3 stamp_version.py --write # re-stamp after editing the package
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import argparse
67
+ import hashlib
68
+ import re
69
+ import sys
70
+ from pathlib import Path
71
+
72
+ HERE = Path(__file__).resolve().parent
73
+ PACKAGE_DIR = HERE / "trillboards_detector"
74
+ PYPROJECT = HERE / "pyproject.toml"
75
+ VERSION_FILE = PACKAGE_DIR / "_version.py"
76
+
77
+ #: How many hex characters of the digest ride in the local version segment. 12
78
+ #: is git's abbreviated-sha length for the same reason: long enough that a
79
+ #: collision is not a thing that happens to a repository, short enough to read
80
+ #: off a `uv tool list` line.
81
+ FINGERPRINT_CHARS = 12
82
+
83
+ #: `version = "..."`, exactly as `[project]` spells it.
84
+ VERSION_LINE_RE = re.compile(r'^version\s*=\s*".*"\s*$', re.MULTILINE)
85
+
86
+ VERSION_FILE_TEMPLATE = '''\
87
+ """GENERATED FILE — do not hand-edit.
88
+
89
+ `VERSION`'s local segment is a fingerprint of the bytes this package ships, so
90
+ that an installer can never mistake new code for code it already has. See
91
+ `stamp_version.py` for the measurement that made this necessary.
92
+
93
+ Re-stamp after editing anything under `trillboards_detector/`:
94
+
95
+ python3 trillboards-edge-sdk/packages/edge-sdk/deploy/detector/stamp_version.py --write
96
+
97
+ `BASE_VERSION` is the hand-editable release line and is the ONLY field here a
98
+ human sets.
99
+ """
100
+
101
+ BASE_VERSION = "{base}"
102
+ SOURCE_FINGERPRINT = "{fingerprint}"
103
+ VERSION = "{version}"
104
+ '''
105
+
106
+
107
+ def _normalise(raw: bytes) -> bytes:
108
+ """CRLF/CR -> LF, so the stamp is a property of the change and not the checkout."""
109
+ return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
110
+
111
+
112
+ def _fingerprint_inputs() -> "list[tuple[str, bytes]]":
113
+ """(label, bytes) pairs, in a stable order, for everything the stamp covers."""
114
+ inputs: "list[tuple[str, bytes]]" = []
115
+ for path in sorted(PACKAGE_DIR.rglob("*.py"), key=lambda p: p.relative_to(HERE).as_posix()):
116
+ if path == VERSION_FILE:
117
+ continue
118
+ inputs.append((path.relative_to(HERE).as_posix(), _normalise(path.read_bytes())))
119
+ pyproject = _normalise(PYPROJECT.read_bytes()).decode("utf-8")
120
+ # The version line is normalised out: it is the OUTPUT of this function and
121
+ # cannot also be an input to it.
122
+ pyproject = VERSION_LINE_RE.sub('version = "<STAMPED>"', pyproject)
123
+ inputs.append(("pyproject.toml", pyproject.encode("utf-8")))
124
+ return inputs
125
+
126
+
127
+ def compute_fingerprint() -> str:
128
+ digest = hashlib.sha256()
129
+ for label, blob in _fingerprint_inputs():
130
+ # Length-delimited so that concatenation is unambiguous: without it,
131
+ # moving bytes from the end of one file to the start of the next leaves
132
+ # the digest unchanged.
133
+ digest.update(f"{label}\0{len(blob)}\0".encode("utf-8"))
134
+ digest.update(blob)
135
+ return digest.hexdigest()[:FINGERPRINT_CHARS]
136
+
137
+
138
+ def read_base_version() -> str:
139
+ text = VERSION_FILE.read_text(encoding="utf-8")
140
+ match = re.search(r'^BASE_VERSION\s*=\s*"([^"]+)"\s*$', text, re.MULTILINE)
141
+ if not match:
142
+ raise SystemExit(f"{VERSION_FILE} has no BASE_VERSION assignment")
143
+ return match.group(1)
144
+
145
+
146
+ def expected_version() -> "tuple[str, str, str]":
147
+ """(base, fingerprint, full version) that the tree SHOULD carry right now."""
148
+ base = read_base_version()
149
+ fingerprint = compute_fingerprint()
150
+ return base, fingerprint, f"{base}+src.{fingerprint}"
151
+
152
+
153
+ def read_stamped_version() -> str:
154
+ text = VERSION_FILE.read_text(encoding="utf-8")
155
+ match = re.search(r'^VERSION\s*=\s*"([^"]+)"\s*$', text, re.MULTILINE)
156
+ if not match:
157
+ raise SystemExit(f"{VERSION_FILE} has no VERSION assignment")
158
+ return match.group(1)
159
+
160
+
161
+ def read_pyproject_version() -> str:
162
+ text = PYPROJECT.read_text(encoding="utf-8")
163
+ match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', text, re.MULTILINE)
164
+ if not match:
165
+ raise SystemExit(f"{PYPROJECT} has no [project] version assignment")
166
+ return match.group(1)
167
+
168
+
169
+ def write_stamp() -> "tuple[str, bool]":
170
+ base, fingerprint, version = expected_version()
171
+ changed = False
172
+
173
+ if not VERSION_FILE.exists() or read_stamped_version() != version:
174
+ VERSION_FILE.write_text(
175
+ VERSION_FILE_TEMPLATE.format(base=base, fingerprint=fingerprint, version=version),
176
+ encoding="utf-8",
177
+ )
178
+ changed = True
179
+
180
+ # pyproject.toml carries the SAME literal, and that is load-bearing rather
181
+ # than a duplicate: uv reads the version out of THIS file to decide whether
182
+ # its cached wheel still describes the directory. A dynamic version sourced
183
+ # from `_version.py` would be read only by BUILDING, which is the step a
184
+ # cache hit skips — so it could never invalidate its own cache.
185
+ pyproject_text = PYPROJECT.read_text(encoding="utf-8")
186
+ stamped = VERSION_LINE_RE.sub(f'version = "{version}"', pyproject_text, count=1)
187
+ if stamped != pyproject_text:
188
+ PYPROJECT.write_text(stamped, encoding="utf-8")
189
+ changed = True
190
+
191
+ return version, changed
192
+
193
+
194
+ def check_stamp() -> int:
195
+ _, _, want = expected_version()
196
+ problems = []
197
+ have_version_file = read_stamped_version()
198
+ have_pyproject = read_pyproject_version()
199
+ if have_version_file != want:
200
+ problems.append(f" {VERSION_FILE.name}: VERSION is {have_version_file!r}, expected {want!r}")
201
+ if have_pyproject != want:
202
+ problems.append(f" pyproject.toml: version is {have_pyproject!r}, expected {want!r}")
203
+ if not problems:
204
+ print(f"detector-version-stamp OK — {want}")
205
+ return 0
206
+ print("detector-version-stamp DRIFT — the shipped bytes changed but the version did not.", file=sys.stderr)
207
+ for line in problems:
208
+ print(line, file=sys.stderr)
209
+ print(
210
+ "\nA version that does not move lets `uv tool install` keep an OBSOLETE class\n"
211
+ "vocabulary on a fielded box while /health reports `ready` and /detect answers\n"
212
+ "400 for the granted specialist's classes. Re-stamp:\n"
213
+ " python3 trillboards-edge-sdk/packages/edge-sdk/deploy/detector/stamp_version.py --write",
214
+ file=sys.stderr,
215
+ )
216
+ return 1
217
+
218
+
219
+ def main(argv: "list[str] | None" = None) -> int:
220
+ parser = argparse.ArgumentParser(prog="stamp_version.py", description=__doc__)
221
+ mode = parser.add_mutually_exclusive_group(required=True)
222
+ mode.add_argument("--check", action="store_true", help="exit 1 if the stamp is stale")
223
+ mode.add_argument("--write", action="store_true", help="re-stamp _version.py and pyproject.toml")
224
+ mode.add_argument("--print", action="store_true", help="print the version the tree should carry")
225
+ args = parser.parse_args(argv)
226
+
227
+ if args.check:
228
+ return check_stamp()
229
+ if args.print:
230
+ print(expected_version()[2])
231
+ return 0
232
+ version, changed = write_stamp()
233
+ print(f"{'stamped' if changed else 'already stamped'} {version}")
234
+ return 0
235
+
236
+
237
+ if __name__ == "__main__":
238
+ raise SystemExit(main())
@@ -217,6 +217,23 @@ class TestHealth(ServerCase):
217
217
  status, _ = self.request(f"{url}/health", method="POST", body={})
218
218
  self.assertEqual(status, 405)
219
219
 
220
+ def test_health_names_the_build_that_is_answering(self):
221
+ """WHICH BUILD, on the wire, on both the healthy and the unhealthy reply.
222
+
223
+ Two boxes running different vocabularies are byte-identical on every
224
+ other field here — `state: ready`, the pinned model, the model path — so
225
+ before this there was nothing an operator or a rig script could read to
226
+ tell a current box from one whose install silently no-opped. The 503
227
+ reply carries it too: a box that is stale AND has no artefact yet is
228
+ exactly when knowing the build matters most.
229
+ """
230
+ from trillboards_detector._version import VERSION
231
+
232
+ _, ready = self.request(f"{self.serve(FakeLoader())}/health")
233
+ self.assertEqual(ready["version"], VERSION)
234
+ _, down = self.request(f"{self.serve(FakeLoader(state='unavailable'))}/health")
235
+ self.assertEqual(down["version"], VERSION)
236
+
220
237
 
221
238
  class TestHealthReportsResidency(ServerCase):
222
239
  """`/health` must say WHICH models are resident, and still load nothing.
@@ -0,0 +1,282 @@
1
+ """The tool's version must move when the bytes it ships move.
2
+
3
+ WHY THIS IS A TEST AND NOT A CONVENTION
4
+ =======================================
5
+
6
+ `pyproject.toml` said `version = "0.2.0"` from the day this package was created.
7
+ Across the seven commits that touched `trillboards_detector/labels.py` the model
8
+ vocabulary went 16,129 -> 37,321 bytes, one model id to eight, and the version
9
+ never moved once. Every one of those commits had an author who could have bumped
10
+ it; nothing asked, so nobody did.
11
+
12
+ That is not bookkeeping. Measured on uv 0.9.2, with the version held constant, a
13
+ change to a module under `trillboards_detector/` survives BOTH
14
+ `uv tool install` and `uv tool install --force` — uv's local-directory build
15
+ cache is not keyed on the package sources — and both print
16
+ "Installed 1 executable". A box therefore keeps an obsolete class VOCABULARY,
17
+ a granted specialist answers 400, and `/health` says `ready` throughout
18
+ (`.claude/lessons/2026-09-02-a-granted-model-is-not-a-served-model.md`).
19
+
20
+ So the version's local segment is DERIVED from the shipped bytes, and this file
21
+ is what makes forgetting impossible.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import importlib.util
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ import unittest
33
+ from pathlib import Path
34
+
35
+ DETECTOR_DIR = Path(__file__).resolve().parents[1]
36
+ STAMPER = DETECTOR_DIR / "stamp_version.py"
37
+ PYPROJECT = DETECTOR_DIR / "pyproject.toml"
38
+ PACKAGE_DIR = DETECTOR_DIR / "trillboards_detector"
39
+
40
+ sys.path.insert(0, str(DETECTOR_DIR))
41
+
42
+
43
+ def _load_stamper(root: Path):
44
+ """Import `stamp_version` bound to `root` rather than to the real tree.
45
+
46
+ Loaded as a FRESH module object per root so the mutation test below cannot
47
+ be answered by a cached module still pointing at the committed sources —
48
+ which is the same class of staleness this whole file exists to catch, and it
49
+ would make the non-vacuity check vacuous.
50
+ """
51
+ spec = importlib.util.spec_from_file_location(f"stamp_version_{abs(hash(str(root)))}", root / "stamp_version.py")
52
+ module = importlib.util.module_from_spec(spec)
53
+ assert spec.loader is not None
54
+ spec.loader.exec_module(module)
55
+ return module
56
+
57
+
58
+ class TestVersionStamp(unittest.TestCase):
59
+ def test_the_committed_stamp_matches_the_committed_bytes(self) -> None:
60
+ """The check CI runs. Fails loudly with the one command that fixes it."""
61
+ result = subprocess.run(
62
+ [sys.executable, str(STAMPER), "--check"],
63
+ capture_output=True,
64
+ text=True,
65
+ )
66
+ self.assertEqual(
67
+ result.returncode,
68
+ 0,
69
+ "the detector's version stamp is stale:\n"
70
+ f"{result.stdout}{result.stderr}",
71
+ )
72
+
73
+ def test_pyproject_and_the_module_carry_the_same_version(self) -> None:
74
+ """Two writers, and they must never disagree.
75
+
76
+ `pyproject.toml` carries the literal because uv reads the version FROM
77
+ THIS FILE to decide whether its cached wheel still describes the
78
+ directory — a hatch dynamic version would be read only by BUILDING,
79
+ which is the step a cache hit skips, so it could never invalidate its own
80
+ cache. `_version.py` carries it because the running service has to be
81
+ able to say what it is without importing package metadata. Both are
82
+ written by one generator; this asserts the generator is the only writer.
83
+ """
84
+ stamper = _load_stamper(DETECTOR_DIR)
85
+ self.assertEqual(stamper.read_pyproject_version(), stamper.read_stamped_version())
86
+
87
+ def test_the_package_exports_the_stamped_version(self) -> None:
88
+ import trillboards_detector
89
+ from trillboards_detector import _version
90
+
91
+ self.assertEqual(trillboards_detector.__version__, _version.VERSION)
92
+ self.assertEqual(_version.VERSION, f"{_version.BASE_VERSION}+src.{_version.SOURCE_FINGERPRINT}")
93
+
94
+ def test_the_version_carries_a_source_fingerprint(self) -> None:
95
+ """`0.2.0` and `0.2.0` were two different builds. `+src.<hex>` cannot be."""
96
+ from trillboards_detector import _version
97
+
98
+ self.assertRegex(_version.VERSION, r"^\d+\.\d+\.\d+\+src\.[0-9a-f]{12}$")
99
+
100
+ def test_editing_a_shipped_module_moves_the_fingerprint(self) -> None:
101
+ """NON-VACUITY. A check that cannot go red is not a check.
102
+
103
+ Copies the package to a temp tree, appends one line to the file whose
104
+ growth started all this, and asserts the derived version changes. This is
105
+ the property the whole design rests on, asserted rather than assumed.
106
+ """
107
+ with tempfile.TemporaryDirectory() as tmp:
108
+ root = Path(tmp) / "detector"
109
+ shutil.copytree(
110
+ DETECTOR_DIR,
111
+ root,
112
+ ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
113
+ )
114
+ before = _load_stamper(root).expected_version()[2]
115
+
116
+ labels = root / "trillboards_detector" / "labels.py"
117
+ labels.write_text(labels.read_text() + "\n# one more model id\n", encoding="utf-8")
118
+ after = _load_stamper(root).expected_version()[2]
119
+
120
+ self.assertNotEqual(before, after, "a shipped-module edit left the version unchanged")
121
+
122
+ def test_the_fingerprint_ignores_line_endings(self) -> None:
123
+ """A Windows checkout must not stamp differently from the Linux one.
124
+
125
+ `core.autocrlf=true` rewrites every checked-out `.py`. Without
126
+ normalisation the stamp would flap per PLATFORM rather than per change,
127
+ and a CI check on one OS would fail a tree that is correct on the other.
128
+ """
129
+ with tempfile.TemporaryDirectory() as tmp:
130
+ root = Path(tmp) / "detector"
131
+ shutil.copytree(
132
+ DETECTOR_DIR,
133
+ root,
134
+ ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
135
+ )
136
+ before = _load_stamper(root).expected_version()[2]
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"))
139
+ after = _load_stamper(root).expected_version()[2]
140
+ self.assertEqual(before, after, "CRLF alone moved the fingerprint")
141
+
142
+ def test_the_stamper_is_idempotent(self) -> None:
143
+ """`--write` on a stamped tree changes nothing, so it is safe in a loop."""
144
+ with tempfile.TemporaryDirectory() as tmp:
145
+ root = Path(tmp) / "detector"
146
+ shutil.copytree(
147
+ DETECTOR_DIR,
148
+ root,
149
+ ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
150
+ )
151
+ stamper = _load_stamper(root)
152
+ first_version, first_changed = stamper.write_stamp()
153
+ second_version, second_changed = stamper.write_stamp()
154
+ self.assertFalse(first_changed, "the committed tree was not already stamped")
155
+ self.assertFalse(second_changed)
156
+ self.assertEqual(first_version, second_version)
157
+
158
+ def test_the_version_line_is_excluded_from_its_own_input(self) -> None:
159
+ """Otherwise the stamp has no fixed point and `--write` never converges."""
160
+ with tempfile.TemporaryDirectory() as tmp:
161
+ root = Path(tmp) / "detector"
162
+ shutil.copytree(
163
+ DETECTOR_DIR,
164
+ root,
165
+ ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
166
+ )
167
+ stamper = _load_stamper(root)
168
+ before = stamper.compute_fingerprint()
169
+ pyproject = root / "pyproject.toml"
170
+ pyproject.write_text(
171
+ re.sub(
172
+ r'^version\s*=\s*".*"\s*$',
173
+ 'version = "9.9.9+src.deadbeefcafe"',
174
+ pyproject.read_text(encoding="utf-8"),
175
+ count=1,
176
+ flags=re.MULTILINE,
177
+ ),
178
+ encoding="utf-8",
179
+ )
180
+ self.assertEqual(before, stamper.compute_fingerprint())
181
+
182
+ def test_a_dependency_pin_change_moves_the_fingerprint(self) -> None:
183
+ """The pinned runtime is part of what this tool IS.
184
+
185
+ `docs/runbooks/cctv-detector-tier.md` §2 picks this detector on a
186
+ measured ~63 ms and says ORT's kernel fusion, thread heuristics and SIMD
187
+ dispatch move between minor releases — so a box on a different
188
+ onnxruntime is a box the measurement does not describe, and an installer
189
+ must not mistake it for one it already has.
190
+ """
191
+ with tempfile.TemporaryDirectory() as tmp:
192
+ root = Path(tmp) / "detector"
193
+ shutil.copytree(
194
+ DETECTOR_DIR,
195
+ root,
196
+ ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
197
+ )
198
+ before = _load_stamper(root).expected_version()[2]
199
+ pyproject = root / "pyproject.toml"
200
+ pyproject.write_text(
201
+ pyproject.read_text(encoding="utf-8").replace(
202
+ '"onnxruntime==1.29.0"', '"onnxruntime==1.30.0"'
203
+ ),
204
+ encoding="utf-8",
205
+ )
206
+ self.assertNotEqual(before, _load_stamper(root).expected_version()[2])
207
+
208
+
209
+ class TestInstallersCannotAcceptAStaleInstall(unittest.TestCase):
210
+ """`--force` is not enough, and all three installers used exactly `--force`.
211
+
212
+ Measured on uv 0.9.2 (2026-09-04), local-directory source, `labels.py`-only
213
+ change with `pyproject.toml` untouched:
214
+
215
+ uv tool install <dir> STALE ("Audited 1 package")
216
+ uv tool install --force <dir> STALE (cached wheel)
217
+ uv tool install --force --refresh-package <name> fresh
218
+
219
+ So the flag is load-bearing, and so is asking the installed binary what it
220
+ is afterwards. Asserted on the shipped text of all three provisioners
221
+ because there is no runner on which all three can be executed.
222
+ """
223
+
224
+ INSTALLERS = (
225
+ DETECTOR_DIR.parent / "macos" / "scripts" / "setup-detector.sh",
226
+ DETECTOR_DIR.parent / "systemd" / "setup-detector.sh",
227
+ DETECTOR_DIR.parent / "windows" / "setup-detector.ps1",
228
+ )
229
+
230
+ @staticmethod
231
+ def _install_invocations(text: str) -> "list[str]":
232
+ """Lines that actually RUN the install, not lines that mention it.
233
+
234
+ Keyed on `--managed-python`, which every real invocation carries and no
235
+ prose line does. A looser rule (any line containing "tool install")
236
+ matches a log message and a PowerShell block comment, and a rule that
237
+ matched nothing would pass vacuously — hence the count assertion at the
238
+ call site.
239
+ """
240
+ return [line for line in text.splitlines() if "tool install" in line and "--managed-python" in line]
241
+
242
+ def test_every_installer_busts_the_package_cache(self) -> None:
243
+ for path in self.INSTALLERS:
244
+ with self.subTest(installer=path.name + f" ({path.parent.name})"):
245
+ text = path.read_text(encoding="utf-8")
246
+ install_lines = self._install_invocations(text)
247
+ # TWO: the ordinary install, and the uninstall-and-retry repair
248
+ # rung. A file that grew a third would be installing somewhere
249
+ # this test never looked.
250
+ self.assertEqual(
251
+ len(install_lines),
252
+ 2,
253
+ f"{path} runs {len(install_lines)} `uv tool install` invocations, expected 2",
254
+ )
255
+ for line in install_lines:
256
+ self.assertIn(
257
+ "--refresh-package trillboards-detector",
258
+ line,
259
+ f"{path} installs without busting uv's cached wheel: {line.strip()}",
260
+ )
261
+
262
+ def test_every_installer_verifies_what_it_installed(self) -> None:
263
+ for path in self.INSTALLERS:
264
+ with self.subTest(installer=path.name + f" ({path.parent.name})"):
265
+ text = path.read_text(encoding="utf-8")
266
+ self.assertIn("--version", text, f"{path} never asks the binary what it is")
267
+ self.assertIn(
268
+ "is STALE after a full reinstall",
269
+ text,
270
+ f"{path} has no loud outcome for an install that did not take",
271
+ )
272
+
273
+ def test_no_installer_uses_the_blunt_refresh(self) -> None:
274
+ """`--refresh` re-downloads the pinned ~250 MB onnxruntime wheel every run."""
275
+ for path in self.INSTALLERS:
276
+ with self.subTest(installer=path.name + f" ({path.parent.name})"):
277
+ for line in self._install_invocations(path.read_text(encoding="utf-8")):
278
+ self.assertNotRegex(line, r"--refresh(?!-package)\b")
279
+
280
+
281
+ if __name__ == "__main__":
282
+ unittest.main()
@@ -5,4 +5,9 @@ Its own uv tool, its own venv, its own OS-supervised loopback service on
5
5
  `docs/runbooks/cctv-detector-tier.md` for what it runs and why that model.
6
6
  """
7
7
 
8
- __version__ = "0.2.0"
8
+ from ._version import VERSION as __version__ # noqa: F401 (re-exported)
9
+
10
+ # THE VERSION IS DERIVED FROM THE BYTES, NOT TYPED. Its local segment is a
11
+ # fingerprint of everything this package ships, because a version that does not
12
+ # move lets `uv tool install` keep an obsolete class VOCABULARY on a fielded box
13
+ # while `/health` still says `ready`. `stamp_version.py` carries the measurement.
@@ -0,0 +1,17 @@
1
+ """GENERATED FILE — do not hand-edit.
2
+
3
+ `VERSION`'s local segment is a fingerprint of the bytes this package ships, so
4
+ that an installer can never mistake new code for code it already has. See
5
+ `stamp_version.py` for the measurement that made this necessary.
6
+
7
+ Re-stamp after editing anything under `trillboards_detector/`:
8
+
9
+ python3 trillboards-edge-sdk/packages/edge-sdk/deploy/detector/stamp_version.py --write
10
+
11
+ `BASE_VERSION` is the hand-editable release line and is the ONLY field here a
12
+ human sets.
13
+ """
14
+
15
+ BASE_VERSION = "0.2.0"
16
+ SOURCE_FINGERPRINT = "2c3db7881945"
17
+ VERSION = "0.2.0+src.2c3db7881945"
@@ -17,6 +17,7 @@ import sys
17
17
  import threading
18
18
  from pathlib import Path
19
19
 
20
+ from ._version import VERSION
20
21
  from .loader import DEFAULT_POLL_INTERVAL_S, DetectorLoader
21
22
  from .modelpath import DEFAULT_MODEL_NAME, resolve_model_path, resolve_ram_budget_mb
22
23
  from .server import DEFAULT_HOST, DEFAULT_PORT, serve
@@ -60,6 +61,14 @@ def _configure_logging(log_file: "str | None", verbose: bool) -> None:
60
61
 
61
62
  def build_parser() -> argparse.ArgumentParser:
62
63
  p = argparse.ArgumentParser(prog="trillboards-detector")
64
+ # `--version` IS THE INSTALLER'S ONLY WAY TO ASK WHAT IT ACTUALLY INSTALLED,
65
+ # and it exists because `uv tool install` reports success on work it did not
66
+ # do. Measured on uv 0.9.2: changing only `labels.py` and re-running the
67
+ # install — even with `--force` — leaves the OLD wheel in place and prints
68
+ # "Installed 1 executable". The three setup-detector scripts therefore run
69
+ # this and compare it against the staged source's version; see
70
+ # `stamp_version.py` for why the version can be trusted to have moved.
71
+ p.add_argument("--version", action="version", version=f"trillboards-detector {VERSION}")
63
72
  sub = p.add_subparsers(dest="command")
64
73
  s = sub.add_parser("serve", help="run the loopback detector service")
65
74
  s.add_argument("--host", default=DEFAULT_HOST)
@@ -132,7 +141,8 @@ def main(argv: "list[str] | None" = None) -> int:
132
141
  )
133
142
 
134
143
  LOG.info(
135
- "[detector] starting on %s:%s, pinned model %s at %s, ceiling %s",
144
+ "[detector] %s starting on %s:%s, pinned model %s at %s, ceiling %s",
145
+ VERSION,
136
146
  args.host,
137
147
  args.port,
138
148
  args.model_name,
@@ -32,6 +32,7 @@ import sys as _sys
32
32
  import threading
33
33
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
34
 
35
+ from ._version import VERSION
35
36
  from .errors import DetectorNotReady, UnknownClasses
36
37
 
37
38
  #: Loopback only. litert-lm owns 9379; this is a SECOND service, not a route on
@@ -264,6 +265,15 @@ class DetectorHandler(BaseHTTPRequestHandler):
264
265
  info = loader.info()
265
266
  ready = info.get("state") == "ready"
266
267
  info["status"] = "ok" if ready else info.get("state", "unavailable")
268
+ # WHICH BUILD IS ANSWERING. A box that kept an obsolete build keeps an
269
+ # obsolete class VOCABULARY with it, and every other field here is
270
+ # identical on both — `state: ready`, the pinned model, the model path —
271
+ # so before this there was nothing on the wire that could tell a current
272
+ # box from a stale one. The 2026-09-02 rig lost a run to exactly that:
273
+ # a granted specialist downloaded, `/health` said `ready`, and `/detect`
274
+ # answered 400 for its classes. Reported on the LOADER's payload rather
275
+ # than only in a log because the reader that needs it is a script.
276
+ info["version"] = VERSION
267
277
  self._json(200 if ready else 503, info)
268
278
 
269
279
  # ── /detect ──────────────────────────────────────────────────────────────
@@ -121,9 +121,32 @@ fi
121
121
  # drifted one. Gating it behind a health check is how a repaired-and-healthy box
122
122
  # keeps a broken venv while reporting fine (setup-litertlm.sh's shim carries the
123
123
  # same note, from the same mistake).
124
+ #
125
+ # THE INSTALL MUST NOT SILENTLY NO-OP, AND `--force` IS NOT ENOUGH.
126
+ #
127
+ # Measured on uv 0.9.2 (2026-09-04) against a local-directory source, changing
128
+ # ONLY a module under `trillboards_detector/` while `pyproject.toml` stays
129
+ # untouched:
130
+ #
131
+ # uv tool install <dir> "Audited 1 package" STALE
132
+ # uv tool install --force <dir> venv rebuilt from the
133
+ # CACHED wheel STALE
134
+ # uv tool install --force --refresh-package <name> fresh
135
+ #
136
+ # uv's build cache for a local directory is NOT keyed on the package sources, so
137
+ # `--force` rebuilds the venv and reinstalls the same stale wheel into it. Both
138
+ # print "Installed 1 executable". That is how a fielded box kept an obsolete
139
+ # class VOCABULARY while every light stayed green: a granted specialist
140
+ # downloaded, sat on disk, and `POST /detect` answered 400 "requested classes
141
+ # not in this box's vocabulary" with `/health` reporting `ready`
142
+ # (`.claude/lessons/2026-09-02-a-granted-model-is-not-a-served-model.md`).
143
+ #
144
+ # `--refresh-package trillboards-detector`, NOT `--refresh`: the latter also
145
+ # re-downloads the pinned ~250 MB onnxruntime wheel on a shop PC every time, for
146
+ # nothing — an exact pin cannot resolve to different bytes.
124
147
  SOURCE="$(find_detector_source)" || { log "ERROR: detector package source not found next to this script"; exit 0; }
125
148
  log "installing/repairing trillboards-detector from $SOURCE with uv-managed Python $DETECTOR_PYTHON"
126
- if ! "$UV" tool install --python "$DETECTOR_PYTHON" --managed-python --force "$SOURCE" >>"$LOG" 2>&1; then
149
+ if ! "$UV" tool install --python "$DETECTOR_PYTHON" --managed-python --force --refresh-package trillboards-detector "$SOURCE" >>"$LOG" 2>&1; then
127
150
  log "ERROR: trillboards-detector install failed; the cascade stays off (the VLM still runs every frame)"
128
151
  exit 0
129
152
  fi
@@ -135,6 +158,48 @@ if [ ! -x "$DETECTOR" ]; then
135
158
  fi
136
159
  log "trillboards-detector installed at $DETECTOR"
137
160
 
161
+ # AND THEN ASK WHAT IS ACTUALLY INSTALLED, because every step above reports
162
+ # success on work it did not do. The version's `+src.<hex>` local segment is a
163
+ # fingerprint of the bytes the staged package ships
164
+ # (`deploy/detector/stamp_version.py`), so this comparison is the only thing in
165
+ # the chain that can tell a real install from a convincing no-op. It reads the
166
+ # STAGED pyproject rather than a constant: a constant here would be a fourth
167
+ # place the same version is written down.
168
+ staged_detector_version() {
169
+ sed -n 's/^version[[:space:]]*=[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$SOURCE/pyproject.toml" 2>/dev/null | head -1
170
+ }
171
+ installed_detector_version() {
172
+ "$DETECTOR" --version 2>/dev/null | awk '{print $NF}'
173
+ }
174
+
175
+ WANT_VERSION="$(staged_detector_version)"
176
+ HAVE_VERSION="$(installed_detector_version)"
177
+ if [ -z "$WANT_VERSION" ]; then
178
+ log "WARN: could not read the staged detector version from $SOURCE/pyproject.toml; the staleness check is skipped"
179
+ elif [ "$HAVE_VERSION" != "$WANT_VERSION" ]; then
180
+ # ONE REPAIR RUNG, and it is a real uninstall rather than another flag: a
181
+ # mismatch here means uv served something other than the staged directory, so
182
+ # asking it again the same way is not a repair.
183
+ log "WARN: the installed detector reports '$HAVE_VERSION' but the staged package is '$WANT_VERSION' - reinstalling from scratch"
184
+ "$UV" tool uninstall trillboards-detector >>"$LOG" 2>&1 || true
185
+ "$UV" tool install --python "$DETECTOR_PYTHON" --managed-python --force --refresh-package trillboards-detector "$SOURCE" >>"$LOG" 2>&1 || true
186
+ DETECTOR="$LOCAL_BIN/trillboards-detector"
187
+ [ -x "$DETECTOR" ] || DETECTOR="$(command -v trillboards-detector || echo "$DETECTOR")"
188
+ HAVE_VERSION="$(installed_detector_version)"
189
+ if [ "$HAVE_VERSION" = "$WANT_VERSION" ]; then
190
+ log "trillboards-detector repaired to $HAVE_VERSION"
191
+ else
192
+ # NOT FATAL - rule 1 of this provisioner is that it may never block camera
193
+ # pairing - but never silent either. A box left here serves the OLD class
194
+ # VOCABULARY, so every granted specialist past it answers 400 and the fields
195
+ # it owns are simply ABSENT from the observation, which reads as a quiet
196
+ # store rather than as a broken instrument.
197
+ log "ERROR: trillboards-detector is STALE after a full reinstall - it reports '$HAVE_VERSION', the staged package is '$WANT_VERSION'. This box answers 400 for any class only the newer vocabulary knows. /health reports the version it is actually running."
198
+ fi
199
+ else
200
+ log "trillboards-detector $HAVE_VERSION matches the staged package"
201
+ fi
202
+
138
203
  # 2b. THE RAM CEILING, derived from THIS Mac.
139
204
  #
140
205
  # Without it the service holds its pinned model and refuses to make any second
@@ -69,8 +69,31 @@ SOURCE="$(find_detector_source)" || { log "ERROR: detector package source not fo
69
69
  # ITS OWN TOOL NAME, so uv gives it its own venv. Never a package added into
70
70
  # litert-lm's: `uv tool install --force` rebuilds that venv from scratch on any
71
71
  # version drift or health failure and would take the addition with it.
72
+ #
73
+ # THE INSTALL MUST NOT SILENTLY NO-OP, AND `--force` IS NOT ENOUGH.
74
+ #
75
+ # Measured on uv 0.9.2 (2026-09-04) against a local-directory source, changing
76
+ # ONLY a module under `trillboards_detector/` while `pyproject.toml` stays
77
+ # untouched:
78
+ #
79
+ # uv tool install <dir> "Audited 1 package" STALE
80
+ # uv tool install --force <dir> venv rebuilt from the
81
+ # CACHED wheel STALE
82
+ # uv tool install --force --refresh-package <name> fresh
83
+ #
84
+ # uv's build cache for a local directory is NOT keyed on the package sources, so
85
+ # `--force` rebuilds the venv and reinstalls the same stale wheel into it. Both
86
+ # print "Installed 1 executable". That is how a fielded box kept an obsolete
87
+ # class VOCABULARY while every light stayed green: a granted specialist
88
+ # downloaded, sat on disk, and `POST /detect` answered 400 "requested classes
89
+ # not in this box's vocabulary" with `/health` reporting `ready`
90
+ # (`.claude/lessons/2026-09-02-a-granted-model-is-not-a-served-model.md`).
91
+ #
92
+ # `--refresh-package trillboards-detector`, NOT `--refresh`: the latter also
93
+ # re-downloads the pinned ~250 MB onnxruntime wheel on a shop PC every time, for
94
+ # nothing — an exact pin cannot resolve to different bytes.
72
95
  log "installing/repairing trillboards-detector from $SOURCE with uv-managed Python $DETECTOR_PYTHON"
73
- if ! "$UV" tool install --python "$DETECTOR_PYTHON" --managed-python --force "$SOURCE" >>"$LOG" 2>&1; then
96
+ if ! "$UV" tool install --python "$DETECTOR_PYTHON" --managed-python --force --refresh-package trillboards-detector "$SOURCE" >>"$LOG" 2>&1; then
74
97
  log "ERROR: trillboards-detector install failed; the cascade stays off"
75
98
  exit 0
76
99
  fi
@@ -79,6 +102,48 @@ DETECTOR="$LOCAL_BIN/trillboards-detector"
79
102
  [ -x "$DETECTOR" ] || { log "ERROR: trillboards-detector binary missing after install"; exit 0; }
80
103
  log "trillboards-detector installed at $DETECTOR"
81
104
 
105
+ # AND THEN ASK WHAT IS ACTUALLY INSTALLED, because every step above reports
106
+ # success on work it did not do. The version's `+src.<hex>` local segment is a
107
+ # fingerprint of the bytes the staged package ships
108
+ # (`deploy/detector/stamp_version.py`), so this comparison is the only thing in
109
+ # the chain that can tell a real install from a convincing no-op. It reads the
110
+ # STAGED pyproject rather than a constant: a constant here would be a fourth
111
+ # place the same version is written down.
112
+ staged_detector_version() {
113
+ sed -n 's/^version[[:space:]]*=[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$SOURCE/pyproject.toml" 2>/dev/null | head -1
114
+ }
115
+ installed_detector_version() {
116
+ "$DETECTOR" --version 2>/dev/null | awk '{print $NF}'
117
+ }
118
+
119
+ WANT_VERSION="$(staged_detector_version)"
120
+ HAVE_VERSION="$(installed_detector_version)"
121
+ if [ -z "$WANT_VERSION" ]; then
122
+ log "WARN: could not read the staged detector version from $SOURCE/pyproject.toml; the staleness check is skipped"
123
+ elif [ "$HAVE_VERSION" != "$WANT_VERSION" ]; then
124
+ # ONE REPAIR RUNG, and it is a real uninstall rather than another flag: a
125
+ # mismatch here means uv served something other than the staged directory, so
126
+ # asking it again the same way is not a repair.
127
+ log "WARN: the installed detector reports '$HAVE_VERSION' but the staged package is '$WANT_VERSION' - reinstalling from scratch"
128
+ "$UV" tool uninstall trillboards-detector >>"$LOG" 2>&1 || true
129
+ "$UV" tool install --python "$DETECTOR_PYTHON" --managed-python --force --refresh-package trillboards-detector "$SOURCE" >>"$LOG" 2>&1 || true
130
+ DETECTOR="$LOCAL_BIN/trillboards-detector"
131
+ [ -x "$DETECTOR" ] || DETECTOR="$(command -v trillboards-detector || echo "$DETECTOR")"
132
+ HAVE_VERSION="$(installed_detector_version)"
133
+ if [ "$HAVE_VERSION" = "$WANT_VERSION" ]; then
134
+ log "trillboards-detector repaired to $HAVE_VERSION"
135
+ else
136
+ # NOT FATAL - rule 1 of this provisioner is that it may never block camera
137
+ # pairing - but never silent either. A box left here serves the OLD class
138
+ # VOCABULARY, so every granted specialist past it answers 400 and the fields
139
+ # it owns are simply ABSENT from the observation, which reads as a quiet
140
+ # store rather than as a broken instrument.
141
+ log "ERROR: trillboards-detector is STALE after a full reinstall - it reports '$HAVE_VERSION', the staged package is '$WANT_VERSION'. This box answers 400 for any class only the newer vocabulary knows. /health reports the version it is actually running."
142
+ fi
143
+ else
144
+ log "trillboards-detector $HAVE_VERSION matches the staged package"
145
+ fi
146
+
82
147
  # ── THE RAM CEILING ─────────────────────────────────────────────────────────
83
148
  #
84
149
  # Without this the detector holds its pinned model and REFUSES to make any
@@ -122,8 +122,29 @@ if (-not $source) {
122
122
  Log "ERROR: detector package source not found near $Here"
123
123
  exit 0
124
124
  }
125
+ # THE INSTALL MUST NOT SILENTLY NO-OP, AND --force IS NOT ENOUGH.
126
+ #
127
+ # Measured on uv 0.9.2 (2026-09-04) against a local-directory source, changing
128
+ # ONLY a module under trillboards_detector\ while pyproject.toml stays untouched:
129
+ #
130
+ # uv tool install <dir> "Audited 1 package" STALE
131
+ # uv tool install --force <dir> venv rebuilt from the
132
+ # CACHED wheel STALE
133
+ # uv tool install --force --refresh-package <name> fresh
134
+ #
135
+ # uv's build cache for a local directory is NOT keyed on the package sources, so
136
+ # --force rebuilds the venv and reinstalls the same stale wheel into it. Both
137
+ # print "Installed 1 executable". That is how a fielded box kept an obsolete
138
+ # class VOCABULARY while every light stayed green: a granted specialist
139
+ # downloaded, sat on disk, and POST /detect answered 400 "requested classes not
140
+ # in this box's vocabulary" with /health reporting `ready`
141
+ # (.claude/lessons/2026-09-02-a-granted-model-is-not-a-served-model.md).
142
+ #
143
+ # --refresh-package trillboards-detector, NOT --refresh: the latter also
144
+ # re-downloads the pinned ~250 MB onnxruntime wheel on a shop PC every time, for
145
+ # nothing - an exact pin cannot resolve to different bytes.
125
146
  Log "installing/repairing trillboards-detector from $source with uv-managed Python $DetectorPython"
126
- & $uvCmd.Source tool install --python $DetectorPython --managed-python --force "$source" *>> $LogPath
147
+ & $uvCmd.Source tool install --python $DetectorPython --managed-python --force --refresh-package trillboards-detector "$source" *>> $LogPath
127
148
  if ($LASTEXITCODE -ne 0) {
128
149
  Log "ERROR: trillboards-detector install failed; the cascade stays off (the VLM still runs every frame)"
129
150
  exit 0
@@ -140,6 +161,56 @@ if (-not (Test-Path -LiteralPath $detectorPath)) {
140
161
  }
141
162
  Log "trillboards-detector installed at $detectorPath"
142
163
 
164
+ # AND THEN ASK WHAT IS ACTUALLY INSTALLED, because every step above reports
165
+ # success on work it did not do. The version's +src.<hex> local segment is a
166
+ # fingerprint of the bytes the staged package ships (deploy/detector/stamp_version.py),
167
+ # so this comparison is the only thing in the chain that can tell a real install
168
+ # from a convincing no-op. It reads the STAGED pyproject rather than a constant:
169
+ # a constant here would be a fourth place the same version is written down.
170
+ function Get-StagedDetectorVersion {
171
+ param([string] $SourceDir)
172
+ $pyproject = Join-Path $SourceDir 'pyproject.toml'
173
+ if (-not (Test-Path -LiteralPath $pyproject)) { return $null }
174
+ foreach ($line in (Get-Content -LiteralPath $pyproject)) {
175
+ if ($line -match '^\s*version\s*=\s*"([^"]+)"\s*$') { return $Matches[1] }
176
+ }
177
+ return $null
178
+ }
179
+ function Get-InstalledDetectorVersion {
180
+ param([string] $Exe)
181
+ if (-not (Test-Path -LiteralPath $Exe)) { return $null }
182
+ # `--version` prints "trillboards-detector <version>"; take the last token.
183
+ $out = & $Exe --version 2>$null
184
+ if ($LASTEXITCODE -ne 0 -or -not $out) { return $null }
185
+ return ([string]$out).Trim().Split(' ')[-1]
186
+ }
187
+
188
+ $wantVersion = Get-StagedDetectorVersion -SourceDir $source
189
+ $haveVersion = Get-InstalledDetectorVersion -Exe $detectorPath
190
+ if (-not $wantVersion) {
191
+ Log "WARN: could not read the staged detector version from $source\pyproject.toml; the staleness check is skipped"
192
+ } elseif ($haveVersion -ne $wantVersion) {
193
+ # ONE REPAIR RUNG, and it is a real uninstall rather than another flag: a
194
+ # mismatch here means uv served something other than the staged directory, so
195
+ # asking it again the same way is not a repair.
196
+ Log "WARN: the installed detector reports '$haveVersion' but the staged package is '$wantVersion' - reinstalling from scratch"
197
+ & $uvCmd.Source tool uninstall trillboards-detector *>> $LogPath
198
+ & $uvCmd.Source tool install --python $DetectorPython --managed-python --force --refresh-package trillboards-detector "$source" *>> $LogPath
199
+ $haveVersion = Get-InstalledDetectorVersion -Exe $detectorPath
200
+ if ($haveVersion -eq $wantVersion) {
201
+ Log "trillboards-detector repaired to $haveVersion"
202
+ } else {
203
+ # NOT FATAL - rule 1 of this provisioner is that it may never block camera
204
+ # pairing - but never silent either. A box left here serves the OLD class
205
+ # VOCABULARY, so every granted specialist past it answers 400 and the fields
206
+ # it owns are simply ABSENT from the observation, which reads as a quiet
207
+ # store rather than as a broken instrument.
208
+ Log "ERROR: trillboards-detector is STALE after a full reinstall - it reports '$haveVersion', the staged package is '$wantVersion'. This box answers 400 for any class only the newer vocabulary knows. /health reports the version it is actually running."
209
+ }
210
+ } else {
211
+ Log "trillboards-detector $haveVersion matches the staged package"
212
+ }
213
+
143
214
  # --- 2b. The RAM ceiling, derived from THIS machine --------------------------
144
215
  #
145
216
  # Without it the service holds its pinned model and refuses to make any second
package/dist/EdgeAgent.js CHANGED
@@ -44,7 +44,7 @@ const edge_ads_1 = require("@trillboards/edge-ads");
44
44
  const CommandRouter_1 = require("./CommandRouter");
45
45
  const config_1 = require("./config");
46
46
  const dataDir_1 = require("./dataDir");
47
- const SDK_VERSION = '0.2.131';
47
+ const SDK_VERSION = '0.2.132';
48
48
  class EdgeAgent extends events_1.EventEmitter {
49
49
  constructor(config, deps) {
50
50
  super();
@@ -31,7 +31,7 @@ export interface DiagnosticReport {
31
31
  skipped: number;
32
32
  };
33
33
  }
34
- declare const SDK_VERSION = "0.2.131";
34
+ declare const SDK_VERSION = "0.2.132";
35
35
  declare function checkNodeVersion(): CheckResult;
36
36
  declare function checkPlatform(): CheckResult;
37
37
  declare function checkMemory(): CheckResult;
package/dist/diagnose.js CHANGED
@@ -63,7 +63,7 @@ const localNetworkAccess_1 = require("./localNetworkAccess");
63
63
  // ---------------------------------------------------------------------------
64
64
  // SDK version — mirrors package.json; kept in sync by CI
65
65
  // ---------------------------------------------------------------------------
66
- const SDK_VERSION = '0.2.131';
66
+ const SDK_VERSION = '0.2.132';
67
67
  exports._SDK_VERSION = SDK_VERSION;
68
68
  // ---------------------------------------------------------------------------
69
69
  // ANSI helpers (no external deps)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trillboards/edge-sdk",
3
- "version": "0.2.131",
3
+ "version": "0.2.132",
4
4
  "description": "Composable cross-platform Edge AI SDK for audience sensing on DOOH/CTV devices",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",