diff --git a/v2/crates/wifi-densepose-bfld/src/emitter.rs b/v2/crates/wifi-densepose-bfld/src/emitter.rs index 32a0945b..f89777ae 100644 --- a/v2/crates/wifi-densepose-bfld/src/emitter.rs +++ b/v2/crates/wifi-densepose-bfld/src/emitter.rs @@ -20,8 +20,15 @@ use crate::coherence_gate::{CoherenceGate, NullOracle, SoulMatchOracle}; use crate::embedding_ring::EmbeddingRing; use crate::identity_risk::{score, GateAction}; +use crate::signature_hasher::SignatureHasher; use crate::{BfldEvent, IdentityEmbedding, PrivacyClass}; +/// Nanoseconds-per-second conversion factor for deriving unix_secs from +/// `timestamp_ns`. The caller is responsible for using unix-epoch nanoseconds +/// if it wants stable daily rotation; monotonic-only clocks won't anchor to +/// UTC midnight. +const NS_PER_SEC: u64 = 1_000_000_000; + /// Per-frame sensing inputs to [`BfldEmitter::emit`]. #[derive(Debug, Clone)] pub struct SensingInputs { @@ -60,6 +67,7 @@ pub struct BfldEmitter { privacy_class: PrivacyClass, gate: CoherenceGate, ring: EmbeddingRing, + signature_hasher: Option, } impl BfldEmitter { @@ -73,9 +81,20 @@ impl BfldEmitter { privacy_class: PrivacyClass::Anonymous, gate: CoherenceGate::new(), ring: EmbeddingRing::new(), + signature_hasher: None, } } + /// Install a [`SignatureHasher`] so the emitter computes `rf_signature_hash` + /// per ADR-120 §2.3 from the supplied embedding (preferred) or the risk + /// factors (fallback when no embedding is supplied). When set, the derived + /// hash overrides `SensingInputs::rf_signature_hash`. + #[must_use] + pub fn with_signature_hasher(mut self, hasher: SignatureHasher) -> Self { + self.signature_hasher = Some(hasher); + self + } + /// Set the default zone ID emitted with each event (None = single-zone). #[must_use] pub fn with_zone(mut self, zone_id: impl Into) -> Self { @@ -123,6 +142,23 @@ impl BfldEmitter { ) -> Option { let risk = score(inputs.sep, inputs.stab, inputs.consist, inputs.risk_conf); + // Compute the derived rf_signature_hash BEFORE moving `embedding` into + // the ring. Derived hash uses the embedding bytes when present and + // falls back to the canonical risk-factor bytes otherwise. + let derived_hash: Option<[u8; 32]> = self.signature_hasher.as_ref().map(|h| { + let unix_secs = inputs.timestamp_ns / NS_PER_SEC; + if let Some(emb) = &embedding { + let bytes: Vec = emb + .as_slice() + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect(); + h.compute_at(unix_secs, &bytes) + } else { + h.compute_at(unix_secs, &canonical_risk_bytes(&inputs)) + } + }); + if let Some(emb) = embedding { // Always push, regardless of action — the ring is the rolling // memory of recent identity embeddings, used for separability. @@ -149,6 +185,10 @@ impl BfldEmitter { _ => Some(risk), }; + // Derived hash (when hasher installed) takes precedence over caller- + // supplied; otherwise pass through whatever the caller provided. + let rf_signature_hash = derived_hash.or(inputs.rf_signature_hash); + Some(BfldEvent::with_privacy_gating( self.node_id.clone(), inputs.timestamp_ns, @@ -159,7 +199,18 @@ impl BfldEmitter { self.default_zone_id.clone(), self.privacy_class, identity_risk_score, - inputs.rf_signature_hash, + rf_signature_hash, )) } } + +/// Canonical byte layout for the risk-factor tuple. Used by the hasher +/// fallback when no embedding is supplied. +fn canonical_risk_bytes(inputs: &SensingInputs) -> [u8; 16] { + let mut buf = [0u8; 16]; + buf[0..4].copy_from_slice(&inputs.sep.to_le_bytes()); + buf[4..8].copy_from_slice(&inputs.stab.to_le_bytes()); + buf[8..12].copy_from_slice(&inputs.consist.to_le_bytes()); + buf[12..16].copy_from_slice(&inputs.risk_conf.to_le_bytes()); + buf +} diff --git a/v2/crates/wifi-densepose-bfld/tests/emitter_hasher.rs b/v2/crates/wifi-densepose-bfld/tests/emitter_hasher.rs new file mode 100644 index 00000000..170a892d --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/emitter_hasher.rs @@ -0,0 +1,97 @@ +//! Acceptance tests for ADR-120 §2.3 ↔ ADR-118 §2.1 wiring — `SignatureHasher` +//! derives `rf_signature_hash` end-to-end through `BfldEmitter`. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{ + BfldEmitter, IdentityEmbedding, SensingInputs, SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN, +}; + +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 embedding(seed: u8) -> IdentityEmbedding { + let mut a = [0.0f32; EMBEDDING_DIM]; + for (i, v) in a.iter_mut().enumerate() { + *v = (i as f32 + seed as f32) * 0.001; + } + IdentityEmbedding::from_raw(a) +} + +fn inputs(seed: u8) -> SensingInputs { + SensingInputs { + timestamp_ns: 1_700_000_000_000_000_000 + (seed as u64) * 1_000_000_000, + presence: true, + motion: 0.5, + person_count: 1, + sensing_confidence: 0.9, + sep: 0.2, + stab: 0.2, + consist: 0.2, + risk_conf: 0.2, + rf_signature_hash: Some([0xFF; 32]), // caller-supplied "wrong" hash + } +} + +#[test] +fn no_hasher_passes_caller_supplied_hash_through() { + let mut e = BfldEmitter::new("seed-01"); + let out = e.emit(inputs(0), Some(embedding(0))).unwrap(); + assert_eq!(out.rf_signature_hash, Some([0xFF; 32])); +} + +#[test] +fn installed_hasher_overrides_caller_supplied_hash() { + let mut e = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7))); + let out = e.emit(inputs(0), Some(embedding(0))).unwrap(); + let hash = out.rf_signature_hash.unwrap(); + assert_ne!(hash, [0xFF; 32], "derived hash must override caller-supplied"); + assert_ne!(hash, [0x00; 32], "derived hash must be non-trivial"); +} + +#[test] +fn same_emitter_same_inputs_produce_same_hash() { + let mut e_a = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7))); + let mut e_b = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7))); + let a = e_a.emit(inputs(0), Some(embedding(0))).unwrap(); + let b = e_b.emit(inputs(0), Some(embedding(0))).unwrap(); + assert_eq!(a.rf_signature_hash, b.rf_signature_hash); +} + +#[test] +fn different_site_salts_produce_different_hashes_end_to_end() { + let mut e_a = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(1))); + let mut e_b = BfldEmitter::new("seed-02").with_signature_hasher(SignatureHasher::new(salt(2))); + // Same embedding, same inputs → different sites must produce different hashes. + let a = e_a.emit(inputs(0), Some(embedding(0))).unwrap(); + let b = e_b.emit(inputs(0), Some(embedding(0))).unwrap(); + assert_ne!( + a.rf_signature_hash, b.rf_signature_hash, + "cross-site emit must produce uncorrelated hashes", + ); +} + +#[test] +fn no_embedding_falls_back_to_risk_factor_bytes() { + let mut e = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(5))); + let out = e.emit(inputs(0), None).unwrap(); + let hash = out.rf_signature_hash.unwrap(); + assert_ne!(hash, [0xFF; 32]); // still derived (fallback path), not caller-supplied +} + +#[test] +fn fallback_hash_differs_from_embedding_hash() { + let mut e_with = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(9))); + let mut e_without = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(9))); + let with_emb = e_with.emit(inputs(0), Some(embedding(0))).unwrap(); + let no_emb = e_without.emit(inputs(0), None).unwrap(); + assert_ne!( + with_emb.rf_signature_hash, no_emb.rf_signature_hash, + "embedding bytes and risk-factor bytes should hash to different values", + ); +}