Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions v2/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions v2/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ members = [
# cognitum-cluster-*, ruvultra). The companion appliance-side crate
# lives in cognitum-one/v0-appliance as `cognitum-pose-estimation`.
"crates/cog-pose-estimation",
# ADR-103: Learned multi-person counter (SOTA path) — replaces the
# PR #491 slot heuristic with a Candle network + Stoer-Wagner fusion.
# Motivated by #499 ghost-skeleton reports.
"crates/cog-person-count",
# rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout):
# lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
Expand Down
42 changes: 42 additions & 0 deletions v2/crates/cog-person-count/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[package]
name = "cog-person-count"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Cognitum Cog: learned multi-person counter from WiFi CSI (ADR-103). Replaces the PR #491 slot heuristic with a Candle-based count head + Stoer-Wagner multi-node fusion."
publish = false

[[bin]]
name = "cog-person-count"
path = "src/main.rs"

[lib]
name = "cog_person_count"
path = "src/lib.rs"

[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] }
sha2 = "0.10"
ureq = { version = "2", default-features = false, features = ["tls"] }
# Same Candle stack the pose cog uses — CPU by default, `cuda` feature
# opt-in for hosts with a CUDA GPU.
candle-core = { version = "0.9", default-features = false }
candle-nn = { version = "0.9", default-features = false }
safetensors = "0.4"

[dev-dependencies]
tempfile = "3"
approx = "0.5"

[features]
default = []
cuda = ["candle-core/cuda", "candle-nn/cuda"]
hailo = []
79 changes: 79 additions & 0 deletions v2/crates/cog-person-count/cog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Person Count Cog

Learned multi-person counter for WiFi CSI — designed in [ADR-103](../../../../docs/adr/ADR-103-learned-multi-person-counter.md), packaged per [ADR-100](../../../../docs/adr/ADR-100-cog-packaging-specification.md), discoverable through [ADR-102](../../../../docs/adr/ADR-102-edge-module-registry.md).

## What it does

Replaces the PR #491 slot heuristic (`subcarrier_diversity / dedup_factor`) with a Candle network that emits a calibrated count distribution + confidence per CSI window. Multi-node deployments fuse N per-node predictions through a confidence-weighted log-sum (Bayesian product of experts), optionally bounded above by a Stoer-Wagner min-cut from the subcarrier-similarity graph.

## Output (per frame)

```json
{
"ts": 1779210883.444,
"level": "info",
"event": "person.count",
"fields": {
"tick": 12345,
"count": 2,
"confidence": 0.81,
"count_p95_low": 1,
"count_p95_high": 3,
"n_nodes": 3,
"probs": [0.01, 0.03, 0.81, 0.13, 0.01, 0.005, 0.003, 0.002]
}
}
```

Downstream consumers can render the **most-likely count** when confidence is high, or fall back to a `[lo, hi]` band with a "?" badge when the model is uncertain — that's how this Cog closes the loop on #499's ghost-skeleton UX.

## Status — v0.0.1 (this scaffold)

| Component | State |
|---|---|
| Crate compiles, library API stable | ✅ |
| Tests pass (`cargo test -p cog-person-count`) | ✅ |
| Four-verb runtime contract (`version`, `manifest`, `health`) | ✅ |
| `run` subcommand (long-running loop) | ⏳ v0.0.1 follow-up |
| Trained `count_v1.safetensors` artifact | ⏳ same training pipeline that produced `pose_v1` — bootstrap on the existing 1,077 paired samples |
| Signed binary on GCS | ⏳ once trained |
| Stoer-Wagner min-cut clip in fusion stage | ⏳ v0.2.0 (hook in `fusion::fuse_with_mincut_clip` is stubbed) |

The stub backend emits a "1 person, confidence 0" prediction so the dashboard surfaces "no model yet" honestly until the trained safetensors lands.

## Security

The cog has a very small attack surface — by design, it's a pure consumer of CSI data, not a server:

