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:
Claude
2026-08-11 02:06:31 +00:00
parent 6506438b83
commit 516331461a
17 changed files with 5143 additions and 0 deletions
Generated
+54
View File
@@ -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"
+6
View File
@@ -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`.
+19
View File
@@ -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
+427
View File
@@ -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;
+271
View File
@@ -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));
}
+15
View File
@@ -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
+72
View File
@@ -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
}
+350
View File
@@ -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
}
}
+40
View File
@@ -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)
}
+197
View File
@@ -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,
}
}
}
+546
View File
@@ -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);
}
}
+15
View File
@@ -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
+753
View File
@@ -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);
}
}
+15
View File
@@ -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
+15
View File
@@ -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