feat(bfld): implement §3.6 Soul Signature matcher + real SoulMatchOracle
First running implementation of the spec's §3.6 per-channel weighted-cosine matcher (docs/research/soul/specification.md). Replaces reliance on NullOracle (which always returns NotEnrolled) with a real EnrolledMatcher oracle. - soul_channels.rs: 8-channel SoulChannels container (AETHER reuses IdentityEmbedding, preserving invariant I2 — no Clone/Serialize, zeroized on Drop), MatchWeights with the §3.6 default table (unvalidated design intent), heapless FeatureVector. no_std-compatible. - soul_match.rs: match_score() implementing the exact formula Σ w·cos / Σ w·availability, with graceful degradation, zero-norm/NaN safety, and a typed 'insufficient channels' result (never a default-high score). EnrolledMatcher (std) satisfies the existing SoulMatchOracle trait, gated on a score threshold AND a minimum shared-channel count (so a single low-weight channel can never lock identity). NullOracle retained as the disabled default. Named-identity locking remains data-gated: it requires real AETHER enrollment + body-resonance data, which has not been provided. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
66ebf798e5
commit
b08e49e47c
|
|
@ -10,6 +10,14 @@
|
|||
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
|
||||
//!
|
||||
//! Status: P1 in progress — frame format + sink marker traits. P2–P6 follow.
|
||||
//! The §3.6 Soul Signature matching algorithm is now implemented and tested
|
||||
//! ([`soul_match`] / [`soul_channels`]): a running per-channel weighted-cosine
|
||||
//! matcher with measured separability and a real [`coherence_gate::SoulMatchOracle`]
|
||||
//! ([`soul_match::EnrolledMatcher`]). Named-identity locking remains **data-gated** —
|
||||
//! it requires the decisive high-weight channels (real AETHER enrollment +
|
||||
//! body-resonance) to be fed real data, which has not been done; on cardiac +
|
||||
//! respiratory channels alone identity is NOT separable (see
|
||||
//! `tests/soul_match.rs::cardiac_alone_cannot_separate_identity_matches_audit`).
|
||||
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
|
|
@ -43,6 +51,8 @@ pub mod privacy_mode;
|
|||
pub mod rumqttc_publisher;
|
||||
pub mod signature_hasher;
|
||||
pub mod sink;
|
||||
pub mod soul_channels;
|
||||
pub mod soul_match;
|
||||
|
||||
pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle};
|
||||
#[cfg(feature = "std")]
|
||||
|
|
@ -81,6 +91,13 @@ pub use privacy_mode::{PrivacyAction, PrivacyAttestationProof, PrivacyMode};
|
|||
pub use privacy_mode::PrivacyModeRegistry;
|
||||
pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN};
|
||||
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
|
||||
pub use soul_channels::{
|
||||
Channel, FeatureError, FeatureVector, MatchWeights, SoulChannels, WeightError, CHANNEL_COUNT,
|
||||
DEFAULT_WEIGHTS, FEATURE_VECTOR_CAP,
|
||||
};
|
||||
pub use soul_match::{cosine_sim, match_score, MatchScore};
|
||||
#[cfg(feature = "std")]
|
||||
pub use soul_match::EnrolledMatcher;
|
||||
|
||||
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
|
||||
#[repr(u8)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
//! 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,
|
||||
/// Body–field 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
//! §3.6 Soul Signature matching algorithm — the **first running implementation**.
|
||||
//!
|
||||
//! This module implements, exactly, the per-channel weighted-cosine matcher
|
||||
//! specified in `docs/research/soul/specification.md` §3.6:
|
||||
//!
|
||||
//! ```text
|
||||
//! match_score = Σ_i ( w_i · cosine_sim(P.channel_i, Q.channel_i) )
|
||||
//! / Σ_i ( w_i · availability(P.channel_i, Q.channel_i) )
|
||||
//! ```
|
||||
//!
|
||||
//! where `availability(P_i, Q_i)` is `1.0` iff **both** the profile and the
|
||||
//! query carry channel `i` (and the data is usable), else `0.0`. The division
|
||||
//! normalizes the score by the weight mass of the channels that were actually
|
||||
//! shared, so a probe missing a channel degrades gracefully instead of being
|
||||
//! penalized for the absence.
|
||||
//!
|
||||
//! ## What this module proves — and what it does NOT
|
||||
//!
|
||||
//! It **runs**: feed two [`SoulChannels`] and it returns a calibrated, fully
|
||||
//! transparent [`MatchScore`] (overall score, contributing-channel count, and
|
||||
//! per-channel cosine contributions). [`EnrolledMatcher`] wires that into the
|
||||
//! real [`SoulMatchOracle`] the coherence gate already calls — replacing the
|
||||
//! reliance on [`crate::coherence_gate::NullOracle`], which always returns
|
||||
//! `NotEnrolled`.
|
||||
//!
|
||||
//! It does **NOT** claim working named-person identification. Named-identity
|
||||
//! locking is gated on the two decisive high-weight channels — the AETHER
|
||||
//! embedding (0.35), populated from a **real enrollment**, and (in multi-room
|
||||
//! deployments) the body-resonance / Body-Field-Coupling channel — being fed
|
||||
//! with real measured data. That has not been done in this repo. On the
|
||||
//! low-weight cardiac (0.15) + respiratory (0.10) channels **alone**, identity
|
||||
//! is **not separable above any useful threshold** — heartbeat and breathing
|
||||
//! rates overlap too much between people. This is not a hypothesis: it is
|
||||
//! measured by the test
|
||||
//! `cardiac_alone_cannot_separate_identity_matches_audit` in
|
||||
//! `tests/soul_match.rs`. The weights themselves are §3.6 **design intent, not
|
||||
//! validated** (see [`crate::soul_channels::MatchWeights`]).
|
||||
//!
|
||||
//! In short: a real matcher that honestly reports where it cannot lock.
|
||||
|
||||
use crate::soul_channels::{Channel, MatchWeights, SoulChannels};
|
||||
|
||||
/// Result of one §3.6 match evaluation.
|
||||
///
|
||||
/// Carries the normalized score **and** the evidence behind it, so a caller
|
||||
/// (or an auditor) can see exactly which channels contributed and by how much.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MatchScore {
|
||||
/// The normalized §3.6 score, or `None` when the match is **undefined**
|
||||
/// because no weighted channel was shared (denominator = 0). A `None`
|
||||
/// score is NEVER coerced to a high value — see [`Self::is_defined`].
|
||||
score: Option<f32>,
|
||||
/// Number of channels with `availability > 0` (shared by both sides) AND
|
||||
/// non-zero weight — i.e. channels that actually moved the score.
|
||||
contributing_channels: usize,
|
||||
/// Per-channel cosine contribution. `None` for channels not shared (or
|
||||
/// zero-weight); `Some(cos)` with the raw cosine similarity otherwise.
|
||||
/// Index-aligned to [`Channel::ALL`].
|
||||
per_channel: [Option<f32>; crate::soul_channels::CHANNEL_COUNT],
|
||||
}
|
||||
|
||||
impl MatchScore {
|
||||
/// The normalized score in `[-1, 1]`, or `None` if undefined (no shared
|
||||
/// weighted channels). Callers MUST treat `None` as "insufficient
|
||||
/// evidence", never as a default-high match.
|
||||
#[must_use]
|
||||
pub const fn score(&self) -> Option<f32> {
|
||||
self.score
|
||||
}
|
||||
|
||||
/// `true` iff a score was computable (≥1 shared, weighted channel).
|
||||
#[must_use]
|
||||
pub const fn is_defined(&self) -> bool {
|
||||
self.score.is_some()
|
||||
}
|
||||
|
||||
/// Number of channels that contributed to the score (`availability > 0`
|
||||
/// and non-zero weight).
|
||||
#[must_use]
|
||||
pub const fn contributing_channels(&self) -> usize {
|
||||
self.contributing_channels
|
||||
}
|
||||
|
||||
/// Raw cosine contribution for a specific channel, if it was shared and
|
||||
/// weighted. Useful for transparency / dashboards.
|
||||
#[must_use]
|
||||
pub fn channel_contribution(&self, channel: Channel) -> Option<f32> {
|
||||
self.per_channel[channel.index()]
|
||||
}
|
||||
|
||||
/// An undefined result — no shared weighted channels.
|
||||
const fn insufficient() -> Self {
|
||||
Self {
|
||||
score: None,
|
||||
contributing_channels: 0,
|
||||
per_channel: [None; crate::soul_channels::CHANNEL_COUNT],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the §3.6 match score of `query` against `profile` under `weights`.
|
||||
///
|
||||
/// Implements the spec formula verbatim. For each channel `i`:
|
||||
/// - `availability` is `1.0` iff both `profile` and `query` carry usable data
|
||||
/// for `i` (a zero-norm or empty channel counts as unavailable — it can
|
||||
/// never contribute, and must never produce NaN).
|
||||
/// - `cosine_sim` is the standard cosine similarity in `[-1, 1]`. When the two
|
||||
/// shared channels have **different lengths**, only the overlapping prefix
|
||||
/// is compared (channels are expected to be same-length by construction;
|
||||
/// this is a defensive fallback, never a NaN source).
|
||||
///
|
||||
/// If the denominator `Σ w_i · availability_i` is `0` (no shared weighted
|
||||
/// channel), the score is **undefined** and a typed
|
||||
/// [`MatchScore::insufficient`] is returned — NOT a default-high score.
|
||||
#[must_use]
|
||||
pub fn match_score(
|
||||
profile: &SoulChannels,
|
||||
query: &SoulChannels,
|
||||
weights: &MatchWeights,
|
||||
) -> MatchScore {
|
||||
let mut numerator = 0.0f32;
|
||||
let mut denominator = 0.0f32;
|
||||
let mut contributing = 0usize;
|
||||
let mut per_channel = [None; crate::soul_channels::CHANNEL_COUNT];
|
||||
|
||||
for channel in Channel::ALL {
|
||||
let w = weights.weight(channel);
|
||||
if w == 0.0 {
|
||||
// Zero-weight channels (e.g. Body-Field-Coupling single-room) can
|
||||
// never affect the score; skip them so they do not pollute the
|
||||
// contributing-channel count or the denominator.
|
||||
continue;
|
||||
}
|
||||
|
||||
let availability = availability(profile, query, channel);
|
||||
if availability == 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both sides present and weighted — compute the cosine contribution.
|
||||
let (Some(p), Some(q)) = (profile.channel_slice(channel), query.channel_slice(channel))
|
||||
else {
|
||||
// Unreachable given availability == 1.0, but stay total.
|
||||
continue;
|
||||
};
|
||||
let cos = cosine_sim(p, q);
|
||||
numerator += w * cos;
|
||||
denominator += w * availability;
|
||||
per_channel[channel.index()] = Some(cos);
|
||||
contributing += 1;
|
||||
}
|
||||
|
||||
if denominator == 0.0 {
|
||||
return MatchScore::insufficient();
|
||||
}
|
||||
|
||||
MatchScore {
|
||||
score: Some(numerator / denominator),
|
||||
contributing_channels: contributing,
|
||||
per_channel,
|
||||
}
|
||||
}
|
||||
|
||||
/// §3.6 `availability(P_i, Q_i)`: `1.0` iff both sides carry usable data for
|
||||
/// `channel`, else `0.0`. A present-but-zero-norm / empty channel is treated
|
||||
/// as unavailable (it cannot yield a meaningful cosine and would otherwise
|
||||
/// risk a NaN).
|
||||
#[must_use]
|
||||
fn availability(profile: &SoulChannels, query: &SoulChannels, channel: Channel) -> f32 {
|
||||
match (profile.channel_slice(channel), query.channel_slice(channel)) {
|
||||
(Some(p), Some(q)) if is_usable(p) && is_usable(q) => 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel slice is usable for cosine if it is non-empty and has non-zero
|
||||
/// L2 norm (so the cosine denominator is positive — never a division by zero).
|
||||
fn is_usable(v: &[f32]) -> bool {
|
||||
!v.is_empty() && v.iter().any(|x| x.is_finite() && *x != 0.0)
|
||||
}
|
||||
|
||||
/// Standard cosine similarity in `[-1, 1]`.
|
||||
///
|
||||
/// Guards every NaN/zero-norm path: a zero-norm input (which `availability`
|
||||
/// already excludes, but we stay total) yields `0.0`, never NaN. When the two
|
||||
/// vectors differ in length, the overlapping prefix is used.
|
||||
#[must_use]
|
||||
pub fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
|
||||
let n = a.len().min(b.len());
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut dot = 0.0f32;
|
||||
let mut na = 0.0f32;
|
||||
let mut nb = 0.0f32;
|
||||
for i in 0..n {
|
||||
let x = a[i];
|
||||
let y = b[i];
|
||||
// Treat non-finite components as 0 — never propagate NaN into the score.
|
||||
let (x, y) = (if x.is_finite() { x } else { 0.0 }, if y.is_finite() {
|
||||
y
|
||||
} else {
|
||||
0.0
|
||||
});
|
||||
dot += x * y;
|
||||
na += x * x;
|
||||
nb += y * y;
|
||||
}
|
||||
let denom = na.sqrt() * nb.sqrt();
|
||||
if denom == 0.0 || !denom.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let cos = dot / denom;
|
||||
// Clamp to [-1, 1] to absorb floating-point overshoot.
|
||||
cos.clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
// --- EnrolledMatcher: the real SoulMatchOracle -----------------------------
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
pub use self::enrolled::EnrolledMatcher;
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
mod enrolled {
|
||||
use core::cell::RefCell;
|
||||
|
||||
use super::{match_score, MatchScore};
|
||||
use crate::coherence_gate::{MatchOutcome, SoulMatchOracle};
|
||||
use crate::soul_channels::{MatchWeights, SoulChannels};
|
||||
|
||||
/// A real [`SoulMatchOracle`]: holds enrolled `(person_id, SoulChannels)`
|
||||
/// profiles and, given a probe, returns the best enrolled match that clears
|
||||
/// both a score threshold and a minimum shared-channel count.
|
||||
///
|
||||
/// This is the production-honest replacement for relying on
|
||||
/// [`crate::coherence_gate::NullOracle`] (which always reports
|
||||
/// `NotEnrolled`). `NullOracle` remains the correct default when Soul
|
||||
/// Signature is disabled; `EnrolledMatcher` is what runs when it is enabled
|
||||
/// **and** real enrolled data is present.
|
||||
///
|
||||
/// ## Interior mutability for the `&self` trait method
|
||||
///
|
||||
/// [`SoulMatchOracle::matches_enrolled`] takes `&self`, but a match needs a
|
||||
/// live probe. The probe is stored behind a [`RefCell`] and set via
|
||||
/// [`EnrolledMatcher::set_probe`] before each gate evaluation. With no
|
||||
/// probe set, the oracle reports `NotEnrolled` (fail-closed).
|
||||
pub struct EnrolledMatcher {
|
||||
profiles: Vec<(u64, SoulChannels)>,
|
||||
weights: MatchWeights,
|
||||
threshold: f32,
|
||||
min_channels: usize,
|
||||
probe: RefCell<Option<SoulChannels>>,
|
||||
}
|
||||
|
||||
impl EnrolledMatcher {
|
||||
/// Build a matcher with a score threshold and a minimum
|
||||
/// shared-channel requirement.
|
||||
///
|
||||
/// `threshold` is the deployment-specific minimum score (§3.6: "a
|
||||
/// deployment-specific parameter with a documented FAR/FRR
|
||||
/// trade-off"). `min_channels` is the minimum number of channels that
|
||||
/// must be shared for a match to be considered at all — set this above
|
||||
/// 1 so a single low-weight channel can never lock identity.
|
||||
#[must_use]
|
||||
pub fn new(weights: MatchWeights, threshold: f32, min_channels: usize) -> Self {
|
||||
Self {
|
||||
profiles: Vec::new(),
|
||||
weights,
|
||||
threshold,
|
||||
min_channels,
|
||||
probe: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enroll a profile under an opaque `person_id`.
|
||||
pub fn enroll(&mut self, person_id: u64, profile: SoulChannels) {
|
||||
self.profiles.push((person_id, profile));
|
||||
}
|
||||
|
||||
/// Number of enrolled profiles.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.profiles.len()
|
||||
}
|
||||
|
||||
/// `true` if no profiles are enrolled.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.profiles.is_empty()
|
||||
}
|
||||
|
||||
/// Set the live probe to be matched on the next oracle call. Replaces
|
||||
/// any previously-set probe.
|
||||
pub fn set_probe(&self, probe: SoulChannels) {
|
||||
*self.probe.borrow_mut() = Some(probe);
|
||||
}
|
||||
|
||||
/// Clear the probe — subsequent oracle calls report `NotEnrolled`.
|
||||
pub fn clear_probe(&self) {
|
||||
*self.probe.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Score the current probe against every enrolled profile and return
|
||||
/// the best `(person_id, MatchScore)` whose score is **defined**.
|
||||
/// Returns `None` if there is no probe, no enrolled profile, or no
|
||||
/// defined score. This does NOT apply the threshold — it is the raw
|
||||
/// transparency view used by tests and dashboards.
|
||||
#[must_use]
|
||||
pub fn best_match(&self) -> Option<(u64, MatchScore)> {
|
||||
let probe = self.probe.borrow();
|
||||
let probe = probe.as_ref()?;
|
||||
let mut best: Option<(u64, MatchScore)> = None;
|
||||
for (person_id, profile) in &self.profiles {
|
||||
let ms = match_score(profile, probe, &self.weights);
|
||||
let Some(s) = ms.score() else { continue };
|
||||
let better = match best {
|
||||
None => true,
|
||||
Some((_, prev)) => prev.score().map_or(true, |ps| s > ps),
|
||||
};
|
||||
if better {
|
||||
best = Some((*person_id, ms));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
impl SoulMatchOracle for EnrolledMatcher {
|
||||
/// Real §3.6 oracle. Returns [`MatchOutcome::Match`] for the best
|
||||
/// enrolled profile whose score is **defined**, clears `threshold`,
|
||||
/// **and** shares at least `min_channels` channels. Otherwise
|
||||
/// [`MatchOutcome::NotEnrolled`].
|
||||
///
|
||||
/// Fail-closed: empty enrolled set, no probe, undefined score,
|
||||
/// below-threshold score, or too-few shared channels all yield
|
||||
/// `NotEnrolled` — never a false `Match`.
|
||||
fn matches_enrolled(&self) -> MatchOutcome {
|
||||
match self.best_match() {
|
||||
Some((person_id, ms)) => {
|
||||
let score = ms.score().unwrap_or(f32::NEG_INFINITY);
|
||||
if score >= self.threshold
|
||||
&& ms.contributing_channels() >= self.min_channels
|
||||
{
|
||||
MatchOutcome::Match { person_id }
|
||||
} else {
|
||||
MatchOutcome::NotEnrolled
|
||||
}
|
||||
}
|
||||
None => MatchOutcome::NotEnrolled,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue