diff --git a/v2/crates/wifi-densepose-bfld/src/identity_risk.rs b/v2/crates/wifi-densepose-bfld/src/identity_risk.rs new file mode 100644 index 00000000..4b564799 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/identity_risk.rs @@ -0,0 +1,113 @@ +//! Identity-risk scoring and coherence-gate action mapping. ADR-121 §2.2–§2.4. +//! +//! The risk score is a multiplicative combination of four bounded factors: +//! +//! ```text +//! identity_risk_score = clamp(sep × stab × consist × conf, 0.0, 1.0) +//! ``` +//! +//! Multiplicative combination is **conservative under uncertainty**: any single +//! near-zero factor (e.g., very low sample confidence) collapses the score +//! toward 0. This biases the system toward "report low risk when unsure", +//! which is the privacy-preferred default. +//! +//! The score maps deterministically to a [`GateAction`]: +//! +//! | Score range | Action | Effect | +//! |------------------------|-----------------|-------------------------------------------| +//! | `score < 0.5` | `Accept` | Publish normally | +//! | `0.5 <= score < 0.7` | `PredictOnly` | Publish with `confidence` flag lowered | +//! | `0.7 <= score < 0.9` | `Reject` | Drop the event entirely | +//! | `score >= 0.9` | `Recalibrate` | Drop AND rotate `site_salt` (per ADR-120) | +//! +//! This iter ships the **stateless** mapping. Hysteresis (±0.05) and the +//! 5-second debounce land in the `CoherenceGate` struct in a subsequent iter. + +/// Lower edge of `PredictOnly` (inclusive). +pub const PREDICT_ONLY_THRESHOLD: f32 = 0.5; +/// Lower edge of `Reject` (inclusive). +pub const REJECT_THRESHOLD: f32 = 0.7; +/// Lower edge of `Recalibrate` (inclusive). Triggers `site_salt` rotation. +pub const RECALIBRATE_THRESHOLD: f32 = 0.9; + +/// Compute the identity-risk score from its four factors. +/// +/// Each input is clamped to `[0.0, 1.0]`; the result is always in that range +/// even if the inputs include NaN (treated as 0.0 by `clamp` per its contract). +#[must_use] +pub fn score(sep: f32, stab: f32, consist: f32, conf: f32) -> f32 { + let s = clamp01(sep); + let t = clamp01(stab); + let p = clamp01(consist); + let c = clamp01(conf); + clamp01(s * t * p * c) +} + +/// `clamp01` — handles NaN by mapping it to 0.0, matching the +/// privacy-conservative bias documented in ADR-121 §2.2. +fn clamp01(v: f32) -> f32 { + if v.is_nan() { + 0.0 + } else { + v.clamp(0.0, 1.0) + } +} + +/// Coherence-gate decision derived from the current risk score. ADR-121 §2.4. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GateAction { + /// Publish the event normally. + Accept, + /// Publish but mark the event as "predicted-only" — downstream consumers + /// (HA, Matter) should display reduced confidence. + PredictOnly, + /// Drop the event entirely; do not publish on any sink. + Reject, + /// Drop the event AND rotate the site-keyed BLAKE3 salt so future + /// `rf_signature_hash` values cannot correlate with past ones. + Recalibrate, +} + +impl GateAction { + /// Map a risk score to the corresponding gate action. + /// + /// Boundary semantics: thresholds are **inclusive of the lower edge**. + /// `score = 0.7` is `Reject`; `score = 0.9` is `Recalibrate`. + #[must_use] + pub fn from_score(score: f32) -> Self { + if score.is_nan() { + // Conservative: an undefined score should not trigger anything + // beyond a normal publish — the gate-runner is responsible for + // logging the NaN as an upstream data-quality issue. + return Self::Accept; + } + if score < PREDICT_ONLY_THRESHOLD { + Self::Accept + } else if score < REJECT_THRESHOLD { + Self::PredictOnly + } else if score < RECALIBRATE_THRESHOLD { + Self::Reject + } else { + Self::Recalibrate + } + } + + /// `true` for `Accept` and `PredictOnly` — both produce a published event. + #[must_use] + pub const fn allows_publish(self) -> bool { + matches!(self, Self::Accept | Self::PredictOnly) + } + + /// `true` for `Reject` and `Recalibrate` — both drop the current event. + #[must_use] + pub const fn drops_event(self) -> bool { + matches!(self, Self::Reject | Self::Recalibrate) + } + + /// `true` only for `Recalibrate` — the gate-runner must rotate `site_salt` + /// and `drain()` the `EmbeddingRing` (per ADR-120 §2.5 + ADR-121 §2.4). + #[must_use] + pub const fn requires_recalibrate(self) -> bool { + matches!(self, Self::Recalibrate) + } +} diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 8d5fda20..c19226f6 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -16,6 +16,7 @@ pub mod embedding; pub mod embedding_ring; pub mod frame; +pub mod identity_risk; #[cfg(feature = "std")] pub mod payload; #[cfg(feature = "std")] @@ -24,6 +25,7 @@ pub mod sink; pub use embedding::{IdentityEmbedding, EMBEDDING_DIM}; pub use embedding_ring::{EmbeddingRing, RING_CAPACITY}; +pub use identity_risk::{score as identity_risk_score, GateAction}; pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE}; #[cfg(feature = "std")] pub use frame::BfldFrame; diff --git a/v2/crates/wifi-densepose-bfld/tests/identity_risk_score.rs b/v2/crates/wifi-densepose-bfld/tests/identity_risk_score.rs new file mode 100644 index 00000000..025a2f44 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/identity_risk_score.rs @@ -0,0 +1,102 @@ +//! Acceptance tests for ADR-121 §2.2–§2.4: risk score formula + gate action. + +use wifi_densepose_bfld::identity_risk::{ + score, GateAction, PREDICT_ONLY_THRESHOLD, RECALIBRATE_THRESHOLD, REJECT_THRESHOLD, +}; + +// --- score formula --- + +#[test] +fn all_ones_yields_one() { + assert!((score(1.0, 1.0, 1.0, 1.0) - 1.0).abs() < 1e-6); +} + +#[test] +fn any_zero_factor_collapses_score_to_zero() { + assert_eq!(score(0.0, 1.0, 1.0, 1.0), 0.0); + assert_eq!(score(1.0, 0.0, 1.0, 1.0), 0.0); + assert_eq!(score(1.0, 1.0, 0.0, 1.0), 0.0); + assert_eq!(score(1.0, 1.0, 1.0, 0.0), 0.0); +} + +#[test] +fn score_is_monotonic_non_decreasing_in_single_factor() { + let baseline = score(0.5, 0.5, 0.5, 0.5); + let higher = score(0.9, 0.5, 0.5, 0.5); + assert!(higher >= baseline); +} + +#[test] +fn out_of_range_inputs_are_clamped_to_unit_interval() { + // Negative input → 0; result still 0. + assert_eq!(score(-0.5, 1.0, 1.0, 1.0), 0.0); + // Above-1 input → 1; result equals the product of the others. + assert!((score(1.5, 1.0, 1.0, 1.0) - 1.0).abs() < 1e-6); +} + +#[test] +fn nan_inputs_treated_as_zero() { + assert_eq!(score(f32::NAN, 1.0, 1.0, 1.0), 0.0); + assert_eq!(score(1.0, f32::NAN, f32::NAN, 1.0), 0.0); +} + +#[test] +fn known_score_matches_hand_calculation() { + let s = score(0.8, 0.9, 0.85, 0.95); + let expected = 0.8 * 0.9 * 0.85 * 0.95; + assert!((s - expected).abs() < 1e-6, "got {s}, expected {expected}"); +} + +// --- GateAction mapping --- + +#[test] +fn from_score_classifies_each_band() { + assert_eq!(GateAction::from_score(0.0), GateAction::Accept); + assert_eq!(GateAction::from_score(0.49), GateAction::Accept); + assert_eq!(GateAction::from_score(0.5), GateAction::PredictOnly); + assert_eq!(GateAction::from_score(0.69), GateAction::PredictOnly); + assert_eq!(GateAction::from_score(0.7), GateAction::Reject); + assert_eq!(GateAction::from_score(0.89), GateAction::Reject); + assert_eq!(GateAction::from_score(0.9), GateAction::Recalibrate); + assert_eq!(GateAction::from_score(1.0), GateAction::Recalibrate); +} + +#[test] +fn threshold_constants_match_documented_values() { + assert!((PREDICT_ONLY_THRESHOLD - 0.5).abs() < 1e-6); + assert!((REJECT_THRESHOLD - 0.7).abs() < 1e-6); + assert!((RECALIBRATE_THRESHOLD - 0.9).abs() < 1e-6); +} + +#[test] +fn nan_score_maps_to_accept_conservatively() { + assert_eq!(GateAction::from_score(f32::NAN), GateAction::Accept); +} + +#[test] +fn allows_publish_partitions_actions_correctly() { + assert!(GateAction::Accept.allows_publish()); + assert!(GateAction::PredictOnly.allows_publish()); + assert!(!GateAction::Reject.allows_publish()); + assert!(!GateAction::Recalibrate.allows_publish()); +} + +#[test] +fn drops_event_inverts_allows_publish() { + for a in [ + GateAction::Accept, + GateAction::PredictOnly, + GateAction::Reject, + GateAction::Recalibrate, + ] { + assert_ne!(a.allows_publish(), a.drops_event()); + } +} + +#[test] +fn requires_recalibrate_is_unique_to_recalibrate() { + assert!(!GateAction::Accept.requires_recalibrate()); + assert!(!GateAction::PredictOnly.requires_recalibrate()); + assert!(!GateAction::Reject.requires_recalibrate()); + assert!(GateAction::Recalibrate.requires_recalibrate()); +}