feat(adr-118/p3.2): identity_risk score + GateAction enum — 72/72 GREEN

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 <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-24 14:57:08 -04:00
parent 4a6498fc2f
commit 2e7f67c933
3 changed files with 217 additions and 0 deletions
@@ -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)
}
}
+2
View File
@@ -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;
@@ -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());
}