| Threat | Mitigation |
|---|---|
| Untrusted model file mmap | `count_v1.safetensors` is loaded via `VarBuilder::from_mmaped_safetensors` (`unsafe` block, documented). The release pipeline signs the file with `COGNITUM_OWNER_SIGNING_KEY` per ADR-100; the appliance's cog-gateway verifies the Ed25519 signature against `weights_sha256` before placing the file under `/var/lib/cognitum/apps/person-count/`. |
| Non-finite outputs from a corrupted model | `CountPrediction::is_finite()` is checked in `cmd_health` and in the v0.0.1 run-loop before any `person.count` event is emitted; non-finite outputs fail-closed. |
| Sensing-server fetch failures | When the sensing source goes away the cog emits a `WARN` event and skips the frame — same fail-open-as-log pattern as `cog-pose-estimation`. No crash, no leaked file descriptors, no stuck `pid` file. |
| Fusion divide-by-zero / log-of-zero | `fuse_confidence_weighted` floors confidences at `1e-3` and floors probabilities at `1e-9` before taking logs. Empty input returns the stub default rather than NaN-propagating. |
| Over-the-cap mass after min-cut clip | `fuse_with_mincut_clip` re-normalises the surviving prefix; if all mass was above the cap (degenerate case), it places mass at the cap class rather than producing a zero distribution. |
| Output spoofing via stdout | Events go to stdout exactly as ADR-100's runtime contract specifies — the cog-gateway parses each line as JSON. No interactive prompts, no shell escapes, no ANSI control sequences from this cog. |

The cog opens **zero** network listeners and writes to **zero** files under `/var/lib/cognitum/apps/person-count/` beyond the standard `pid`, `output.log`, and `error.log` that the cog-gateway manages externally.

## Performance / optimization

Release build: **2.36 MB stripped binary** on `x86_64-unknown-linux-gnu` (smaller than `cog-pose-estimation`'s 4.5 MB because we don't transitively pull `wifi-densepose-train`).

Workspace release profile already enables `opt-level = 3`, `lto = "fat"`, `codegen-units = 1`, `strip = true`. No further per-cog optimization knobs needed.

Cold-start latency (30 sequential `health` invocations, Windows x86_64, candle-cpu backend):

| Cog | Cold-start |
|---|---|
| `cog-pose-estimation` | 76.2 ms |
| **`cog-person-count`** | **53.3 ms** |

Long-running `run` warm inference: sub-millisecond per frame in the stub backend (single softmax over 8 classes is essentially free). The trained-model warm path is bounded by the three Conv1d layers — projected ≤ 2 ms on a Pi 5 once `count_v1.safetensors` lands, well under the ≤ 5 ms ADR-103 budget.

## See also

- ADR-103 — Design, SOTA comparison, acceptance gates.
- ADR-100 — Cog packaging spec.
- PR #491 — The heuristic this Cog replaces.
- Issue #499 — Original "double skeletons" report that motivated ADR-103.
25 changes: 25 additions & 0 deletions v2/crates/cog-person-count/cog/config.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cognitum.one/schemas/cog-person-count-config-v1.json",
"title": "Person Count Cog Runtime Config",
"type": "object",
"additionalProperties": false,
"properties": {
"sensing_url": {
"type": "string",
"format": "uri",
"default": "http://127.0.0.1:3000/api/v1/sensing/latest"
},
"model_path": {
"type": "string",
"description": "Filesystem path to count_v1.safetensors. Resolved relative to /var/lib/cognitum/apps/person-count/ when not absolute."
},
"poll_ms": {
"type": "integer",
"minimum": 10,
"maximum": 1000,
"default": 40
}
},
"required": ["model_path"]
}
17 changes: 17 additions & 0 deletions v2/crates/cog-person-count/cog/manifest.template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "person-count",
"version": "{{VERSION}}",
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/{{ARCH}}/cog-person-count-{{ARCH}}",
"binary_bytes": 0,
"binary_sha256": "",
"binary_signature": "",
"weights_url": "https://storage.googleapis.com/cognitum-apps/cogs/{{ARCH}}/cog-person-count-count_v1.safetensors",
"weights_bytes": 0,
"weights_sha256": "",
"arch": "{{ARCH}}",
"target_triple": "{{TARGET_TRIPLE}}",
"installed_at": 0,
"status": "installed",
"signed_by": "COGNITUM_OWNER_SIGNING_KEY",
"sig_algo": "Ed25519"
}
181 changes: 181 additions & 0 deletions v2/crates/cog-person-count/src/fusion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Multi-node fusion — combine N per-node count distributions into one.
//!
//! v0.1.0 ships **confidence-weighted log-sum** (Bayesian product of expert
//! distributions): the more confident a node, the more its distribution
//! shapes the fused output. With one node the fusion is a no-op; with N
//! nodes uncertainty can only go down (or stay equal), never up.
//!
//! v0.2.0 will add a **Stoer-Wagner min-cut upper bound** on the fused
//! distribution — see ADR-103 §"Multi-node fusion". That requires
//! `ruvector-mincut` as a workspace dep on this crate; it's stubbed below
//! behind `fuse_with_mincut_clip()` so callers can opt in once the dep
//! lands and the min-cut graph builder for our subcarrier feature
//! similarities is ready.

