Files
ruvnet--RuView/v2/crates/cog-person-count/tests/smoke.rs
T
rUv 6959a42312 feat(cog-person-count): v0.0.1 scaffold + tests + fusion math + bench (ADR-103) (#694)
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.
2026-05-21 18:46:57 -04:00

85 lines
2.6 KiB
Rust

//! Smoke tests for cog-person-count.
use cog_person_count::{
fusion::{fuse_confidence_weighted, fuse_with_mincut_clip},
inference::{
CountPrediction, CsiWindow, InferenceEngine, SyntheticInput,
COUNT_CLASSES, INPUT_SUBCARRIERS, INPUT_TIMESTEPS,
},
};
#[test]
fn synthetic_window_has_correct_shape() {
let w = SyntheticInput::default().as_window();
assert_eq!(w.data.len(), INPUT_SUBCARRIERS * INPUT_TIMESTEPS);
}
#[test]
fn stub_engine_returns_finite_output() {
let engine = InferenceEngine::with_weights(None).expect("stub engine");
let pred = engine.infer(&SyntheticInput::default().as_window()).expect("infer");
assert!(pred.is_finite());
assert_eq!(pred.probs.len(), COUNT_CLASSES);
let sum: f32 = pred.probs.iter().sum();
assert!((sum - 1.0).abs() < 1e-5, "stub probs must sum to 1, got {}", sum);
assert_eq!(pred.argmax(), 1, "stub default is 1-person");
assert_eq!(pred.confidence, 0.0, "stub confidence is 0");
}
#[test]
fn engine_rejects_wrong_shape_input() {
let engine = InferenceEngine::with_weights(None).expect("stub engine");
let bad = CsiWindow { data: vec![0.0; 10] };
assert!(engine.infer(&bad).is_err());
}
#[test]
fn stub_backend_string_is_stable() {
let engine = InferenceEngine::with_weights(None).expect("stub engine");
assert_eq!(engine.backend(), "stub");
}
#[test]
fn p95_range_includes_mode() {
// Sharp peak at 2
let mut probs = [0.0_f32; COUNT_CLASSES];
probs[2] = 0.85;
probs[1] = 0.08;
probs[3] = 0.07;
let p = CountPrediction { probs, confidence: 0.9 };
let (lo, hi) = p.p95_range();
assert!(lo <= 2 && hi >= 2);
}
#[test]
fn fusion_with_no_inputs_is_safe_default() {
let p = fuse_confidence_weighted(&[]);
assert_eq!(p.argmax(), 1);
assert_eq!(p.confidence, 0.0);
}
#[test]
fn fusion_passes_through_single_node() {
// A single-node ESP32 deployment must produce the same output as the
// raw inference — fusion is a no-op for N=1.
let mut probs = [0.0_f32; COUNT_CLASSES];
probs[3] = 1.0;
let input = CountPrediction { probs, confidence: 0.6 };
let out = fuse_confidence_weighted(&[input.clone()]);
assert_eq!(out.argmax(), 3);
assert!((out.confidence - 0.6).abs() < 1e-6);
}
#[test]
fn mincut_clip_with_high_cap_is_noop() {
let mut probs = [0.0_f32; COUNT_CLASSES];
probs[2] = 0.5;
probs[3] = 0.5;
let input = CountPrediction { probs, confidence: 0.7 };
let clipped = fuse_with_mincut_clip(&[input], 7);
// No clip happened (cap == max class)
assert!((clipped.probs[2] - 0.5).abs() < 1e-6);
assert!((clipped.probs[3] - 0.5).abs() < 1e-6);
}