mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
feat: implement ADR-297 phase-1 dependent wave — OOD, witness, certify, scorecard, policy
Completes the phase-1 certificate spine; both acceptance tests now pass as code. ruview-ood (ADR-299): domain-distance vs the certified fingerprint and a pure DomainState KNOWN/DEGRADED/UNKNOWN classifier implementing the ADR-297 VALID->DEGRADED->UNKNOWN staleness guard; InferenceGate suppresses the class (UNKNOWN as a first-class value) when domain is not KNOWN; RecalibrationRequest signalled on DEGRADED/UNKNOWN. 25 tests. ruview-witness (ADR-316): ordered, append-only, BLAKE3 hash-linked stage chain (observation->DSP->inference->corroboration->spatial->policy) rooted in an attest VerifiedMeasurement; verify() catches mutation/reorder/dropped/broken links; effective level is the minimum across stages. 19 tests. ruview-certify (ADR-315): signed CapabilityCertificate minted from a single- context evidence slice (never pooled), evidence level capped at the slice floor, valid_until bounded by calibration validity; is_valid(now, domain) returns false when expired OR domain != KNOWN (certificate conditional on the live domain signature). 17 tests incl. non_known_domain_invalidates. ruview-scorecard (ADR-314): multi-domain scorecard with per-domain CIs, worst_domain(), and a promotion gate that fails when only pooled average improved while a worst-domain slice regressed. 17 tests. ruview-policy (ADR-318): fail-closed action gate; Convenience/Security/ SafetyCritical assurance classes; authorize() denies with a named failed condition; UNKNOWN denies high-assurance actions. Includes acceptance_test_b_post_drift_unknown_denies_safety_critical. 10 tests. All five verified green independently (88 tests). Registers the five crates as workspace members. SYNTHETIC/L0 reference crypto; no hardware claims. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
This commit is contained in:
Generated
+54
@@ -7899,6 +7899,20 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-certify"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ruview-attest",
|
||||
"ruview-evidence",
|
||||
"ruview-ontology",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-calibration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-evidence"
|
||||
version = "0.3.1"
|
||||
@@ -7917,6 +7931,36 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-ood"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-calibration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-policy"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"ruview-evidence",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-scorecard"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"ruview-evidence",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
@@ -7957,6 +8001,16 @@ dependencies = [
|
||||
"wifi-densepose-hardware",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-witness"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"ruview-attest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -98,6 +98,12 @@ members = [
|
||||
"crates/ruview-ontology", # ADR-303 canonical spatial ontology (Site..Event)
|
||||
"crates/ruview-attest", # ADR-302 authenticated sensor identity / RF chain of custody
|
||||
"crates/ruview-evidence", # ADR-301 evidence engine (per-room/device/subject ledger)
|
||||
# ADR-297 phase 1 — dependent wave (build on the spine roots above):
|
||||
"crates/ruview-ood", # ADR-299 OOD KNOWN/DEGRADED/UNKNOWN gating
|
||||
"crates/ruview-witness", # ADR-316 witness chain (staged signed provenance)
|
||||
"crates/ruview-certify", # ADR-315 capability certificate
|
||||
"crates/ruview-scorecard", # ADR-314 multi-domain benchmark scorecard
|
||||
"crates/ruview-policy", # ADR-318 decision policy / action authorization
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ruview-certify"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
ruview-ontology = { path = "../ruview-ontology" }
|
||||
ruview-attest = { path = "../ruview-attest" }
|
||||
ruview-evidence = { path = "../ruview-evidence" }
|
||||
wifi-densepose-calibration = { path = "../wifi-densepose-calibration", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,427 @@
|
||||
//! # `ruview-certify` — signed capability certificates (ADR-315, ADR-297 §1)
|
||||
//!
|
||||
//! A [`CapabilityCertificate`] is a bounded, signed attestation that a specific
|
||||
//! capability (e.g. presence, pose) has been *validated for a specific
|
||||
//! environment*, for a *bounded* time. RuView must stop making unconditional
|
||||
//! capability claims: "supports presence" is not a true statement — presence
|
||||
//! works in some rooms, on some hardware, for some subject dynamics, and fails
|
||||
//! on a stationary subject at range in an uncalibrated room. The honest unit of
|
||||
//! the claim is a signed, expiring certificate, never a feature flag.
|
||||
//!
|
||||
//! ## What the certificate binds
|
||||
//!
|
||||
//! - the **capability** ([`Capability`]);
|
||||
//! - the **room** ([`SpaceId`], ADR-303) plus the **calibration-certificate
|
||||
//! version** (ADR-298) it was validated against;
|
||||
//! - the **hardware** ([`DeviceId`], ADR-302);
|
||||
//! - the scored **model** version;
|
||||
//! - the **calibrated date** the calibration was captured;
|
||||
//! - the operating **metrics** (`moving_recall`, `stationary_recall`,
|
||||
//! `false_presence_per_24h`) sliced from the ADR-301 ledger for **exactly this
|
||||
//! context** (never pooled across contexts);
|
||||
//! - a `valid_until` expiry that is **never open-ended** and **cannot outlive the
|
||||
//! calibration validity**;
|
||||
//! - exactly one [`EvidenceLevel`] (ADR-282) that **cannot exceed the evidence
|
||||
//! slice's floor** — a certificate never upgrades the ledger it is minted from;
|
||||
//! - a **signature** over the canonical serialization ([`ruview_attest`]); an
|
||||
//! unsigned certificate is not a valid certificate.
|
||||
//!
|
||||
//! ## Honest by construction (ADR-297 rule)
|
||||
//!
|
||||
//! - Minting from a slice that reports **no evidence** yields no certificate —
|
||||
//! absence of evidence is never a capability.
|
||||
//! - The evidence level is the ledger floor, never an upgrade.
|
||||
//! - A certificate minted from a synthetic ledger slice is `L0`/SYNTHETIC by
|
||||
//! construction; nothing here invents a MEASURED number.
|
||||
//! - [`CapabilityCertificate::is_valid`] is *conditional on the live domain
|
||||
//! signature* (ADR-299): a certificate over a `DEGRADED`/`UNKNOWN` domain is
|
||||
//! not valid, and an expired certificate is not valid — the honest failure is
|
||||
//! UNKNOWN, not a best-effort guess.
|
||||
//!
|
||||
//! Time is always injected (no wall clock); no randomness; malformed input is a
|
||||
//! returned error, never a panic; allocation is bounded at every boundary.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use ruview_attest::{DeviceId, Signature, Signer, Verifier};
|
||||
use ruview_evidence::{EvidenceLevel, EvidenceSlice, SummaryEvidence};
|
||||
use ruview_ontology::SpaceId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wifi_densepose_calibration::CalibrationCertificate;
|
||||
|
||||
/// Maximum accepted byte length for the model-version identifier. Bounds
|
||||
/// allocation at the untrusted-input boundary (CLAUDE.md).
|
||||
pub const MAX_MODEL_LEN: usize = 256;
|
||||
|
||||
/// Domain-separation tag for the canonical signing bytes. Distinguishes a
|
||||
/// capability-certificate signature from any other signed object in the system.
|
||||
const DOMAIN: &[u8] = b"ruview-certify/CapabilityCertificate/v1";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The phenomenon a certificate is about. A device may only be certified for a
|
||||
/// capability it is attested to sense (ADR-302/ADR-141); the attestation gate is
|
||||
/// a phase-2 concern — this phase binds the capability into the signed object.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Capability {
|
||||
/// Presence / occupancy detection.
|
||||
Presence,
|
||||
/// Body-pose (DensePose) estimation.
|
||||
Pose,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Stable byte tag used inside the canonical serialization. Never `0`, so a
|
||||
/// field boundary can never be confused with an absent value.
|
||||
const fn tag(self) -> u8 {
|
||||
match self {
|
||||
Capability::Presence => 1,
|
||||
Capability::Pose => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The live domain-state signature a consumer supplies at validation time
|
||||
/// (ADR-299). Only `Known` permits a capability; `Degraded`/`Unknown` gate the
|
||||
/// certificate to invalid — the honest failure is UNKNOWN, not a guess.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum DomainState {
|
||||
/// The domain is characterized and within its calibration envelope.
|
||||
Known,
|
||||
/// The domain has drifted or is degraded — no capability.
|
||||
Degraded,
|
||||
/// The domain is uncharacterized / unknown — no capability.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DomainState {
|
||||
/// Whether the live domain permits consuming a capability.
|
||||
#[must_use]
|
||||
pub fn is_known(self) -> bool {
|
||||
matches!(self, DomainState::Known)
|
||||
}
|
||||
}
|
||||
|
||||
/// The operating metrics frozen onto a certificate, sliced from the ADR-301
|
||||
/// ledger for one exact context (never a global average).
|
||||
///
|
||||
/// `false_presence_per_24h` carries the ledger's context false-positive rate;
|
||||
/// no per-24h count is invented here — the value is the number the ledger
|
||||
/// reports for this context, relabelled to the certificate's operating vocab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OperatingMetrics {
|
||||
/// Recall on moving subjects, `[0, 1]`.
|
||||
pub moving_recall: f64,
|
||||
/// Recall on stationary subjects, `[0, 1]`.
|
||||
pub stationary_recall: f64,
|
||||
/// False-presence operating metric (ledger context false-positive rate).
|
||||
pub false_presence_per_24h: f64,
|
||||
}
|
||||
|
||||
/// The unsigned content a signature binds: everything a verifier must
|
||||
/// reconstruct byte-for-byte to check the tag.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CertificateContent {
|
||||
/// The certified phenomenon.
|
||||
pub capability: Capability,
|
||||
/// The room this claim is validated for (ADR-303).
|
||||
pub room: SpaceId,
|
||||
/// Version of the calibration certificate the validation ran against
|
||||
/// (ADR-298). The certificate cannot outlive this calibration.
|
||||
pub calibration_version: u64,
|
||||
/// Expiry of the calibration certificate (unix seconds); the ceiling on
|
||||
/// `valid_until`.
|
||||
pub calibration_expires_at_unix_s: i64,
|
||||
/// The authenticated device the claim is validated for (ADR-302).
|
||||
pub hardware: DeviceId,
|
||||
/// The scored model version.
|
||||
pub model_version: String,
|
||||
/// Capture time of the calibration certificate (unix seconds).
|
||||
pub calibrated_date_unix_s: i64,
|
||||
/// Operating metrics, sliced from the ledger for this exact context.
|
||||
pub metrics: OperatingMetrics,
|
||||
/// Explicit expiry (unix seconds); never open-ended, never past the
|
||||
/// calibration expiry.
|
||||
pub valid_until_unix_s: i64,
|
||||
/// Exactly one evidence level; the ledger floor, never an upgrade.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
}
|
||||
|
||||
impl CertificateContent {
|
||||
/// Deterministic, length-prefixed canonical serialization used as the
|
||||
/// signing input. Length prefixes make the encoding unambiguous (no field
|
||||
/// can be confused with another) and independent of any serde format, so
|
||||
/// two byte-identical contents always sign identically.
|
||||
#[must_use]
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(
|
||||
DOMAIN.len() + 128 + self.room.as_str().len() + self.hardware.as_str().len(),
|
||||
);
|
||||
out.extend_from_slice(DOMAIN);
|
||||
out.push(self.capability.tag());
|
||||
push_field(&mut out, self.room.as_str().as_bytes());
|
||||
out.extend_from_slice(&self.calibration_version.to_le_bytes());
|
||||
out.extend_from_slice(&self.calibration_expires_at_unix_s.to_le_bytes());
|
||||
push_field(&mut out, self.hardware.as_str().as_bytes());
|
||||
push_field(&mut out, self.model_version.as_bytes());
|
||||
out.extend_from_slice(&self.calibrated_date_unix_s.to_le_bytes());
|
||||
out.extend_from_slice(&self.metrics.moving_recall.to_bits().to_le_bytes());
|
||||
out.extend_from_slice(&self.metrics.stationary_recall.to_bits().to_le_bytes());
|
||||
out.extend_from_slice(&self.metrics.false_presence_per_24h.to_bits().to_le_bytes());
|
||||
out.extend_from_slice(&self.valid_until_unix_s.to_le_bytes());
|
||||
out.push(level_byte(self.evidence_level));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed capability certificate: the [`CertificateContent`] together with a
|
||||
/// signature over its canonical bytes. `signature` is [`None`] for an unsigned
|
||||
/// certificate, which is never valid (ADR-315 §1).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CapabilityCertificate {
|
||||
/// The signed content.
|
||||
pub content: CertificateContent,
|
||||
/// Tag over [`CertificateContent::canonical_bytes`]; `None` means unsigned.
|
||||
pub signature: Option<Signature>,
|
||||
}
|
||||
|
||||
impl CapabilityCertificate {
|
||||
/// Wrap content as an **unsigned** certificate. Useful for tests and for
|
||||
/// staging content before signing; [`Self::verify`] and [`Self::is_valid`]
|
||||
/// both reject it because an unsigned certificate is not a valid
|
||||
/// certificate (ADR-315 §1).
|
||||
#[must_use]
|
||||
pub fn unsigned(content: CertificateContent) -> Self {
|
||||
Self {
|
||||
content,
|
||||
signature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the signature over the canonical bytes. Returns `false` for an
|
||||
/// unsigned certificate or a tampered one. This is the cryptographic check;
|
||||
/// [`Self::is_valid`] adds the expiry and live-domain gates.
|
||||
#[must_use]
|
||||
pub fn verify<V: Verifier + ?Sized>(&self, verifier: &V) -> bool {
|
||||
match &self.signature {
|
||||
Some(sig) => verifier.verify(&self.content.canonical_bytes(), sig),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The consumer gate (ADR-315 §3, ADR-297/ADR-299): the certificate is valid
|
||||
/// **iff** it is signed, it has not expired (`now < valid_until`), and the
|
||||
/// live domain state is `Known`. A `Degraded`/`Unknown` domain or an expired
|
||||
/// or unsigned certificate resolves to *not valid* — the honest UNKNOWN,
|
||||
/// never a best-effort guess. This is the crypto-independent gate; call
|
||||
/// [`Self::verify`] with the enrolled key for the signature check.
|
||||
#[must_use]
|
||||
pub fn is_valid(&self, now_unix_s: i64, domain: DomainState) -> bool {
|
||||
self.signature.is_some()
|
||||
&& domain.is_known()
|
||||
&& now_unix_s < self.content.valid_until_unix_s
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The inputs to [`mint`], other than the signer and the evidence slice. Owned
|
||||
/// so the minted certificate freezes its own copy of every bound field.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MintRequest<'c> {
|
||||
/// The phenomenon being certified.
|
||||
pub capability: Capability,
|
||||
/// The room the claim is validated for.
|
||||
pub room: SpaceId,
|
||||
/// The authenticated device the claim is validated for.
|
||||
pub hardware: DeviceId,
|
||||
/// The scored model version.
|
||||
pub model_version: String,
|
||||
/// The calibration certificate the validation ran against; supplies the
|
||||
/// version, calibrated date, and the expiry ceiling.
|
||||
pub calibration: &'c CalibrationCertificate,
|
||||
/// Requested expiry (unix seconds); must not exceed the calibration expiry.
|
||||
pub valid_until_unix_s: i64,
|
||||
/// Requested evidence level; must not exceed the slice floor.
|
||||
pub evidence_level: EvidenceLevel,
|
||||
}
|
||||
|
||||
/// Mint a signed [`CapabilityCertificate`] from an ADR-301 evidence slice for
|
||||
/// one `(room, device, model)` context.
|
||||
///
|
||||
/// Minting is a pure function over the slice: the metrics are frozen into the
|
||||
/// signed object. It refuses to issue a certificate unless every honesty
|
||||
/// invariant holds.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`CertifyError::NoEvidence`] — the slice reports no evidence for the
|
||||
/// context; absence of evidence is never a capability.
|
||||
/// - [`CertifyError::ContextMismatch`] — the slice's context does not match the
|
||||
/// bound room/hardware/model, so the metrics would not describe the claim.
|
||||
/// - [`CertifyError::CalibrationRoomMismatch`] — the calibration certificate is
|
||||
/// for a different room than the claim.
|
||||
/// - [`CertifyError::EvidenceLevelUpgrade`] — the requested level exceeds the
|
||||
/// ledger floor (no upgrade).
|
||||
/// - [`CertifyError::OutlivesCalibration`] — `valid_until` is past the
|
||||
/// calibration expiry; a certificate cannot outlive its calibration.
|
||||
/// - [`CertifyError::ModelTooLong`] — the model version exceeds [`MAX_MODEL_LEN`].
|
||||
pub fn mint<S: Signer + ?Sized>(
|
||||
signer: &S,
|
||||
request: MintRequest<'_>,
|
||||
slice: &EvidenceSlice<'_>,
|
||||
) -> Result<CapabilityCertificate, CertifyError> {
|
||||
// Bound untrusted input at the boundary.
|
||||
if request.model_version.len() > MAX_MODEL_LEN {
|
||||
return Err(CertifyError::ModelTooLong {
|
||||
len: request.model_version.len(),
|
||||
max: MAX_MODEL_LEN,
|
||||
});
|
||||
}
|
||||
|
||||
// Absence of evidence is never a capability (ADR-315 §2).
|
||||
let summary = slice.summarize();
|
||||
let (floor, agg) = match summary.evidence {
|
||||
SummaryEvidence::NoEvidence => return Err(CertifyError::NoEvidence),
|
||||
SummaryEvidence::Aggregated { level, metrics, .. } => (level, metrics),
|
||||
};
|
||||
|
||||
// The metrics must describe *this* context, or the claim is unbacked.
|
||||
let ctx = slice.context();
|
||||
if ctx.room != request.room.as_str() {
|
||||
return Err(CertifyError::ContextMismatch { field: "room" });
|
||||
}
|
||||
if ctx.device != request.hardware.as_str() {
|
||||
return Err(CertifyError::ContextMismatch { field: "device" });
|
||||
}
|
||||
if ctx.model_version != request.model_version {
|
||||
return Err(CertifyError::ContextMismatch {
|
||||
field: "model_version",
|
||||
});
|
||||
}
|
||||
|
||||
// The calibration certificate must be for the same room as the claim.
|
||||
if request.calibration.space_id != request.room.as_str() {
|
||||
return Err(CertifyError::CalibrationRoomMismatch);
|
||||
}
|
||||
|
||||
// Evidence level is inherited from the ledger and can never be upgraded.
|
||||
if request.evidence_level > floor {
|
||||
return Err(CertifyError::EvidenceLevelUpgrade {
|
||||
requested: request.evidence_level,
|
||||
floor,
|
||||
});
|
||||
}
|
||||
|
||||
// A certificate can never outlive the calibration it was validated against.
|
||||
let calibration_expires_at_unix_s = request.calibration.expires_at_unix_s;
|
||||
if request.valid_until_unix_s > calibration_expires_at_unix_s {
|
||||
return Err(CertifyError::OutlivesCalibration {
|
||||
valid_until_unix_s: request.valid_until_unix_s,
|
||||
calibration_expires_at_unix_s,
|
||||
});
|
||||
}
|
||||
|
||||
let content = CertificateContent {
|
||||
capability: request.capability,
|
||||
room: request.room,
|
||||
calibration_version: request.calibration.version,
|
||||
calibration_expires_at_unix_s,
|
||||
hardware: request.hardware,
|
||||
model_version: request.model_version,
|
||||
calibrated_date_unix_s: request.calibration.captured_at_unix_s,
|
||||
metrics: OperatingMetrics {
|
||||
moving_recall: agg.moving_recall,
|
||||
stationary_recall: agg.stationary_recall,
|
||||
false_presence_per_24h: agg.false_positive_rate,
|
||||
},
|
||||
valid_until_unix_s: request.valid_until_unix_s,
|
||||
evidence_level: request.evidence_level,
|
||||
};
|
||||
|
||||
let signature = signer.sign(&content.canonical_bytes());
|
||||
Ok(CapabilityCertificate {
|
||||
content,
|
||||
signature: Some(signature),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Errors raised at the minting boundary. No variant panics; a malformed or
|
||||
/// dishonest request is always a returned error (CLAUDE.md).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CertifyError {
|
||||
/// The evidence slice reports no evidence for the context — no capability.
|
||||
#[error("no evidence for the context; a certificate cannot be minted")]
|
||||
NoEvidence,
|
||||
/// The slice's context does not match a bound field.
|
||||
#[error("evidence slice context field `{field}` does not match the bound claim")]
|
||||
ContextMismatch {
|
||||
/// The mismatched field name.
|
||||
field: &'static str,
|
||||
},
|
||||
/// The calibration certificate is for a different room than the claim.
|
||||
#[error("calibration certificate room does not match the certified room")]
|
||||
CalibrationRoomMismatch,
|
||||
/// The requested evidence level exceeds the ledger floor (no upgrade).
|
||||
#[error("requested evidence level {requested:?} exceeds ledger floor {floor:?}")]
|
||||
EvidenceLevelUpgrade {
|
||||
/// The requested (too-high) level.
|
||||
requested: EvidenceLevel,
|
||||
/// The ledger floor that caps it.
|
||||
floor: EvidenceLevel,
|
||||
},
|
||||
/// `valid_until` is past the calibration expiry.
|
||||
#[error(
|
||||
"valid_until {valid_until_unix_s} outlives calibration expiry \
|
||||
{calibration_expires_at_unix_s}"
|
||||
)]
|
||||
OutlivesCalibration {
|
||||
/// The requested expiry.
|
||||
valid_until_unix_s: i64,
|
||||
/// The calibration ceiling it exceeded.
|
||||
calibration_expires_at_unix_s: i64,
|
||||
},
|
||||
/// The model version exceeded [`MAX_MODEL_LEN`].
|
||||
#[error("model version is {len} bytes, exceeds max {max}")]
|
||||
ModelTooLong {
|
||||
/// The offending length.
|
||||
len: usize,
|
||||
/// The maximum accepted length.
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Length-prefixed field push (8-byte LE length + bytes) for unambiguous
|
||||
/// canonical encoding.
|
||||
fn push_field(out: &mut Vec<u8>, field: &[u8]) {
|
||||
out.extend_from_slice(&(field.len() as u64).to_le_bytes());
|
||||
out.extend_from_slice(field);
|
||||
}
|
||||
|
||||
/// Stable byte for an evidence level, ordered `L0 < … < L5`.
|
||||
fn level_byte(level: EvidenceLevel) -> u8 {
|
||||
match level {
|
||||
EvidenceLevel::L0 => 0,
|
||||
EvidenceLevel::L1 => 1,
|
||||
EvidenceLevel::L2 => 2,
|
||||
EvidenceLevel::L3 => 3,
|
||||
EvidenceLevel::L4 => 4,
|
||||
EvidenceLevel::L5 => 5,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Deterministic tests (ADR-315 validation matrix): mint+verify, no-evidence =>
|
||||
//! no certificate, evidence-level floor enforced, expiry, unsigned invalid,
|
||||
//! not-KNOWN domain invalidates, canonical-bytes determinism, serde round-trip,
|
||||
//! calibration-linked expiry ceiling, and context binding. No wall clock, no
|
||||
//! randomness; every fixture is synthetic (L0) and built in code.
|
||||
|
||||
use super::*;
|
||||
|
||||
use ruview_attest::{Blake3MacSigner, DeviceId};
|
||||
use ruview_evidence::{
|
||||
AccuracyMetrics, EvidenceContext, EvidenceLedger, EvidenceLevel as EvLevel, EvidenceRecord,
|
||||
};
|
||||
use ruview_ontology::SpaceId;
|
||||
use wifi_densepose_calibration::{
|
||||
CalibrationCertificate, CalibrationTier, CharacterizationSource, CompatibilityEnvelope,
|
||||
EvidenceLevel as CalibLevel, KeyedHashSigner, MintParams, SpecialistBank,
|
||||
};
|
||||
use wifi_densepose_calibration::extract::AnchorFeature;
|
||||
use wifi_densepose_calibration::AnchorLabel;
|
||||
|
||||
const ROOM: &str = "kitchen";
|
||||
const DEVICE: &str = "dev-1";
|
||||
const MODEL: &str = "m-1";
|
||||
|
||||
fn cert_signer() -> Blake3MacSigner {
|
||||
Blake3MacSigner::new([7u8; 32])
|
||||
}
|
||||
|
||||
/// A synthetic calibration certificate (L0) for `ROOM`, captured at
|
||||
/// `captured_at`, valid for `validity` seconds.
|
||||
fn calibration(captured_at: i64, validity: i64) -> CalibrationCertificate {
|
||||
let anchors = vec![AnchorFeature::from_series(
|
||||
ROOM,
|
||||
AnchorLabel::Empty,
|
||||
&[0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1],
|
||||
20.0,
|
||||
)];
|
||||
let bank = SpecialistBank::train(ROOM, "base-1", &anchors, captured_at).unwrap();
|
||||
let signer = KeyedHashSigner::new("sensor-1", b"secret".to_vec());
|
||||
let params = MintParams {
|
||||
space_id: ROOM.into(),
|
||||
sensor_id: "sensor-1".into(),
|
||||
captured_at_unix_s: captured_at,
|
||||
validity_secs: validity,
|
||||
version: 1,
|
||||
tier: CalibrationTier::Auto,
|
||||
evidence: CalibLevel::L0Synthetic,
|
||||
source: CharacterizationSource::Synthetic,
|
||||
envelope: CompatibilityEnvelope::default(),
|
||||
};
|
||||
CalibrationCertificate::mint(params, &bank, &signer).unwrap()
|
||||
}
|
||||
|
||||
fn context() -> EvidenceContext {
|
||||
EvidenceContext::new(ROOM, DEVICE, "moving", MODEL).unwrap()
|
||||
}
|
||||
|
||||
fn metrics() -> AccuracyMetrics {
|
||||
AccuracyMetrics {
|
||||
moving_recall: 0.8,
|
||||
stationary_recall: 0.4,
|
||||
false_positive_rate: 0.02,
|
||||
drift: 0.1,
|
||||
uncertainty: 0.05,
|
||||
calibration_age_secs: 100,
|
||||
sample_count: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// A ledger holding one synthetic (L0) record for `context()`.
|
||||
fn synthetic_ledger() -> EvidenceLedger {
|
||||
let mut ledger = EvidenceLedger::new();
|
||||
ledger
|
||||
.append(EvidenceRecord::synthetic(context(), metrics(), 1).unwrap())
|
||||
.unwrap();
|
||||
ledger
|
||||
}
|
||||
|
||||
fn base_request<'c>(calibration: &'c CalibrationCertificate) -> MintRequest<'c> {
|
||||
MintRequest {
|
||||
capability: Capability::Presence,
|
||||
room: SpaceId::new(ROOM).unwrap(),
|
||||
hardware: DeviceId::new(DEVICE).unwrap(),
|
||||
model_version: MODEL.into(),
|
||||
calibration,
|
||||
valid_until_unix_s: 5_000,
|
||||
evidence_level: EvLevel::L0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_then_verify_round_trips_and_rejects_tampering() {
|
||||
let calibration = calibration(1_000, 5_000); // expires at 6_000
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
|
||||
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||
|
||||
// Frozen from the ledger slice, not a global average.
|
||||
assert_eq!(cert.content.metrics.moving_recall, 0.8);
|
||||
assert_eq!(cert.content.metrics.stationary_recall, 0.4);
|
||||
assert_eq!(cert.content.metrics.false_presence_per_24h, 0.02);
|
||||
// Synthetic ledger => L0 by construction (never upgraded).
|
||||
assert_eq!(cert.content.evidence_level, EvLevel::L0);
|
||||
// Calibration binding carried through.
|
||||
assert_eq!(cert.content.calibration_version, 1);
|
||||
assert_eq!(cert.content.calibration_expires_at_unix_s, 6_000);
|
||||
assert_eq!(cert.content.calibrated_date_unix_s, 1_000);
|
||||
|
||||
assert!(cert.verify(&signer), "freshly minted certificate verifies");
|
||||
|
||||
// Tamper with a signed field: the signature no longer verifies.
|
||||
let mut tampered = cert.clone();
|
||||
tampered.content.metrics.moving_recall = 0.99;
|
||||
assert!(!tampered.verify(&signer), "tampered metric is rejected");
|
||||
|
||||
let mut tampered2 = cert.clone();
|
||||
tampered2.content.valid_until_unix_s += 1;
|
||||
assert!(!tampered2.verify(&signer), "tampered expiry is rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_evidence_context_yields_no_certificate() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
let ledger = EvidenceLedger::new(); // empty
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
|
||||
let err = mint(&signer, base_request(&calibration), &slice).unwrap_err();
|
||||
assert_eq!(err, CertifyError::NoEvidence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_level_cannot_exceed_the_slice_floor() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
// One measured L3 record => floor L3.
|
||||
let mut ledger = EvidenceLedger::new();
|
||||
ledger
|
||||
.append(EvidenceRecord::measured(context(), metrics(), EvLevel::L3, "repro-1", 1).unwrap())
|
||||
.unwrap();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
|
||||
// Requesting L4 over an L3 floor is an upgrade — refused.
|
||||
let mut req = base_request(&calibration);
|
||||
req.evidence_level = EvLevel::L4;
|
||||
let err = mint(&signer, req, &slice).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
CertifyError::EvidenceLevelUpgrade {
|
||||
requested: EvLevel::L4,
|
||||
floor: EvLevel::L3,
|
||||
}
|
||||
);
|
||||
|
||||
// Requesting at or below the floor is honest and permitted.
|
||||
let mut req_ok = base_request(&calibration);
|
||||
req_ok.evidence_level = EvLevel::L2;
|
||||
let cert = mint(&signer, req_ok, &slice).unwrap();
|
||||
assert_eq!(cert.content.evidence_level, EvLevel::L2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_until_cannot_outlive_calibration() {
|
||||
let calibration = calibration(1_000, 5_000); // expires 6_000
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
|
||||
let mut req = base_request(&calibration);
|
||||
req.valid_until_unix_s = 7_000; // past calibration expiry
|
||||
let err = mint(&signer, req, &slice).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
CertifyError::OutlivesCalibration {
|
||||
valid_until_unix_s: 7_000,
|
||||
calibration_expires_at_unix_s: 6_000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_enforces_expiry() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||
// valid_until = 5_000.
|
||||
|
||||
assert!(cert.is_valid(4_999, DomainState::Known), "before expiry");
|
||||
assert!(!cert.is_valid(5_000, DomainState::Known), "at expiry");
|
||||
assert!(!cert.is_valid(6_000, DomainState::Known), "after expiry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_certificate_is_never_valid() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||
|
||||
let unsigned = CapabilityCertificate::unsigned(cert.content.clone());
|
||||
assert!(!unsigned.verify(&signer), "unsigned does not verify");
|
||||
assert!(
|
||||
!unsigned.is_valid(0, DomainState::Known),
|
||||
"unsigned is never valid even fresh and KNOWN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_known_domain_invalidates() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||
|
||||
// Same instant, only the live domain signature differs.
|
||||
assert!(cert.is_valid(4_000, DomainState::Known));
|
||||
assert!(!cert.is_valid(4_000, DomainState::Degraded));
|
||||
assert!(!cert.is_valid(4_000, DomainState::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mismatch_refuses_to_bind_metrics() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
|
||||
let mut req = base_request(&calibration);
|
||||
req.hardware = DeviceId::new("other-device").unwrap();
|
||||
let err = mint(&signer, req, &slice).unwrap_err();
|
||||
assert_eq!(err, CertifyError::ContextMismatch { field: "device" });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_are_deterministic() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
|
||||
let a = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||
let b = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||
assert_eq!(
|
||||
a.content.canonical_bytes(),
|
||||
b.content.canonical_bytes(),
|
||||
"identical content => identical bytes"
|
||||
);
|
||||
assert_eq!(a, b, "mint is a pure function of its inputs");
|
||||
assert_eq!(a.signature, b.signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trips() {
|
||||
let calibration = calibration(1_000, 5_000);
|
||||
let ledger = synthetic_ledger();
|
||||
let slice = ledger.query(&context());
|
||||
let signer = cert_signer();
|
||||
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||
|
||||
let json = serde_json::to_string(&cert).unwrap();
|
||||
let back: CapabilityCertificate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(cert, back);
|
||||
// The deserialized certificate still verifies against the same key.
|
||||
assert!(back.verify(&signer));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ruview-ood"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
wifi-densepose-calibration = { path = "../wifi-densepose-calibration", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Cross-ADR adapter: turn an ADR-298 [`CalibrationCertificate`] plus a live
|
||||
//! fingerprint into the two OOD inputs it governs — the [`FingerprintDistance`]
|
||||
//! and the [`CalibrationCompat`] (ADR-299 §1 inputs 1 and 3).
|
||||
//!
|
||||
//! This is the point where certificate *staleness* becomes a domain signal:
|
||||
//! an expired, tampered, drifted, or identity-mismatched certificate maps to a
|
||||
//! non-`Valid` compatibility, which the state machine drives straight to
|
||||
//! UNKNOWN (ADR-297 staleness guard). Absence of a certificate is handled by
|
||||
//! [`no_certificate`] and likewise defaults to UNKNOWN — absence of evidence is
|
||||
//! absence of capability (ADR-299 §3).
|
||||
|
||||
use wifi_densepose_calibration::certificate::{
|
||||
CalibrationCertificate, CertificateStatus, CertificateVerifier, FingerprintDistance, RoomFingerprint,
|
||||
};
|
||||
|
||||
use crate::domain::CalibrationCompat;
|
||||
|
||||
/// Identity the live inference expects the certificate to attest: which space
|
||||
/// (ADR-303) and which signed device (ADR-302). Validated before the
|
||||
/// certificate's own status, so a certificate for the wrong room/device can
|
||||
/// never present as compatible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExpectedIdentity<'a> {
|
||||
/// The canonical space id the inference is running in.
|
||||
pub space_id: &'a str,
|
||||
/// The signed device id producing the live traffic.
|
||||
pub device_id: &'a str,
|
||||
}
|
||||
|
||||
/// Assess a present certificate against the live fingerprint and expected
|
||||
/// identity, returning the domain distance and the calibration compatibility.
|
||||
///
|
||||
/// `now_unix_s` is **injected** — never read from the wall clock — so the
|
||||
/// staleness decision is deterministic and testable. The distance is always the
|
||||
/// certificate-fingerprint-vs-live distance, computed even for a stale/tampered
|
||||
/// certificate so the drift is still reported.
|
||||
///
|
||||
/// Precedence mirrors ADR-298 `status()` but adds the identity checks first:
|
||||
/// space mismatch → device mismatch → tampered → expired → drifted → valid.
|
||||
pub fn assess_certificate<V: CertificateVerifier>(
|
||||
cert: &CalibrationCertificate,
|
||||
live: &RoomFingerprint,
|
||||
expected: ExpectedIdentity<'_>,
|
||||
now_unix_s: i64,
|
||||
verifier: &V,
|
||||
) -> (FingerprintDistance, CalibrationCompat) {
|
||||
let distance = cert.fingerprint.distance(live);
|
||||
|
||||
// Identity binding first (ADR-302/303): a certificate for the wrong
|
||||
// space/device is incompatible regardless of its own validity.
|
||||
if cert.space_id != expected.space_id {
|
||||
return (distance, CalibrationCompat::SpaceMismatch);
|
||||
}
|
||||
if cert.sensor_id != expected.device_id {
|
||||
return (distance, CalibrationCompat::DeviceMismatch);
|
||||
}
|
||||
|
||||
let compat = match cert.status(live, now_unix_s, verifier) {
|
||||
CertificateStatus::Valid { .. } => CalibrationCompat::Valid,
|
||||
CertificateStatus::Expired { .. } => CalibrationCompat::Expired,
|
||||
CertificateStatus::Drifted { .. } => CalibrationCompat::DriftedBeyondEnvelope,
|
||||
CertificateStatus::TamperedSignature => CalibrationCompat::Tampered,
|
||||
};
|
||||
(distance, compat)
|
||||
}
|
||||
|
||||
/// The compatibility for a space/device with **no** certificate present. Always
|
||||
/// [`CalibrationCompat::Absent`], which the gate treats as UNKNOWN (ADR-299 §3:
|
||||
/// the default state without a valid certificate is UNKNOWN, not KNOWN).
|
||||
pub fn no_certificate() -> CalibrationCompat {
|
||||
CalibrationCompat::Absent
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! The domain-state machine: KNOWN → DEGRADED → UNKNOWN.
|
||||
//!
|
||||
//! Implements the ADR-297 staleness guard `VALID → DEGRADED → UNKNOWN` as a
|
||||
//! **pure** classification over four measured inputs (ADR-299 §1):
|
||||
//!
|
||||
//! 1. **domain distance** — [`FingerprintDistance`] of the live fingerprint vs
|
||||
//! the certified one (ADR-298 `distance()`);
|
||||
//! 2. **signal quality** — [`SignalQuality`] (ADR-137 coherence/contradiction
|
||||
//! plus per-frame validity);
|
||||
//! 3. **calibration compatibility** — [`CalibrationCompat`]: is a valid,
|
||||
//! non-invalidated, device/space-matched certificate present?
|
||||
//!
|
||||
//! (The model's own predictive **uncertainty** — the fourth ADR-299 input — is
|
||||
//! attached and acted on at the [`crate::InferenceGate`], keeping `classify`'s
|
||||
//! signature exactly the three-plus-envelope form the phase-1 spec pins.)
|
||||
//!
|
||||
//! The transition is monotone escalation (worst signal wins) so a degraded
|
||||
//! room can never be reported as KNOWN, and hysteresis is provided by keeping
|
||||
//! the inner (enter-DEGRADED) and outer (enter-UNKNOWN) thresholds distinct so
|
||||
//! the gate does not flap on drift noise straddling a single line.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wifi_densepose_calibration::certificate::{CompatibilityEnvelope, FingerprintDistance, RoomFingerprint};
|
||||
|
||||
use crate::error::{require_unit_interval, Result};
|
||||
|
||||
/// The domain-distance primitive (ADR-299 §1): drift of the **live** room
|
||||
/// fingerprint away from the **certified** reference distribution.
|
||||
///
|
||||
/// Reuses the calibration crate's [`FingerprintDistance`] (ADR-298), which
|
||||
/// already splits drift into an empty-baseline (geometry) component and an
|
||||
/// occupancy component, so a consumer can distinguish "the room itself changed"
|
||||
/// from "occupancy statistics changed". This is a thin, documented adapter — no
|
||||
/// second distance definition is introduced.
|
||||
///
|
||||
/// `certified` is the certificate's attested fingerprint; `live` is the
|
||||
/// currently observed one.
|
||||
pub fn domain_distance(certified: &RoomFingerprint, live: &RoomFingerprint) -> FingerprintDistance {
|
||||
certified.distance(live)
|
||||
}
|
||||
|
||||
/// The specific reason a domain left KNOWN. Always reported alongside the state
|
||||
/// (ADR-299: "never a bare label").
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DomainCause {
|
||||
// --- UNKNOWN-grade causes (hard) ---
|
||||
/// No calibration certificate is present for this space/device.
|
||||
NoCertificate,
|
||||
/// The certificate is past its expiry (stale — ADR-297 staleness guard).
|
||||
CertificateExpired,
|
||||
/// The certificate's signature did not verify (tamper).
|
||||
CertificateTampered,
|
||||
/// The certificate was minted by a different signed device (ADR-302).
|
||||
DeviceMismatch,
|
||||
/// The certificate attests a different space (ADR-303).
|
||||
SpaceMismatch,
|
||||
/// Empty-baseline / total drift crossed the **outer** envelope threshold —
|
||||
/// the room changed materially (furniture, AP channel, geometry).
|
||||
DriftBeyondEnvelope,
|
||||
/// Signal quality fell below the usability floor — nothing can be trusted.
|
||||
SignalUnusable,
|
||||
|
||||
// --- DEGRADED-grade causes (soft) ---
|
||||
/// Moderate drift: past the **inner** threshold but within the envelope.
|
||||
ModerateDrift,
|
||||
/// An ADR-137 contradiction flag was raised (tolerated, but lower-evidence).
|
||||
Contradiction,
|
||||
/// Signal quality dipped below the KNOWN threshold but above the floor.
|
||||
LowSignalQuality,
|
||||
/// The model's own predictive uncertainty is elevated (attached at the gate).
|
||||
ElevatedUncertainty,
|
||||
}
|
||||
|
||||
impl DomainCause {
|
||||
/// A stable machine-readable slug for evidence records (ADR-301).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
DomainCause::NoCertificate => "no_certificate",
|
||||
DomainCause::CertificateExpired => "certificate_expired",
|
||||
DomainCause::CertificateTampered => "certificate_tampered",
|
||||
DomainCause::DeviceMismatch => "device_mismatch",
|
||||
DomainCause::SpaceMismatch => "space_mismatch",
|
||||
DomainCause::DriftBeyondEnvelope => "drift_beyond_envelope",
|
||||
DomainCause::SignalUnusable => "signal_unusable",
|
||||
DomainCause::ModerateDrift => "moderate_drift",
|
||||
DomainCause::Contradiction => "contradiction",
|
||||
DomainCause::LowSignalQuality => "low_signal_quality",
|
||||
DomainCause::ElevatedUncertainty => "elevated_uncertainty",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate's decision for one inference (ADR-299 §2).
|
||||
///
|
||||
/// `DEGRADED` and `UNKNOWN` always carry the triggering [`DomainCause`]; a bare
|
||||
/// state is never produced.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DomainState {
|
||||
/// In-distribution: drift within the envelope, quality high, certificate
|
||||
/// valid & compatible. Confident classifications may be returned.
|
||||
Known,
|
||||
/// A soft threshold was crossed. Classifications are still returned but must
|
||||
/// be treated as lower-evidence; carries the specific cause.
|
||||
Degraded(DomainCause),
|
||||
/// The room changed materially or calibration is absent/stale. RuView stops
|
||||
/// returning confident classifications. This is required behavior, not an
|
||||
/// error (ADR-297 rule 1).
|
||||
Unknown(DomainCause),
|
||||
}
|
||||
|
||||
impl DomainState {
|
||||
/// `true` only for [`DomainState::Known`].
|
||||
pub fn is_known(self) -> bool {
|
||||
matches!(self, DomainState::Known)
|
||||
}
|
||||
|
||||
/// `true` for [`DomainState::Unknown`].
|
||||
pub fn is_unknown(self) -> bool {
|
||||
matches!(self, DomainState::Unknown(_))
|
||||
}
|
||||
|
||||
/// `true` for [`DomainState::Degraded`].
|
||||
pub fn is_degraded(self) -> bool {
|
||||
matches!(self, DomainState::Degraded(_))
|
||||
}
|
||||
|
||||
/// The triggering cause, if the domain is not KNOWN.
|
||||
pub fn cause(self) -> Option<DomainCause> {
|
||||
match self {
|
||||
DomainState::Known => None,
|
||||
DomainState::Degraded(c) | DomainState::Unknown(c) => Some(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure classification with the default thresholds (ADR-299 §2). This is the
|
||||
/// canonical `classify(distance, envelope, signal_quality, calibration_compat)`
|
||||
/// entry point: it takes only measured inputs and returns a state — no clock,
|
||||
/// no randomness, no allocation.
|
||||
pub fn classify(
|
||||
distance: FingerprintDistance,
|
||||
envelope: CompatibilityEnvelope,
|
||||
signal_quality: SignalQuality,
|
||||
calibration_compat: CalibrationCompat,
|
||||
) -> DomainState {
|
||||
DomainThresholds::default().classify(distance, envelope, signal_quality, calibration_compat)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame signal-quality summary (ADR-137 reuse + per-frame validity).
|
||||
///
|
||||
/// `score` folds fusion coherence and per-frame SNR/validity into a single
|
||||
/// `[0, 1]` health value; `contradiction` mirrors the ADR-137 contradiction
|
||||
/// flag; `valid` is the per-frame validity bit. Constructed through a validated
|
||||
/// boundary so a non-finite or out-of-range score can never enter the gate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SignalQuality {
|
||||
/// Combined coherence/SNR health in `[0, 1]` (higher is better).
|
||||
pub score: f32,
|
||||
/// ADR-137 contradiction flag for this frame.
|
||||
pub contradiction: bool,
|
||||
/// Per-frame validity bit (a structurally invalid frame is unusable).
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
impl SignalQuality {
|
||||
/// Validated constructor. Rejects a non-finite or out-of-`[0, 1]` score
|
||||
/// (bounded-input discipline at the fusion boundary).
|
||||
pub fn new(score: f32, contradiction: bool, valid: bool) -> Result<Self> {
|
||||
let score = require_unit_interval("signal_quality.score", score)?;
|
||||
Ok(Self {
|
||||
score,
|
||||
contradiction,
|
||||
valid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive a quality score from raw ADR-137 signals. `coherence` is clamped
|
||||
/// to `[0, 1]`; `snr_db` is mapped through a bounded, monotone squash so a
|
||||
/// hostile/NaN SNR cannot poison the score. Never fails — a wholly invalid
|
||||
/// input yields a zero score and `valid = false`.
|
||||
pub fn from_signals(coherence: f32, snr_db: f32, contradiction: bool, valid: bool) -> Self {
|
||||
let coherence = clamp_unit(coherence);
|
||||
// Map SNR (dB) into [0, 1]: <=0 dB -> 0, >=30 dB -> 1, linear between.
|
||||
let snr_norm = if snr_db.is_finite() {
|
||||
(snr_db / 30.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let score = 0.5 * coherence + 0.5 * snr_norm;
|
||||
Self {
|
||||
score,
|
||||
contradiction,
|
||||
valid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a valid, non-invalidated calibration certificate is present for this
|
||||
/// space and signed device (ADR-299 §1 input 3). Derived from an ADR-298
|
||||
/// [`CertificateStatus`](wifi_densepose_calibration::certificate::CertificateStatus)
|
||||
/// plus space/device identity checks; see [`crate::assess_certificate`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CalibrationCompat {
|
||||
/// A valid certificate, matching space and device, drift within envelope.
|
||||
Valid,
|
||||
/// Certificate present but drifted beyond its envelope (stale distribution).
|
||||
DriftedBeyondEnvelope,
|
||||
/// Certificate present but expired.
|
||||
Expired,
|
||||
/// Certificate signature did not verify.
|
||||
Tampered,
|
||||
/// Certificate was minted by a different signed device.
|
||||
DeviceMismatch,
|
||||
/// Certificate attests a different space.
|
||||
SpaceMismatch,
|
||||
/// No certificate at all for this space/device.
|
||||
Absent,
|
||||
}
|
||||
|
||||
impl CalibrationCompat {
|
||||
/// `true` only when a fully valid, compatible certificate is present.
|
||||
pub fn is_compatible(self) -> bool {
|
||||
matches!(self, CalibrationCompat::Valid)
|
||||
}
|
||||
|
||||
/// The hard (UNKNOWN-grade) cause this compatibility state implies, if any.
|
||||
/// A non-`Valid` compatibility is always a hard failure: a stale, absent,
|
||||
/// or mismatched certificate cannot support a KNOWN domain (ADR-299 §3,
|
||||
/// "absence of evidence is absence of capability").
|
||||
fn hard_cause(self) -> Option<DomainCause> {
|
||||
match self {
|
||||
CalibrationCompat::Valid => None,
|
||||
CalibrationCompat::DriftedBeyondEnvelope => Some(DomainCause::DriftBeyondEnvelope),
|
||||
CalibrationCompat::Expired => Some(DomainCause::CertificateExpired),
|
||||
CalibrationCompat::Tampered => Some(DomainCause::CertificateTampered),
|
||||
CalibrationCompat::DeviceMismatch => Some(DomainCause::DeviceMismatch),
|
||||
CalibrationCompat::SpaceMismatch => Some(DomainCause::SpaceMismatch),
|
||||
CalibrationCompat::Absent => Some(DomainCause::NoCertificate),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate's calibration thresholds (ADR-299 §2). These are the "calibration
|
||||
/// parameters, reported with each decision" the ADR requires — not baked-in
|
||||
/// magic numbers. All are validated at construction.
|
||||
///
|
||||
/// Hysteresis is expressed as the gap between the inner (enter-DEGRADED) and
|
||||
/// outer (enter-UNKNOWN) drift lines: `inner = envelope.max_total_drift *
|
||||
/// inner_drift_fraction`, strictly below the outer envelope, so drift noise
|
||||
/// straddling one line cannot flap KNOWN⇄UNKNOWN directly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DomainThresholds {
|
||||
/// Fraction of the envelope's `max_total_drift` at which drift enters
|
||||
/// DEGRADED. In `[0, 1)` so the inner line stays strictly inside the outer.
|
||||
pub inner_drift_fraction: f32,
|
||||
/// Minimum signal-quality score to remain KNOWN. Below it (but at/above the
|
||||
/// floor) → DEGRADED.
|
||||
pub quality_known_min: f32,
|
||||
/// Usability floor. Below it the frame is unusable → UNKNOWN.
|
||||
pub quality_floor: f32,
|
||||
}
|
||||
|
||||
impl Default for DomainThresholds {
|
||||
fn default() -> Self {
|
||||
// Conservative phase-1 defaults; consumers tune per space/model.
|
||||
Self {
|
||||
inner_drift_fraction: 0.6,
|
||||
quality_known_min: 0.6,
|
||||
quality_floor: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DomainThresholds {
|
||||
/// Validated constructor. Enforces `0 <= floor <= known_min <= 1`, and
|
||||
/// `inner_drift_fraction` in `[0, 1)`, so the inner drift line is always
|
||||
/// strictly below the outer envelope (bounded-input discipline).
|
||||
pub fn new(inner_drift_fraction: f32, quality_known_min: f32, quality_floor: f32) -> Result<Self> {
|
||||
if !inner_drift_fraction.is_finite() || !(0.0..1.0).contains(&inner_drift_fraction) {
|
||||
return Err(crate::error::OodError::InvalidParameter {
|
||||
field: "inner_drift_fraction",
|
||||
reason: format!("must be finite in [0, 1), got {inner_drift_fraction}"),
|
||||
});
|
||||
}
|
||||
let quality_known_min = require_unit_interval("quality_known_min", quality_known_min)?;
|
||||
let quality_floor = require_unit_interval("quality_floor", quality_floor)?;
|
||||
if quality_floor > quality_known_min {
|
||||
return Err(crate::error::OodError::InvalidParameter {
|
||||
field: "quality_floor",
|
||||
reason: format!(
|
||||
"floor {quality_floor} must not exceed known_min {quality_known_min}"
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
inner_drift_fraction,
|
||||
quality_known_min,
|
||||
quality_floor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pure classification (ADR-297 staleness guard `VALID → DEGRADED →
|
||||
/// UNKNOWN`). Monotone escalation: the first matching hard cause wins
|
||||
/// UNKNOWN; otherwise the first matching soft cause wins DEGRADED; else
|
||||
/// KNOWN. Deterministic, allocation-free, no clock.
|
||||
pub fn classify(
|
||||
self,
|
||||
distance: FingerprintDistance,
|
||||
envelope: CompatibilityEnvelope,
|
||||
signal_quality: SignalQuality,
|
||||
calibration_compat: CalibrationCompat,
|
||||
) -> DomainState {
|
||||
// --- Hard failures → UNKNOWN (checked first; certificate before drift) ---
|
||||
if let Some(cause) = calibration_compat.hard_cause() {
|
||||
return DomainState::Unknown(cause);
|
||||
}
|
||||
let outer = envelope.max_total_drift;
|
||||
// A non-finite live distance is treated as maximal drift, never a panic.
|
||||
if !distance.total.is_finite() || distance.total > outer {
|
||||
return DomainState::Unknown(DomainCause::DriftBeyondEnvelope);
|
||||
}
|
||||
if !signal_quality.valid || signal_quality.score < self.quality_floor {
|
||||
return DomainState::Unknown(DomainCause::SignalUnusable);
|
||||
}
|
||||
|
||||
// --- Soft failures → DEGRADED (drift first, then quality signals) ---
|
||||
let inner = outer * self.inner_drift_fraction;
|
||||
if distance.total > inner {
|
||||
return DomainState::Degraded(DomainCause::ModerateDrift);
|
||||
}
|
||||
if signal_quality.contradiction {
|
||||
return DomainState::Degraded(DomainCause::Contradiction);
|
||||
}
|
||||
if signal_quality.score < self.quality_known_min {
|
||||
return DomainState::Degraded(DomainCause::LowSignalQuality);
|
||||
}
|
||||
|
||||
DomainState::Known
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp into `[0, 1]`, mapping non-finite to `0.0` (worst). Shared helper so no
|
||||
/// untrusted float can escape the unit interval without panicking.
|
||||
pub(crate) fn clamp_unit(v: f32) -> f32 {
|
||||
if v.is_finite() {
|
||||
v.clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Boundary errors for the OOD gate.
|
||||
//!
|
||||
//! Errors are raised only when *configuration* input is malformed (a threshold
|
||||
//! outside its valid range, a non-finite quality score). Runtime domain
|
||||
//! ambiguity is **never** an error: it is the first-class [`DomainState::Unknown`]
|
||||
//! value (ADR-297 rule 1). Nothing in this crate panics on malformed runtime
|
||||
//! input.
|
||||
//!
|
||||
//! [`DomainState::Unknown`]: crate::DomainState::Unknown
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors from constructing OOD configuration values at their boundary.
|
||||
#[derive(Debug, Error, Clone, PartialEq)]
|
||||
pub enum OodError {
|
||||
/// A configuration value was non-finite or outside its documented range.
|
||||
#[error("invalid OOD parameter '{field}': {reason}")]
|
||||
InvalidParameter {
|
||||
/// The offending field.
|
||||
field: &'static str,
|
||||
/// Why it was rejected (value + expected range).
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Convenience result alias for boundary-validated constructors.
|
||||
pub type Result<T> = core::result::Result<T, OodError>;
|
||||
|
||||
/// Validate that `value` is finite and within `[0, 1]`, or return a boundary
|
||||
/// error naming `field`. Shared by every bounded `[0, 1]` config field so the
|
||||
/// discipline is identical at each boundary.
|
||||
pub(crate) fn require_unit_interval(field: &'static str, value: f32) -> Result<f32> {
|
||||
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
|
||||
return Err(OodError::InvalidParameter {
|
||||
field,
|
||||
reason: format!("must be finite in [0, 1], got {value}"),
|
||||
});
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The inference gate (ADR-299 §2, ADR-297 rule 1).
|
||||
//!
|
||||
//! Every inference passes through the gate. It:
|
||||
//!
|
||||
//! 1. classifies the domain from distance + envelope + signal quality +
|
||||
//! calibration compatibility;
|
||||
//! 2. attaches the model's own predictive **uncertainty** (the fourth ADR-299
|
||||
//! input), escalating a KNOWN domain to DEGRADED when uncertainty is
|
||||
//! elevated;
|
||||
//! 3. **suppresses the confident class** when the domain is not KNOWN — an
|
||||
//! UNKNOWN domain returns no class, a first-class value rather than a
|
||||
//! confidently-wrong label (ADR-297 rule 1);
|
||||
//! 4. emits a [`RecalibrationRequest`] whenever the state is DEGRADED or
|
||||
//! UNKNOWN — a *signal*, never an action; recalibration itself is out of
|
||||
//! scope for this crate (ADR-297 staleness guard).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wifi_densepose_calibration::certificate::{CompatibilityEnvelope, FingerprintDistance};
|
||||
|
||||
use crate::domain::{clamp_unit, CalibrationCompat, DomainCause, DomainState, DomainThresholds, SignalQuality};
|
||||
use crate::error::{require_unit_interval, Result};
|
||||
|
||||
/// A model head's proposed inference, before gating. `class` is the model's
|
||||
/// candidate label of any type; `confidence`/`uncertainty` are its own scores.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Inference<C> {
|
||||
/// The model's candidate class/label.
|
||||
pub class: C,
|
||||
/// The model's reported confidence in `[0, 1]` (sanitized at the gate).
|
||||
pub confidence: f32,
|
||||
/// The model's predictive uncertainty in `[0, 1]` (sanitized at the gate).
|
||||
pub uncertainty: f32,
|
||||
}
|
||||
|
||||
impl<C> Inference<C> {
|
||||
/// Construct an inference. Confidence/uncertainty are stored as given and
|
||||
/// sanitized (clamped, NaN → worst) when the gate consumes them, so a
|
||||
/// hostile model score cannot escape `[0, 1]` downstream.
|
||||
pub fn new(class: C, confidence: f32, uncertainty: f32) -> Self {
|
||||
Self {
|
||||
class,
|
||||
confidence,
|
||||
uncertainty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How urgently recalibration is needed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RecalibrationUrgency {
|
||||
/// DEGRADED: recommended — the domain still supports flagged inferences.
|
||||
Recommended,
|
||||
/// UNKNOWN: required — confident inference is suspended until re-cal.
|
||||
Required,
|
||||
}
|
||||
|
||||
/// A signal that recalibration should be triggered (ADR-299 §2 / ADR-297
|
||||
/// staleness guard). This crate **emits** the request; it never performs
|
||||
/// recalibration (that is ADR-298's job). Carries the triggering cause so the
|
||||
/// caller can route it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecalibrationRequest {
|
||||
/// Why recalibration is being requested.
|
||||
pub reason: DomainCause,
|
||||
/// How urgent the request is.
|
||||
pub urgency: RecalibrationUrgency,
|
||||
}
|
||||
|
||||
/// The fully-contextualized result of gating one inference. Carries the domain
|
||||
/// state, all four input measurements, and either a (flagged) class or none —
|
||||
/// so downstream consumers (ADR-301 evidence engine) get the whole decision,
|
||||
/// never a bare label.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GatedInference<C> {
|
||||
/// The domain state (KNOWN / DEGRADED / UNKNOWN + cause).
|
||||
pub state: DomainState,
|
||||
/// Live-vs-certified domain distance (ADR-299 input 1).
|
||||
pub distance: FingerprintDistance,
|
||||
/// Signal quality (ADR-299 input 2).
|
||||
pub signal_quality: SignalQuality,
|
||||
/// Calibration compatibility (ADR-299 input 3).
|
||||
pub calibration_compat: CalibrationCompat,
|
||||
/// Model predictive uncertainty, sanitized to `[0, 1]` (ADR-299 input 4).
|
||||
pub uncertainty: f32,
|
||||
/// The returned class. `None` in UNKNOWN — the confident label is
|
||||
/// suppressed (ADR-297 rule 1). `Some` in KNOWN and DEGRADED (flagged).
|
||||
pub class: Option<C>,
|
||||
/// Sanitized confidence, present iff a class is returned.
|
||||
pub confidence: Option<f32>,
|
||||
/// A recalibration signal, present iff the state is DEGRADED or UNKNOWN.
|
||||
pub recalibration: Option<RecalibrationRequest>,
|
||||
}
|
||||
|
||||
impl<C> GatedInference<C> {
|
||||
/// `true` iff a confident class survived the gate (only in KNOWN).
|
||||
pub fn is_confident(&self) -> bool {
|
||||
self.state.is_known() && self.class.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared OOD gate every inference routes through (ADR-299 §2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct InferenceGate {
|
||||
thresholds: DomainThresholds,
|
||||
/// Max uncertainty tolerated while KNOWN; above it, a KNOWN domain is
|
||||
/// escalated to DEGRADED (the fourth ADR-299 input acting on the state).
|
||||
max_uncertainty_known: f32,
|
||||
}
|
||||
|
||||
impl Default for InferenceGate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
thresholds: DomainThresholds::default(),
|
||||
max_uncertainty_known: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InferenceGate {
|
||||
/// Validated constructor. `max_uncertainty_known` must be finite in `[0, 1]`.
|
||||
pub fn new(thresholds: DomainThresholds, max_uncertainty_known: f32) -> Result<Self> {
|
||||
let max_uncertainty_known = require_unit_interval("max_uncertainty_known", max_uncertainty_known)?;
|
||||
Ok(Self {
|
||||
thresholds,
|
||||
max_uncertainty_known,
|
||||
})
|
||||
}
|
||||
|
||||
/// The thresholds in effect (reported with each decision per ADR-299 §2).
|
||||
pub fn thresholds(&self) -> DomainThresholds {
|
||||
self.thresholds
|
||||
}
|
||||
|
||||
/// Gate one inference. Pure: deterministic in its inputs, no clock, no
|
||||
/// randomness, bounded allocation. Consumes `inference` (the class is moved
|
||||
/// into the result or dropped when suppressed).
|
||||
///
|
||||
/// Behavior:
|
||||
/// - KNOWN → class + confidence returned, no recalibration signal;
|
||||
/// - DEGRADED → class + confidence returned **flagged**, recalibration
|
||||
/// *recommended*;
|
||||
/// - UNKNOWN → class suppressed (`None`), recalibration *required*.
|
||||
pub fn evaluate<C>(
|
||||
&self,
|
||||
inference: Inference<C>,
|
||||
distance: FingerprintDistance,
|
||||
envelope: CompatibilityEnvelope,
|
||||
signal_quality: SignalQuality,
|
||||
calibration_compat: CalibrationCompat,
|
||||
) -> GatedInference<C> {
|
||||
let uncertainty = clamp_unit(inference.uncertainty);
|
||||
|
||||
let mut state = self
|
||||
.thresholds
|
||||
.classify(distance, envelope, signal_quality, calibration_compat);
|
||||
|
||||
// Fourth input: elevated uncertainty escalates a KNOWN domain to
|
||||
// DEGRADED. It never *upgrades* a state — worst signal always wins.
|
||||
if state.is_known() && uncertainty > self.max_uncertainty_known {
|
||||
state = DomainState::Degraded(DomainCause::ElevatedUncertainty);
|
||||
}
|
||||
|
||||
let confidence = clamp_unit(inference.confidence);
|
||||
let (class, confidence, recalibration) = match state {
|
||||
DomainState::Known => (Some(inference.class), Some(confidence), None),
|
||||
DomainState::Degraded(reason) => (
|
||||
Some(inference.class),
|
||||
Some(confidence),
|
||||
Some(RecalibrationRequest {
|
||||
reason,
|
||||
urgency: RecalibrationUrgency::Recommended,
|
||||
}),
|
||||
),
|
||||
// ADR-297 rule 1: no confident class in UNKNOWN. The class is
|
||||
// dropped, not returned with lowered confidence.
|
||||
DomainState::Unknown(reason) => (
|
||||
None,
|
||||
None,
|
||||
Some(RecalibrationRequest {
|
||||
reason,
|
||||
urgency: RecalibrationUrgency::Required,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
GatedInference {
|
||||
state,
|
||||
distance,
|
||||
signal_quality,
|
||||
calibration_compat,
|
||||
uncertainty,
|
||||
class,
|
||||
confidence,
|
||||
recalibration,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! # ruview-ood — out-of-distribution detection (ADR-299)
|
||||
//!
|
||||
//! Primitive 2 of the ADR-297 perception substrate: the gate that attaches a
|
||||
//! [`DomainState`] — `KNOWN` / `DEGRADED` / `UNKNOWN` — to **every** inference,
|
||||
//! so RuView can say *"I do not recognize this situation"* instead of returning
|
||||
//! a confidently-wrong label when it leaves its calibrated domain.
|
||||
//!
|
||||
//! It fuses four measured inputs (ADR-299 §1) against the ADR-298
|
||||
//! [`CalibrationCertificate`](wifi_densepose_calibration::certificate::CalibrationCertificate):
|
||||
//!
|
||||
//! 1. **domain distance** — [`domain_distance`] over live vs certified
|
||||
//! fingerprints (reusing ADR-298's [`FingerprintDistance`]);
|
||||
//! 2. **signal quality** — [`SignalQuality`] (ADR-137);
|
||||
//! 3. **calibration compatibility** — [`CalibrationCompat`], derived from a
|
||||
//! certificate via [`assess_certificate`] / [`no_certificate`];
|
||||
//! 4. **uncertainty** — the model head's own predictive uncertainty, attached
|
||||
//! at the [`InferenceGate`].
|
||||
//!
|
||||
//! ## The four non-negotiable rules (ADR-297)
|
||||
//!
|
||||
//! - **UNKNOWN is a first-class value, never an error.** [`DomainState::Unknown`]
|
||||
//! is returned, not thrown; the gate suppresses the confident class rather
|
||||
//! than defaulting to one or silently holding a stale value.
|
||||
//! - **Staleness guard `VALID → DEGRADED → UNKNOWN`.** [`DomainThresholds::classify`]
|
||||
//! escalates monotonically: crossing the envelope's inner threshold →
|
||||
//! DEGRADED, the outer threshold (or a missing/stale/mismatched certificate)
|
||||
//! → UNKNOWN. DEGRADED/UNKNOWN both raise a [`RecalibrationRequest`] — a
|
||||
//! *signal*, not an action.
|
||||
//! - **Honesty.** No accuracy is claimed here; this crate ships the gating
|
||||
//! machinery only. Synthetic test fixtures are labelled as such; no MEASURED
|
||||
//! or hardware claim is made.
|
||||
//!
|
||||
//! All logic is pure and deterministic: time is injected, there is no
|
||||
//! randomness, allocation is bounded, and malformed runtime input yields
|
||||
//! UNKNOWN rather than a panic.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod certificate;
|
||||
pub mod domain;
|
||||
pub mod error;
|
||||
pub mod gate;
|
||||
|
||||
pub use certificate::{assess_certificate, no_certificate, ExpectedIdentity};
|
||||
pub use domain::{
|
||||
domain_distance, CalibrationCompat, DomainCause, DomainState, DomainThresholds, SignalQuality,
|
||||
};
|
||||
pub use error::{OodError, Result};
|
||||
pub use gate::{
|
||||
GatedInference, Inference, InferenceGate, RecalibrationRequest, RecalibrationUrgency,
|
||||
};
|
||||
|
||||
// Re-export the calibration primitives this crate gates against, so consumers
|
||||
// have one import surface.
|
||||
pub use wifi_densepose_calibration::certificate::{
|
||||
CompatibilityEnvelope, FingerprintDistance, RoomFingerprint,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wifi_densepose_calibration::certificate::{
|
||||
CalibrationCertificate, CalibrationTier, CharacterizationSource, CompatibilityEnvelope,
|
||||
EvidenceLevel, FingerprintDistance, KeyedHashSigner, MintParams, RoomFingerprint,
|
||||
};
|
||||
use wifi_densepose_calibration::{
|
||||
anchor::AnchorLabel,
|
||||
bank::SpecialistBank,
|
||||
extract::{AnchorFeature, Features},
|
||||
};
|
||||
|
||||
// --- synthetic fixtures (SYNTHETIC / L0) -------------------------------
|
||||
|
||||
/// A synthetic fingerprint with a tunable empty-baseline mean, so drift is
|
||||
/// deterministic and monotone. SYNTHETIC — no measured/hardware claim.
|
||||
fn fingerprint(empty_mean: f32) -> RoomFingerprint {
|
||||
RoomFingerprint {
|
||||
schema_version: 1,
|
||||
empty_mean,
|
||||
empty_variance: 1.0,
|
||||
occupied_variance: 10.0,
|
||||
presence_threshold: 5.0,
|
||||
occupancy_mean_shift: 2.0,
|
||||
geometry: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn envelope() -> CompatibilityEnvelope {
|
||||
// outer = 0.15; with default inner_drift_fraction 0.6, inner = 0.09.
|
||||
CompatibilityEnvelope::default()
|
||||
}
|
||||
|
||||
fn good_quality() -> SignalQuality {
|
||||
SignalQuality::new(0.9, false, true).unwrap()
|
||||
}
|
||||
|
||||
/// Distance producing exactly `total` (bypassing fingerprint math when a
|
||||
/// precise drift value is needed for a boundary test). Fields are public in
|
||||
/// the calibration crate, so this is a legitimate synthetic construction.
|
||||
fn dist(total: f32) -> FingerprintDistance {
|
||||
FingerprintDistance {
|
||||
baseline_drift: total,
|
||||
occupancy_drift: 0.0,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
// --- (1) domain distance ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn domain_distance_reuses_fingerprint_metric() {
|
||||
let certified = fingerprint(1.0);
|
||||
let identical = fingerprint(1.0);
|
||||
let drifted = fingerprint(50.0);
|
||||
|
||||
let d0 = domain_distance(&certified, &identical);
|
||||
assert_eq!(d0.total, 0.0, "identical fingerprints have zero drift");
|
||||
|
||||
let d1 = domain_distance(&certified, &drifted);
|
||||
assert!(d1.total > 0.0, "a moved empty-baseline registers drift");
|
||||
// Matches the calibration crate's own metric (no second definition).
|
||||
assert_eq!(d1, certified.distance(&drifted));
|
||||
}
|
||||
|
||||
// --- (2) classify: KNOWN / DEGRADED / UNKNOWN --------------------------
|
||||
|
||||
#[test]
|
||||
fn known_within_envelope() {
|
||||
let state = DomainState::classify(dist(0.02), envelope(), good_quality(), CalibrationCompat::Valid);
|
||||
assert_eq!(state, DomainState::Known);
|
||||
assert!(state.is_known());
|
||||
assert_eq!(state.cause(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_at_inner_threshold_crossing() {
|
||||
// inner = 0.15 * 0.6 = 0.09; just above it, still within the outer 0.15.
|
||||
let state = DomainState::classify(dist(0.10), envelope(), good_quality(), CalibrationCompat::Valid);
|
||||
assert_eq!(state, DomainState::Degraded(DomainCause::ModerateDrift));
|
||||
assert!(state.is_degraded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_past_outer_threshold() {
|
||||
let state = DomainState::classify(dist(0.20), envelope(), good_quality(), CalibrationCompat::Valid);
|
||||
assert_eq!(state, DomainState::Unknown(DomainCause::DriftBeyondEnvelope));
|
||||
assert!(state.is_unknown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_missing_certificate_defaults_unknown() {
|
||||
// Absent certificate → UNKNOWN even with zero drift and perfect quality.
|
||||
let state = DomainState::classify(dist(0.0), envelope(), good_quality(), no_certificate());
|
||||
assert_eq!(state, DomainState::Unknown(DomainCause::NoCertificate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stale_and_mismatched_certificates() {
|
||||
for (compat, cause) in [
|
||||
(CalibrationCompat::Expired, DomainCause::CertificateExpired),
|
||||
(CalibrationCompat::Tampered, DomainCause::CertificateTampered),
|
||||
(CalibrationCompat::DeviceMismatch, DomainCause::DeviceMismatch),
|
||||
(CalibrationCompat::SpaceMismatch, DomainCause::SpaceMismatch),
|
||||
(CalibrationCompat::DriftedBeyondEnvelope, DomainCause::DriftBeyondEnvelope),
|
||||
] {
|
||||
let state = DomainState::classify(dist(0.0), envelope(), good_quality(), compat);
|
||||
assert_eq!(state, DomainState::Unknown(cause), "compat {compat:?} → UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_check_precedes_drift_in_staleness_guard() {
|
||||
// Absent certificate wins over an otherwise-in-envelope distance.
|
||||
let state = DomainState::classify(dist(0.01), envelope(), good_quality(), CalibrationCompat::Absent);
|
||||
assert_eq!(state, DomainState::Unknown(DomainCause::NoCertificate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_on_contradiction_and_low_quality() {
|
||||
let contra = SignalQuality::new(0.9, true, true).unwrap();
|
||||
assert_eq!(
|
||||
DomainState::classify(dist(0.0), envelope(), contra, CalibrationCompat::Valid),
|
||||
DomainState::Degraded(DomainCause::Contradiction)
|
||||
);
|
||||
|
||||
let lowish = SignalQuality::new(0.45, false, true).unwrap(); // floor 0.3 < 0.45 < 0.6
|
||||
assert_eq!(
|
||||
DomainState::classify(dist(0.0), envelope(), lowish, CalibrationCompat::Valid),
|
||||
DomainState::Degraded(DomainCause::LowSignalQuality)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_on_unusable_signal() {
|
||||
let below_floor = SignalQuality::new(0.1, false, true).unwrap();
|
||||
assert_eq!(
|
||||
DomainState::classify(dist(0.0), envelope(), below_floor, CalibrationCompat::Valid),
|
||||
DomainState::Unknown(DomainCause::SignalUnusable)
|
||||
);
|
||||
let invalid = SignalQuality::new(0.9, false, false).unwrap();
|
||||
assert_eq!(
|
||||
DomainState::classify(dist(0.0), envelope(), invalid, CalibrationCompat::Valid),
|
||||
DomainState::Unknown(DomainCause::SignalUnusable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hysteresis_inner_below_outer() {
|
||||
// The inner (DEGRADED) line is strictly below the outer (UNKNOWN) line,
|
||||
// so drift straddling one boundary cannot flap KNOWN⇄UNKNOWN directly.
|
||||
let t = DomainThresholds::default();
|
||||
let outer = envelope().max_total_drift;
|
||||
let inner = outer * t.inner_drift_fraction;
|
||||
assert!(inner < outer);
|
||||
// A value between the two lines is DEGRADED, not KNOWN and not UNKNOWN.
|
||||
let mid = 0.5 * (inner + outer);
|
||||
assert_eq!(
|
||||
DomainState::classify(dist(mid), envelope(), good_quality(), CalibrationCompat::Valid),
|
||||
DomainState::Degraded(DomainCause::ModerateDrift)
|
||||
);
|
||||
}
|
||||
|
||||
// --- (3) gate suppresses confident class under DEGRADED / UNKNOWN ------
|
||||
|
||||
#[test]
|
||||
fn gate_returns_confident_class_when_known() {
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("standing", 0.95, 0.1),
|
||||
dist(0.02),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert_eq!(out.state, DomainState::Known);
|
||||
assert_eq!(out.class, Some("standing"));
|
||||
assert_eq!(out.confidence, Some(0.95));
|
||||
assert!(out.recalibration.is_none());
|
||||
assert!(out.is_confident());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_flags_but_keeps_class_when_degraded() {
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("sitting", 0.9, 0.1),
|
||||
dist(0.10), // inner-crossing drift
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert!(out.state.is_degraded());
|
||||
// DEGRADED still returns the class, but flagged + recalibration recommended.
|
||||
assert_eq!(out.class, Some("sitting"));
|
||||
assert!(!out.is_confident(), "a degraded class is not a confident class");
|
||||
let rec = out.recalibration.expect("degraded requests recalibration");
|
||||
assert_eq!(rec.urgency, RecalibrationUrgency::Recommended);
|
||||
assert_eq!(rec.reason, DomainCause::ModerateDrift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_suppresses_class_when_unknown() {
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("lying_down", 0.99, 0.05), // model is very "confident"
|
||||
dist(0.30), // past the outer envelope
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert!(out.state.is_unknown());
|
||||
// ADR-297 rule 1: no confident class survives an UNKNOWN domain.
|
||||
assert_eq!(out.class, None);
|
||||
assert_eq!(out.confidence, None);
|
||||
assert!(!out.is_confident());
|
||||
let rec = out.recalibration.expect("unknown requires recalibration");
|
||||
assert_eq!(rec.urgency, RecalibrationUrgency::Required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_suppresses_class_when_certificate_absent() {
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("standing", 0.99, 0.01),
|
||||
dist(0.0),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
no_certificate(),
|
||||
);
|
||||
assert_eq!(out.state, DomainState::Unknown(DomainCause::NoCertificate));
|
||||
assert_eq!(out.class, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_escalates_known_to_degraded_on_uncertainty() {
|
||||
let gate = InferenceGate::default();
|
||||
// In-envelope + good quality would be KNOWN, but high uncertainty (>0.5).
|
||||
let out = gate.evaluate(
|
||||
Inference::new("standing", 0.8, 0.9),
|
||||
dist(0.02),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert_eq!(out.state, DomainState::Degraded(DomainCause::ElevatedUncertainty));
|
||||
assert_eq!(out.class, Some("standing")); // degraded keeps the flagged class
|
||||
assert!(out.recalibration.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertainty_never_upgrades_a_worse_state() {
|
||||
// Even zero uncertainty cannot rescue an UNKNOWN domain.
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("x", 1.0, 0.0),
|
||||
dist(0.5),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert!(out.state.is_unknown());
|
||||
assert_eq!(out.class, None);
|
||||
}
|
||||
|
||||
// --- (4) recalibration signalled on DEGRADED and UNKNOWN --------------
|
||||
|
||||
#[test]
|
||||
fn recalibration_signalled_only_when_not_known() {
|
||||
let gate = InferenceGate::default();
|
||||
|
||||
let known = gate.evaluate(
|
||||
Inference::new(1u8, 0.9, 0.1),
|
||||
dist(0.0),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert!(known.recalibration.is_none());
|
||||
|
||||
let degraded = gate.evaluate(
|
||||
Inference::new(1u8, 0.9, 0.1),
|
||||
dist(0.10),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert!(degraded.recalibration.is_some());
|
||||
|
||||
let unknown = gate.evaluate(
|
||||
Inference::new(1u8, 0.9, 0.1),
|
||||
dist(0.0),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
no_certificate(),
|
||||
);
|
||||
assert!(unknown.recalibration.is_some());
|
||||
}
|
||||
|
||||
// --- determinism -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn classification_is_deterministic() {
|
||||
let inputs = (dist(0.10), envelope(), good_quality(), CalibrationCompat::Valid);
|
||||
let first = DomainState::classify(inputs.0, inputs.1, inputs.2, inputs.3);
|
||||
for _ in 0..1000 {
|
||||
assert_eq!(DomainState::classify(inputs.0, inputs.1, inputs.2, inputs.3), first);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_inference_serializes_stably() {
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("standing".to_string(), 0.9, 0.1),
|
||||
dist(0.10),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
let a = serde_json::to_string(&out).unwrap();
|
||||
let b = serde_json::to_string(&out).unwrap();
|
||||
assert_eq!(a, b, "serialization is deterministic");
|
||||
assert!(a.contains("Degraded"), "state is present on the record");
|
||||
}
|
||||
|
||||
// --- boundary validation ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn malformed_config_is_rejected_not_panicked() {
|
||||
assert!(SignalQuality::new(f32::NAN, false, true).is_err());
|
||||
assert!(SignalQuality::new(1.5, false, true).is_err());
|
||||
assert!(SignalQuality::new(-0.1, false, true).is_err());
|
||||
|
||||
assert!(DomainThresholds::new(1.0, 0.6, 0.3).is_err()); // fraction not < 1
|
||||
assert!(DomainThresholds::new(f32::INFINITY, 0.6, 0.3).is_err());
|
||||
assert!(DomainThresholds::new(0.6, 0.3, 0.6).is_err()); // floor > known_min
|
||||
assert!(DomainThresholds::new(0.6, 0.6, 0.3).is_ok());
|
||||
|
||||
assert!(InferenceGate::new(DomainThresholds::default(), 2.0).is_err());
|
||||
assert!(InferenceGate::new(DomainThresholds::default(), 0.5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_runtime_input_yields_unknown_not_panic() {
|
||||
// A non-finite live distance is treated as maximal drift → UNKNOWN.
|
||||
let state = DomainState::classify(dist(f32::NAN), envelope(), good_quality(), CalibrationCompat::Valid);
|
||||
assert_eq!(state, DomainState::Unknown(DomainCause::DriftBeyondEnvelope));
|
||||
|
||||
// A hostile model uncertainty (NaN) is sanitized (→ worst), never panics.
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("x", f32::NAN, f32::NAN),
|
||||
dist(0.02),
|
||||
envelope(),
|
||||
good_quality(),
|
||||
CalibrationCompat::Valid,
|
||||
);
|
||||
assert!(out.uncertainty.is_finite());
|
||||
// NaN uncertainty clamps to 0.0 here (worst-for-unit maps low); the
|
||||
// point is no panic and a finite, bounded value.
|
||||
assert!((0.0..=1.0).contains(&out.uncertainty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_quality_from_signals_is_bounded_under_hostile_input() {
|
||||
let q = SignalQuality::from_signals(f32::NAN, f32::INFINITY, false, true);
|
||||
assert!((0.0..=1.0).contains(&q.score));
|
||||
let q2 = SignalQuality::from_signals(2.0, 100.0, false, true); // out-of-range clamps
|
||||
assert!((0.0..=1.0).contains(&q2.score));
|
||||
}
|
||||
|
||||
// --- cross-ADR: consume a real ADR-298 certificate --------------------
|
||||
|
||||
fn af(label: AnchorLabel, mean: f32, variance: f32, motion: f32) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: "living-room".into(),
|
||||
label,
|
||||
features: Features {
|
||||
mean,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: 0.0,
|
||||
breathing_hz: 0.0,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_bank() -> SpecialistBank {
|
||||
let anchors = vec![
|
||||
af(AnchorLabel::Empty, 1.0, 1.0, 0.1),
|
||||
af(AnchorLabel::StandStill, 3.0, 10.0, 0.2),
|
||||
af(AnchorLabel::Sit, 1.0, 6.0, 0.2),
|
||||
af(AnchorLabel::LieDown, 1.0, 3.0, 0.2),
|
||||
];
|
||||
SpecialistBank::train("living-room", "base-1", &anchors, 1000).unwrap()
|
||||
}
|
||||
|
||||
/// Mint a SYNTHETIC / L0 certificate — honest labelling (CLAUDE.md).
|
||||
fn synthetic_certificate() -> (CalibrationCertificate, KeyedHashSigner) {
|
||||
let signer = KeyedHashSigner::new("sensor-42", b"secret".to_vec());
|
||||
let params = MintParams {
|
||||
space_id: "home/living-room".into(),
|
||||
sensor_id: "sensor-42".into(),
|
||||
captured_at_unix_s: 1_000_000,
|
||||
validity_secs: 3600,
|
||||
version: 1,
|
||||
tier: CalibrationTier::Auto,
|
||||
evidence: EvidenceLevel::L0Synthetic,
|
||||
source: CharacterizationSource::Synthetic,
|
||||
envelope: CompatibilityEnvelope::default(),
|
||||
};
|
||||
let cert = CalibrationCertificate::mint(params, &synthetic_bank(), &signer).unwrap();
|
||||
(cert, signer)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_adr_valid_certificate_drives_known() {
|
||||
let (cert, signer) = synthetic_certificate();
|
||||
let live = cert.fingerprint.clone(); // no drift
|
||||
let now = cert.captured_at_unix_s + 10;
|
||||
let expected = ExpectedIdentity {
|
||||
space_id: "home/living-room",
|
||||
device_id: "sensor-42",
|
||||
};
|
||||
let (distance, compat) = assess_certificate(&cert, &live, expected, now, &signer);
|
||||
assert_eq!(compat, CalibrationCompat::Valid);
|
||||
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("standing", 0.9, 0.1),
|
||||
distance,
|
||||
cert.envelope,
|
||||
good_quality(),
|
||||
compat,
|
||||
);
|
||||
assert_eq!(out.state, DomainState::Known);
|
||||
assert_eq!(out.class, Some("standing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_adr_expired_certificate_drives_unknown() {
|
||||
let (cert, signer) = synthetic_certificate();
|
||||
let live = cert.fingerprint.clone();
|
||||
let now = cert.expires_at_unix_s + 1; // stale
|
||||
let expected = ExpectedIdentity {
|
||||
space_id: "home/living-room",
|
||||
device_id: "sensor-42",
|
||||
};
|
||||
let (distance, compat) = assess_certificate(&cert, &live, expected, now, &signer);
|
||||
assert_eq!(compat, CalibrationCompat::Expired);
|
||||
|
||||
let gate = InferenceGate::default();
|
||||
let out = gate.evaluate(
|
||||
Inference::new("standing", 0.99, 0.01),
|
||||
distance,
|
||||
cert.envelope,
|
||||
good_quality(),
|
||||
compat,
|
||||
);
|
||||
assert_eq!(out.state, DomainState::Unknown(DomainCause::CertificateExpired));
|
||||
assert_eq!(out.class, None, "no confident class from a stale certificate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_adr_device_and_space_mismatch_drive_unknown() {
|
||||
let (cert, signer) = synthetic_certificate();
|
||||
let live = cert.fingerprint.clone();
|
||||
let now = cert.captured_at_unix_s + 10;
|
||||
|
||||
let wrong_device = ExpectedIdentity {
|
||||
space_id: "home/living-room",
|
||||
device_id: "sensor-99",
|
||||
};
|
||||
let (_d, compat) = assess_certificate(&cert, &live, wrong_device, now, &signer);
|
||||
assert_eq!(compat, CalibrationCompat::DeviceMismatch);
|
||||
|
||||
let wrong_space = ExpectedIdentity {
|
||||
space_id: "office/lab",
|
||||
device_id: "sensor-42",
|
||||
};
|
||||
let (_d2, compat2) = assess_certificate(&cert, &live, wrong_space, now, &signer);
|
||||
assert_eq!(compat2, CalibrationCompat::SpaceMismatch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ruview-policy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ruview-evidence = { path = "../ruview-evidence" }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,753 @@
|
||||
//! # `ruview-policy` — action authorization gate (ADR-318, ADR-297 phase 1)
|
||||
//!
|
||||
//! A capability certificate (ADR-315) is a statement of *knowledge*, not a
|
||||
//! *grant of action*. The same certificate that is adequate to dim a light is
|
||||
//! wholly inadequate to release a door lock. This crate is the authorization
|
||||
//! layer that sits between governed spatial state and any actuator: given the
|
||||
//! assurance an action demands and the live assurance actually available, it
|
||||
//! returns [`Authorization::Allow`] or a **fail-closed**
|
||||
//! [`Authorization::Deny`] that names the *specific* condition that failed.
|
||||
//!
|
||||
//! ## The four non-negotiable rules (ADR-297)
|
||||
//!
|
||||
//! - **UNKNOWN is a first-class value, never an error.** An UNKNOWN domain
|
||||
//! ([`DomainState::Unknown`]) does not raise — it *denies* high-assurance
|
||||
//! actions. It may still authorize a [`ActionClass::Convenience`] action if
|
||||
//! that class does not require a known domain, but the resulting
|
||||
//! [`Authorization::Allow`] *records* that it proceeded under UNKNOWN
|
||||
//! (`under_unknown_domain`).
|
||||
//! - **Staleness guard `VALID → DEGRADED → UNKNOWN`.** A safety- or
|
||||
//! security-class action requires the live domain signature (ADR-299) to be
|
||||
//! `KNOWN`; a `DEGRADED` domain denies with [`FailedCondition::DomainDegraded`]
|
||||
//! and an `UNKNOWN` domain denies with [`FailedCondition::DomainNotKnown`].
|
||||
//! - **Honesty / no silent optimism.** A missing or expired certificate, a
|
||||
//! certificate class below the floor, an over-ceiling uncertainty, an
|
||||
//! evidence level below the floor, or the *absence of any policy* all deny by
|
||||
//! default. Absence of a policy is not permission. No accuracy is claimed
|
||||
//! here; the crate ships the gating machinery only, and its test fixtures are
|
||||
//! SYNTHETIC / L0.
|
||||
//!
|
||||
//! The decision is a **pure function** of (action class, assurance inputs):
|
||||
//! deterministic, clock-free (time is pre-reduced by the caller into a bool +
|
||||
//! an age), free of randomness, bounded in allocation, and panic-free on
|
||||
//! malformed input (a `NaN` uncertainty fails closed rather than aborting).
|
||||
//!
|
||||
//! ## Adapter note — real certificate + OOD domain state → [`AssuranceInputs`]
|
||||
//!
|
||||
//! To stay parallel-buildable this crate does **not** depend on the concrete
|
||||
//! `ruview-certify` / `ruview-ood` types; it owns [`AssuranceInputs`]. A caller
|
||||
//! that *does* hold those types maps them on as follows:
|
||||
//!
|
||||
//! - `certificate_valid` ← the certificate's **time + signature** validity
|
||||
//! only: `cert.verify(key) && now < content.valid_until_unix_s`. Note this is
|
||||
//! deliberately *not* `CapabilityCertificate::is_valid`, which also folds the
|
||||
//! live domain in — the domain gate is applied *separately* by this policy so
|
||||
//! that an out-of-domain deny is attributed to the domain condition
|
||||
//! ([`FailedCondition::DomainNotKnown`]) rather than being hidden inside a
|
||||
//! generic "certificate invalid".
|
||||
//! - `certificate_age` ← `now - content.calibrated_date_unix_s`, clamped at 0.
|
||||
//! - `certificate_class` ← the ADR-315 assurance tier the certificate was
|
||||
//! minted at (derived by the caller from the certificate's evidence floor and
|
||||
//! validated capability); see [`CertificateClass`].
|
||||
//! - `domain_state` ← `ruview_ood::DomainState`: `Known → `[`DomainState::Known`],
|
||||
//! `Degraded(_) → `[`DomainState::Degraded`], `Unknown(_) → `[`DomainState::Unknown`].
|
||||
//! - `uncertainty` ← the model head's live predictive uncertainty (ADR-299/301).
|
||||
//! - `evidence_level` ← the certificate's [`EvidenceLevel`] (ADR-282/301).
|
||||
//!
|
||||
//! Every allow or deny is intended to be emitted as the terminal stage of the
|
||||
//! witness chain (ADR-316); this crate returns the decision, the caller records
|
||||
//! it.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use ruview_evidence::EvidenceLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value types owned by this crate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The assurance tier a certificate was minted at (ADR-315). Ordering is
|
||||
/// meaningful and load-bearing: an action declares a
|
||||
/// [`AssuranceRequirements::min_certificate_class`] and a certificate at a
|
||||
/// class strictly below that floor is rejected. `Basic < Standard < High`.
|
||||
///
|
||||
/// This is a policy-side ladder: the ADR-315 certificate binds a capability and
|
||||
/// an evidence level, and the adapter (see crate docs) derives the class from
|
||||
/// them. Keeping the ladder local lets the policy crate build in parallel with
|
||||
/// the certificate crate.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum CertificateClass {
|
||||
/// Convenience-grade attestation: adequate to gate low-stakes actions.
|
||||
Basic,
|
||||
/// Security-grade attestation: bounded uncertainty, held-out evidence.
|
||||
Standard,
|
||||
/// Safety-grade attestation: the strictest tier, for actuators whose
|
||||
/// failure is unsafe.
|
||||
High,
|
||||
}
|
||||
|
||||
/// Local, simplified mirror of the ADR-299 domain signature. The concrete
|
||||
/// `ruview_ood::DomainState` carries a `DomainCause`; this policy only needs
|
||||
/// the three-way outcome, so the cause is dropped at the adapter boundary (see
|
||||
/// crate docs). `Known` is the only state that satisfies a "requires known
|
||||
/// domain" action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum DomainState {
|
||||
/// The live situation is recognized: inside the calibrated domain (ADR-299).
|
||||
Known,
|
||||
/// Drift/quality has crossed the inner envelope — degraded but not lost.
|
||||
Degraded,
|
||||
/// The situation is not recognized (ADR-299). A first-class value, never an
|
||||
/// error; it *denies* high-assurance actions rather than guessing.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DomainState {
|
||||
/// `true` only for [`DomainState::Known`].
|
||||
#[must_use]
|
||||
pub const fn is_known(self) -> bool {
|
||||
matches!(self, DomainState::Known)
|
||||
}
|
||||
|
||||
/// `true` only for [`DomainState::Unknown`].
|
||||
#[must_use]
|
||||
pub const fn is_unknown(self) -> bool {
|
||||
matches!(self, DomainState::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
/// The live assurance actually available at the moment of the decision. Owned
|
||||
/// by this crate so it does not depend on the concrete certificate / OOD types
|
||||
/// (see the crate-level adapter note for the mapping).
|
||||
///
|
||||
/// Time is pre-reduced by the caller: `certificate_valid` is the injected
|
||||
/// time+signature validity and `certificate_age_secs` the injected age. This
|
||||
/// keeps [`authorize`] a pure, clock-free function.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AssuranceInputs {
|
||||
/// The certificate's assurance tier (ADR-315), derived by the adapter.
|
||||
pub certificate_class: CertificateClass,
|
||||
/// Whether the certificate is currently signed and unexpired (time +
|
||||
/// signature validity **only** — the domain gate is applied separately).
|
||||
/// `false` covers both a *missing* and an *expired* certificate: absence is
|
||||
/// not permission.
|
||||
pub certificate_valid: bool,
|
||||
/// Age of the certificate's calibration, in seconds (`now - calibrated_date`).
|
||||
pub certificate_age_secs: u64,
|
||||
/// The live domain signature (ADR-299), reduced to three states.
|
||||
pub domain_state: DomainState,
|
||||
/// The model head's live predictive uncertainty, in `[0.0, 1.0]`. A `NaN`
|
||||
/// or out-of-range value is treated as over any ceiling (fail-closed).
|
||||
pub uncertainty: f64,
|
||||
/// The evidence floor backing this inference (ADR-282/301).
|
||||
pub evidence_level: EvidenceLevel,
|
||||
}
|
||||
|
||||
/// The assurance an [`ActionClass`] demands (ADR-318 §1). Every field is a
|
||||
/// gate; an input that fails any one denies.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AssuranceRequirements {
|
||||
/// The certificate must be at least this class.
|
||||
pub min_certificate_class: CertificateClass,
|
||||
/// The certificate calibration must be no older than this (freshness).
|
||||
pub max_certificate_age_secs: u64,
|
||||
/// Inference uncertainty must not exceed this ceiling.
|
||||
pub max_uncertainty: f64,
|
||||
/// The evidence level must be at least this floor.
|
||||
pub min_evidence_level: EvidenceLevel,
|
||||
/// Whether the live domain must be [`DomainState::Known`]. When `true`, a
|
||||
/// `Degraded`/`Unknown` domain denies (the staleness guard). When `false`,
|
||||
/// an `Unknown` domain is allowed but recorded on the [`Authorization`].
|
||||
pub requires_domain_known: bool,
|
||||
}
|
||||
|
||||
/// The class of action being authorized (ADR-318 §1). Each class declares the
|
||||
/// assurance it demands via [`ActionClass::requirements`]. The classes are
|
||||
/// reference defaults — illustrative and, in a fuller system, configurable.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ActionClass {
|
||||
/// Lighting, scenes: tolerant — `Basic`+, higher uncertainty ok, does not
|
||||
/// require a known domain (but records an UNKNOWN proceed).
|
||||
Convenience,
|
||||
/// Alerts, arming: stricter — valid `Standard`+ cert, bounded uncertainty,
|
||||
/// requires a known domain.
|
||||
Security,
|
||||
/// Door lock, machine stop: strict — fresh `High` cert, low uncertainty,
|
||||
/// `L3`+ evidence, known domain only.
|
||||
SafetyCritical,
|
||||
}
|
||||
|
||||
/// One day / one week / thirty days in seconds, for the reference freshness
|
||||
/// ceilings below.
|
||||
const ONE_DAY_SECS: u64 = 86_400;
|
||||
const ONE_WEEK_SECS: u64 = 7 * ONE_DAY_SECS;
|
||||
const THIRTY_DAYS_SECS: u64 = 30 * ONE_DAY_SECS;
|
||||
|
||||
impl ActionClass {
|
||||
/// The reference assurance requirements for this class (ADR-318 §1 table).
|
||||
#[must_use]
|
||||
pub const fn requirements(self) -> AssuranceRequirements {
|
||||
match self {
|
||||
ActionClass::Convenience => AssuranceRequirements {
|
||||
min_certificate_class: CertificateClass::Basic,
|
||||
max_certificate_age_secs: THIRTY_DAYS_SECS,
|
||||
max_uncertainty: 0.6,
|
||||
min_evidence_level: EvidenceLevel::L1,
|
||||
requires_domain_known: false,
|
||||
},
|
||||
ActionClass::Security => AssuranceRequirements {
|
||||
min_certificate_class: CertificateClass::Standard,
|
||||
max_certificate_age_secs: ONE_WEEK_SECS,
|
||||
max_uncertainty: 0.3,
|
||||
min_evidence_level: EvidenceLevel::L2,
|
||||
requires_domain_known: true,
|
||||
},
|
||||
ActionClass::SafetyCritical => AssuranceRequirements {
|
||||
min_certificate_class: CertificateClass::High,
|
||||
max_certificate_age_secs: ONE_DAY_SECS,
|
||||
max_uncertainty: 0.1,
|
||||
min_evidence_level: EvidenceLevel::L3,
|
||||
requires_domain_known: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The specific condition that caused a [`Authorization::Deny`]. A denial always
|
||||
/// names exactly one — the *first* unmet condition in the fixed evaluation
|
||||
/// order — so "why was this actuator denied" is unambiguous.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FailedCondition {
|
||||
/// No policy was supplied for the action — an unrecognized action class.
|
||||
/// Absence of a policy is not permission (ADR-318 §3).
|
||||
NoPolicy,
|
||||
/// The certificate is missing or expired (`certificate_valid == false`).
|
||||
CertificateInvalid,
|
||||
/// The certificate's class is below the action's floor.
|
||||
CertificateClassTooLow {
|
||||
/// The floor the action requires.
|
||||
required: CertificateClass,
|
||||
/// The class actually presented.
|
||||
actual: CertificateClass,
|
||||
},
|
||||
/// The certificate calibration is older than the freshness ceiling.
|
||||
CertificateStale {
|
||||
/// Actual age, seconds.
|
||||
age_secs: u64,
|
||||
/// Maximum permitted age, seconds.
|
||||
max_secs: u64,
|
||||
},
|
||||
/// The action requires a known domain and the live domain is `DEGRADED`.
|
||||
DomainDegraded,
|
||||
/// The action requires a known domain and the live domain is `UNKNOWN`
|
||||
/// (ADR-297 acceptance test: drift-invalidated capability denied at the
|
||||
/// actuator). This is the canonical `domain_not_known` failure.
|
||||
DomainNotKnown,
|
||||
/// Inference uncertainty exceeds the ceiling (a `NaN` lands here too).
|
||||
UncertaintyOverCeiling {
|
||||
/// The ceiling the action requires; the actual value is elided because
|
||||
/// `f64` is not `Eq`/`Hash`-friendly across the wire, but the ceiling
|
||||
/// names the boundary that was crossed.
|
||||
max_uncertainty: f64,
|
||||
},
|
||||
/// The evidence level is below the action's floor.
|
||||
EvidenceBelowFloor {
|
||||
/// The floor the action requires.
|
||||
required: EvidenceLevel,
|
||||
/// The level actually backing the inference.
|
||||
actual: EvidenceLevel,
|
||||
},
|
||||
}
|
||||
|
||||
impl FailedCondition {
|
||||
/// A stable, lower-snake-case name for the condition. Useful for witness
|
||||
/// records and log lines; the acceptance test asserts the SafetyCritical
|
||||
/// drift case names `domain_not_known`.
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
FailedCondition::NoPolicy => "no_policy",
|
||||
FailedCondition::CertificateInvalid => "certificate_invalid",
|
||||
FailedCondition::CertificateClassTooLow { .. } => "certificate_class_too_low",
|
||||
FailedCondition::CertificateStale { .. } => "certificate_stale",
|
||||
FailedCondition::DomainDegraded => "domain_degraded",
|
||||
FailedCondition::DomainNotKnown => "domain_not_known",
|
||||
FailedCondition::UncertaintyOverCeiling { .. } => "uncertainty_over_ceiling",
|
||||
FailedCondition::EvidenceBelowFloor { .. } => "evidence_below_floor",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The authorization decision (ADR-318 §2). Fail-closed: anything that is not an
|
||||
/// [`Authorization::Allow`] is a deny that names its condition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Authorization {
|
||||
/// The action is authorized. `under_unknown_domain` is `true` only when a
|
||||
/// class that does *not* require a known domain (e.g.
|
||||
/// [`ActionClass::Convenience`]) was allowed while the domain was `UNKNOWN`
|
||||
/// — the allow is honest about having proceeded out-of-domain.
|
||||
Allow {
|
||||
/// Records that the allow proceeded while the domain was `UNKNOWN`.
|
||||
under_unknown_domain: bool,
|
||||
},
|
||||
/// The action is denied; `failed_condition` names the specific unmet gate.
|
||||
Deny {
|
||||
/// The first unmet condition in evaluation order.
|
||||
failed_condition: FailedCondition,
|
||||
},
|
||||
}
|
||||
|
||||
impl Authorization {
|
||||
/// `true` only for [`Authorization::Allow`].
|
||||
#[must_use]
|
||||
pub const fn is_allowed(self) -> bool {
|
||||
matches!(self, Authorization::Allow { .. })
|
||||
}
|
||||
|
||||
/// The failed condition, if this is a deny.
|
||||
#[must_use]
|
||||
pub const fn failed_condition(self) -> Option<FailedCondition> {
|
||||
match self {
|
||||
Authorization::Deny { failed_condition } => Some(failed_condition),
|
||||
Authorization::Allow { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The decision
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Authorize an action of `class` against the live `inputs` (ADR-318 §2).
|
||||
///
|
||||
/// A **pure**, fail-closed function of `(class, inputs)`: deterministic, no
|
||||
/// clock, no randomness, no panics. It applies the class's reference
|
||||
/// [`AssuranceRequirements`]; use [`authorize_with`] to supply custom
|
||||
/// requirements or to model an unrecognized action (a `None` policy denies).
|
||||
#[must_use]
|
||||
pub fn authorize(class: ActionClass, inputs: &AssuranceInputs) -> Authorization {
|
||||
authorize_with(Some(&class.requirements()), inputs)
|
||||
}
|
||||
|
||||
/// Authorize against an explicit, optional policy. `None` means *no policy was
|
||||
/// found for this action* — an unrecognized action class — and denies with
|
||||
/// [`FailedCondition::NoPolicy`] (absence of a policy is not permission,
|
||||
/// ADR-318 §3).
|
||||
///
|
||||
/// Evaluation order (the first unmet condition is the one named):
|
||||
/// 1. policy present,
|
||||
/// 2. certificate valid (present + unexpired),
|
||||
/// 3. certificate class ≥ floor,
|
||||
/// 4. certificate age ≤ freshness ceiling,
|
||||
/// 5. domain gate (when the class requires a known domain),
|
||||
/// 6. uncertainty ≤ ceiling,
|
||||
/// 7. evidence ≥ floor.
|
||||
#[must_use]
|
||||
pub fn authorize_with(
|
||||
requirements: Option<&AssuranceRequirements>,
|
||||
inputs: &AssuranceInputs,
|
||||
) -> Authorization {
|
||||
let req = match requirements {
|
||||
Some(req) => req,
|
||||
None => {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::NoPolicy,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. A missing or expired certificate denies by default.
|
||||
if !inputs.certificate_valid {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::CertificateInvalid,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Certificate class must meet the floor.
|
||||
if inputs.certificate_class < req.min_certificate_class {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::CertificateClassTooLow {
|
||||
required: req.min_certificate_class,
|
||||
actual: inputs.certificate_class,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Freshness / staleness ceiling on certificate age.
|
||||
if inputs.certificate_age_secs > req.max_certificate_age_secs {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::CertificateStale {
|
||||
age_secs: inputs.certificate_age_secs,
|
||||
max_secs: req.max_certificate_age_secs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Domain gate. A class that requires a known domain denies on
|
||||
// DEGRADED/UNKNOWN, naming the specific state.
|
||||
if req.requires_domain_known {
|
||||
match inputs.domain_state {
|
||||
DomainState::Known => {}
|
||||
DomainState::Degraded => {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::DomainDegraded,
|
||||
}
|
||||
}
|
||||
DomainState::Unknown => {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::DomainNotKnown,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Uncertainty ceiling. `!(<=)` catches NaN too, failing closed.
|
||||
if !(inputs.uncertainty <= req.max_uncertainty) {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::UncertaintyOverCeiling {
|
||||
max_uncertainty: req.max_uncertainty,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Evidence floor.
|
||||
if inputs.evidence_level < req.min_evidence_level {
|
||||
return Authorization::Deny {
|
||||
failed_condition: FailedCondition::EvidenceBelowFloor {
|
||||
required: req.min_evidence_level,
|
||||
actual: inputs.evidence_level,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// All gates passed. Record if we proceeded under an UNKNOWN domain (only
|
||||
// reachable for a class that does not require a known domain).
|
||||
Authorization::Allow {
|
||||
under_unknown_domain: inputs.domain_state.is_unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — all fixtures are SYNTHETIC / L0 (CLAUDE.md honesty rule).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A baseline SYNTHETIC input that *passes* every gate for `class`. Tests
|
||||
/// then mutate exactly one field to force a specific deny.
|
||||
fn passing(class: ActionClass) -> AssuranceInputs {
|
||||
let req = class.requirements();
|
||||
AssuranceInputs {
|
||||
certificate_class: req.min_certificate_class,
|
||||
certificate_valid: true,
|
||||
certificate_age_secs: 0,
|
||||
domain_state: DomainState::Known,
|
||||
uncertainty: req.max_uncertainty, // exactly at ceiling → allowed
|
||||
evidence_level: req.min_evidence_level, // exactly at floor → allowed
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_passes_for_every_class() {
|
||||
for class in [
|
||||
ActionClass::Convenience,
|
||||
ActionClass::Security,
|
||||
ActionClass::SafetyCritical,
|
||||
] {
|
||||
assert_eq!(
|
||||
authorize(class, &passing(class)),
|
||||
Authorization::Allow {
|
||||
under_unknown_domain: false
|
||||
},
|
||||
"baseline should allow {class:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absence_of_policy_denies() {
|
||||
let inputs = passing(ActionClass::SafetyCritical);
|
||||
assert_eq!(
|
||||
authorize_with(None, &inputs),
|
||||
Authorization::Deny {
|
||||
failed_condition: FailedCondition::NoPolicy
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_expired_certificate_denies() {
|
||||
let mut inputs = passing(ActionClass::Convenience);
|
||||
inputs.certificate_valid = false;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::Convenience, &inputs).failed_condition(),
|
||||
Some(FailedCondition::CertificateInvalid),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_class_too_low_denies() {
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
inputs.certificate_class = CertificateClass::Basic;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::SafetyCritical, &inputs).failed_condition(),
|
||||
Some(FailedCondition::CertificateClassTooLow {
|
||||
required: CertificateClass::High,
|
||||
actual: CertificateClass::Basic,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_certificate_denies() {
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
inputs.certificate_age_secs = ONE_DAY_SECS + 1;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::SafetyCritical, &inputs).failed_condition(),
|
||||
Some(FailedCondition::CertificateStale {
|
||||
age_secs: ONE_DAY_SECS + 1,
|
||||
max_secs: ONE_DAY_SECS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertainty_over_ceiling_denies() {
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
inputs.uncertainty = 0.1 + 1e-6; // just above the 0.1 ceiling
|
||||
match authorize(ActionClass::SafetyCritical, &inputs).failed_condition() {
|
||||
Some(FailedCondition::UncertaintyOverCeiling { .. }) => {}
|
||||
other => panic!("expected uncertainty deny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_uncertainty_fails_closed() {
|
||||
let mut inputs = passing(ActionClass::Convenience);
|
||||
inputs.uncertainty = f64::NAN;
|
||||
match authorize(ActionClass::Convenience, &inputs).failed_condition() {
|
||||
Some(FailedCondition::UncertaintyOverCeiling { .. }) => {}
|
||||
other => panic!("NaN uncertainty must fail closed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_below_floor_denies() {
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
inputs.evidence_level = EvidenceLevel::L2; // floor is L3
|
||||
assert_eq!(
|
||||
authorize(ActionClass::SafetyCritical, &inputs).failed_condition(),
|
||||
Some(FailedCondition::EvidenceBelowFloor {
|
||||
required: EvidenceLevel::L3,
|
||||
actual: EvidenceLevel::L2,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_domain_denies_security() {
|
||||
let mut inputs = passing(ActionClass::Security);
|
||||
inputs.domain_state = DomainState::Unknown;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::Security, &inputs).failed_condition(),
|
||||
Some(FailedCondition::DomainNotKnown),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_domain_denies_safety_critical() {
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
inputs.domain_state = DomainState::Unknown;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::SafetyCritical, &inputs).failed_condition(),
|
||||
Some(FailedCondition::DomainNotKnown),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_domain_denies_high_assurance_with_its_own_condition() {
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
inputs.domain_state = DomainState::Degraded;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::SafetyCritical, &inputs).failed_condition(),
|
||||
Some(FailedCondition::DomainDegraded),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convenience_may_proceed_under_unknown_but_records_it() {
|
||||
let mut inputs = passing(ActionClass::Convenience);
|
||||
inputs.domain_state = DomainState::Unknown;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::Convenience, &inputs),
|
||||
Authorization::Allow {
|
||||
under_unknown_domain: true
|
||||
},
|
||||
);
|
||||
|
||||
// Degraded convenience is allowed and is not "under unknown".
|
||||
inputs.domain_state = DomainState::Degraded;
|
||||
assert_eq!(
|
||||
authorize(ActionClass::Convenience, &inputs),
|
||||
Authorization::Allow {
|
||||
under_unknown_domain: false
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// ADR-297 / ADR-318 acceptance-test B: a post-drift UNKNOWN domain causes a
|
||||
/// `SafetyCritical` authorize() to Deny with `domain_not_known`, *before*
|
||||
/// the inference reaches the actuator. The certificate is otherwise valid
|
||||
/// (signed, unexpired, correct class, fresh) — the domain gate is what
|
||||
/// stops it.
|
||||
#[test]
|
||||
fn acceptance_test_b_post_drift_unknown_denies_safety_critical() {
|
||||
// Pre-drift: domain KNOWN → the safety-critical action is authorized.
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
assert!(authorize(ActionClass::SafetyCritical, &inputs).is_allowed());
|
||||
|
||||
// Drift drives the domain to UNKNOWN (ADR-299 VALID→DEGRADED→UNKNOWN).
|
||||
inputs.domain_state = DomainState::Unknown;
|
||||
let decision = authorize(ActionClass::SafetyCritical, &inputs);
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
Authorization::Deny {
|
||||
failed_condition: FailedCondition::DomainNotKnown
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
decision.failed_condition().map(FailedCondition::name),
|
||||
Some("domain_not_known"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_deny_names_a_condition() {
|
||||
// Force a deny in each class and assert the decision carries a named
|
||||
// condition (never a bare/empty deny).
|
||||
let cases = [
|
||||
(ActionClass::Convenience, {
|
||||
let mut i = passing(ActionClass::Convenience);
|
||||
i.certificate_valid = false;
|
||||
i
|
||||
}),
|
||||
(ActionClass::Security, {
|
||||
let mut i = passing(ActionClass::Security);
|
||||
i.domain_state = DomainState::Unknown;
|
||||
i
|
||||
}),
|
||||
(ActionClass::SafetyCritical, {
|
||||
let mut i = passing(ActionClass::SafetyCritical);
|
||||
i.evidence_level = EvidenceLevel::L0;
|
||||
i
|
||||
}),
|
||||
];
|
||||
for (class, inputs) in cases {
|
||||
let decision = authorize(class, &inputs);
|
||||
let cond = decision
|
||||
.failed_condition()
|
||||
.expect("deny must name a condition");
|
||||
assert!(
|
||||
!cond.name().is_empty(),
|
||||
"{class:?} deny must have a non-empty condition name",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_is_deterministic() {
|
||||
let inputs = passing(ActionClass::SafetyCritical);
|
||||
let first = authorize(ActionClass::SafetyCritical, &inputs);
|
||||
for _ in 0..1_000 {
|
||||
assert_eq!(authorize(ActionClass::SafetyCritical, &inputs), first);
|
||||
}
|
||||
}
|
||||
|
||||
/// The full authorization matrix:
|
||||
/// (cert valid / invalid) × (age fresh / stale) × (Known/Degraded/Unknown)
|
||||
/// × (uncertainty below / above ceiling) × (evidence above / below floor).
|
||||
/// Asserts the outcome and, for every deny, that a condition is named.
|
||||
#[test]
|
||||
fn full_matrix() {
|
||||
for class in [
|
||||
ActionClass::Convenience,
|
||||
ActionClass::Security,
|
||||
ActionClass::SafetyCritical,
|
||||
] {
|
||||
let req = class.requirements();
|
||||
for cert_valid in [true, false] {
|
||||
for age in [0u64, req.max_certificate_age_secs + 1] {
|
||||
for domain in [
|
||||
DomainState::Known,
|
||||
DomainState::Degraded,
|
||||
DomainState::Unknown,
|
||||
] {
|
||||
// "below ceiling" = ceiling itself (allowed, since <=);
|
||||
// "above ceiling" = ceiling + a hair.
|
||||
for &unc in &[req.max_uncertainty, req.max_uncertainty + 0.01] {
|
||||
for evidence in [req.min_evidence_level, EvidenceLevel::L0] {
|
||||
let inputs = AssuranceInputs {
|
||||
certificate_class: req.min_certificate_class,
|
||||
certificate_valid: cert_valid,
|
||||
certificate_age_secs: age,
|
||||
domain_state: domain,
|
||||
uncertainty: unc,
|
||||
evidence_level: evidence,
|
||||
};
|
||||
let decision = authorize(class, &inputs);
|
||||
|
||||
// Compute the expected outcome independently.
|
||||
let unc_ok = unc <= req.max_uncertainty;
|
||||
let evidence_ok = evidence >= req.min_evidence_level;
|
||||
let age_ok = age <= req.max_certificate_age_secs;
|
||||
let domain_ok = !req.requires_domain_known || domain.is_known();
|
||||
let should_allow =
|
||||
cert_valid && age_ok && domain_ok && unc_ok && evidence_ok;
|
||||
|
||||
if should_allow {
|
||||
let under_unknown = !req.requires_domain_known
|
||||
&& domain == DomainState::Unknown;
|
||||
assert_eq!(
|
||||
decision,
|
||||
Authorization::Allow {
|
||||
under_unknown_domain: under_unknown
|
||||
},
|
||||
"class {class:?} inputs {inputs:?}",
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
!decision.is_allowed(),
|
||||
"class {class:?} inputs {inputs:?} should deny",
|
||||
);
|
||||
assert!(
|
||||
decision.failed_condition().is_some(),
|
||||
"deny must name a condition for {inputs:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trips_the_decision() {
|
||||
let mut inputs = passing(ActionClass::SafetyCritical);
|
||||
inputs.domain_state = DomainState::Unknown;
|
||||
let decision = authorize(ActionClass::SafetyCritical, &inputs);
|
||||
let json = serde_json::to_string(&decision).expect("serialize");
|
||||
let back: Authorization = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decision, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ruview-scorecard"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ruview-evidence = { path = "../ruview-evidence" }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ruview-witness"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ruview-attest = { path = "../ruview-attest" }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user