Phase 4 of ADR-103. Adds the long-running polling loop so the cog's
fourth verb (`run`) does real work, completing the ADR-100 runtime
contract end-to-end:
cog-person-count version → "person-count 0.3.0"
cog-person-count manifest → JSON skeleton
cog-person-count health → loads weights + 1-shot infer + emit
cog-person-count run --config → long-running per-frame emit ← THIS
What ships:
* src/runtime.rs (new) — `run_loop` polls sensing_url every poll_ms,
slides a [56, 20] CSI window, runs InferenceEngine::infer, emits
publisher::person_count events. Same shape as
cog-pose-estimation::runtime — fetch_frame extracts amplitudes
from `snapshot.nodes[0].amplitude[]`, fails open on connect errors
with a WARN log rather than crashing.
* src/lib.rs — registers the runtime module.
* src/main.rs — cmd_run now loads RunConfig from a JSON file, builds
the InferenceEngine (with weights if cfg.model_path is set,
otherwise auto-discover), emits a run.started event, and hands off
to the Tokio multi-thread runtime's block_on(run_loop). Single-node
fusion is a no-op for N=1 today; v0.2.0 will append predictions
from sibling nodes and call fusion::fuse_confidence_weighted before
emit.
Verified locally:
cargo check -p cog-person-count --no-default-features → clean
cargo test -p cog-person-count → 15/15 pass (no regressions)
cargo build -p cog-person-count --release → 2.36 MB unchanged
./cog-person-count run --config bad-config.json:
line 1: {"event":"run.started","fields":{"cog":"person-count",
"sensing_url":"http://127.0.0.1:9999/...",poll_ms:100,
"model_path":"(auto-discover)"}}
line 2: WARN sensing-server fetch failed
error=Connection Failed: Connect error: actively refused
(loop alive — exits cleanly on SIGTERM, no crash, no NaN)
Also adds a "Relationship to the in-process score_to_person_count
heuristic" section to cog/README.md explaining the dual-emitter
design (sensing-server keeps emitting the PR #491 slot heuristic;
the cog runs out-of-process and emits person.count events from the
learned model). Operators choose by installing the cog or not — no
sensing-server rebuild required.
ADR-103 §"Migration" status:
1. Land ADR + scaffold ........... done (#693, #694)
2. Train count_v1 ................ done (#695)
3. Cross-compile + sign + GCS .... done (#696)
4. Server-side wiring ............ done — out-of-process design
means no rewire needed; this
cog is the wiring.
5. v0.2.0 multi-room + LoRA ...... data-bound (#645)
First implementation PR for ADR-103. Same incremental shape that
ADR-101 used: scaffold the cog crate, ship a stub-backend release
that satisfies the runtime contract + 15 tests + measured cold-start,
then follow up with the trained count_v1.safetensors in a separate PR.
What ships:
* v2/crates/cog-person-count/ — new workspace member.
- Cargo.toml: candle-core/candle-nn 0.9 (cpu default, cuda feature
opt-in), safetensors, ureq, sha2 — same dep shape as the pose cog
but minus wifi-densepose-train (this cog has no training-side
consumer, so the dep tree is materially smaller → 2.36 MB
binary vs the pose cog's 4.5 MB).
- src/inference.rs: CountNet (Conv1d 56→64→128→128 encoder + count
head Linear(128→64→8)+softmax + confidence head
Linear(128→32→1)+sigmoid). Stub backend returns
`{1-person, 0-confidence}` honestly when no safetensors present.
- src/fusion.rs: fuse_confidence_weighted() — Bayesian product of
per-node distributions with confidence-weighted log-sum, plus
fuse_with_mincut_clip() hook for the v0.2.0 Stoer-Wagner
upper-bound (`ruvector-mincut` dep lands when min-cut graph
builder is ready). Confidences floored at 1e-3 and probs floored
at 1e-9 before logs — no NaN propagation.
- src/publisher.rs: emits {count, confidence, count_p95_low,
count_p95_high, n_nodes, probs} per ADR-103 §"Output".
- src/main.rs: full ADR-100 four-verb CLI (version|manifest|health
|run). The `run` subcommand explicitly returns "wiring pending
v0.0.1" so the in-process library API is the v0.0.1-clean
integration path.
- tests/smoke.rs (8 tests) + fusion::tests (7 tests, in-lib) — 15
total, all green. Cover stub-backend behaviour, wrong-shape
rejection, fusion math (empty / single / agreement / high-conf
override / normalisation), p95-range correctness, and min-cut
clip semantics.
- cog/{manifest.template.json, config.schema.json, README.md} +
cog/artifacts/ placeholder dir.
* v2/Cargo.toml: registers the new workspace member.
Verified locally:
cargo check -p cog-person-count --no-default-features → clean
cargo test -p cog-person-count --no-default-features → 8/8 pass
cargo test -p cog-person-count --lib → 7/7 pass
cargo build -p cog-person-count --release → 2.36 MB binary
./cog-person-count version → "person-count 0.3.0"
./cog-person-count manifest → JSON skeleton
./cog-person-count health → backend:stub,
count:1, conf:0,
p95:[1,1]
Cold-start: 30 sequential `health` invocations → 53.3 ms/invocation
(vs cog-pose-estimation's 76.2 ms — smaller dep tree)
cog/README.md adds:
* Security section — six-row threat table covering safetensor mmap
trust, non-finite outputs, sensing fetch failures, fusion
divide-by-zero / log-of-zero, min-cut degenerate cases, and stdout
spoofing.
* Performance / optimization section — binary size, release profile
(already opt-level=3 / lto=fat / codegen-units=1 / strip=true at
workspace level), cold-start comparison table, projected warm-path
latency budget.
Still pending (separate PRs, ADR-103 §"Migration"):
* Train count_v1.safetensors on the existing 1,077 paired samples
with `n_persons` labels (Candle on RTX 5080, same script that
produced pose_v1.safetensors yesterday).
* `run` subcommand wiring (long-running polling loop, same shape as
cog-pose-estimation::runtime).
* Cross-compile + sign + GCS upload (mirror of cog-pose-estimation
release pipeline).
* Server-side `csi.rs::score_to_person_count` call-site rewire to
consume this cog when installed; falls back to PR #491's heuristic
when not.