feat(adr-118/p4.3): wire SignatureHasher into BfldEmitter (123/123 GREEN)

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<SignatureHasher> 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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 15:57:44 -04:00
parent 0ca8a38cbc
commit 351af66084
2 changed files with 149 additions and 1 deletions

View File

@ -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<SignatureHasher>,
}
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<String>) -> Self {
@ -123,6 +142,23 @@ impl BfldEmitter {
) -> Option<BfldEvent> {
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<u8> = 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
}

View File

@ -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",
);
}