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:
parent
0ca8a38cbc
commit
351af66084
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue