Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/src/identity_risk.rs
T
ruv 2e7f67c933 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>
2026-05-24 14:57:08 -04:00

114 lines
4.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
}
}