@trillboards/edge-sdk 0.2.131 → 0.2.134

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 ──────────────────────────────────────────────────────────────