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:
parent
4a6498fc2f
commit
2e7f67c933
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
Loading…
Reference in New Issue