//! 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, ); }