@trillboards/edge-sdk 0.2.104 → 0.2.107
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.
- package/deploy/detector/README.md +33 -0
- package/deploy/detector/bench/litert-twin-parity.json +27 -1
- package/deploy/detector/bench/venue_adjudication.py +790 -0
- package/deploy/detector/bench/verify_litert_twin_parity.py +34 -10
- package/deploy/detector/ram-budget.js +225 -0
- package/deploy/detector/ram-budget.json +76 -0
- package/deploy/detector/tests/test_ram_budget.py +124 -0
- package/deploy/detector/trillboards_detector/labels.py +23 -1
- package/deploy/macos/com.trillboards.detector.plist +14 -3
- package/deploy/macos/scripts/setup-detector.sh +34 -0
- package/deploy/systemd/install.sh +14 -1
- package/deploy/systemd/setup-detector.sh +71 -0
- package/deploy/windows/install-update.ps1 +60 -0
- package/deploy/windows/register-detector-task.ps1 +45 -1
- package/deploy/windows/setup-detector.ps1 +53 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -143,22 +143,44 @@ def main() -> int:
|
|
|
143
143
|
out_details = interp.get_output_details()
|
|
144
144
|
shape = tuple(int(v) for v in in_detail["shape"])
|
|
145
145
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
# READ THE INPUT LAYOUT OFF THE GRAPH, the same rule this file already applies
|
|
147
|
+
# to outputs. It used to assert NCHW and refuse anything else, on the reasoning
|
|
148
|
+
# that "the LiteRT export of an ultralytics detect model is NOT NHWC". That is
|
|
149
|
+
# true of the toolchain the 21 committed twins were exported with and it is NOT
|
|
150
|
+
# a property of LiteRT: `onnx2tf` transposes to channels-last by design, and an
|
|
151
|
+
# export made with ultralytics 8.3.145 declares [1,640,640,3]. The assertion
|
|
152
|
+
# fired on a real, correct twin (gh_fall_tonlong, 2026-08-26).
|
|
153
|
+
#
|
|
154
|
+
# The warning it carried is still right and is why this branches on the DECLARED
|
|
155
|
+
# shape rather than on a flag: transposing "because LiteRT is channels-last"
|
|
156
|
+
# feeds a rotated image to a model that wanted NCHW, and every residual then
|
|
157
|
+
# looks like export noise. Both runtimes must receive the SAME IMAGE, each in
|
|
158
|
+
# the layout its own graph declares — never the same buffer.
|
|
159
|
+
if len(shape) != 4:
|
|
160
|
+
raise ParityError(f"expected a 4-D [1,3,H,W] or [1,H,W,3] input, got {shape}")
|
|
161
|
+
if shape[1] == 3 and shape[3] != 3:
|
|
162
|
+
litert_layout, imgsz = "nchw", shape[2]
|
|
163
|
+
elif shape[3] == 3 and shape[1] != 3:
|
|
164
|
+
litert_layout, imgsz = "nhwc", shape[1]
|
|
165
|
+
elif shape[1] == 3 and shape[3] == 3:
|
|
166
|
+
# [1,3,H,3] is ambiguous and no real detector input is 3 px wide.
|
|
167
|
+
raise ParityError(f"input {shape} is ambiguous between NCHW and NHWC")
|
|
168
|
+
else:
|
|
169
|
+
raise ParityError(f"no channel axis of size 3 in {shape}")
|
|
152
170
|
|
|
153
171
|
if args.image:
|
|
154
172
|
from PIL import Image
|
|
155
173
|
|
|
156
174
|
pixels = np.asarray(Image.open(args.image).convert("RGB").resize((imgsz, imgsz)))
|
|
157
|
-
|
|
175
|
+
nchw = (pixels.astype(np.float32) / 255.0).transpose(2, 0, 1)[None]
|
|
158
176
|
else:
|
|
159
|
-
|
|
177
|
+
nchw = np.random.default_rng(args.seed).random((1, 3, imgsz, imgsz)).astype(np.float32)
|
|
178
|
+
|
|
179
|
+
# ONNX always gets NCHW; LiteRT gets whatever it declared, from the same pixels.
|
|
180
|
+
x = nchw
|
|
181
|
+
x_litert = nchw if litert_layout == "nchw" else nchw.transpose(0, 2, 3, 1).copy()
|
|
160
182
|
|
|
161
|
-
interp.set_tensor(in_detail["index"],
|
|
183
|
+
interp.set_tensor(in_detail["index"], x_litert)
|
|
162
184
|
interp.invoke()
|
|
163
185
|
litert_out = interp.get_tensor(out_details[0]["index"])
|
|
164
186
|
|
|
@@ -173,11 +195,13 @@ def main() -> int:
|
|
|
173
195
|
result["reason"] = why
|
|
174
196
|
result["onnx"] = Path(args.onnx).name
|
|
175
197
|
result["litert"] = Path(args.litert).name
|
|
198
|
+
result["litert_input_layout"] = litert_layout
|
|
176
199
|
|
|
177
200
|
if args.json:
|
|
178
201
|
print(json.dumps(result, indent=2))
|
|
179
202
|
else:
|
|
180
|
-
print(f"{'PASS' if ok else 'FAIL'} {result['onnx']} layout={layout}
|
|
203
|
+
print(f"{'PASS' if ok else 'FAIL'} {result['onnx']} layout={layout} "
|
|
204
|
+
f"litert_in={litert_layout} {why}")
|
|
181
205
|
print(f" box x{result['box_scale_applied']:g}: {result['box_residual_px']:.4g} px"
|
|
182
206
|
f" (unscaled {result['box_residual_unscaled']:.4g})")
|
|
183
207
|
print(f" cls : {result['cls_residual']:.4g}")
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The detector's RAM ceiling, DERIVED FROM THIS BOX — run by the installer, for
|
|
4
|
+
* the supervisor to write down.
|
|
5
|
+
*
|
|
6
|
+
* ── Why this file exists ────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* `registry.py::_admit` refuses to make any non-pinned model resident unless a
|
|
9
|
+
* ceiling was injected, and says so out loud:
|
|
10
|
+
*
|
|
11
|
+
* cannot admit ['gh_forklift_combine'] — no RAM ceiling was injected, so this
|
|
12
|
+
* service has no basis for a portfolio decision and will not invent one.
|
|
13
|
+
*
|
|
14
|
+
* That refusal is correct. What was missing is the other half of it:
|
|
15
|
+
* `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB` appeared in the detector's source, its
|
|
16
|
+
* README, its tests and its CHANGELOG, and in ZERO of the six supervisor
|
|
17
|
+
* definitions — three plists, three units, three Windows scripts. So on every
|
|
18
|
+
* fielded box the ceiling was absent, and every delivered specialist stayed
|
|
19
|
+
* permanently `idle` while `/detect` answered 503. Measured on a live GCE
|
|
20
|
+
* connector on 2026-08-26.
|
|
21
|
+
*
|
|
22
|
+
* ── Why the number is derived and not a constant ────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* `docs/runbooks/sensing-model-library.md` §2 is the arithmetic: the binding
|
|
25
|
+
* field box is a Ryzen 5 7640HS with a measured 9.8 GB carrying 6 of the 9
|
|
26
|
+
* spec-reporting screens, and Gemma's working set alone is 3.31 GB. **The SUM
|
|
27
|
+
* has to fit, not each member** — a selector that approves each model
|
|
28
|
+
* independently approves a set that OOM-kills the box, and the failure then
|
|
29
|
+
* looks like a camera problem. A hardcoded 4096 is wrong on that box and wrong
|
|
30
|
+
* again on the 31.4 GB Core Ultra 9 285H beside it in the same table.
|
|
31
|
+
*
|
|
32
|
+
* ── Why HERE and not in the service ─────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* `registry.py`'s header settles it: a ceiling chosen by the detector would be a
|
|
35
|
+
* SECOND allocation authority that cannot see the first. So the service still
|
|
36
|
+
* has no default and still refuses; this runs at INSTALL time, in the
|
|
37
|
+
* installer, and the supervisor records the answer in its own definition where
|
|
38
|
+
* an operator can read it and override it.
|
|
39
|
+
*
|
|
40
|
+
* It also could not honestly live in the Python package: every quantity it
|
|
41
|
+
* subtracts is a CONNECTOR cost — Gemma's working set, the connector process,
|
|
42
|
+
* one ffmpeg per feed — and the detector knows none of them. It sits in
|
|
43
|
+
* `deploy/detector/` because that is the one directory all three installers
|
|
44
|
+
* already resolve a path to (`find_detector_source` / `Resolve-DetectorSource`),
|
|
45
|
+
* so no packaging step had to change on any platform. It is NOT part of the
|
|
46
|
+
* Python distribution: `pyproject.toml` declares
|
|
47
|
+
* `packages = ["trillboards_detector"]`, so the wheel never sees this file.
|
|
48
|
+
*
|
|
49
|
+
* ── Node, and only Node built-ins ───────────────────────────────────────────
|
|
50
|
+
*
|
|
51
|
+
* ONE implementation for three supervisors. A bash twin and a PowerShell twin
|
|
52
|
+
* would be two more places for the arithmetic to drift, and `os.totalmem()` is
|
|
53
|
+
* the same probe on all three. Node is present wherever this runs: `install.sh`
|
|
54
|
+
* requires >= 18 before anything else, the macOS pkg bundles `$ROOT/bin/node`
|
|
55
|
+
* and the Windows payload bundles `bin\node.exe`.
|
|
56
|
+
*
|
|
57
|
+
* ── Contract ────────────────────────────────────────────────────────────────
|
|
58
|
+
*
|
|
59
|
+
* stdout the ceiling, as a bare integer number of MiB. Nothing else, so a
|
|
60
|
+
* caller can `$(node ram-budget.js)` it.
|
|
61
|
+
* stderr with --explain, the whole subtraction, so an operator reading a
|
|
62
|
+
* setup log can see WHY the number is what it is.
|
|
63
|
+
* exit 0 derived. exit 1: could not read this box's memory — the caller
|
|
64
|
+
* then injects NOTHING, which leaves the service in its existing,
|
|
65
|
+
* honest refusing state rather than acting on a guess.
|
|
66
|
+
*
|
|
67
|
+
* `--assume-total-mb` / `--assume-platform` exist for the fixture matrix in
|
|
68
|
+
* `src/__tests__/detectorRamBudget.test.ts` and for reproducing a field box's
|
|
69
|
+
* number on a laptop. An INSTALLER must never pass them: a number an installer
|
|
70
|
+
* supplied is not a number derived from the box.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
'use strict';
|
|
74
|
+
|
|
75
|
+
const fs = require('fs');
|
|
76
|
+
const os = require('os');
|
|
77
|
+
const path = require('path');
|
|
78
|
+
|
|
79
|
+
const CONTRACT_PATH = path.join(__dirname, 'ram-budget.json');
|
|
80
|
+
const BYTES_PER_MIB = 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/** Read the committed inputs. Keys prefixed `_` are prose and are ignored. */
|
|
83
|
+
function loadContract(contractPath) {
|
|
84
|
+
const raw = fs.readFileSync(contractPath || CONTRACT_PATH, 'utf8');
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
for (const key of ['pinnedModelMb', 'localVlmWorkingSetMb', 'connectorProcessMb', 'captureMb']) {
|
|
87
|
+
if (!Number.isFinite(parsed[key]) || parsed[key] <= 0) {
|
|
88
|
+
throw new Error(`ram-budget.json: ${key} must be a positive number, got ${parsed[key]}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!parsed.operatingSystemMb || !Number.isFinite(parsed.operatingSystemMb.default)) {
|
|
92
|
+
throw new Error('ram-budget.json: operatingSystemMb.default must be a positive number');
|
|
93
|
+
}
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The ceiling, in MiB, and the subtraction that produced it.
|
|
99
|
+
*
|
|
100
|
+
* FLOORED AT THE PINNED MODEL, never below. `ModelRegistry.__init__` RAISES on a
|
|
101
|
+
* budget it cannot hold the pinned model in — "the service would be permanently
|
|
102
|
+
* unavailable rather than merely constrained" — and every supervisor restarts a
|
|
103
|
+
* non-zero exit, so a ceiling of 200 MiB on a tiny box would be a crash-loop
|
|
104
|
+
* rather than a constraint. At the floor the service still starts, still holds
|
|
105
|
+
* the model it was told to hold, and refuses each specialist with a CAPACITY
|
|
106
|
+
* reason naming what did not fit, which is the truthful thing to say about a box
|
|
107
|
+
* that has no room.
|
|
108
|
+
*/
|
|
109
|
+
function deriveRamBudgetMb(opts) {
|
|
110
|
+
const contract = opts.contract;
|
|
111
|
+
const totalMb = opts.totalMb;
|
|
112
|
+
const platform = opts.platform;
|
|
113
|
+
|
|
114
|
+
const osMb = Number.isFinite(contract.operatingSystemMb[platform])
|
|
115
|
+
? contract.operatingSystemMb[platform]
|
|
116
|
+
: contract.operatingSystemMb.default;
|
|
117
|
+
|
|
118
|
+
const reserved = osMb
|
|
119
|
+
+ contract.connectorProcessMb
|
|
120
|
+
+ contract.localVlmWorkingSetMb
|
|
121
|
+
+ contract.captureMb;
|
|
122
|
+
|
|
123
|
+
const available = totalMb - reserved;
|
|
124
|
+
const floored = available < contract.pinnedModelMb;
|
|
125
|
+
const budgetMb = floored ? contract.pinnedModelMb : Math.floor(available);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
budgetMb,
|
|
129
|
+
totalMb,
|
|
130
|
+
platform,
|
|
131
|
+
osMb,
|
|
132
|
+
osKeyed: Number.isFinite(contract.operatingSystemMb[platform]),
|
|
133
|
+
connectorProcessMb: contract.connectorProcessMb,
|
|
134
|
+
localVlmWorkingSetMb: contract.localVlmWorkingSetMb,
|
|
135
|
+
captureMb: contract.captureMb,
|
|
136
|
+
pinnedModelMb: contract.pinnedModelMb,
|
|
137
|
+
reserved,
|
|
138
|
+
available,
|
|
139
|
+
floored,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `os.totalmem()` in whole MiB, or null when it reported nothing usable. */
|
|
144
|
+
function readTotalMemoryMb(totalmem) {
|
|
145
|
+
let bytes;
|
|
146
|
+
try {
|
|
147
|
+
bytes = (totalmem || os.totalmem)();
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
|
152
|
+
return Math.floor(bytes / BYTES_PER_MIB);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function explain(d) {
|
|
156
|
+
const floorNote = d.floored
|
|
157
|
+
? `\n FLOORED at the pinned model (${d.pinnedModelMb} MiB): this box has `
|
|
158
|
+
+ `${d.available} MiB left after the reservations, which cannot hold a second model. `
|
|
159
|
+
+ 'The detector still starts and still holds its pinned model; every specialist is '
|
|
160
|
+
+ 'refused with a capacity reason.'
|
|
161
|
+
: '';
|
|
162
|
+
const osNote = d.osKeyed ? d.platform : `${d.platform} -> default (unrecognised platform)`;
|
|
163
|
+
return `detector RAM ceiling ${d.budgetMb} MiB`
|
|
164
|
+
+ `\n ${d.totalMb} MiB total on this box`
|
|
165
|
+
+ `\n - ${d.osMb} MiB operating system (${osNote})`
|
|
166
|
+
+ `\n - ${d.connectorProcessMb} MiB connector process`
|
|
167
|
+
+ `\n - ${d.localVlmWorkingSetMb} MiB on-device VLM working set`
|
|
168
|
+
+ `\n - ${d.captureMb} MiB capture (one persistent ffmpeg per feed, at the 16-session ceiling)`
|
|
169
|
+
+ `\n = ${d.available} MiB for every resident detector session TOGETHER`
|
|
170
|
+
+ floorNote;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseArgs(argv) {
|
|
174
|
+
const opts = { explain: false, totalMb: null, platform: null };
|
|
175
|
+
for (const arg of argv) {
|
|
176
|
+
if (arg === '--explain') opts.explain = true;
|
|
177
|
+
else if (arg.startsWith('--assume-total-mb=')) opts.totalMb = Number(arg.split('=')[1]);
|
|
178
|
+
else if (arg.startsWith('--assume-platform=')) opts.platform = arg.split('=')[1];
|
|
179
|
+
else throw new Error(`unknown argument ${arg}`);
|
|
180
|
+
}
|
|
181
|
+
return opts;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function main(argv) {
|
|
185
|
+
let opts;
|
|
186
|
+
try {
|
|
187
|
+
opts = parseArgs(argv);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
process.stderr.write(`${err.message}\n`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const totalMb = opts.totalMb !== null ? Math.floor(opts.totalMb) : readTotalMemoryMb();
|
|
194
|
+
if (!Number.isFinite(totalMb) || totalMb <= 0) {
|
|
195
|
+
// NOTHING IS INJECTED, and that is the designed outcome rather than a
|
|
196
|
+
// fallback. An absent ceiling is a STATE the service already handles
|
|
197
|
+
// correctly and loudly; a made-up one is a portfolio decision nobody made.
|
|
198
|
+
process.stderr.write(
|
|
199
|
+
'could not read this box\'s total memory, so no RAM ceiling is derived. '
|
|
200
|
+
+ 'The detector will start, hold its pinned model, and refuse to make a second '
|
|
201
|
+
+ 'model resident — naming why.\n',
|
|
202
|
+
);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let derived;
|
|
207
|
+
try {
|
|
208
|
+
derived = deriveRamBudgetMb({
|
|
209
|
+
contract: loadContract(),
|
|
210
|
+
totalMb,
|
|
211
|
+
platform: opts.platform || process.platform,
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
process.stderr.write(`${err.message}\n`);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (opts.explain) process.stderr.write(`${explain(derived)}\n`);
|
|
219
|
+
process.stdout.write(`${derived.budgetMb}\n`);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { loadContract, deriveRamBudgetMb, readTotalMemoryMb, explain, main, CONTRACT_PATH };
|
|
224
|
+
|
|
225
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"Inputs to the detector's RAM ceiling. ONE file, read by the one derivation",
|
|
4
|
+
"(ram-budget.js) that all three installers run, so a number can never mean two",
|
|
5
|
+
"things on two platforms.",
|
|
6
|
+
"",
|
|
7
|
+
"Every entry carries its own basis, and the basis says whether it is a",
|
|
8
|
+
"MEASUREMENT or a POLICY. That distinction is the whole point of writing them",
|
|
9
|
+
"down separately: a measurement is re-derived when the thing is re-measured, a",
|
|
10
|
+
"policy is argued about. Collapsing them into one 'reserve' number is how a",
|
|
11
|
+
"guess acquires the authority of a measurement."
|
|
12
|
+
],
|
|
13
|
+
|
|
14
|
+
"pinnedModelMb": 632,
|
|
15
|
+
"_pinnedModelMb": [
|
|
16
|
+
"MEASURED. RF-DETR Medium's peak RSS — docs/runbooks/cctv-detector-tier.md §6,",
|
|
17
|
+
"and the same figure `labels.py` commits as RFDETR_MEDIUM.approx_ram_bytes.",
|
|
18
|
+
"It is the FLOOR of the derived ceiling, not a reservation: ModelRegistry",
|
|
19
|
+
"REFUSES to construct with a budget below the pinned model's footprint",
|
|
20
|
+
"(`the service would be permanently unavailable rather than merely",
|
|
21
|
+
"constrained`), so a ceiling under this would crash-loop the service on a",
|
|
22
|
+
"small box instead of merely denying it specialists. tests/test_ram_budget.py",
|
|
23
|
+
"pins this to labels.py so the two cannot drift."
|
|
24
|
+
],
|
|
25
|
+
|
|
26
|
+
"localVlmWorkingSetMb": 3390,
|
|
27
|
+
"_localVlmWorkingSetMb": [
|
|
28
|
+
"MEASURED. Gemma's working set on the SHIPPED GPU-vision path —",
|
|
29
|
+
"docs/runbooks/cctv-local-inference-hardware-floor.md §3, re-measured",
|
|
30
|
+
"2026-08-19 at 3.31 GB (2.7 GB on the retired stock config; the GPU vision",
|
|
31
|
+
"encoder costs ~0.6 GB). The 4.3 GB .litertlm bundle is memory-MAPPED and is",
|
|
32
|
+
"deliberately not reserved here — what binds is the working set.",
|
|
33
|
+
"3.31 read as GiB, which is the larger of the two readings of that unit and",
|
|
34
|
+
"therefore the safe one: over-reserving costs a specialist slot, under-",
|
|
35
|
+
"reserving OOM-kills a box and looks like a camera fault."
|
|
36
|
+
],
|
|
37
|
+
|
|
38
|
+
"connectorProcessMb": 2048,
|
|
39
|
+
"_connectorProcessMb": [
|
|
40
|
+
"DECLARED, not measured — and it is our own declaration, which is why it is",
|
|
41
|
+
"usable: trillboards-edge.service sets MemoryMax=2G, so on Linux this is not",
|
|
42
|
+
"an estimate of the connector, it is the cgroup ceiling the kernel enforces on",
|
|
43
|
+
"it. macOS and Windows run the same program with no such cap, so the same",
|
|
44
|
+
"number carries as the best statement we have about it."
|
|
45
|
+
],
|
|
46
|
+
|
|
47
|
+
"captureMb": 384,
|
|
48
|
+
"_captureMb": [
|
|
49
|
+
"MEASURED. RTSPStreamCameraProvider holds one long-lived ffmpeg per feed into",
|
|
50
|
+
"a bounded ring; docs/runbooks/sensing-catch-rate.md §2b measured that at",
|
|
51
|
+
"+366 MB RSS at 16 feeds. 16 is the learned per-recorder session ceiling, so",
|
|
52
|
+
"this reserves the whole capture lane at its widest rather than per feed —",
|
|
53
|
+
"the feed count is not known at install time, and a per-feed reservation",
|
|
54
|
+
"would have to be revised every time an operator adds a camera."
|
|
55
|
+
],
|
|
56
|
+
|
|
57
|
+
"operatingSystemMb": {
|
|
58
|
+
"linux": 1024,
|
|
59
|
+
"darwin": 3072,
|
|
60
|
+
"win32": 2048,
|
|
61
|
+
"default": 3072
|
|
62
|
+
},
|
|
63
|
+
"_operatingSystemMb": [
|
|
64
|
+
"POLICY, NOT MEASUREMENT, and it must not be read as one. There is no fleet",
|
|
65
|
+
"measurement of idle OS footprint anywhere in this tree; these are floors",
|
|
66
|
+
"chosen to be comfortably above what each platform idles at, because the",
|
|
67
|
+
"consequence of under-reserving here is the OOM killer choosing which of our",
|
|
68
|
+
"processes dies. `default` takes the largest value, so an unrecognised",
|
|
69
|
+
"platform fails conservative rather than optimistic.",
|
|
70
|
+
"linux 1024: the connector's Linux SKU is a headless mini-PC; a Debian/Ubuntu",
|
|
71
|
+
" server idles well under 512 MB, and this is roughly twice that.",
|
|
72
|
+
"win32 2048: Windows 11 idles at roughly 2-3 GB of working set.",
|
|
73
|
+
"darwin 3072: macOS holds a large (compressed) working set even idle; on the",
|
|
74
|
+
" 16 GB Mac mini M4 that is the recommended SKU this is ~19% of the box."
|
|
75
|
+
]
|
|
76
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The supervisor's ceiling and this service's floor are ONE number.
|
|
2
|
+
|
|
3
|
+
`ram-budget.json` is read by the installer, not by this package — the ceiling is
|
|
4
|
+
INJECTED and this service owns no RAM policy (`registry.py`'s header). But the
|
|
5
|
+
supervisor's derivation floors its answer at the pinned model's footprint, and
|
|
6
|
+
that floor is only correct if it is EXACTLY the boundary `ModelRegistry` refuses
|
|
7
|
+
below. One MiB either way and the two artifacts disagree in one of two ways:
|
|
8
|
+
|
|
9
|
+
* floor too LOW -> `ModelRegistry.__init__` raises on a small box, the process
|
|
10
|
+
exits non-zero, and all three supervisors restart it. A crash-loop instead of
|
|
11
|
+
a constraint, on exactly the boxes least able to report it.
|
|
12
|
+
* floor too HIGH -> the derivation quietly hands small boxes more than they
|
|
13
|
+
have, which is the OOM this whole portfolio design exists to prevent.
|
|
14
|
+
|
|
15
|
+
So the JSON is pinned to `labels.py` here, in the package that owns the number,
|
|
16
|
+
rather than restated in the JS that reads it.
|
|
17
|
+
|
|
18
|
+
Stdlib only, like every other suite in this directory.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import tempfile
|
|
25
|
+
import unittest
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from trillboards_detector.labels import RFDETR_MEDIUM, ModelVocabulary
|
|
29
|
+
from trillboards_detector.registry import ModelEntry, ModelRegistry
|
|
30
|
+
|
|
31
|
+
MB = 1024 * 1024
|
|
32
|
+
CONTRACT_PATH = Path(__file__).resolve().parent.parent / "ram-budget.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _contract() -> dict:
|
|
36
|
+
return json.loads(CONTRACT_PATH.read_text())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestTheFloorIsThePinnedModel(unittest.TestCase):
|
|
40
|
+
def test_the_contract_ships_next_to_the_package(self):
|
|
41
|
+
# All three installers resolve the detector SOURCE directory and read the
|
|
42
|
+
# derivation out of it, so the file has to be here and not somewhere a
|
|
43
|
+
# packaging step would have to be taught about.
|
|
44
|
+
self.assertTrue(CONTRACT_PATH.is_file(), f"{CONTRACT_PATH} is missing")
|
|
45
|
+
|
|
46
|
+
def test_pinned_model_mb_is_exactly_the_pinned_vocabulary_footprint(self):
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
_contract()["pinnedModelMb"] * MB,
|
|
49
|
+
RFDETR_MEDIUM.approx_ram_bytes,
|
|
50
|
+
"ram-budget.json's floor and RFDETR_MEDIUM.approx_ram_bytes are the same "
|
|
51
|
+
"quantity; a supervisor flooring below it crash-loops this service",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def test_every_reservation_is_a_positive_number_of_mib(self):
|
|
55
|
+
contract = _contract()
|
|
56
|
+
for key in (
|
|
57
|
+
"pinnedModelMb",
|
|
58
|
+
"localVlmWorkingSetMb",
|
|
59
|
+
"connectorProcessMb",
|
|
60
|
+
"captureMb",
|
|
61
|
+
):
|
|
62
|
+
self.assertIsInstance(contract[key], int, key)
|
|
63
|
+
self.assertGreater(contract[key], 0, key)
|
|
64
|
+
os_mb = contract["operatingSystemMb"]
|
|
65
|
+
self.assertIn("default", os_mb)
|
|
66
|
+
for platform, value in os_mb.items():
|
|
67
|
+
self.assertIsInstance(value, int, platform)
|
|
68
|
+
self.assertGreater(value, 0, platform)
|
|
69
|
+
# An unrecognised platform must be treated at least as expensively as
|
|
70
|
+
# every recognised one — failing conservative is the only safe direction
|
|
71
|
+
# when the consequence is the OOM killer picking a victim.
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
os_mb["default"],
|
|
74
|
+
max(v for k, v in os_mb.items() if k != "default"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestTheFloorIsCONSTRUCTIBLE(unittest.TestCase):
|
|
79
|
+
"""The boundary, exercised against the real registry rather than asserted.
|
|
80
|
+
|
|
81
|
+
`ModelRegistry.__init__` refuses a ceiling below the pinned model's footprint
|
|
82
|
+
— "the service would be permanently unavailable rather than merely
|
|
83
|
+
constrained". These two cases are the MiB either side of the floor the
|
|
84
|
+
supervisor derives.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def setUp(self):
|
|
88
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
89
|
+
self.root = Path(self._tmp.name)
|
|
90
|
+
self.addCleanup(self._tmp.cleanup)
|
|
91
|
+
self.pinned = ModelVocabulary(
|
|
92
|
+
model_name=RFDETR_MEDIUM.model_name,
|
|
93
|
+
slot_names=dict(RFDETR_MEDIUM.slot_names),
|
|
94
|
+
logit_slots=RFDETR_MEDIUM.logit_slots,
|
|
95
|
+
approx_ram_bytes=RFDETR_MEDIUM.approx_ram_bytes,
|
|
96
|
+
onnx_variant=RFDETR_MEDIUM.onnx_variant,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _registry(self, budget_bytes: int) -> ModelRegistry:
|
|
100
|
+
entry = ModelEntry(
|
|
101
|
+
self.pinned,
|
|
102
|
+
self.root / "models" / self.pinned.model_name / "model.onnx",
|
|
103
|
+
pinned=True,
|
|
104
|
+
)
|
|
105
|
+
return ModelRegistry(
|
|
106
|
+
entries=[entry],
|
|
107
|
+
session_factory=lambda _e: object(),
|
|
108
|
+
probe=lambda _s: None,
|
|
109
|
+
ram_budget_bytes=budget_bytes,
|
|
110
|
+
log=lambda _m: None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def test_the_derived_floor_constructs(self):
|
|
114
|
+
floor_mb = _contract()["pinnedModelMb"]
|
|
115
|
+
self.assertIsNotNone(self._registry(floor_mb * MB))
|
|
116
|
+
|
|
117
|
+
def test_one_mib_below_the_floor_refuses(self):
|
|
118
|
+
floor_mb = _contract()["pinnedModelMb"]
|
|
119
|
+
with self.assertRaises(ValueError):
|
|
120
|
+
self._registry((floor_mb - 1) * MB)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|
|
@@ -523,7 +523,28 @@ PPE_KILLUMINATI_19CLS_V8S = ModelVocabulary(
|
|
|
523
523
|
#: this artifact as the fixture that exercises `_verify_class_axis_width`, where
|
|
524
524
|
#: `4 + 7 = 11` matches its real `[1, 11, 8400]` head and `4 + 6 = 10` would
|
|
525
525
|
#: reject it.
|
|
526
|
-
|
|
526
|
+
#: Person Down Detector — published 2026-08-27 by
|
|
527
|
+
#: scripts/sensing/publish-sensing-model.js. The class list is the artifact's own
|
|
528
|
+
#: (graph metadata key 'names'); the footprint is MEASURED, 817.3594 MiB after a
|
|
529
|
+
#: probe inference via rusage-maxrss, not an estimate from the file size.
|
|
530
|
+
#: Coordinate space MEASURED, not assumed: max|box channel| = 1.5467 over 94 real frames; / imgsz 640 = 0.0024. pixels at >= 0.2, normalized at <= 0.02, refused in between.
|
|
531
|
+
FALL_1CLS_11X_CLASS_NAMES: "MappingProxyType[int, str]" = MappingProxyType({
|
|
532
|
+
0: "fall detected",
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
FALL_1CLS_11X = ModelVocabulary(
|
|
536
|
+
model_name="fall_1cls_11x",
|
|
537
|
+
slot_names=FALL_1CLS_11X_CLASS_NAMES,
|
|
538
|
+
logit_slots=1,
|
|
539
|
+
approx_ram_bytes=857063451,
|
|
540
|
+
onnx_variant=VariantContract(
|
|
541
|
+
output_layout="anchor_4_plus_nc",
|
|
542
|
+
box_units="normalized",
|
|
543
|
+
box_scale_to_pixels=640.0,
|
|
544
|
+
),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
BUILTIN_VOCABULARIES: "tuple[ModelVocabulary, ...]" = (RFDETR_MEDIUM, GH_FORKLIFT_COMBINE, PPE_VYRA_14CLS_V8M, PPE_CONSTRUCTION_17CLS_V8N, PPE_KILLUMINATI_19CLS_V8S, FALL_1CLS_11X,)
|
|
527
548
|
|
|
528
549
|
|
|
529
550
|
class ClassIndex:
|
|
@@ -617,6 +638,7 @@ class ClassIndex:
|
|
|
617
638
|
|
|
618
639
|
|
|
619
640
|
__all__ = [
|
|
641
|
+
"FALL_1CLS_11X",
|
|
620
642
|
"PPE_KILLUMINATI_19CLS_V8S",
|
|
621
643
|
"PPE_CONSTRUCTION_17CLS_V8N",
|
|
622
644
|
"PPE_VYRA_14CLS_V8M",
|
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
here. Port 9380; litert-lm owns 9379 and is not touched.
|
|
13
13
|
|
|
14
14
|
This is a TEMPLATE: setup-detector.sh substitutes __DETECTOR__ (absolute path
|
|
15
|
-
to the uv-installed `trillboards-detector` binary) / __PORT__ / __LOG_DIR__
|
|
16
|
-
writes the result to
|
|
17
|
-
then loads it via
|
|
15
|
+
to the uv-installed `trillboards-detector` binary) / __PORT__ / __LOG_DIR__ /
|
|
16
|
+
__RAM_BUDGET_ARGS__ and writes the result to
|
|
17
|
+
~/Library/LaunchAgents/com.trillboards.detector.plist, then loads it via
|
|
18
|
+
`launchctl bootstrap gui/$UID`.
|
|
19
|
+
|
|
20
|
+
__RAM_BUDGET_ARGS__ becomes either the two <string> elements of
|
|
21
|
+
`--ram-budget-mb <N>` or NOTHING AT ALL, and the emptiness is meaningful: the
|
|
22
|
+
detector distinguishes "the supervisor told me a ceiling" from "nobody has
|
|
23
|
+
told me one", and refuses to invent one in the second state
|
|
24
|
+
(`registry.py::_admit`). A placeholder value here would be this file quietly
|
|
25
|
+
becoming a second allocation authority. The number is derived from THIS
|
|
26
|
+
machine by deploy/detector/ram-budget.js — see its header for why a constant
|
|
27
|
+
is wrong on both of the two field boxes we have measured.
|
|
18
28
|
|
|
19
29
|
KeepAlive=true + ThrottleInterval=30 exactly as com.trillboards.litertlm.plist.
|
|
20
30
|
ThrottleInterval is load-bearing here for a specific reason: the model is OTA'd,
|
|
@@ -35,6 +45,7 @@
|
|
|
35
45
|
<string>127.0.0.1</string>
|
|
36
46
|
<string>--port</string>
|
|
37
47
|
<string>__PORT__</string>
|
|
48
|
+
__RAM_BUDGET_ARGS__
|
|
38
49
|
</array>
|
|
39
50
|
|
|
40
51
|
<key>RunAtLoad</key>
|
|
@@ -135,6 +135,39 @@ if [ ! -x "$DETECTOR" ]; then
|
|
|
135
135
|
fi
|
|
136
136
|
log "trillboards-detector installed at $DETECTOR"
|
|
137
137
|
|
|
138
|
+
# 2b. THE RAM CEILING, derived from THIS Mac.
|
|
139
|
+
#
|
|
140
|
+
# Without it the service holds its pinned model and refuses to make any second
|
|
141
|
+
# model resident — `registry.py::_admit` says so in the 503 — so every delivered
|
|
142
|
+
# specialist stays permanently `idle`. The refusal is correct; the injection was
|
|
143
|
+
# missing from all six supervisor definitions until 2026-08-26.
|
|
144
|
+
#
|
|
145
|
+
# A COMMAND-LINE ARGUMENT here, not an environment variable, because this
|
|
146
|
+
# supervisor definition is GENERATED by this script — same for the Windows
|
|
147
|
+
# Scheduled Task. Only Linux, whose unit is a static file, uses the env var.
|
|
148
|
+
# One derivation (deploy/detector/ram-budget.js), three transports.
|
|
149
|
+
#
|
|
150
|
+
# EMPTY WHEN IT CANNOT BE DERIVED, never a placeholder: "nobody has told me a
|
|
151
|
+
# ceiling" is a state the detector handles correctly and loudly, and a number
|
|
152
|
+
# invented here would be a second allocation authority.
|
|
153
|
+
RAM_BUDGET_ARGS=""
|
|
154
|
+
NODE_BIN="$SCRIPT_DIR/../bin/node"
|
|
155
|
+
[ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node || true)"
|
|
156
|
+
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
|
|
157
|
+
log "WARN: no node runtime found, so no RAM ceiling is derived; the detector will refuse a second resident model"
|
|
158
|
+
elif [ ! -f "$SOURCE/ram-budget.js" ]; then
|
|
159
|
+
log "WARN: $SOURCE/ram-budget.js is missing, so no RAM ceiling is derived"
|
|
160
|
+
else
|
|
161
|
+
_budget="$("$NODE_BIN" "$SOURCE/ram-budget.js" --explain 2>>"$LOG")" || _budget=""
|
|
162
|
+
case "$_budget" in
|
|
163
|
+
''|*[!0-9]*) log "WARN: could not derive a RAM ceiling for this Mac (see $LOG); the detector will refuse a second resident model" ;;
|
|
164
|
+
*)
|
|
165
|
+
RAM_BUDGET_ARGS="<string>--ram-budget-mb</string><string>$_budget</string>"
|
|
166
|
+
log "RAM ceiling $_budget MiB (derived from this Mac; see the log above for the subtraction)"
|
|
167
|
+
;;
|
|
168
|
+
esac
|
|
169
|
+
fi
|
|
170
|
+
|
|
138
171
|
# 3. Render + bootstrap the LaunchAgent.
|
|
139
172
|
TEMPLATE="$SHARE_DIR/$SERVE_LABEL.plist"
|
|
140
173
|
TARGET="$LA_DIR/$SERVE_LABEL.plist"
|
|
@@ -146,6 +179,7 @@ fi
|
|
|
146
179
|
sed -e "s#__DETECTOR__#$DETECTOR#g" \
|
|
147
180
|
-e "s#__PORT__#$SERVE_PORT#g" \
|
|
148
181
|
-e "s#__LOG_DIR__#$LOG_DIR#g" \
|
|
182
|
+
-e "s#__RAM_BUDGET_ARGS__#$RAM_BUDGET_ARGS#g" \
|
|
149
183
|
"$TEMPLATE" > "$TARGET" 2>/dev/null || { log "WARN: could not render the detector plist"; exit 0; }
|
|
150
184
|
|
|
151
185
|
SERVICE_DOMAIN="gui/$(id -u)"
|
|
@@ -157,7 +157,20 @@ sudo -u trillboards HOME=/opt/trillboards bash \
|
|
|
157
157
|
# starting left a fresh Linux box with no detector until its next reboot, with
|
|
158
158
|
# the installer reporting success. `--now` starts it here, where we have root,
|
|
159
159
|
# and after the provisioner so the binary the unit points at already exists.
|
|
160
|
-
|
|
160
|
+
#
|
|
161
|
+
# `enable` THEN `restart`, NOT `enable --now`, AND THE DIFFERENCE ONLY SHOWS ON AN
|
|
162
|
+
# UPGRADE. `--now` starts a unit that is INACTIVE and does nothing at all to one
|
|
163
|
+
# that is already running — so on a box where the detector is already up, the
|
|
164
|
+
# provisioner above would write a new `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB` into
|
|
165
|
+
# /etc/trillboards/env and the running process would keep the environment it was
|
|
166
|
+
# exec'd with, until the next reboot. A ceiling the service never re-read is a
|
|
167
|
+
# decision nobody acted on: exactly what the litert-lm block above this one
|
|
168
|
+
# records about `write_litertlm_config` without a restart.
|
|
169
|
+
#
|
|
170
|
+
# `restart` is correct in BOTH states — it starts an inactive unit and replaces a
|
|
171
|
+
# running one — so the fresh-install behaviour `--now` was added for is kept.
|
|
172
|
+
sudo systemctl enable trillboards-detector 2>/dev/null || true
|
|
173
|
+
sudo systemctl restart trillboards-detector \
|
|
161
174
|
|| echo " *** The detector unit did not start. Sensing is unaffected. Check:
|
|
162
175
|
*** sudo systemctl status trillboards-detector"
|
|
163
176
|
|