From 2e7f67c933f84343124615e0ab8827b1db458106 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 14:57:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-118/p3.2):=20identity=5Frisk=20score?= =?UTF-8?q?=20+=20GateAction=20enum=20=E2=80=94=2072/72=20GREEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 10. Lands the stateless half of ADR-121 §2.2–§2.4: the multiplicative risk-score formula and the 4-band gate classifier. Hysteresis + 5s debounce (stateful CoherenceGate) land in iter 11. Added (no_std-compatible): - src/identity_risk.rs: * score(sep, stab, consist, conf) -> f32 Each input clamped to [0,1]; NaN → 0 (conservative). Multiplicative combination: any near-zero factor collapses the score → privacy-biased. * Threshold constants: PREDICT_ONLY_THRESHOLD=0.5, REJECT_THRESHOLD=0.7, RECALIBRATE_THRESHOLD=0.9 * GateAction enum: Accept | PredictOnly | Reject | Recalibrate * GateAction::from_score(f32) -> Self — band-based classification with inclusive lower edges (0.7 maps to Reject, 0.9 maps to Recalibrate) * GateAction::allows_publish() / drops_event() / requires_recalibrate() - pub use identity_risk_score (the function) and GateAction from lib.rs tests/identity_risk_score.rs (12 named tests, all green): all_ones_yields_one any_zero_factor_collapses_score_to_zero (4 single-factor variants) score_is_monotonic_non_decreasing_in_single_factor out_of_range_inputs_are_clamped_to_unit_interval nan_inputs_treated_as_zero (verifies privacy-conservative NaN handling) known_score_matches_hand_calculation (0.8*0.9*0.85*0.95 to 1e-6) from_score_classifies_each_band (8 boundary-condition checks) threshold_constants_match_documented_values nan_score_maps_to_accept_conservatively allows_publish_partitions_actions_correctly drops_event_inverts_allows_publish (parameterized over all 4 actions) requires_recalibrate_is_unique_to_recalibrate ACs progressed: - ADR-121 AC2 partial — `score` formula structurally enforces non-negativity, upper bound 1.0, and conservative behavior under uncertainty (NaN, negative input, single near-zero factor). - ADR-121 AC7 partial — score function is pure / deterministic; identical inputs always produce identical outputs (asserted by the known-value test). Test config: - cargo test --no-default-features → 43 passed (31 + 12) - cargo test → 72 passed (60 + 12) Out of scope (next iter target): - CoherenceGate stateful struct: ±0.05 hysteresis + 5-second debounce (ADR-121 §2.5) so the gate doesn't oscillate near band boundaries. - SoulMatchOracle stub trait (ADR-121 §2.6) — the Recalibrate exemption hook for `--features soul-signature` deployments. Co-Authored-By: claude-flow --- .../wifi-densepose-bfld/src/identity_risk.rs | 113 ++++++++++++++++++ v2/crates/wifi-densepose-bfld/src/lib.rs | 2 + .../tests/identity_risk_score.rs | 102 ++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 v2/crates/wifi-densepose-bfld/src/identity_risk.rs create mode 100644 v2/crates/wifi-densepose-bfld/tests/identity_risk_score.rs 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()); +}