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:
ruv 2026-06-11 21:16:05 -04:00
parent 66ebf798e5
commit b08e49e47c
3 changed files with 698 additions and 0 deletions

View File

@ -10,6 +10,14 @@
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
//!
//! Status: P1 in progress — frame format + sink marker traits. P2P6 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)]

View File

@ -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,
/// 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()
}
}

View File

@ -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,
}
}
}
}