diff --git a/v2/crates/wifi-densepose-bfld/tests/pipeline_i3_isolation.rs b/v2/crates/wifi-densepose-bfld/tests/pipeline_i3_isolation.rs new file mode 100644 index 00000000..e1e18296 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/pipeline_i3_isolation.rs @@ -0,0 +1,176 @@ +//! End-to-end ADR-118 invariant I3 + ADR-120 §2.7 AC2 proof at the public +//! `BfldPipeline` surface — not just inside `SignatureHasher`. Validates that +//! the same physical person at: +//! +//! - **Different sites** produces uncorrelated `rf_signature_hash` values. +//! - **Different days** at the same site rotates the hash. +//! - **30 days apart** at the same site produces a different hash (the +//! rotation isn't a one-bit difference; the whole digest changes). +//! +//! All assertions go through `BfldPipeline::process()` so the test exercises +//! the wired-up emitter + hasher + identity_features encoder path, not the +//! lower-level `SignatureHasher::compute` direct API. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{ + BfldConfig, BfldPipeline, IdentityEmbedding, PrivacyClass, SensingInputs, SignatureHasher, + EMBEDDING_DIM, SITE_SALT_LEN, +}; + +const SECONDS_PER_DAY: u64 = 86_400; +const NS_PER_SEC: u64 = 1_000_000_000; + +fn salt(seed: u8) -> [u8; SITE_SALT_LEN] { + let mut s = [0u8; SITE_SALT_LEN]; + for (i, b) in s.iter_mut().enumerate() { + *b = seed.wrapping_add(i as u8); + } + s +} + +fn person_embedding() -> IdentityEmbedding { + // A deterministic "person" — same vector across all sites and days in + // the test so we're only varying salt + day_epoch. + let mut a = [0.0f32; EMBEDDING_DIM]; + for (i, v) in a.iter_mut().enumerate() { + *v = ((i as f32) * 0.0073).sin(); + } + IdentityEmbedding::from_raw(a) +} + +fn inputs_at(unix_secs: u64) -> SensingInputs { + SensingInputs { + timestamp_ns: unix_secs * NS_PER_SEC, + presence: true, + motion: 0.4, + person_count: 1, + sensing_confidence: 0.9, + sep: 0.2, + stab: 0.2, + consist: 0.2, + risk_conf: 0.2, + rf_signature_hash: None, // hasher derives + } +} + +fn pipeline_with_salt(node_id: &str, salt: [u8; SITE_SALT_LEN]) -> BfldPipeline { + BfldPipeline::new( + BfldConfig::new(node_id).with_signature_hasher(SignatureHasher::new(salt)), + ) +} + +fn hash_for(p: &mut BfldPipeline, unix_secs: u64) -> [u8; 32] { + p.process(inputs_at(unix_secs), Some(person_embedding())) + .expect("low-risk emit must succeed") + .rf_signature_hash + .expect("hasher-equipped pipeline must emit a hash") +} + +fn hamming_distance(a: &[u8; 32], b: &[u8; 32]) -> u32 { + a.iter().zip(b).map(|(x, y)| (x ^ y).count_ones()).sum() +} + +// --- cross-site (same person, same day, different salt) ----------------- + +#[test] +fn same_person_at_different_sites_same_day_produces_different_hashes() { + let mut site_a = pipeline_with_salt("seed-a", salt(1)); + let mut site_b = pipeline_with_salt("seed-b", salt(2)); + let day_0_secs = 1_700_000_000; + let h_a = hash_for(&mut site_a, day_0_secs); + let h_b = hash_for(&mut site_b, day_0_secs); + assert_ne!(h_a, h_b); +} + +// --- same site, different days ------------------------------------------ + +#[test] +fn same_person_same_site_different_day_rotates_the_hash() { + let mut site = pipeline_with_salt("seed-a", salt(1)); + let day_0 = 1_700_000_000; + let day_1 = day_0 + SECONDS_PER_DAY; + let h_0 = hash_for(&mut site, day_0); + let h_1 = hash_for(&mut site, day_1); + assert_ne!(h_0, h_1, "day rotation must change the hash at the pipeline surface"); +} + +#[test] +fn thirty_day_gap_produces_thoroughly_different_hash() { + let mut site = pipeline_with_salt("seed-a", salt(1)); + let day_0 = 1_700_000_000; + let day_30 = day_0 + 30 * SECONDS_PER_DAY; + let h_0 = hash_for(&mut site, day_0); + let h_30 = hash_for(&mut site, day_30); + let dist = hamming_distance(&h_0, &h_30); + // Two independent BLAKE3 outputs differ by ~128 bits on average. Require + // at least 80 bits to catch a regression where day_epoch is only weakly + // mixed into the digest. + assert!(dist >= 80, "30-day rotation Hamming distance too low: {dist}"); +} + +// --- same person, same site, same day -> stable hash -------------------- + +#[test] +fn same_person_same_site_same_day_produces_stable_hash() { + let mut a = pipeline_with_salt("seed-a", salt(1)); + let mut b = pipeline_with_salt("seed-a", salt(1)); + let day_0 = 1_700_000_000; + assert_eq!(hash_for(&mut a, day_0), hash_for(&mut b, day_0)); +} + +// --- cross-site Hamming distance at the pipeline surface ---------------- + +#[test] +fn cross_site_hamming_distance_at_pipeline_surface_is_statistically_high() { + let n_trials = 32usize; + let mut total: u32 = 0; + let day_0 = 1_700_000_000; + for trial in 0..n_trials { + let mut a = pipeline_with_salt("seed-a", salt(trial as u8)); + let mut b = pipeline_with_salt("seed-b", salt((trial as u8).wrapping_add(0xA5))); + let dist = hamming_distance(&hash_for(&mut a, day_0), &hash_for(&mut b, day_0)); + total += dist; + } + let mean = total as f32 / n_trials as f32; + assert!( + mean >= 120.0, + "pipeline-surface cross-site mean Hamming distance must be >= 120 (ADR-120 §2.7 AC2), got {mean}", + ); +} + +// --- restricted class still rotates internally even though hash is stripped --- + +#[test] +fn restricted_class_strips_hash_but_pipeline_state_advances() { + // Class 3 strips rf_signature_hash from the event, but the underlying + // pipeline state (ring, gate) still advances. This test pins that + // contract so a future PR doesn't accidentally short-circuit the + // pipeline at class 3 and miss legitimate sensing. + let mut p = BfldPipeline::new( + BfldConfig::new("seed-r") + .with_privacy_class(PrivacyClass::Restricted) + .with_signature_hasher(SignatureHasher::new(salt(7))), + ); + let evt = p + .process(inputs_at(1_700_000_000), Some(person_embedding())) + .expect("low-risk emit"); + assert!(evt.rf_signature_hash.is_none()); + assert!(evt.identity_risk_score.is_none()); + assert!(evt.presence); // sensing fields still landed +} + +// --- pipeline without hasher leaves hash as None or caller-supplied ---- + +#[test] +fn pipeline_without_signature_hasher_does_not_invent_a_hash() { + let mut p = BfldPipeline::new(BfldConfig::new("seed-x")); + let evt = p + .process(inputs_at(1_700_000_000), Some(person_embedding())) + .expect("low-risk emit"); + assert!( + evt.rf_signature_hash.is_none(), + "no hasher installed → no hash; got {:?}", + evt.rf_signature_hash, + ); +}