use crate::inference::{CountPrediction, COUNT_CLASSES};

/// Confidence-weighted log-sum of per-node count distributions.
///
/// For each class k, computes `log p_fused(k) = Σ_n c_n · log p_n(k)`,
/// then re-normalises. The fused `confidence` is the **maximum** per-node
/// confidence rather than the average — having at least one confident
/// observation is worth more than many low-confidence ones.
///
/// Edge cases:
/// * Empty input → 1-person, 0-confidence default (matches the stub).
/// * Single input → returned as-is (defined behaviour, no-op).
/// * Zero confidences across all nodes → unweighted log-sum.
pub fn fuse_confidence_weighted(preds: &[CountPrediction]) -> CountPrediction {
if preds.is_empty() {
let mut probs = [0.0_f32; COUNT_CLASSES];
probs[1] = 1.0;
return CountPrediction { probs, confidence: 0.0 };
}
if preds.len() == 1 {
return preds[0].clone();
}

// Compute weights c_n with a small floor so zero-confidence nodes still
// contribute (log-of-zero would otherwise blow the math up).
const EPS_CONF: f32 = 1e-3;
let weights: Vec<f32> = preds.iter().map(|p| p.confidence.max(EPS_CONF)).collect();
let weight_sum: f32 = weights.iter().sum();

// Log-sum.
let mut log_p = [0.0_f32; COUNT_CLASSES];
for (pred, &w) in preds.iter().zip(weights.iter()) {
for k in 0..COUNT_CLASSES {
let p = pred.probs[k].max(1e-9); // floor to avoid log(0)
log_p[k] += (w / weight_sum) * p.ln();
}
}

// Subtract max for numerical stability, exponentiate, renormalise.
let m = log_p.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut p = [0.0_f32; COUNT_CLASSES];
let mut s = 0.0_f32;
for k in 0..COUNT_CLASSES {
p[k] = (log_p[k] - m).exp();
s += p[k];
}
if s > 0.0 {
for k in 0..COUNT_CLASSES { p[k] /= s; }
} else {
// Pathological — fall back to uniform.
for k in 0..COUNT_CLASSES { p[k] = 1.0 / COUNT_CLASSES as f32; }
}

let conf = preds.iter().map(|x| x.confidence).fold(0.0_f32, f32::max);
CountPrediction { probs: p, confidence: conf }
}

/// **Stoer-Wagner-clipped fusion** — v0.2.0 hook.
///
/// Takes the same per-node predictions plus a **max-distinct-persons**
/// upper bound derived from the subcarrier-similarity graph's min-cut.
/// Clips the fused distribution to `{0..=max}` and re-normalises.
///
/// Live `ruvector_mincut` integration lands in a follow-up PR; this entry
/// point is here so the runtime can wire to it without an API break.
pub fn fuse_with_mincut_clip(preds: &[CountPrediction], max_distinct: usize) -> CountPrediction {
let mut fused = fuse_confidence_weighted(preds);
let max_idx = max_distinct.min(COUNT_CLASSES - 1);
let mut leak = 0.0_f32;
for k in (max_idx + 1)..COUNT_CLASSES {
leak += fused.probs[k];
fused.probs[k] = 0.0;
}
if leak > 0.0 {
// Re-normalise the surviving prefix.
let sum: f32 = fused.probs[..=max_idx].iter().sum();
if sum > 0.0 {
for k in 0..=max_idx {
fused.probs[k] /= sum;
}
} else {
// All mass was above the cap — degenerate; place mass at the cap.
fused.probs[max_idx] = 1.0;
}
}
fused
}

