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::coherence_gate::{CoherenceGate, NullOracle, SoulMatchOracle};
|
||||||
use crate::embedding_ring::EmbeddingRing;
|
use crate::embedding_ring::EmbeddingRing;
|
||||||
use crate::identity_risk::{score, GateAction};
|
use crate::identity_risk::{score, GateAction};
|
||||||
|
use crate::signature_hasher::SignatureHasher;
|
||||||
use crate::{BfldEvent, IdentityEmbedding, PrivacyClass};
|
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`].
|
/// Per-frame sensing inputs to [`BfldEmitter::emit`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SensingInputs {
|
pub struct SensingInputs {
|
||||||
|
|
@ -60,6 +67,7 @@ pub struct BfldEmitter {
|
||||||
privacy_class: PrivacyClass,
|
privacy_class: PrivacyClass,
|
||||||
gate: CoherenceGate,
|
gate: CoherenceGate,
|
||||||
ring: EmbeddingRing,
|
ring: EmbeddingRing,
|
||||||
|
signature_hasher: Option<SignatureHasher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BfldEmitter {
|
impl BfldEmitter {
|
||||||
|
|
@ -73,9 +81,20 @@ impl BfldEmitter {
|
||||||
privacy_class: PrivacyClass::Anonymous,
|
privacy_class: PrivacyClass::Anonymous,
|
||||||
gate: CoherenceGate::new(),
|
gate: CoherenceGate::new(),
|
||||||
ring: EmbeddingRing::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).
|
/// Set the default zone ID emitted with each event (None = single-zone).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_zone(mut self, zone_id: impl Into<String>) -> Self {
|
pub fn with_zone(mut self, zone_id: impl Into<String>) -> Self {
|
||||||
|
|
@ -123,6 +142,23 @@ impl BfldEmitter {
|
||||||
) -> Option<BfldEvent> {
|
) -> Option<BfldEvent> {
|
||||||
let risk = score(inputs.sep, inputs.stab, inputs.consist, inputs.risk_conf);
|
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 {
|
if let Some(emb) = embedding {
|
||||||
// Always push, regardless of action — the ring is the rolling
|
// Always push, regardless of action — the ring is the rolling
|
||||||
// memory of recent identity embeddings, used for separability.
|
// memory of recent identity embeddings, used for separability.
|
||||||
|
|
@ -149,6 +185,10 @@ impl BfldEmitter {
|
||||||
_ => Some(risk),
|
_ => 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(
|
Some(BfldEvent::with_privacy_gating(
|
||||||
self.node_id.clone(),
|
self.node_id.clone(),
|
||||||
inputs.timestamp_ns,
|
inputs.timestamp_ns,
|
||||||
|
|
@ -159,7 +199,18 @@ impl BfldEmitter {
|
||||||
self.default_zone_id.clone(),
|
self.default_zone_id.clone(),
|
||||||
self.privacy_class,
|
self.privacy_class,
|
||||||
identity_risk_score,
|
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