@trillboards/edge-sdk 0.2.74 → 0.2.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/deploy/detector/README.md +133 -0
  2. package/deploy/detector/bench/verify_against_reference.py +120 -0
  3. package/deploy/detector/pyproject.toml +49 -0
  4. package/deploy/detector/tests/__init__.py +0 -0
  5. package/deploy/detector/tests/test_modelpath.py +108 -0
  6. package/deploy/detector/tests/test_server.py +591 -0
  7. package/deploy/detector/tests/test_session.py +242 -0
  8. package/deploy/detector/trillboards_detector/__init__.py +8 -0
  9. package/deploy/detector/trillboards_detector/__main__.py +4 -0
  10. package/deploy/detector/trillboards_detector/cli.py +141 -0
  11. package/deploy/detector/trillboards_detector/errors.py +16 -0
  12. package/deploy/detector/trillboards_detector/labels.py +107 -0
  13. package/deploy/detector/trillboards_detector/loader.py +257 -0
  14. package/deploy/detector/trillboards_detector/modelpath.py +74 -0
  15. package/deploy/detector/trillboards_detector/server.py +460 -0
  16. package/deploy/detector/trillboards_detector/session.py +354 -0
  17. package/deploy/macos/build-macos-pkg.sh +48 -0
  18. package/deploy/macos/com.trillboards.detector.plist +61 -0
  19. package/deploy/macos/scripts/ensure-detector.sh +85 -0
  20. package/deploy/macos/scripts/postinstall +28 -0
  21. package/deploy/macos/scripts/setup-detector.sh +185 -0
  22. package/deploy/systemd/install.sh +35 -0
  23. package/deploy/systemd/setup-detector.sh +130 -0
  24. package/deploy/systemd/trillboards-detector.service +54 -0
  25. package/deploy/windows/build-windows.ps1 +38 -0
  26. package/deploy/windows/installer.nsi +24 -1
  27. package/deploy/windows/register-detector-task.ps1 +161 -0
  28. package/deploy/windows/register-task.ps1 +27 -0
  29. package/deploy/windows/setup-detector.ps1 +176 -0
  30. package/deploy/windows/stop-existing.ps1 +1 -0
  31. package/deploy/windows/trillboards-edge.cmd +25 -0
  32. package/dist/EdgeAgent.js +1 -1
  33. package/dist/LocalDetectorClient.d.ts +92 -0
  34. package/dist/LocalDetectorClient.d.ts.map +1 -0
  35. package/dist/LocalDetectorClient.js +241 -0
  36. package/dist/LocalDetectorClient.js.map +1 -0
  37. package/dist/SensingProfileClient.d.ts +25 -22
  38. package/dist/SensingProfileClient.d.ts.map +1 -1
  39. package/dist/SensingProfileClient.js.map +1 -1
  40. package/dist/diagnose.d.ts +1 -1
  41. package/dist/diagnose.js +1 -1
  42. package/dist/index.d.ts +4 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +17 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/localInferenceSchema.d.ts +11 -2
  47. package/dist/localInferenceSchema.d.ts.map +1 -1
  48. package/dist/localInferenceSchema.js.map +1 -1
  49. package/dist/zoneContainment.d.ts +148 -0
  50. package/dist/zoneContainment.d.ts.map +1 -0
  51. package/dist/zoneContainment.js +172 -0
  52. package/dist/zoneContainment.js.map +1 -0
  53. package/package.json +2 -2