#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;

fn pred(probs: [f32; 8], conf: f32) -> CountPrediction {
CountPrediction { probs, confidence: conf }
}

#[test]
fn empty_returns_one_person_default() {
let p = fuse_confidence_weighted(&[]);
assert_eq!(p.argmax(), 1);
assert_eq!(p.confidence, 0.0);
}

#[test]
fn single_input_is_passthrough() {
let probs = [0.0, 0.1, 0.7, 0.2, 0.0, 0.0, 0.0, 0.0];
let p = fuse_confidence_weighted(&[pred(probs, 0.8)]);
assert_eq!(p.argmax(), 2);
assert_relative_eq!(p.confidence, 0.8, max_relative = 1e-6);
}

#[test]
fn two_agreeing_nodes_sharpen_the_peak() {
// Both nodes vote 2 with moderate spread. Fusion should sharpen.
let probs = [0.05, 0.15, 0.60, 0.15, 0.05, 0.0, 0.0, 0.0];
let fused = fuse_confidence_weighted(&[pred(probs, 0.7), pred(probs, 0.7)]);
assert_eq!(fused.argmax(), 2);
assert!(
fused.probs[2] >= probs[2],
"expected fusion to sharpen the peak: pre={} post={}",
probs[2], fused.probs[2]
);
}

#[test]
fn high_confidence_node_overrides_low_confidence_disagreement() {
let strong = [0.0, 0.95, 0.05, 0.0, 0.0, 0.0, 0.0, 0.0]; // says 1
let weak = [0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.4]; // weak, says 7
let fused = fuse_confidence_weighted(&[pred(strong, 0.95), pred(weak, 0.05)]);
assert_eq!(fused.argmax(), 1, "high-confidence vote should win");
}

#[test]
fn fusion_preserves_normalisation() {
let a = [0.1, 0.2, 0.3, 0.2, 0.1, 0.05, 0.03, 0.02];
let b = [0.05, 0.25, 0.35, 0.20, 0.10, 0.03, 0.01, 0.01];
let fused = fuse_confidence_weighted(&[pred(a, 0.5), pred(b, 0.5)]);
let s: f32 = fused.probs.iter().sum();
assert_relative_eq!(s, 1.0, max_relative = 1e-5);
}

#[test]
fn mincut_clip_caps_distribution_at_max_distinct() {
let probs = [0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.3, 0.2]; // mass on 5,6,7
let clipped = fuse_with_mincut_clip(&[pred(probs, 0.9)], 4);
// Anything above 4 must be zero
for k in 5..8 {
assert_eq!(clipped.probs[k], 0.0, "class {} should be clipped to 0", k);
}
// What's left has to renormalise to sum to 1 — even though pre-clip
// mass below 4 was zero, the degenerate fallback places mass at the cap.
let s: f32 = clipped.probs.iter().sum();
assert_relative_eq!(s, 1.0, max_relative = 1e-5);
assert_eq!(clipped.argmax(), 4);
}

#[test]
fn p95_range_is_inclusive_and_covers_at_least_95pct() {
let probs = [0.05, 0.6, 0.25, 0.05, 0.03, 0.01, 0.005, 0.005];
let p = pred(probs, 0.9);
let (lo, hi) = p.p95_range();
assert!(lo <= 1 && hi >= 1, "mode (1) must be inside [{}, {}]", lo, hi);
let mass: f32 = probs[lo..=hi].iter().sum();
assert!(mass >= 0.95, "[{}, {}] only covers {:.3}, need >= 0.95", lo, hi, mass);
}
}
Loading
Loading