mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
49c594822f
The layer that turns the certificate spine into a modality-agnostic perception substrate. Four crates, all deterministic and green independently (43 tests). ruview-hal (ADR-317): one abstraction mapping any modality (CSI/802.11bf/BLE/ UWB/mmWave/acoustic/camera/lidar/IMU/custom) to a canonical ontology Observation. SensorHal trait + two SYNTHETIC/L0 reference adapters; malformed input yields a degraded UNKNOWN observation, never a panic; synthetic can never alias measured. 8 tests. ruview-groundtruth (ADR-300): reference sensors as a formal VALIDATION plane (never an estimator input, enforced by the type boundary); modality-agnostic ReferenceSeries, deterministic cross-correlation alignment, AgreementReport with mandatory SessionScope, emitting per-context ruview-evidence records; Measured requires reference + coverage + reproducer. 15 tests. ruview-track (ADR-304): privacy-preserving persistent tracks (opaque person ids, coarse non-reversible features, no civil-identity binding); ambiguous detections stay tentative rather than misassigned; cross-zone hand-off. 8 tests. ruview-fusion (ADR-308): multiple HalObservations -> one probabilistic WorldState, uncertainty-aware (confidence-weighted, not naive averaging); irreconcilable conflict or insufficient coverage yields UNKNOWN, not a confident average. 9+ tests incl. irreconcilable_conflict_yields_unknown. Flips ADR-300/304/308/317 to implemented; registers the four crates as workspace members. SYNTHETIC/L0 throughout; no hardware/MEASURED claims. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
278 lines
9.8 KiB
Rust
278 lines
9.8 KiB
Rust
//! ADR-304 scenario tests: continuity, no-swap, spawn/expire, ambiguity,
|
|
//! id opacity, and determinism. All fixtures are synthetic and in-code; time is
|
|
//! injected (no wall clock); no randomness.
|
|
|
|
use ruview_ontology::{Container, EvidenceLevel, SpaceId};
|
|
use ruview_track::*;
|
|
|
|
fn space(id: &str) -> Container {
|
|
Container::Space {
|
|
id: SpaceId::new(id).unwrap(),
|
|
}
|
|
}
|
|
|
|
fn feat(v: &[f64]) -> CoarseFeature {
|
|
CoarseFeature::quantize(v).unwrap()
|
|
}
|
|
|
|
/// kitchen ▸ hallway ▸ bedroom, wired as a corridor.
|
|
fn corridor() -> Topology {
|
|
let mut t = Topology::new();
|
|
t.connect(&space("kitchen"), &space("hallway"));
|
|
t.connect(&space("hallway"), &space("bedroom"));
|
|
t
|
|
}
|
|
|
|
#[test]
|
|
fn single_target_continuity_across_zones() {
|
|
let mut mgr = TrackManager::new(TrackerConfig::default(), corridor());
|
|
let f = feat(&[0.2, 0.6, 0.3]);
|
|
|
|
let o1 = mgr
|
|
.ingest(Detection::new(space("kitchen"), [1.0, 1.0], f.clone(), 1_000).unwrap())
|
|
.unwrap();
|
|
let o2 = mgr
|
|
.ingest(Detection::new(space("hallway"), [1.3, 1.1], f.clone(), 1_500).unwrap())
|
|
.unwrap();
|
|
let o3 = mgr
|
|
.ingest(Detection::new(space("bedroom"), [1.6, 1.0], f, 2_000).unwrap())
|
|
.unwrap();
|
|
|
|
// One persistent entity, one pseudonym across all three rooms.
|
|
assert_eq!(mgr.len(), 1);
|
|
assert_eq!(o1.person, o2.person);
|
|
assert_eq!(o2.person, o3.person);
|
|
assert!(matches!(o2.association, Association::Matched { .. }));
|
|
assert!(matches!(o3.association, Association::Matched { .. }));
|
|
|
|
// History reads kitchen -> hallway -> bedroom.
|
|
let traj = mgr.trajectory(&o1.track).unwrap();
|
|
assert_eq!(
|
|
traj,
|
|
vec![space("kitchen"), space("hallway"), space("bedroom")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn topology_blocks_non_adjacent_handoff() {
|
|
// kitchen and bedroom are NOT adjacent (no hallway hop recorded here).
|
|
let mut t = Topology::new();
|
|
t.connect(&space("kitchen"), &space("hallway"));
|
|
let mut mgr = TrackManager::new(TrackerConfig::default(), t);
|
|
let f = feat(&[0.2, 0.6, 0.3]);
|
|
|
|
let a = mgr
|
|
.ingest(Detection::new(space("kitchen"), [1.0, 1.0], f.clone(), 1_000).unwrap())
|
|
.unwrap();
|
|
let b = mgr
|
|
.ingest(Detection::new(space("bedroom"), [1.0, 1.0], f, 1_200).unwrap())
|
|
.unwrap();
|
|
|
|
// Non-adjacent: a fresh pseudonym rather than a false join.
|
|
assert_ne!(a.person, b.person);
|
|
assert!(matches!(
|
|
b.association,
|
|
Association::Unknown {
|
|
reason: UnknownReason::TopologyBlocked,
|
|
..
|
|
}
|
|
));
|
|
assert_eq!(mgr.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn two_targets_no_swap_under_separation() {
|
|
let mut mgr = TrackManager::with_defaults(); // single space, empty topology
|
|
let fa = feat(&[0.1, 0.1, 0.1]);
|
|
let fb = feat(&[0.9, 0.9, 0.9]);
|
|
|
|
// Frame 1: two well-separated detections spawn two tracks.
|
|
let f1 = mgr
|
|
.ingest_frame(&[
|
|
Detection::new(space("kitchen"), [0.0, 0.0], fa.clone(), 1_000).unwrap(),
|
|
Detection::new(space("kitchen"), [10.0, 0.0], fb.clone(), 1_000).unwrap(),
|
|
])
|
|
.unwrap();
|
|
let (pa, pb) = (f1[0].person.clone(), f1[1].person.clone());
|
|
let (ta, tb) = (f1[0].track.clone(), f1[1].track.clone());
|
|
assert_ne!(pa, pb);
|
|
|
|
// Several frames of parallel motion, staying separated.
|
|
for k in 1..=5 {
|
|
let t = 1_000 + k * 200;
|
|
let x = k as f64 * 0.1;
|
|
let out = mgr
|
|
.ingest_frame(&[
|
|
Detection::new(space("kitchen"), [x, 0.0], fa.clone(), t).unwrap(),
|
|
Detection::new(space("kitchen"), [10.0 + x, 0.0], fb.clone(), t).unwrap(),
|
|
])
|
|
.unwrap();
|
|
// Each detection stays with its own original track — no swap.
|
|
assert_eq!(out[0].track, ta);
|
|
assert_eq!(out[1].track, tb);
|
|
assert_eq!(out[0].person, pa);
|
|
assert_eq!(out[1].person, pb);
|
|
}
|
|
assert_eq!(mgr.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn track_spawn_and_expire() {
|
|
let mut mgr = TrackManager::with_defaults();
|
|
let f = feat(&[0.5]);
|
|
let out = mgr
|
|
.ingest(Detection::new(space("kitchen"), [0.0, 0.0], f.clone(), 1_000).unwrap())
|
|
.unwrap();
|
|
assert_eq!(mgr.len(), 1);
|
|
assert!(matches!(
|
|
out.association,
|
|
Association::Unknown {
|
|
reason: UnknownReason::NoCandidate,
|
|
..
|
|
}
|
|
));
|
|
assert_eq!(mgr.state(&out.track), Some(TrackState::Tentative));
|
|
|
|
// A second hit confirms the track (default confirm_after = 2).
|
|
let out2 = mgr
|
|
.ingest(Detection::new(space("kitchen"), [0.1, 0.0], f, 1_100).unwrap())
|
|
.unwrap();
|
|
assert_eq!(out2.track, out.track);
|
|
assert_eq!(mgr.state(&out.track), Some(TrackState::Active));
|
|
|
|
// Within the horizon: idle-but-alive (lost), not expired.
|
|
let horizon = mgr.config().max_coast_ms;
|
|
let expired = mgr.tick(1_100 + horizon);
|
|
assert!(expired.is_empty());
|
|
assert_eq!(mgr.len(), 1);
|
|
assert_eq!(mgr.state(&out.track), Some(TrackState::Lost));
|
|
|
|
// Past the horizon: expired and dropped.
|
|
let expired = mgr.tick(1_100 + horizon + 1);
|
|
assert_eq!(expired, vec![out.track.clone()]);
|
|
assert_eq!(mgr.len(), 0);
|
|
assert_eq!(mgr.state(&out.track), None);
|
|
}
|
|
|
|
#[test]
|
|
fn beyond_horizon_mints_fresh_pseudonym() {
|
|
let mut mgr = TrackManager::with_defaults();
|
|
let f = feat(&[0.5, 0.5]);
|
|
let a = mgr
|
|
.ingest(Detection::new(space("kitchen"), [0.0, 0.0], f.clone(), 1_000).unwrap())
|
|
.unwrap();
|
|
let horizon = mgr.config().max_coast_ms;
|
|
// Same place and feature, but long after the horizon: under-link, do not join.
|
|
let b = mgr
|
|
.ingest(Detection::new(space("kitchen"), [0.0, 0.0], f, 1_000 + horizon + 500).unwrap())
|
|
.unwrap();
|
|
assert_ne!(a.person, b.person);
|
|
assert!(matches!(b.association, Association::Unknown { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn ambiguous_detection_stays_tentative_not_misassigned() {
|
|
// Confirm immediately so the two seed tracks are active and equal-footing.
|
|
let cfg = TrackerConfig {
|
|
confirm_after: 1,
|
|
..TrackerConfig::default()
|
|
};
|
|
let mut mgr = TrackManager::new(cfg, Topology::new());
|
|
let f = feat(&[0.5, 0.5]);
|
|
|
|
// Two tracks with identical features, symmetric about the origin. Their
|
|
// separation (3.0) exceeds the position gate (2.0) so they stay distinct,
|
|
// yet each sits within the gate of the midpoint.
|
|
let a = mgr
|
|
.ingest(Detection::new(space("kitchen"), [-1.5, 0.0], f.clone(), 1_000).unwrap())
|
|
.unwrap();
|
|
let b = mgr
|
|
.ingest(Detection::new(space("kitchen"), [1.5, 0.0], f.clone(), 1_000).unwrap())
|
|
.unwrap();
|
|
assert_eq!(mgr.len(), 2);
|
|
|
|
// A detection exactly between them, same feature: equidistant → ambiguous.
|
|
let mid = mgr
|
|
.ingest(Detection::new(space("kitchen"), [0.0, 0.0], f, 1_100).unwrap())
|
|
.unwrap();
|
|
|
|
assert!(matches!(
|
|
mid.association,
|
|
Association::Unknown {
|
|
reason: UnknownReason::Ambiguous,
|
|
..
|
|
}
|
|
));
|
|
// It was NOT attached to either existing track — a third pseudonym.
|
|
assert_ne!(mid.person, a.person);
|
|
assert_ne!(mid.person, b.person);
|
|
assert_eq!(mgr.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn pseudonyms_are_opaque_and_rotatable_with_no_civil_fields() {
|
|
let mut mgr = TrackManager::with_defaults();
|
|
let out = mgr
|
|
.ingest(Detection::new(space("kitchen"), [0.0, 0.0], feat(&[0.3]), 1_000).unwrap())
|
|
.unwrap();
|
|
|
|
// Opaque synthetic form, no civil identifier embedded.
|
|
let pid = out.person.as_str().to_string();
|
|
assert!(pid.starts_with("person_"));
|
|
|
|
// Rotate: new opaque id, same track and history preserved.
|
|
let before = mgr.trajectory(&out.track).unwrap();
|
|
let rotated = mgr.rotate_pseudonym(&out.track).unwrap();
|
|
assert_ne!(rotated.as_str(), pid);
|
|
assert!(rotated.as_str().starts_with("person_"));
|
|
assert_eq!(mgr.person_of(&out.track), Some(&rotated));
|
|
assert_eq!(mgr.trajectory(&out.track).unwrap(), before);
|
|
|
|
// The emitted canonical Person/Track carry no civil-identity field.
|
|
let person = mgr.to_person(&out.track).unwrap();
|
|
let track = mgr.to_track(&out.track).unwrap();
|
|
let pj = serde_json::to_string(&person).unwrap();
|
|
let tj = serde_json::to_string(&track).unwrap();
|
|
for forbidden in ["name", "mac", "email", "phone", "account", "ssid"] {
|
|
assert!(!pj.contains(forbidden), "person leaked `{forbidden}`: {pj}");
|
|
assert!(!tj.contains(forbidden), "track leaked `{forbidden}`: {tj}");
|
|
}
|
|
// Tentative track is floored to L0; feature never appears in the node.
|
|
assert_eq!(person.evidence_level, EvidenceLevel::L0);
|
|
assert!(!pj.contains("bins"));
|
|
}
|
|
|
|
#[test]
|
|
fn ingest_is_deterministic() {
|
|
fn run() -> Vec<(String, String)> {
|
|
let mut mgr = TrackManager::new(TrackerConfig::default(), corridor());
|
|
let script = [
|
|
(space("kitchen"), [0.0, 0.0], vec![0.1, 0.2], 1_000i64),
|
|
(space("kitchen"), [5.0, 0.0], vec![0.8, 0.9], 1_000),
|
|
(space("hallway"), [0.3, 0.1], vec![0.1, 0.2], 1_400),
|
|
(space("hallway"), [5.3, 0.1], vec![0.8, 0.9], 1_400),
|
|
(space("bedroom"), [0.6, 0.0], vec![0.1, 0.2], 1_800),
|
|
];
|
|
let mut trace = Vec::new();
|
|
for (c, p, v, t) in script {
|
|
let o = mgr
|
|
.ingest(Detection::new(c, p, feat(&v), t).unwrap())
|
|
.unwrap();
|
|
let kind = match o.association {
|
|
Association::Matched { .. } => "matched",
|
|
Association::Unknown { .. } => "unknown",
|
|
};
|
|
trace.push((o.person.as_str().to_string(), kind.to_string()));
|
|
}
|
|
trace
|
|
}
|
|
assert_eq!(run(), run());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_position_is_a_boundary_error_not_unknown() {
|
|
// NaN position is rejected at the boundary — distinct from association UNKNOWN.
|
|
let err = Detection::new(space("kitchen"), [f64::NAN, 0.0], feat(&[0.5]), 1_000);
|
|
assert_eq!(err.unwrap_err(), TrackError::NonFinitePosition);
|
|
}
|