@@ -0,0 +1,133 @@
1
+ # `trillboards-detector` — the on-device object-detector service
2
+
3
+ RF-DETR Medium, fp32 ONNX, served over loopback on **127.0.0.1:9380**. It is the
4
+ cascade gate that sits in front of the on-device VLM: at the ANALYZE cadence it
5
+ removes ~84.7% of 5,645 ms VLM calls for ~2.1% of events.
6
+
7
+ The decision record — which model, why not the alternatives, and every number
8
+ above — is [`docs/runbooks/cctv-detector-tier.md`](../../../../../docs/runbooks/cctv-detector-tier.md).
9
+ Read it before changing anything about the model. **Do not quantise**: INT8 loses
10
+ 31.7 points of recall *silently* and is slower than fp32.
11
+
12
+ ## The wire
13
+
14
+ The consumer is
15
+ [`src/LocalDetectorClient.ts`](../../src/LocalDetectorClient.ts). It shipped
16
+ first and **its contract is frozen** — this service accommodates it, never the
17
+ other way round.
18
+
19
+ ```
20
+ POST /detect { image_b64, classes?, min_confidence }
21
+ 200 { detections: [{ label, confidence, box: {x,y,width,height} }],
22
+ inference_ms, model }
23
+ 400 the request cannot be acted on (bad base64, empty body,
24
+ min_confidence outside [0,1], every requested class unknown)
25
+ 413 body over 32 MiB
26
+ 500 inference failed, or a detection could not be put on the wire
27
+ 503 no usable model — NEVER a 200 with an empty array
28
+
29
+ GET /health 200 when a load completed AND a probe inference succeeded
30
+ 503 otherwise, with the reason. Runs no inference.
31
+ ```
32
+
33
+ * `label` is the class **name**, never an index.
34
+ * `box` is in **pixels of the submitted frame**, not normalised.
35
+ * `classes` filters at the **service**, so the wire stays small. Absent means
36
+ every class; an empty array is a 400, never a synonym for "all".
37
+
38
+ Three properties are load-bearing rather than stylistic:
39
+
40
+ **Down is never empty.** A missing model is 503 and a failed inference is 500.
41
+ Neither may become `200 []`. A cascade that reads "detector broken" as "venue
42
+ quiet" suppresses every alert while every health surface stays green.
43
+
44
+ **We refuse what the client would refuse.** `build_detect_response` re-checks
45
+ every detection against the client's own fifteen rules and answers 500 rather
46
+ than emitting a payload the client will reject — because a rejected payload
47
+ costs the whole frame *and* is indistinguishable, from the client's side, from
48
+ the service being down.
49
+
50
+ **Health reports a load that actually happened.** `GET /v1/models` on litert-lm
51
+ answers 200 without ever building an engine, which is how a UTF-8 BOM in its
52
+ config 500'd every frame on every Windows connector while the installer's health
53
+ check *and* its CPU-fallback re-check both passed. So this loader builds the
54
+ session, runs a real probe inference, and `/health` reports that outcome.
55
+
56
+ ## Where it runs
57
+
58
+ Its own `uv tool`, so its own venv; its own OS-supervised service; Node never
59
+ spawns it.
60
+
61
+ | platform | supervisor | provisioned by |
62
+ | --- | --- | --- |
63
+ | macOS | `deploy/macos/com.trillboards.detector.plist` (LaunchAgent) | `deploy/macos/scripts/setup-detector.sh` |
64
+ | Linux | `deploy/systemd/trillboards-detector.service` | `deploy/systemd/setup-detector.sh` |
65
+ | Windows | Scheduled Task `TrillboardsDetector` | `deploy/windows/setup-detector.ps1` |
66
+
67
+ All three are **best effort**. Without Gemma the connector cannot sense at all,
68
+ so that provisioner may abort an install; this one only makes sensing cheaper,
69
+ and a cost optimisation must never block a customer pairing a camera.
70
+
71
+ ## The model is OTA'd, never bundled
72
+
73
+ `~/.trillboards/models/rfdetr_medium/model.onnx`, or wherever
74
+ `${TRILLBOARDS_DATA_DIR:-$HOME/.trillboards}` resolves — the same derivation
75
+ `config.ts`'s `DEFAULTS.dataDir` uses, because a service that resolves a
76
+ different directory from the process that downloads the model is permanently
77
+ unhealthy on a box whose artifact is sitting right there.
78
+
79
+ The macOS code-only build is capped at `MAX_SLIM_PKG_BYTES` = 250 MiB and the
80
+ code slice is already ~207 MB, so a ~128 MiB ONNX would make the build `die`.
81
+ It arrives through edge-core's `ModelManager`, the same channel Gemma's weights
82
+ use. The service **starts without it**, reports 503 with the reason, and picks
83
+ the artifact up within one poll interval of it landing — no restart.
84
+
85
+ One directory per model name and a fixed file name inside it, so an update
86
+ reclaims the old bytes. There is no GC for abandoned model dirs anywhere on this
87
+ path and the existing OTA already orphans 752 MiB per update; a version-named
88
+ directory would add to that.
89
+
90
+ ## Tests
91
+
92
+ ```bash
93
+ # The wire contract. Stdlib only — no model, no onnxruntime, no pip install.
94
+ python3 -m unittest discover -s . -p "test_*.py" -v
95
+ ```
96
+
97
+ Runs in `guards-fast`. **Everything except the post-processing cases needs
98
+ nothing but the standard library** — those are the only ones that touch numpy,
99
+ and they skip themselves with a stated reason when it is absent. That invariant
100
+ is the durable claim; the counts (45 of 60 on 2026-08-24) are a snapshot and go
101
+ stale the next time a case is added, so derive them by running it rather than
102
+ reading them here.
103
+
104
+ ```bash
105
+ # The real client against the real service, over loopback.
106
+ cd ../.. && TRILLBOARDS_DETECTOR_MODEL=/path/to/rfdetr-medium.onnx \
107
+ npx jest src/__tests__/localDetectorService.integration.test.ts
108
+ ```
109
+
110
+ Skips — loudly, with the reason — when the Python tool or the artifact is
111
+ absent. It is the only thing that can catch a two-language disagreement, and it
112
+ earned its place immediately: it found that the client sends every request
113
+ `Transfer-Encoding: chunked` (it sets no `Content-Length`) while `http.server`
114
+ does not decode chunked bodies, so the service was answering 400 to every real
115
+ call.
116
+
117
+ ## The transcription control
118
+
119
+ `session.py` transcribes four steps from the `rfdetr` package — the
120
+ antialias-free square resize, ImageNet normalisation, `_select_topk`, and
121
+ `_gather_and_scale_boxes` — because that package pulls ~3 GB of torch and is not
122
+ going on a shop PC. A transcription can drift, so:
123
+
124
+ ```bash
125
+ uv venv /tmp/rfdetr-ref --python 3.12
126
+ uv pip install --python /tmp/rfdetr-ref/bin/python rfdetr onnx onnxruntime pillow
127
+ /tmp/rfdetr-ref/bin/python bench/verify_against_reference.py \
128
+ --onnx /path/to/rfdetr-medium.onnx --image ../windows/e2e/fixtures/person.jpg
129
+ ```
130
+
131
+ Runs both pipelines on one image and fails on any disagreement. A documented
132
+ manual run, not a CI step — it needs a checkpoint download and ~3 GB of
133
+ dependencies.
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """The control: this service's pipeline vs. the `rfdetr` package, same image.
3
+
4
+ WHY THIS EXISTS. `session.py` TRANSCRIBES four steps from `rfdetr` — the
5
+ antialias-free square resize, the ImageNet normalisation, `_select_topk`, and
6
+ `_gather_and_scale_boxes` — because shipping torch + torchvision +
7
+ transformers (~3 GB) to a shop PC to run a 128 MiB graph is not an option. A
8
+ transcription can drift from its source silently: every one of those four steps
9
+ has a plausible wrong version that still returns boxes, and boxes that are
10
+ plausible and wrong are exactly what `docs/runbooks/cctv-detector-tier.md` §5
11
+ was written about.
12
+
13
+ So this runs both and fails on any disagreement. It is the same instinct as §5
14
+ rule 5 — *validate the rig before believing a surprise* — applied to the rig
15
+ itself.
16
+
17
+ NOT A CI STEP, deliberately: it needs the `rfdetr` package and its ~3 GB of
18
+ dependencies plus a checkpoint download. It is a documented manual run, the same
19
+ shape as the live half of `scripts/proposals/endpoint-matrix.js`.
20
+
21
+ uv venv /tmp/rfdetr-ref --python 3.12
22
+ uv pip install --python /tmp/rfdetr-ref/bin/python rfdetr onnx onnxruntime pillow
23
+ /tmp/rfdetr-ref/bin/python -m pip install -e <this dir>
24
+ /tmp/rfdetr-ref/bin/python bench/verify_against_reference.py \\
25
+ --onnx <exported>.onnx --image ../windows/e2e/fixtures/person.jpg
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
35
+
36
+ from trillboards_detector.session import OnnxDetector # noqa: E402
37
+
38
+
39
+ def main() -> int:
40
+ ap = argparse.ArgumentParser()
41
+ ap.add_argument("--onnx", required=True)
42
+ ap.add_argument("--image", required=True)
43
+ ap.add_argument("--threshold", type=float, default=0.25)
44
+ # Tolerances. The exported fp32 graph is numerically identical to the `.pth`
45
+ # to ~1e-6 per the runbook, so the only real slack is the resize kernel:
46
+ # Pillow's BILINEAR and torchvision's antialias=False agree closely but not
47
+ # bit-for-bit, which moves a confidence in the third decimal and a box edge
48
+ # by well under a pixel.
49
+ ap.add_argument("--conf-tol", type=float, default=0.02)
50
+ ap.add_argument("--box-tol-px", type=float, default=2.0)
51
+ args = ap.parse_args()
52
+
53
+ from PIL import Image
54
+ from rfdetr import RFDETRMedium
55
+
56
+ image_path = Path(args.image)
57
+ with Image.open(image_path) as im:
58
+ width, height = im.size
59
+ print(f"image: {image_path} ({width}x{height})")
60
+
61
+ # ── the reference ────────────────────────────────────────────────────────
62
+ reference = RFDETRMedium()
63
+ ref = reference.predict(str(image_path), threshold=args.threshold)
64
+ ref_rows = sorted(
65
+ (
66
+ str(name),
67
+ round(float(conf), 4),
68
+ tuple(round(float(v), 2) for v in box),
69
+ )
70
+ for name, conf, box in zip(
71
+ ref.data.get("class_name", [""] * len(ref.confidence)),
72
+ ref.confidence,
73
+ ref.xyxy,
74
+ )
75
+ )
76
+
77
+ # ── this service ─────────────────────────────────────────────────────────
78
+ detector = OnnxDetector(Path(args.onnx), "rfdetr_medium")
79
+ batch = detector.detect(image_path.read_bytes(), args.threshold, None)
80
+ ours_rows = sorted(
81
+ (d.label, round(d.confidence, 4), (round(d.x, 2), round(d.y, 2), round(d.x + d.width, 2), round(d.y + d.height, 2)))
82
+ for d in batch.detections
83
+ )
84
+
85
+ print(f"\nreference ({len(ref_rows)}):")
86
+ for r in ref_rows:
87
+ print(" ", r)
88
+ print(f"\nthis service ({len(ours_rows)}): [inference {batch.inference_ms:.1f} ms]")
89
+ for r in ours_rows:
90
+ print(" ", r)
91
+
92
+ problems: list[str] = []
93
+ if len(ref_rows) != len(ours_rows):
94
+ problems.append(f"detection COUNT differs: reference {len(ref_rows)} vs ours {len(ours_rows)}")
95
+ else:
96
+ for i, (a, b) in enumerate(zip(ref_rows, ours_rows)):
97
+ if a[0] != b[0]:
98
+ problems.append(f"row {i}: label {a[0]!r} vs {b[0]!r}")
99
+ continue
100
+ if abs(a[1] - b[1]) > args.conf_tol:
101
+ problems.append(f"row {i} ({a[0]}): confidence {a[1]} vs {b[1]}")
102
+ for axis, (u, v) in enumerate(zip(a[2], b[2])):
103
+ if abs(u - v) > args.box_tol_px:
104
+ problems.append(f"row {i} ({a[0]}): box[{axis}] {u} vs {v}")
105
+
106
+ if problems:
107
+ print("\nDISAGREEMENT — the transcription in session.py has drifted:")
108
+ for p in problems:
109
+ print(" -", p)
110
+ return 1
111
+
112
+ print(
113
+ f"\nAGREE: {len(ref_rows)} detection(s), labels identical, "
114
+ f"confidence within {args.conf_tol}, boxes within {args.box_tol_px}px."
115
+ )
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ raise SystemExit(main())
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "trillboards-detector"
3
+ version = "0.1.0"
4
+ description = "On-device object-detector service for the Trillboards BYO-CCTV connector (loopback HTTP, ONNX Runtime)."
5
+ requires-python = ">=3.10"
6
+
7
+ # PINNED, for the same reason litert-lm is pinned, and the reason is written out
8
+ # in `deploy/macos/scripts/setup-litertlm.sh`: an UNPINNED `uv tool install`
9
+ # installs whatever is current on the day a customer happens to reinstall, and
10
+ # the thing that then changes is not a changelog entry — it is the number this
11
+ # whole placement decision was made on. `docs/runbooks/cctv-detector-tier.md`
12
+ # picks RF-DETR Medium over the on-device VLM on a measured ~63 ms, and ORT's
13
+ # CPU execution provider is where that ms lives: kernel fusion, thread
14
+ # heuristics and SIMD dispatch all move between minor releases. litert-lm's
15
+ # accelerator behaviour moved twice with no release of ours and reproduced on no
16
+ # dev box; the pin is what stops that shape repeating here.
17
+ #
18
+ # These are the versions the service was measured against — see
19
+ # `docs/runbooks/cctv-detector-tier.md` §Runtime. A bump is a re-measurement,
20
+ # not a version-number edit.
21
+ dependencies = [
22
+ "onnxruntime==1.29.0",
23
+ "numpy==2.5.2",
24
+ "pillow==12.3.0",
25
+ ]
26
+
27
+ [project.scripts]
28
+ trillboards-detector = "trillboards_detector.cli:main"
29
+
30
+ # HATCHLING, NOT SETUPTOOLS, AND THAT IS A CORRECTNESS CHOICE.
31
+ #
32
+ # setuptools stages the package into a `build/lib/` directory and copies from
33
+ # THERE on later builds. A stale `build/` therefore silently shadows the real
34
+ # source: measured here on 2026-08-23, `uv tool install --force` reported
35
+ # "Installed 1 executable" and installed the PREVIOUS revision of server.py,
36
+ # with no warning anywhere. On a customer machine that is the whole failure
37
+ # shape this codebase keeps hitting — an operation that reports success while
38
+ # doing nothing — and it would ship an old detector behind a green install.
39
+ #
40
+ # Hatchling builds from the source tree with no intermediate staging directory
41
+ # and writes no `.egg-info`, so the staleness has nowhere to live. `.gitignore`
42
+ # and the two installer staging excludes cover the leftovers of the old
43
+ # backend; this makes new ones impossible.
44
+ [build-system]
45
+ requires = ["hatchling"]
46
+ build-backend = "hatchling.build"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["trillboards_detector"]
File without changes
@@ -0,0 +1,108 @@
1
+ """The artifact's home, and the label map's invariants. Stdlib only.
2
+
3
+ `resolve_data_dir` has to agree with `config.ts`'s `DEFAULTS.dataDir` exactly:
4
+
5
+ path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards')
6
+
7
+ If the Python service and the Node process that downloads the model disagree
8
+ about that directory, the detector is permanently unhealthy on a machine whose
9
+ artifact is sitting right there — and `/health` would report the truth about a
10
+ path nobody else uses.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import unittest
16
+ from pathlib import Path
17
+
18
+ from trillboards_detector.labels import (
19
+ COCO91_CLASS_NAMES,
20
+ EXPECTED_LOGIT_SLOTS,
21
+ resolve_requested_classes,
22
+ )
23
+ from trillboards_detector.modelpath import (
24
+ DEFAULT_MODEL_NAME,
25
+ resolve_data_dir,
26
+ resolve_model_path,
27
+ )
28
+
29
+
30
+ class TestModelPath(unittest.TestCase):
31
+ def test_home_wins(self):
32
+ env = {"HOME": "/Users/x", "USERPROFILE": r"C:\Users\y"}
33
+ self.assertEqual(resolve_data_dir(env), Path("/Users/x/.trillboards"))
34
+
35
+ def test_userprofile_is_the_windows_fallback(self):
36
+ self.assertEqual(
37
+ resolve_data_dir({"USERPROFILE": r"C:\Users\y"}), Path(r"C:\Users\y") / ".trillboards"
38
+ )
39
+
40
+ def test_tmp_is_the_last_resort_exactly_as_in_config_ts(self):
41
+ self.assertEqual(resolve_data_dir({}), Path("/tmp/.trillboards"))
42
+
43
+ def test_an_empty_home_falls_through_rather_than_resolving_to_dot_trillboards(self):
44
+ """`''` is falsy in JS too, so `config.ts` skips it. A Python `.get`
45
+ returning `''` would otherwise produce a RELATIVE `.trillboards`, i.e. a
46
+ different directory per working directory."""
47
+ self.assertEqual(resolve_data_dir({"HOME": "", "USERPROFILE": ""}), Path("/tmp/.trillboards"))
48
+
49
+ def test_the_default_layout_is_one_directory_per_model_name(self):
50
+ p = resolve_model_path(DEFAULT_MODEL_NAME, {"HOME": "/h"})
51
+ self.assertEqual(p, Path("/h/.trillboards/models/rfdetr_medium/model.onnx"))
52
+
53
+ def test_a_fixed_file_name_means_an_update_reclaims_the_old_bytes(self):
54
+ """No version in the path, deliberately. The model OTA already orphans
55
+ 752 MiB per update and nothing GCs abandoned model dirs; a
56
+ version-named directory would add 128 MiB per release on a box with no
57
+ disk-space preflight anywhere on this path."""
58
+ a = resolve_model_path("rfdetr_medium", {"HOME": "/h"})
59
+ b = resolve_model_path("rfdetr_medium", {"HOME": "/h"})
60
+ self.assertEqual(a, b)
61
+
62
+ def test_the_explicit_override_wins_over_everything(self):
63
+ env = {"HOME": "/h", "TRILLBOARDS_DETECTOR_MODEL": "/bench/rfdetr.onnx"}
64
+ self.assertEqual(resolve_model_path(env=env), Path("/bench/rfdetr.onnx"))
65
+
66
+ def test_the_data_dir_override_relocates_the_whole_store(self):
67
+ env = {"HOME": "/h", "TRILLBOARDS_DATA_DIR": "/var/lib/trillboards"}
68
+ self.assertEqual(
69
+ resolve_model_path("rfdetr_medium", env),
70
+ Path("/var/lib/trillboards/models/rfdetr_medium/model.onnx"),
71
+ )
72
+
73
+
74
+ class TestLabelMap(unittest.TestCase):
75
+ def test_person_is_slot_1_which_is_what_the_eval_scored(self):
76
+ """`detector-detections/rfdetr-medium.json` records
77
+ `class_selected: person, class_index: 1, class_confirmed_live: true`.
78
+ Every accuracy number in the runbook is a claim about THAT index."""
79
+ self.assertEqual(COCO91_CLASS_NAMES[1], "person")
80
+
81
+ def test_the_map_fits_inside_the_declared_logit_width(self):
82
+ self.assertLess(max(COCO91_CLASS_NAMES), EXPECTED_LOGIT_SLOTS)
83
+ self.assertEqual(EXPECTED_LOGIT_SLOTS, 91)
84
+
85
+ def test_it_is_the_80_named_coco_categories_with_the_gaps_intact(self):
86
+ """80 names across a 91-wide axis. The eleven gaps are real — they are
87
+ why an unnamed slot has to be dropped rather than guessed."""
88
+ self.assertEqual(len(COCO91_CLASS_NAMES), 80)
89
+ for gap in (0, 12, 26, 29, 30, 45, 66, 68, 69, 71, 83):
90
+ self.assertNotIn(gap, COCO91_CLASS_NAMES)
91
+
92
+ def test_names_are_unique_so_a_filter_cannot_be_ambiguous(self):
93
+ names = list(COCO91_CLASS_NAMES.values())
94
+ self.assertEqual(len(names), len(set(names)))
95
+
96
+ def test_requested_classes_fold_case_underscores_and_hyphens(self):
97
+ wanted, unknown = resolve_requested_classes(["Person", "CELL_PHONE", "dining-table"])
98
+ self.assertEqual(wanted, {"person", "cell phone", "dining table"})
99
+ self.assertEqual(unknown, [])
100
+
101
+ def test_an_unknown_class_is_returned_never_silently_dropped(self):
102
+ wanted, unknown = resolve_requested_classes(["person", "shoplifter"])
103
+ self.assertEqual(wanted, {"person"})
104
+ self.assertEqual(unknown, ["shoplifter"])
105
+
106
+
107
+ if __name__ == "__main__":
108
+ unittest.main()