Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/src/identity_features.rs
T
ruv ea98ceb335 feat(adr-118/p3.6): IdentityFeatures canonical-bytes encoder (137/137 GREEN)
Iter 18. Consolidates the embedding-vs-risk-factor hashing-input
selection behind a single typed API. Replaces the two ad-hoc paths
that lived in emitter.rs through iter 17:
  * inline `emb.as_slice().iter().flat_map(|f| f.to_le_bytes())`
  * private `canonical_risk_bytes(&inputs) -> [u8; 16]`

Added (gated on `feature = "std"`):
- src/identity_features.rs:
  * IdentityFeatures<'a> enum: Embedding(&'a IdentityEmbedding) |
    RiskFactors { sep, stab, consist, conf }
  * from_embedding / from_risk_factors const constructors
  * canonical_byte_len() const fn — no allocation, predicts wire length
  * write_canonical_bytes(&mut Vec<u8>) — reusable-buffer path
  * canonical_bytes() -> Vec<u8> — allocating convenience
  * compute_hash(&SignatureHasher, day_epoch) -> [u8; 32]
  * RISK_FACTOR_BYTES const (= 16)
- pub use IdentityFeatures, RISK_FACTOR_BYTES from lib.rs

Refactor:
- src/emitter.rs: derived_hash now uses
    let features = match &embedding {
        Some(emb) => IdentityFeatures::from_embedding(emb),
        None => IdentityFeatures::from_risk_factors(sep, stab, consist, conf),
    };
    features.compute_hash(h, day_epoch)
  Local canonical_risk_bytes helper removed (superseded).

tests/identity_features_encoder.rs (9 named tests, all green):
  embedding_canonical_length_is_dim_times_four
  risk_factor_canonical_length_is_sixteen_bytes
  embedding_canonical_bytes_match_manual_flatten
  risk_factor_canonical_bytes_match_explicit_le_layout
  write_canonical_bytes_appends_to_existing_buffer
  compute_hash_matches_direct_hasher_invocation
  embedding_and_risk_factors_produce_different_hashes
  iter_16_wire_compat_embedding_path   *** backward-compat regression ***
  iter_16_wire_compat_risk_factor_path *** backward-compat regression ***
    These two tests assert that the refactored encoder produces
    bit-identical hashes to iter 16's inline path. Existing deployed
    nodes upgrading to iter 18 see no rf_signature_hash flip.

ACs progressed:
- ADR-120 §2.3 — features canonical-bytes representation now has a
  single source of truth in the codebase; future feature additions
  pass through one named encoder rather than scattered byte-fiddling.
- ADR-118 invariant I2 — IdentityFeatures borrows &IdentityEmbedding,
  it doesn't take ownership. The embedding's Drop / no-Serialize
  guarantees continue to hold across the canonical-bytes path.

Test config:
- cargo test --no-default-features → 72 passed (identity_features cfg-out)
- cargo test                       → 137 passed (128 + 9)

Out of scope (next iter target):
- Wire IdentityFeatures into a public emitter input path so callers
  can supply pre-constructed IdentityFeatures rather than the bare
  embedding + risk factors. (Soft refactor; current API is sufficient.)
- BfldPipeline facade — single struct combining BfldEmitter +
  BfldFrame producer + MQTT publisher (ADR-118 §2.1 lib.rs entry point).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 16:18:33 -04:00

117 lines
4.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `IdentityFeatures` — typed canonical-bytes encoder for `SignatureHasher`.
//!
//! Wraps the two possible feature sources (a borrowed [`IdentityEmbedding`] or
//! the four-tuple of risk factors) behind a single API so callers don't need
//! to know which one ultimately feeds the BLAKE3 keyed hash. Replaces the
//! ad-hoc `canonical_risk_bytes` + inline embedding-flatten paths that lived
//! in `emitter.rs` through iter 17.
//!
//! Borrowing semantics:
//! - `IdentityFeatures::Embedding(&IdentityEmbedding)` is the **preferred**
//! source — it carries the AETHER cluster identity directly.
//! - `IdentityFeatures::RiskFactors { .. }` is the fallback used when the
//! per-frame embedding is unavailable.
//!
//! Both variants emit canonical little-endian f32 bytes. Embedding produces
//! `EMBEDDING_DIM * 4` bytes (512 by default); risk factors produce
//! [`RISK_FACTOR_BYTES`] bytes (16).
#![cfg(feature = "std")]
use crate::signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN};
use crate::{IdentityEmbedding, EMBEDDING_DIM};
/// Wire-form length for the `RiskFactors` variant (4 × f32 little-endian).
pub const RISK_FACTOR_BYTES: usize = 16;
/// Borrowed feature source for the signature hasher.
#[derive(Debug)]
pub enum IdentityFeatures<'a> {
/// Preferred: a borrowed identity embedding. The embedding stays in-RAM
/// (invariant I2) — this enum holds only a reference.
Embedding(&'a IdentityEmbedding),
/// Fallback: the four risk-score factors. Less identity-stable than the
/// embedding, but always available even when the encoder is offline.
RiskFactors {
/// `identity_separability_score`.
sep: f32,
/// `temporal_stability`.
stab: f32,
/// `cross_perspective_consistency`.
consist: f32,
/// Risk-score sample confidence factor.
conf: f32,
},
}
impl<'a> IdentityFeatures<'a> {
/// Build from a borrowed embedding (preferred path).
#[must_use]
pub const fn from_embedding(emb: &'a IdentityEmbedding) -> Self {
Self::Embedding(emb)
}
/// Build from the risk-factor four-tuple (fallback path).
#[must_use]
pub const fn from_risk_factors(sep: f32, stab: f32, consist: f32, conf: f32) -> Self {
Self::RiskFactors {
sep,
stab,
consist,
conf,
}
}
/// Predicted wire length without allocating.
#[must_use]
pub const fn canonical_byte_len(&self) -> usize {
match self {
Self::Embedding(_) => EMBEDDING_DIM * 4,
Self::RiskFactors { .. } => RISK_FACTOR_BYTES,
}
}
/// Append canonical little-endian bytes to `out`. Useful for callers that
/// already own a buffer (avoids the `canonical_bytes` allocation).
pub fn write_canonical_bytes(&self, out: &mut Vec<u8>) {
out.reserve(self.canonical_byte_len());
match self {
Self::Embedding(emb) => {
for f in emb.as_slice() {
out.extend_from_slice(&f.to_le_bytes());
}
}
Self::RiskFactors {
sep,
stab,
consist,
conf,
} => {
out.extend_from_slice(&sep.to_le_bytes());
out.extend_from_slice(&stab.to_le_bytes());
out.extend_from_slice(&consist.to_le_bytes());
out.extend_from_slice(&conf.to_le_bytes());
}
}
}
/// Allocating convenience wrapper around [`Self::write_canonical_bytes`].
#[must_use]
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut v = Vec::with_capacity(self.canonical_byte_len());
self.write_canonical_bytes(&mut v);
v
}
/// Drive `hasher` with this feature source at the given `day_epoch`. The
/// returned hash is what the emitter publishes as `rf_signature_hash`.
#[must_use]
pub fn compute_hash(
&self,
hasher: &SignatureHasher,
day_epoch: u32,
) -> [u8; RF_SIGNATURE_LEN] {
hasher.compute(day_epoch, &self.canonical_bytes())
}
}