wifi-densepose/v2/crates/wifi-densepose-bfld/src/soul_channels.rs

329 lines
11 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.

//! Per-channel signature container + weight table for the §3.6 matcher.
//!
//! This module ports the channel inventory and default weight table from
//! `docs/research/soul/specification.md` §3.6 into running types. It is the
//! data half of the matcher; the algorithm lives in
//! [`crate::soul_match`].
//!
//! ## What a `SoulChannels` is (and is NOT)
//!
//! A [`SoulChannels`] holds, for one signature, the per-channel feature
//! vectors that §3.6 fuses. Each channel is `Option<...>`: `None` means the
//! channel could not be measured in this window (the matcher treats it as
//! *unavailable* and excludes it from the normalized denominator — graceful
//! degradation, §3.6).
//!
//! The AETHER channel reuses the crate's [`IdentityEmbedding`]
//! ([`crate::embedding`]) so it inherits structural invariant **I2**
//! (in-RAM-only; no `Serialize`/`Clone`/`Copy`; zeroized on `Drop`). As a
//! direct consequence, `SoulChannels` is itself **not `Clone`** — you build a
//! signature once and move it into an enrolled set or use it as a probe.
//!
//! ## Weights are design-intent, not validated
//!
//! The [`MatchWeights::default`] values come from the §3.6 table, which the
//! spec explicitly labels *"open research; these are design intent, not
//! validated"*. They are reproduced faithfully here **with that caveat
//! intact**. Nothing in this crate has tuned them against measured FAR/FRR.
use crate::embedding::IdentityEmbedding;
/// Number of channels fused by the §3.6 matcher.
pub const CHANNEL_COUNT: usize = 8;
/// The eight Soul Signature channels, in the §3.6 table order.
///
/// The enum is the stable index into [`MatchWeights`] and into the
/// per-channel contribution array returned by the matcher. AETHER is index 0
/// (highest design-intent weight); the order otherwise follows the spec table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum Channel {
/// AETHER contrastive embedding (ADR-024). Primary identity anchor.
AetherEmbedding = 0,
/// Subcarrier reflection profile — body geometry, angle-stable.
SubcarrierReflectionProfile = 1,
/// Cardiac heart-rate profile — physiologically stable in healthy adults.
CardiacHrProfile = 2,
/// Gait timing — well-studied, discriminative biometric.
GaitTiming = 3,
/// Respiratory pattern — more variable than cardiac.
RespiratoryPattern = 4,
/// Skeletal proportions — proxy for body shape; CSI-only is noisy.
SkeletalProportions = 5,
/// Bodyfield coupling — valid only with a room field model
/// (weight 0.0 single-room).
BodyFieldCoupling = 6,
/// Cardiac waveform morphology — supplementary, high-SNR requirement.
CardiacWaveformMorphology = 7,
}
impl Channel {
/// All channels in index order. Handy for iterating the matcher.
pub const ALL: [Channel; CHANNEL_COUNT] = [
Channel::AetherEmbedding,
Channel::SubcarrierReflectionProfile,
Channel::CardiacHrProfile,
Channel::GaitTiming,
Channel::RespiratoryPattern,
Channel::SkeletalProportions,
Channel::BodyFieldCoupling,
Channel::CardiacWaveformMorphology,
];
/// Index of this channel (0..[`CHANNEL_COUNT`]).
#[must_use]
pub const fn index(self) -> usize {
self as usize
}
}
/// The §3.6 default weights, faithfully reproduced.
///
/// These are **unvalidated design intent** per the spec table. `weights[i]`
/// is the weight of `Channel::ALL[i]`.
///
/// | Channel | Weight |
/// |---|---|
/// | AETHER_Embedding | 0.35 |
/// | Subcarrier_Reflection_Profile | 0.20 |
/// | Cardiac_HR_Profile | 0.15 |
/// | Gait_Timing | 0.15 |
/// | Respiratory_Pattern | 0.10 |
/// | Skeletal_Proportions | 0.05 |
/// | Body_Field_Coupling | 0.00 (single-room) |
/// | Cardiac_Waveform_Morphology | 0.05 |
pub const DEFAULT_WEIGHTS: [f32; CHANNEL_COUNT] =
[0.35, 0.20, 0.15, 0.15, 0.10, 0.05, 0.00, 0.05];
/// Per-channel fusion weights for the §3.6 score.
///
/// Construct with [`MatchWeights::default`] for the spec table, or
/// [`MatchWeights::new`] for a custom (validated, non-negative, finite)
/// weight vector.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MatchWeights {
weights: [f32; CHANNEL_COUNT],
}
impl MatchWeights {
/// Build from an explicit weight vector.
///
/// # Errors
/// Returns [`WeightError`] if any weight is negative, NaN, or infinite, or
/// if all weights are zero (a degenerate table that can never produce a
/// defined score).
pub fn new(weights: [f32; CHANNEL_COUNT]) -> Result<Self, WeightError> {
let mut any_positive = false;
for &w in &weights {
if w.is_nan() || w.is_infinite() {
return Err(WeightError::NotFinite);
}
if w < 0.0 {
return Err(WeightError::Negative);
}
if w > 0.0 {
any_positive = true;
}
}
if !any_positive {
return Err(WeightError::AllZero);
}
Ok(Self { weights })
}
/// Weight of a specific channel.
#[must_use]
pub const fn weight(&self, channel: Channel) -> f32 {
self.weights[channel.index()]
}
/// Borrow the raw weight vector (index-aligned to [`Channel::ALL`]).
#[must_use]
pub const fn as_array(&self) -> &[f32; CHANNEL_COUNT] {
&self.weights
}
}
impl Default for MatchWeights {
/// The §3.6 default table — **unvalidated design intent**.
fn default() -> Self {
Self {
weights: DEFAULT_WEIGHTS,
}
}
}
/// Why a [`MatchWeights`] construction was rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum WeightError {
/// A weight was negative — weights must be in `[0, ∞)`.
#[error("match weight must be non-negative")]
Negative,
/// A weight was NaN or infinite.
#[error("match weight must be finite")]
NotFinite,
/// Every weight was zero — the score denominator could never be positive.
#[error("at least one match weight must be positive")]
AllZero,
}
/// One signature's per-channel feature vectors.
///
/// `aether` reuses [`IdentityEmbedding`] (invariant I2); the remaining seven
/// channels are plain feature vectors held as fixed-capacity arrays so the
/// type is `no_std`-compatible with no heap allocation. A channel set to
/// `None` is *unavailable* and is excluded from the §3.6 denominator.
///
/// Because `IdentityEmbedding` is intentionally not `Clone`, `SoulChannels`
/// is not `Clone` either — build it once, then move it into the enrolled set
/// or hand it to the matcher as a probe.
pub struct SoulChannels {
/// AETHER embedding channel (in-RAM-only; I2). `None` if not enrolled/measured.
pub aether: Option<IdentityEmbedding>,
/// The seven non-AETHER channels, index-aligned to `Channel` 1..=7.
/// `vectors[c.index() - 1]` holds channel `c` (AETHER lives in `aether`).
vectors: [Option<FeatureVector>; CHANNEL_COUNT - 1],
}
/// Fixed-capacity feature vector for a non-AETHER channel.
///
/// Capacity is chosen to comfortably hold the largest non-AETHER channel in
/// the §3.6 schema (the 336-element subcarrier reflection profile, §3.1).
pub const FEATURE_VECTOR_CAP: usize = 336;
/// A bounded, heapless per-channel feature vector.
#[derive(Debug, Clone, Copy)]
pub struct FeatureVector {
data: [f32; FEATURE_VECTOR_CAP],
len: usize,
}
impl FeatureVector {
/// Build a feature vector from a slice.
///
/// # Errors
/// Returns [`WeightError::NotFinite`] reused as a generic "bad data"
/// signal if `values` is longer than [`FEATURE_VECTOR_CAP`].
pub fn from_slice(values: &[f32]) -> Result<Self, FeatureError> {
if values.len() > FEATURE_VECTOR_CAP {
return Err(FeatureError::TooLong {
got: values.len(),
cap: FEATURE_VECTOR_CAP,
});
}
let mut data = [0.0f32; FEATURE_VECTOR_CAP];
data[..values.len()].copy_from_slice(values);
Ok(Self {
data,
len: values.len(),
})
}
/// Borrow the populated values.
#[must_use]
pub fn as_slice(&self) -> &[f32] {
&self.data[..self.len]
}
/// Number of populated elements.
#[must_use]
pub const fn len(&self) -> usize {
self.len
}
/// `true` if the vector has no elements.
#[must_use]
pub const fn is_empty(&self) -> bool {
self.len == 0
}
}
/// Why a [`FeatureVector`] construction was rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum FeatureError {
/// The input slice exceeded [`FEATURE_VECTOR_CAP`].
#[error("feature vector too long: got {got}, cap {cap}")]
TooLong {
/// Length of the supplied slice.
got: usize,
/// Maximum capacity.
cap: usize,
},
}
impl SoulChannels {
/// Build an empty signature — every channel `None` (unavailable).
#[must_use]
pub const fn empty() -> Self {
Self {
aether: None,
vectors: [const { None }; CHANNEL_COUNT - 1],
}
}
/// Set the AETHER embedding channel (consumes the embedding; I2).
#[must_use]
pub fn with_aether(mut self, embedding: IdentityEmbedding) -> Self {
self.aether = Some(embedding);
self
}
/// Set a non-AETHER channel from a feature vector. Passing
/// `Channel::AetherEmbedding` is a no-op (use [`Self::with_aether`]).
#[must_use]
pub fn with_channel(mut self, channel: Channel, vector: FeatureVector) -> Self {
if let Some(slot) = self.vector_slot_mut(channel) {
*slot = Some(vector);
}
self
}
/// Borrow a non-AETHER channel's vector, if present.
#[must_use]
pub fn channel_vector(&self, channel: Channel) -> Option<&FeatureVector> {
match channel {
Channel::AetherEmbedding => None,
other => self.vectors[other.index() - 1].as_ref(),
}
}
/// `true` if `channel` carries a usable (present) vector.
#[must_use]
pub fn has_channel(&self, channel: Channel) -> bool {
match channel {
Channel::AetherEmbedding => self.aether.is_some(),
other => self.vectors[other.index() - 1].is_some(),
}
}
/// Borrow channel data as an `f32` slice, regardless of channel kind.
/// Returns `None` if the channel is unavailable.
#[must_use]
pub fn channel_slice(&self, channel: Channel) -> Option<&[f32]> {
match channel {
Channel::AetherEmbedding => self.aether.as_ref().map(IdentityEmbedding::as_slice),
other => self.channel_vector(other).map(FeatureVector::as_slice),
}
}
/// Count of channels currently present (available).
#[must_use]
pub fn available_count(&self) -> usize {
Channel::ALL.iter().filter(|&&c| self.has_channel(c)).count()
}
fn vector_slot_mut(&mut self, channel: Channel) -> Option<&mut Option<FeatureVector>> {
match channel {
Channel::AetherEmbedding => None,
other => Some(&mut self.vectors[other.index() - 1]),
}
}
}
impl Default for SoulChannels {
fn default() -> Self {
Self::empty()
}
}