From 351af6608483a72737cccd47d7afed99c4a06b59 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 15:57:44 -0400 Subject: [PATCH] feat(adr-118/p4.3): wire SignatureHasher into BfldEmitter (123/123 GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 16. End-to-end ADR-120 §2.3 wiring: BfldEmitter now produces rf_signature_hash derived from (site_salt, day_epoch, features), with the IdentityEmbedding bytes as the preferred feature source. Closes the gap from iter 15 — the hasher is now reachable from the pipeline. Added (in src/emitter.rs): - BfldEmitter.signature_hasher: Option field - BfldEmitter::with_signature_hasher(SignatureHasher) -> Self builder - emit_with_oracle computes derived_hash BEFORE pushing embedding to ring: 1. unix_secs = inputs.timestamp_ns / NS_PER_SEC 2. feature bytes: embedding.as_slice() flattened to LE f32 bytes, OR fallback canonical_risk_bytes(&inputs) (4-tuple of LE f32) 3. hasher.compute_at(unix_secs, &bytes) - Derived hash overrides inputs.rf_signature_hash; when hasher absent caller-supplied value passes through unchanged (backward compat) - canonical_risk_bytes(&inputs) -> [u8; 16] private helper for fallback tests/emitter_hasher.rs (6 named tests, all green): no_hasher_passes_caller_supplied_hash_through installed_hasher_overrides_caller_supplied_hash same_emitter_same_inputs_produce_same_hash (determinism through emitter) different_site_salts_produce_different_hashes_end_to_end *** cross-site isolation proven via the BfldEmitter API, not just via the SignatureHasher direct API (iter 15) *** no_embedding_falls_back_to_risk_factor_bytes fallback_hash_differs_from_embedding_hash (embedding-based and fallback-based hashes are distinct paths) ACs progressed: - ADR-120 §2.7 AC2 — cross-site isolation now provable at the public emitter surface, not just inside the hasher module. - ADR-118 §2.1 pipeline integration — derived rf_signature_hash flows through to the BfldEvent without caller participation. Operators install the hasher once at boot; per-frame code never sees site_salt. Test config: - cargo test --no-default-features → 72 passed (emitter_hasher cfg-out) - cargo test → 123 passed (117 + 6) Out of scope (next iter target): - IdentityFeatures struct — typed canonical-bytes encoder so callers don't need to know that embedding bytes feed the hasher directly. - Cross-iter integration test: BfldEmitter → BfldEvent::to_json with derived hash, parsed back, hash field present and base64-encoded (or hex-encoded) per the JSON wire spec. Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-bfld/src/emitter.rs | 53 +++++++++- .../tests/emitter_hasher.rs | 97 +++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 v2/crates/wifi-densepose-bfld/tests/emitter_hasher.rs 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", + ); +}