diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index a2d99e68..2789910b 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -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)] diff --git a/v2/crates/wifi-densepose-bfld/src/soul_channels.rs b/v2/crates/wifi-densepose-bfld/src/soul_channels.rs new file mode 100644 index 00000000..4c50f069 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/soul_channels.rs @@ -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 { + 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, + /// The seven non-AETHER channels, index-aligned to `Channel` 1..=7. + /// `vectors[c.index() - 1]` holds channel `c` (AETHER lives in `aether`). + vectors: [Option; 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 { + 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> { + match channel { + Channel::AetherEmbedding => None, + other => Some(&mut self.vectors[other.index() - 1]), + } + } +} + +impl Default for SoulChannels { + fn default() -> Self { + Self::empty() + } +} diff --git a/v2/crates/wifi-densepose-bfld/src/soul_match.rs b/v2/crates/wifi-densepose-bfld/src/soul_match.rs new file mode 100644 index 00000000..85b95db7 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/soul_match.rs @@ -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, + /// 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; 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 { + 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 { + 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>, + } + + 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, + } + } + } +}