From 516331461a80852d596d356f0bf6782724266505 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 02:06:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20ADR-297=20phase-1=20depende?= =?UTF-8?q?nt=20wave=20=E2=80=94=20OOD,=20witness,=20certify,=20scorecard,?= =?UTF-8?q?=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS --- v2/Cargo.lock | 54 + v2/Cargo.toml | 6 + v2/crates/ruview-certify/Cargo.toml | 19 + v2/crates/ruview-certify/src/lib.rs | 427 ++++++++ v2/crates/ruview-certify/src/tests.rs | 271 +++++ v2/crates/ruview-ood/Cargo.toml | 15 + v2/crates/ruview-ood/src/certificate.rs | 72 ++ v2/crates/ruview-ood/src/domain.rs | 350 +++++++ v2/crates/ruview-ood/src/error.rs | 40 + v2/crates/ruview-ood/src/gate.rs | 197 ++++ v2/crates/ruview-ood/src/lib.rs | 546 +++++++++++ v2/crates/ruview-policy/Cargo.toml | 15 + v2/crates/ruview-policy/src/lib.rs | 753 ++++++++++++++ v2/crates/ruview-scorecard/Cargo.toml | 15 + v2/crates/ruview-scorecard/src/lib.rs | 1154 ++++++++++++++++++++++ v2/crates/ruview-witness/Cargo.toml | 15 + v2/crates/ruview-witness/src/lib.rs | 1194 +++++++++++++++++++++++ 17 files changed, 5143 insertions(+) create mode 100644 v2/crates/ruview-certify/Cargo.toml create mode 100644 v2/crates/ruview-certify/src/lib.rs create mode 100644 v2/crates/ruview-certify/src/tests.rs create mode 100644 v2/crates/ruview-ood/Cargo.toml create mode 100644 v2/crates/ruview-ood/src/certificate.rs create mode 100644 v2/crates/ruview-ood/src/domain.rs create mode 100644 v2/crates/ruview-ood/src/error.rs create mode 100644 v2/crates/ruview-ood/src/gate.rs create mode 100644 v2/crates/ruview-ood/src/lib.rs create mode 100644 v2/crates/ruview-policy/Cargo.toml create mode 100644 v2/crates/ruview-policy/src/lib.rs create mode 100644 v2/crates/ruview-scorecard/Cargo.toml create mode 100644 v2/crates/ruview-scorecard/src/lib.rs create mode 100644 v2/crates/ruview-witness/Cargo.toml create mode 100644 v2/crates/ruview-witness/src/lib.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 82e25ee8..5e511bd8 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index f0e3ca66..931456a2 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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`. diff --git a/v2/crates/ruview-certify/Cargo.toml b/v2/crates/ruview-certify/Cargo.toml new file mode 100644 index 00000000..c539e9c1 --- /dev/null +++ b/v2/crates/ruview-certify/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-certify/src/lib.rs b/v2/crates/ruview-certify/src/lib.rs new file mode 100644 index 00000000..3b95a534 --- /dev/null +++ b/v2/crates/ruview-certify/src/lib.rs @@ -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 { + 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, +} + +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(&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( + signer: &S, + request: MintRequest<'_>, + slice: &EvidenceSlice<'_>, +) -> Result { + // 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, 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; diff --git a/v2/crates/ruview-certify/src/tests.rs b/v2/crates/ruview-certify/src/tests.rs new file mode 100644 index 00000000..d4ccc0d8 --- /dev/null +++ b/v2/crates/ruview-certify/src/tests.rs @@ -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)); +} diff --git a/v2/crates/ruview-ood/Cargo.toml b/v2/crates/ruview-ood/Cargo.toml new file mode 100644 index 00000000..d9410d7a --- /dev/null +++ b/v2/crates/ruview-ood/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-ood/src/certificate.rs b/v2/crates/ruview-ood/src/certificate.rs new file mode 100644 index 00000000..f0196793 --- /dev/null +++ b/v2/crates/ruview-ood/src/certificate.rs @@ -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( + 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 +} diff --git a/v2/crates/ruview-ood/src/domain.rs b/v2/crates/ruview-ood/src/domain.rs new file mode 100644 index 00000000..9800a9fb --- /dev/null +++ b/v2/crates/ruview-ood/src/domain.rs @@ -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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/v2/crates/ruview-ood/src/error.rs b/v2/crates/ruview-ood/src/error.rs new file mode 100644 index 00000000..a1ef98a0 --- /dev/null +++ b/v2/crates/ruview-ood/src/error.rs @@ -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 = core::result::Result; + +/// 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 { + 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) +} diff --git a/v2/crates/ruview-ood/src/gate.rs b/v2/crates/ruview-ood/src/gate.rs new file mode 100644 index 00000000..0a42ac0b --- /dev/null +++ b/v2/crates/ruview-ood/src/gate.rs @@ -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 { + /// 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 Inference { + /// 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 { + /// 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, + /// Sanitized confidence, present iff a class is returned. + pub confidence: Option, + /// A recalibration signal, present iff the state is DEGRADED or UNKNOWN. + pub recalibration: Option, +} + +impl GatedInference { + /// `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 { + 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( + &self, + inference: Inference, + distance: FingerprintDistance, + envelope: CompatibilityEnvelope, + signal_quality: SignalQuality, + calibration_compat: CalibrationCompat, + ) -> GatedInference { + 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, + } + } +} diff --git a/v2/crates/ruview-ood/src/lib.rs b/v2/crates/ruview-ood/src/lib.rs new file mode 100644 index 00000000..22d47067 --- /dev/null +++ b/v2/crates/ruview-ood/src/lib.rs @@ -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); + } +} diff --git a/v2/crates/ruview-policy/Cargo.toml b/v2/crates/ruview-policy/Cargo.toml new file mode 100644 index 00000000..a93c4d97 --- /dev/null +++ b/v2/crates/ruview-policy/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-policy/src/lib.rs b/v2/crates/ruview-policy/src/lib.rs new file mode 100644 index 00000000..d5287eba --- /dev/null +++ b/v2/crates/ruview-policy/src/lib.rs @@ -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 { + 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); + } +} diff --git a/v2/crates/ruview-scorecard/Cargo.toml b/v2/crates/ruview-scorecard/Cargo.toml new file mode 100644 index 00000000..cef6c0f5 --- /dev/null +++ b/v2/crates/ruview-scorecard/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-scorecard/src/lib.rs b/v2/crates/ruview-scorecard/src/lib.rs new file mode 100644 index 00000000..4201ba61 --- /dev/null +++ b/v2/crates/ruview-scorecard/src/lib.rs @@ -0,0 +1,1154 @@ +//! # `ruview-scorecard` — the multi-domain benchmark scorecard (ADR-314, ADR-297 §4) +//! +//! ADR-297 program rule 4 is non-negotiable: **pooled accuracy is never +//! sufficient for promotion**. A single headline number is exactly the surface +//! a domain-generalization regression hides behind — a model can raise mean +//! presence accuracy while quietly collapsing on unseen rooms, unseen devices, +//! or a stationary subject at range (the canonical WiFi failure case). +//! +//! This crate is the *data model* for that discipline (ADR-314 §1). It holds, +//! per capability, one cell **per operating domain** rather than one pooled +//! figure: +//! +//! - **Presence**: `room-known`, `room-unseen`, `device-unseen`, +//! `stationary-10m`. +//! - **Pose**: `matched`, `subject-unseen`, `room-unseen`. +//! - **OOD rejection**: the rate at which genuinely out-of-distribution input +//! is correctly returned as UNKNOWN (a capability, ADR-299). +//! - **Calibration drift**: the fingerprint-distance trajectory against the +//! ADR-298 certificate (lower is better) plus the fraction of inferences in +//! each ADR-299 [`DomainState`] under the `VALID → DEGRADED → UNKNOWN` +//! staleness guard (ADR-297). +//! +//! ## Honesty by construction (CLAUDE.md, ADR-282, ADR-297) +//! +//! - Every scored [`Cell`] carries a point estimate, a **confidence interval** +//! (a documented deterministic Wilson score interval — no RNG), and exactly +//! one [`EvidenceLevel`]. A slice scored on synthetic input is `L0` by +//! construction ([`Metric::synthetic`]); nothing raises it here. +//! - An empty domain is [`Cell::NoEvidence`], a first-class value distinct +//! from a present-but-zero score. The promotion gate treats no evidence as +//! **no coverage, never a pass** (ADR-314 §Provenance). +//! - [`Scorecard::worst_domain`] returns the promotion-relevant number: the +//! minimum across a task's domain slices. [`promotion_gate`] fails if *any* +//! worst-domain slice regresses beyond its budget, **even when the pooled +//! average improved** — a regression cannot hide behind pooled accuracy. +//! +//! Deterministic and leaf-shaped: no wall clock, no randomness, bounded input +//! validation at every constructor. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub use ruview_evidence::EvidenceLevel; + +/// The two-sided z-multiplier for a 95% Wilson score interval (the 97.5th +/// percentile of the standard normal). Fixed and documented so intervals are +/// reproducible byte-for-byte. +pub const Z_95: f64 = 1.959_963_984_540_054; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Boundary-validation failures. No constructor panics on malformed input. +#[derive(Clone, Copy, Debug, PartialEq, Error)] +pub enum ScorecardError { + /// A rate/point estimate was not a finite value inside `[0, 1]`. + #[error("point estimate {value} out of range (expected finite in [0, 1])")] + PointOutOfRange { + /// The offending value. + value: f64, + }, + /// A metric was minted with zero samples; a confidence interval needs at + /// least one observation. + #[error("sample_count must be >= 1")] + ZeroSamples, + /// The supplied confidence z-multiplier was not finite and positive. + #[error("confidence z-multiplier {value} must be finite and > 0")] + BadConfidence { + /// The offending value. + value: f64, + }, + /// A [`StateFractions`] triple was out of range or did not sum to ~1. + #[error("domain-state fractions invalid: {reason}")] + BadStateFractions { + /// Human-readable reason. + reason: &'static str, + }, +} + +// --------------------------------------------------------------------------- +// Wilson score interval (deterministic, no RNG) +// --------------------------------------------------------------------------- + +/// Compute the two-sided **Wilson score interval** for a binomial proportion. +/// +/// For an observed proportion `p` over `n` samples at z-multiplier `z`: +/// +/// ```text +/// center = (p + z²/2n) / (1 + z²/n) +/// margin = (z / (1 + z²/n)) · sqrt( p(1-p)/n + z²/4n² ) +/// [lo, hi] = clamp(center ∓ margin, 0, 1) +/// ``` +/// +/// The Wilson interval is preferred over the naive normal approximation +/// `p ± z·sqrt(p(1-p)/n)` because it stays inside `[0, 1]` and behaves well at +/// the `p → 0` / `p → 1` extremes and for small `n` — the regimes a thin +/// unseen-domain slice lives in. Caller guarantees `p ∈ [0,1]`, `n ≥ 1`, and a +/// finite `z > 0`; the result is clamped defensively regardless. +#[must_use] +pub fn wilson_interval(p: f64, n: u64, z: f64) -> (f64, f64) { + let n = n as f64; + let z2 = z * z; + let denom = 1.0 + z2 / n; + let center = (p + z2 / (2.0 * n)) / denom; + let radicand = (p * (1.0 - p) / n) + z2 / (4.0 * n * n); + let margin = (z / denom) * radicand.max(0.0).sqrt(); + let lo = (center - margin).clamp(0.0, 1.0); + let hi = (center + margin).clamp(0.0, 1.0); + (lo, hi) +} + +// --------------------------------------------------------------------------- +// Metric: a scored cell +// --------------------------------------------------------------------------- + +/// A single scored measurement: a point estimate, its confidence interval, the +/// sample count it was computed from, and exactly one [`EvidenceLevel`]. The CI +/// is derived deterministically at construction; there is no setter that could +/// desynchronise the interval from its inputs. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Metric { + point: f64, + ci_low: f64, + ci_high: f64, + sample_count: u64, + level: EvidenceLevel, +} + +impl Metric { + /// Construct a metric at 95% confidence ([`Z_95`]), computing the Wilson + /// interval from `point` and `sample_count`. + /// + /// # Errors + /// [`ScorecardError::PointOutOfRange`] if `point` is not finite in + /// `[0, 1]`; [`ScorecardError::ZeroSamples`] if `sample_count == 0`. + pub fn new( + point: f64, + sample_count: u64, + level: EvidenceLevel, + ) -> Result { + Self::with_confidence(point, sample_count, level, Z_95) + } + + /// Construct a metric at a caller-chosen z-multiplier. + /// + /// # Errors + /// As [`Metric::new`], plus [`ScorecardError::BadConfidence`] if `z` is not + /// finite and positive. + pub fn with_confidence( + point: f64, + sample_count: u64, + level: EvidenceLevel, + z: f64, + ) -> Result { + if !point.is_finite() || !(0.0..=1.0).contains(&point) { + return Err(ScorecardError::PointOutOfRange { value: point }); + } + if sample_count == 0 { + return Err(ScorecardError::ZeroSamples); + } + if !z.is_finite() || z <= 0.0 { + return Err(ScorecardError::BadConfidence { value: z }); + } + let (ci_low, ci_high) = wilson_interval(point, sample_count, z); + Ok(Self { + point, + ci_low, + ci_high, + sample_count, + level, + }) + } + + /// Construct a **synthetic** metric: the evidence level is forced to `L0` + /// (ADR-282/ADR-297 — synthetic input is `L0` by construction and cannot be + /// raised here). + /// + /// # Errors + /// As [`Metric::new`]. + pub fn synthetic(point: f64, sample_count: u64) -> Result { + Self::new(point, sample_count, EvidenceLevel::L0) + } + + /// The point estimate. + #[must_use] + pub fn point(&self) -> f64 { + self.point + } + + /// The lower confidence bound. + #[must_use] + pub fn ci_low(&self) -> f64 { + self.ci_low + } + + /// The upper confidence bound. + #[must_use] + pub fn ci_high(&self) -> f64 { + self.ci_high + } + + /// The confidence interval as `(low, high)`. + #[must_use] + pub fn ci(&self) -> (f64, f64) { + (self.ci_low, self.ci_high) + } + + /// The number of samples the estimate was computed from. + #[must_use] + pub fn sample_count(&self) -> u64 { + self.sample_count + } + + /// The evidence level travelling with this cell. + #[must_use] + pub fn level(&self) -> EvidenceLevel { + self.level + } +} + +// --------------------------------------------------------------------------- +// Cell: scored or explicitly no-evidence +// --------------------------------------------------------------------------- + +/// One scorecard cell. A domain with no coverage is [`Cell::NoEvidence`] — a +/// first-class value the promotion gate treats as no coverage, never a pass +/// (ADR-314). It is deliberately distinct from a scored cell whose point +/// happens to be `0.0`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum Cell { + /// No coverage for this domain slice. + NoEvidence, + /// A scored measurement. + Scored(Metric), +} + +impl Cell { + /// The point estimate if scored, else `None`. + #[must_use] + pub fn point(&self) -> Option { + match self { + Cell::NoEvidence => None, + Cell::Scored(m) => Some(m.point), + } + } + + /// The evidence level if scored, else `None`. + #[must_use] + pub fn level(&self) -> Option { + match self { + Cell::NoEvidence => None, + Cell::Scored(m) => Some(m.level), + } + } + + /// The scored metric, if any. + #[must_use] + pub fn metric(&self) -> Option { + match self { + Cell::NoEvidence => None, + Cell::Scored(m) => Some(*m), + } + } + + /// Whether this cell carries evidence. + #[must_use] + pub fn has_evidence(&self) -> bool { + matches!(self, Cell::Scored(_)) + } +} + +impl From for Cell { + fn from(m: Metric) -> Self { + Cell::Scored(m) + } +} + +// --------------------------------------------------------------------------- +// Domain-state staleness guard (ADR-299 / ADR-297) +// --------------------------------------------------------------------------- + +/// The ADR-299 domain state under the ADR-297 staleness guard +/// `VALID → DEGRADED → UNKNOWN`. A certificate is conditional on a continuously +/// evaluated domain signature; crossing the OOD threshold degrades the state +/// rather than silently continuing. `Unknown` is a first-class output, not an +/// error (ADR-297 rule 1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DomainState { + /// In-distribution; the certificate holds. + Valid, + /// Drifting; the affected capability is degraded pending recalibration. + Degraded, + /// Out of distribution; the surface answers UNKNOWN. + Unknown, +} + +/// The fraction of scored inferences observed in each [`DomainState`] over the +/// scoring window (ADR-314 calibration-drift axis). Fractions are each in +/// `[0, 1]` and sum to ~1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct StateFractions { + /// Fraction of inferences in `VALID`. + pub valid: f64, + /// Fraction of inferences in `DEGRADED`. + pub degraded: f64, + /// Fraction of inferences in `UNKNOWN`. + pub unknown: f64, +} + +impl StateFractions { + /// Tolerance on the fractions summing to one. + pub const SUM_EPS: f64 = 1e-6; + + /// Validate and construct. Each fraction must be finite in `[0, 1]` and the + /// three must sum to `1 ± [`Self::SUM_EPS`]`. + /// + /// # Errors + /// [`ScorecardError::BadStateFractions`]. + pub fn new(valid: f64, degraded: f64, unknown: f64) -> Result { + for v in [valid, degraded, unknown] { + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(ScorecardError::BadStateFractions { + reason: "each fraction must be finite in [0, 1]", + }); + } + } + if (valid + degraded + unknown - 1.0).abs() > Self::SUM_EPS { + return Err(ScorecardError::BadStateFractions { + reason: "fractions must sum to 1", + }); + } + Ok(Self { + valid, + degraded, + unknown, + }) + } + + /// The dominant [`DomainState`] under the staleness guard. Monotone, + /// documented thresholds: `UNKNOWN` when a majority of inferences fell out + /// of distribution (`unknown >= 0.5`); otherwise `DEGRADED` when a majority + /// were not `VALID` (`valid < 0.5`); otherwise `VALID`. This mirrors the + /// `VALID → DEGRADED → UNKNOWN` progression: rising OOD mass walks the + /// state strictly downward, never silently back up. + #[must_use] + pub fn dominant(&self) -> DomainState { + if self.unknown >= 0.5 { + DomainState::Unknown + } else if self.valid < 0.5 { + DomainState::Degraded + } else { + DomainState::Valid + } + } +} + +// --------------------------------------------------------------------------- +// Slice identity +// --------------------------------------------------------------------------- + +/// A capability task grouping domain slices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Task { + /// Presence detection. + Presence, + /// Pose estimation. + Pose, +} + +/// The identity of a single scorecard cell across every capability and domain. +/// Enumerable ([`SliceId::ALL`]) so `worst_domain`, the gate, and `render` all +/// walk the same canonical order deterministically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SliceId { + /// Presence on a room seen during training. + PresenceRoomKnown, + /// Presence on an unseen room (a pooled score hides regressions here). + PresenceRoomUnseen, + /// Presence on unseen device hardware. + PresenceDeviceUnseen, + /// Presence on a stationary subject at ~10 m — the canonical WiFi failure. + PresenceStationary10m, + /// Pose on a matched (in-distribution) split. + PoseMatched, + /// Pose on an unseen subject. + PoseSubjectUnseen, + /// Pose on an unseen room. + PoseRoomUnseen, + /// Rate of correctly rejecting out-of-distribution input as UNKNOWN. + OodRejection, + /// Calibration-drift fingerprint distance against the ADR-298 certificate. + CalibrationDrift, +} + +impl SliceId { + /// Every slice in canonical render/iteration order. + pub const ALL: [SliceId; 9] = [ + SliceId::PresenceRoomKnown, + SliceId::PresenceRoomUnseen, + SliceId::PresenceDeviceUnseen, + SliceId::PresenceStationary10m, + SliceId::PoseMatched, + SliceId::PoseSubjectUnseen, + SliceId::PoseRoomUnseen, + SliceId::OodRejection, + SliceId::CalibrationDrift, + ]; + + /// Human-readable label used by [`Scorecard::render`]. + #[must_use] + pub fn label(self) -> &'static str { + match self { + SliceId::PresenceRoomKnown => "presence/room-known", + SliceId::PresenceRoomUnseen => "presence/room-unseen", + SliceId::PresenceDeviceUnseen => "presence/device-unseen", + SliceId::PresenceStationary10m => "presence/stationary-10m", + SliceId::PoseMatched => "pose/matched", + SliceId::PoseSubjectUnseen => "pose/subject-unseen", + SliceId::PoseRoomUnseen => "pose/room-unseen", + SliceId::OodRejection => "ood-rejection", + SliceId::CalibrationDrift => "calibration-drift", + } + } + + /// The task this slice belongs to, if it is a per-domain accuracy task. + /// OOD rejection and calibration drift are single cells and return `None`. + #[must_use] + pub fn task(self) -> Option { + match self { + SliceId::PresenceRoomKnown + | SliceId::PresenceRoomUnseen + | SliceId::PresenceDeviceUnseen + | SliceId::PresenceStationary10m => Some(Task::Presence), + SliceId::PoseMatched | SliceId::PoseSubjectUnseen | SliceId::PoseRoomUnseen => { + Some(Task::Pose) + } + SliceId::OodRejection | SliceId::CalibrationDrift => None, + } + } + + /// Whether a *higher* point estimate is better. True for every accuracy / + /// rejection slice; false for calibration drift, where a larger + /// fingerprint distance is a regression. + #[must_use] + pub fn higher_is_better(self) -> bool { + !matches!(self, SliceId::CalibrationDrift) + } + + /// Whether this is a strict-budget domain (unseen / stationary / OOD) — + /// the ones a pooled score hides, per ADR-314 §2. + #[must_use] + pub fn is_strict(self) -> bool { + matches!( + self, + SliceId::PresenceRoomUnseen + | SliceId::PresenceDeviceUnseen + | SliceId::PresenceStationary10m + | SliceId::PoseSubjectUnseen + | SliceId::PoseRoomUnseen + | SliceId::OodRejection + ) + } + + /// Whether this slice contributes to the pooled accuracy average (every + /// higher-is-better slice; drift has different units and direction and is + /// excluded). + #[must_use] + fn is_pooled(self) -> bool { + self.higher_is_better() + } +} + +// --------------------------------------------------------------------------- +// Scorecard +// --------------------------------------------------------------------------- + +/// The multi-domain scorecard (ADR-314 §1): one [`Cell`] per operating domain, +/// never pooled into a single figure. Fields are public for direct +/// construction from an evidence query; every cell defaults to +/// [`Cell::NoEvidence`] via [`Scorecard::empty`]. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Scorecard { + /// Presence on a known room. + pub presence_room_known: Cell, + /// Presence on an unseen room. + pub presence_room_unseen: Cell, + /// Presence on unseen device hardware. + pub presence_device_unseen: Cell, + /// Presence on a stationary subject at ~10 m. + pub presence_stationary_10m: Cell, + /// Pose on a matched split. + pub pose_matched: Cell, + /// Pose on an unseen subject. + pub pose_subject_unseen: Cell, + /// Pose on an unseen room. + pub pose_room_unseen: Cell, + /// OOD-rejection rate. + pub ood_rejection: Cell, + /// Calibration-drift fingerprint distance (lower is better). + pub calibration_drift: Cell, + /// Fraction of inferences per [`DomainState`] over the window, if tracked. + pub state_fractions: Option, +} + +impl Default for Scorecard { + fn default() -> Self { + Self::empty() + } +} + +impl Scorecard { + /// An all-`NoEvidence` scorecard. Fill the cells that have coverage; the + /// rest stay honestly empty. + #[must_use] + pub fn empty() -> Self { + Self { + presence_room_known: Cell::NoEvidence, + presence_room_unseen: Cell::NoEvidence, + presence_device_unseen: Cell::NoEvidence, + presence_stationary_10m: Cell::NoEvidence, + pose_matched: Cell::NoEvidence, + pose_subject_unseen: Cell::NoEvidence, + pose_room_unseen: Cell::NoEvidence, + ood_rejection: Cell::NoEvidence, + calibration_drift: Cell::NoEvidence, + state_fractions: None, + } + } + + /// The cell for a given slice. + #[must_use] + pub fn cell(&self, slice: SliceId) -> Cell { + match slice { + SliceId::PresenceRoomKnown => self.presence_room_known, + SliceId::PresenceRoomUnseen => self.presence_room_unseen, + SliceId::PresenceDeviceUnseen => self.presence_device_unseen, + SliceId::PresenceStationary10m => self.presence_stationary_10m, + SliceId::PoseMatched => self.pose_matched, + SliceId::PoseSubjectUnseen => self.pose_subject_unseen, + SliceId::PoseRoomUnseen => self.pose_room_unseen, + SliceId::OodRejection => self.ood_rejection, + SliceId::CalibrationDrift => self.calibration_drift, + } + } + + /// The dominant [`DomainState`] under the staleness guard, if state + /// fractions were tracked. Absent tracking is `None`, not `Valid` — the + /// scorecard never invents a healthy state it did not observe. + #[must_use] + pub fn domain_state(&self) -> Option { + self.state_fractions.map(|f| f.dominant()) + } + + /// The **worst domain** for a task: the promotion-relevant number + /// (ADR-297 §4). Returns the slice with the lowest point estimate, with a + /// [`Cell::NoEvidence`] slice ranking below any scored cell — an uncovered + /// domain is the worst possible outcome, never silently skipped. + /// + /// Every task in this scorecard is higher-is-better, so "worst" is + /// unambiguously the minimum. Returns the first slice in canonical order on + /// a tie for determinism. + #[must_use] + pub fn worst_domain(&self, task: Task) -> WorstCell { + let mut worst: Option = None; + for slice in SliceId::ALL { + if slice.task() != Some(task) { + continue; + } + let cell = self.cell(slice); + let candidate = WorstCell { slice, cell }; + worst = Some(match worst { + None => candidate, + Some(cur) => { + if candidate.is_worse_than(&cur) { + candidate + } else { + cur + } + } + }); + } + // Every task has at least one member slice, so this is always `Some`. + worst.expect("task has at least one slice") + } + + /// The pooled accuracy average across covered higher-is-better slices — the + /// figure ADR-297 rule 4 forbids relying on alone. Provided precisely so + /// [`promotion_gate`] can prove a regression was *hidden behind* a rising + /// pool. `None` when no such slice is covered. + #[must_use] + pub fn pooled_accuracy(&self) -> Option { + let mut sum = 0.0; + let mut count = 0u64; + for slice in SliceId::ALL { + if !slice.is_pooled() { + continue; + } + if let Some(p) = self.cell(slice).point() { + sum += p; + count += 1; + } + } + if count == 0 { + None + } else { + Some(sum / count as f64) + } + } + + /// Render an ASCII scorecard approximating the ADR-314 layout: one row per + /// domain slice with its point estimate, confidence interval, evidence + /// level, and sample count; the per-task worst domain; the pooled figure + /// (labelled as insufficient on its own); and the domain state. Empty + /// slices print `no-evidence`, never a fabricated number. + #[must_use] + pub fn render(&self) -> String { + let mut out = String::new(); + out.push_str("ADR-314 multi-domain scorecard\n"); + out.push_str( + " (per-domain; pooled accuracy is never sufficient for promotion, ADR-297 §4)\n", + ); + out.push_str( + " slice point ci_low ci_high level samples\n", + ); + out.push_str( + " ------------------------- ------- ------- -------- ------ -------\n", + ); + for slice in SliceId::ALL { + let cell = self.cell(slice); + match cell { + Cell::NoEvidence => { + out.push_str(&format!( + " {:<25} {:>7} {:>7} {:>8} {:>6} {:>7}\n", + slice.label(), + "no-ev", + "-", + "-", + "-", + "0", + )); + } + Cell::Scored(m) => { + out.push_str(&format!( + " {:<25} {:>7.4} {:>7.4} {:>8.4} {:>6} {:>7}\n", + slice.label(), + m.point(), + m.ci_low(), + m.ci_high(), + format!("{:?}", m.level()), + m.sample_count(), + )); + } + } + } + out.push('\n'); + for task in [Task::Presence, Task::Pose] { + let worst = self.worst_domain(task); + let shown = match worst.cell { + Cell::NoEvidence => "no-evidence (no coverage)".to_string(), + Cell::Scored(m) => format!("{:.4}", m.point()), + }; + out.push_str(&format!( + " worst {:<9} -> {} = {}\n", + format!("{:?}", task).to_lowercase(), + worst.slice.label(), + shown, + )); + } + match self.pooled_accuracy() { + Some(p) => out.push_str(&format!( + " pooled accuracy = {:.4} (INSUFFICIENT ALONE — see worst-domain)\n", + p + )), + None => out.push_str(" pooled accuracy = no-evidence\n"), + } + match self.domain_state() { + Some(s) => out.push_str(&format!(" domain state = {:?}\n", s)), + None => out.push_str(" domain state = untracked\n"), + } + out + } +} + +/// The result of [`Scorecard::worst_domain`]: which slice was worst and its +/// cell (which may be [`Cell::NoEvidence`]). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct WorstCell { + /// The worst slice. + pub slice: SliceId, + /// Its cell. + pub cell: Cell, +} + +impl WorstCell { + /// Order for "worseness": a [`Cell::NoEvidence`] cell is worse than any + /// scored cell; among scored cells a lower point estimate is worse (every + /// task is higher-is-better). + fn is_worse_than(&self, other: &WorstCell) -> bool { + match (self.cell.point(), other.cell.point()) { + (None, None) => false, + (None, Some(_)) => true, + (Some(_), None) => false, + (Some(a), Some(b)) => a < b, + } + } + + /// The worst point estimate, if the worst cell was scored. + #[must_use] + pub fn point(&self) -> Option { + self.cell.point() + } +} + +// --------------------------------------------------------------------------- +// Promotion gate +// --------------------------------------------------------------------------- + +/// Per-capability regression budgets. Strict-budget domains +/// (unseen / stationary / OOD) carry the tightest tolerance because they are +/// the ones a pooled score hides (ADR-314 §2). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GatePolicy { + /// Budget for non-strict presence/pose slices (e.g. room-known, matched). + pub base_tolerance: f64, + /// Budget for strict domains (unseen / stationary / OOD). + pub strict_tolerance: f64, + /// Budget for a *rise* in calibration drift before it is a regression. + pub drift_tolerance: f64, +} + +impl Default for GatePolicy { + /// Conservative defaults: a small base budget, a much tighter strict budget + /// for the domains that hide behind a pool, and a small drift budget. + fn default() -> Self { + Self { + base_tolerance: 0.02, + strict_tolerance: 0.005, + drift_tolerance: 0.01, + } + } +} + +impl GatePolicy { + /// The tolerance that applies to a slice. + #[must_use] + pub fn tolerance(&self, slice: SliceId) -> f64 { + if slice == SliceId::CalibrationDrift { + self.drift_tolerance + } else if slice.is_strict() { + self.strict_tolerance + } else { + self.base_tolerance + } + } +} + +/// Why a slice regressed. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum RegressionKind { + /// A higher-is-better point estimate dropped beyond tolerance. + AccuracyDrop, + /// Calibration drift rose beyond tolerance. + DriftIncrease, + /// A previously-covered domain lost all evidence — no coverage is never a + /// pass (ADR-314 §Provenance). + CoverageLoss, +} + +/// A single per-domain regression found by [`promotion_gate`]. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Regression { + /// The slice that regressed. + pub slice: SliceId, + /// The nature of the regression. + pub kind: RegressionKind, + /// The previous point estimate, if it was scored. + pub prev: Option, + /// The current point estimate, if it is scored. + pub curr: Option, + /// The tolerance that was exceeded. + pub tolerance: f64, +} + +/// The verdict of the promotion gate. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GateReport { + /// True only when no domain regressed. + pub passed: bool, + /// Pooled accuracy of the previous scorecard, if computable. + pub pooled_prev: Option, + /// Pooled accuracy of the current scorecard, if computable. + pub pooled_curr: Option, + /// Whether the pooled average improved. + pub pooled_improved: bool, + /// Every per-domain regression found (empty iff `passed`). + pub regressions: Vec, +} + +impl GateReport { + /// True when the pooled average improved yet the gate still failed — the + /// exact "regression hiding behind pooled accuracy" case ADR-297 rule 4 + /// exists to catch. + #[must_use] + pub fn hidden_behind_pooled(&self) -> bool { + self.pooled_improved && !self.passed + } +} + +/// Compare a candidate scorecard against a baseline and decide promotion. +/// +/// The gate **fails if any single domain regresses beyond its budget**, even +/// when the pooled average improved (ADR-314 §2, ADR-297 rule 4): improvement +/// on `room-known` cannot buy a regression on `room-unseen`. Because the gate +/// evaluates every domain independently, a regression can never hide behind a +/// flattering pool; [`GateReport::hidden_behind_pooled`] reports when exactly +/// that was attempted. +/// +/// Rules per slice: +/// - baseline `NoEvidence`: nothing to regress from — skipped (a newly covered +/// or still-empty domain is not itself a regression). +/// - baseline scored, candidate `NoEvidence`: [`RegressionKind::CoverageLoss`] +/// — losing a covered domain is a failure, never a pass. +/// - both scored, higher-is-better: regression if +/// `curr < prev - tolerance(slice)`. +/// - both scored, calibration drift: regression if +/// `curr > prev + tolerance(slice)`. +#[must_use] +pub fn promotion_gate(prev: &Scorecard, curr: &Scorecard, policy: &GatePolicy) -> GateReport { + let mut regressions = Vec::new(); + for slice in SliceId::ALL { + let tol = policy.tolerance(slice); + let prev_cell = prev.cell(slice); + let curr_cell = curr.cell(slice); + match (prev_cell.point(), curr_cell.point()) { + (None, _) => { + // No baseline for this domain: cannot regress below nothing. + } + (Some(_), None) => { + regressions.push(Regression { + slice, + kind: RegressionKind::CoverageLoss, + prev: prev_cell.point(), + curr: None, + tolerance: tol, + }); + } + (Some(p), Some(c)) => { + let regressed = if slice.higher_is_better() { + c < p - tol + } else { + c > p + tol + }; + if regressed { + regressions.push(Regression { + slice, + kind: if slice.higher_is_better() { + RegressionKind::AccuracyDrop + } else { + RegressionKind::DriftIncrease + }, + prev: Some(p), + curr: Some(c), + tolerance: tol, + }); + } + } + } + } + + let pooled_prev = prev.pooled_accuracy(); + let pooled_curr = curr.pooled_accuracy(); + let pooled_improved = matches!((pooled_prev, pooled_curr), (Some(a), Some(b)) if b > a); + + GateReport { + passed: regressions.is_empty(), + pooled_prev, + pooled_curr, + pooled_improved, + regressions, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scored(point: f64, n: u64) -> Cell { + // Route the inputs through `black_box` so the Wilson math runs at + // runtime (as it does in the ADR-149 flow, computed from live + // evidence) rather than being const-folded — const-eval and the + // runtime FPU can disagree at the last ULP, which is a compiler + // artifact, not real non-determinism. + let point = std::hint::black_box(point); + let n = std::hint::black_box(n); + Cell::Scored(Metric::synthetic(point, n).expect("valid metric")) + } + + // ---- CI computation vs hand-computed fixtures ------------------------- + + #[test] + fn wilson_ci_matches_hand_computed_50_of_100() { + // p=0.5, n=100, z=1.96 (approx): classic Wilson 95% CI ≈ [0.4038, 0.5962]. + let m = Metric::with_confidence(0.5, 100, EvidenceLevel::L0, 1.96).unwrap(); + assert!((m.ci_low() - 0.4038).abs() < 1e-3, "lo={}", m.ci_low()); + assert!((m.ci_high() - 0.5962).abs() < 1e-3, "hi={}", m.ci_high()); + // Interval is symmetric about the point at p=0.5. + assert!(((m.ci_low() + m.ci_high()) / 2.0 - 0.5).abs() < 1e-9); + } + + #[test] + fn wilson_ci_matches_hand_computed_10_of_10() { + // p=1.0, n=10, z=1.96: Wilson lower bound ≈ 0.7225, upper clamps to 1.0. + let m = Metric::with_confidence(1.0, 10, EvidenceLevel::L0, 1.96).unwrap(); + assert!((m.ci_low() - 0.7225).abs() < 1e-3, "lo={}", m.ci_low()); + assert!(m.ci_high() <= 1.0 && m.ci_high() > 0.999, "hi={}", m.ci_high()); + } + + #[test] + fn wilson_ci_stays_in_unit_interval_at_extremes() { + for &p in &[0.0, 1.0, 0.01, 0.99] { + for &n in &[1u64, 5, 1000] { + let (lo, hi) = wilson_interval(p, n, Z_95); + assert!((0.0..=1.0).contains(&lo), "lo={lo} p={p} n={n}"); + assert!((0.0..=1.0).contains(&hi), "hi={hi} p={p} n={n}"); + assert!(lo <= hi); + } + } + } + + #[test] + fn ci_narrows_with_more_samples() { + let few = Metric::synthetic(0.8, 10).unwrap(); + let many = Metric::synthetic(0.8, 10_000).unwrap(); + let w_few = few.ci_high() - few.ci_low(); + let w_many = many.ci_high() - many.ci_low(); + assert!(w_many < w_few, "expected tighter CI with more samples"); + } + + // ---- boundary validation --------------------------------------------- + + #[test] + fn malformed_metric_input_is_an_error_not_a_panic() { + assert_eq!( + Metric::new(1.5, 10, EvidenceLevel::L0).unwrap_err(), + ScorecardError::PointOutOfRange { value: 1.5 } + ); + // NaN != NaN, so match the variant rather than compare the payload. + assert!(matches!( + Metric::new(f64::NAN, 10, EvidenceLevel::L0).unwrap_err(), + ScorecardError::PointOutOfRange { .. } + )); + assert_eq!( + Metric::new(0.5, 0, EvidenceLevel::L0).unwrap_err(), + ScorecardError::ZeroSamples + ); + assert!(matches!( + Metric::with_confidence(0.5, 10, EvidenceLevel::L0, 0.0).unwrap_err(), + ScorecardError::BadConfidence { .. } + )); + } + + #[test] + fn synthetic_metric_is_l0_by_construction() { + assert_eq!(Metric::synthetic(0.9, 100).unwrap().level(), EvidenceLevel::L0); + } + + #[test] + fn state_fractions_validate_and_pick_dominant() { + assert_eq!( + StateFractions::new(0.9, 0.08, 0.02).unwrap().dominant(), + DomainState::Valid + ); + assert_eq!( + StateFractions::new(0.3, 0.6, 0.1).unwrap().dominant(), + DomainState::Degraded + ); + assert_eq!( + StateFractions::new(0.2, 0.2, 0.6).unwrap().dominant(), + DomainState::Unknown + ); + assert!(StateFractions::new(0.5, 0.4, 0.4).is_err()); // sums to 1.3 + assert!(StateFractions::new(-0.1, 0.6, 0.5).is_err()); + } + + // ---- worst-domain selection ------------------------------------------ + + #[test] + fn worst_domain_is_the_minimum_slice() { + let mut sc = Scorecard::empty(); + sc.presence_room_known = scored(0.95, 500); + sc.presence_room_unseen = scored(0.70, 500); + sc.presence_device_unseen = scored(0.82, 500); + sc.presence_stationary_10m = scored(0.61, 500); + let worst = sc.worst_domain(Task::Presence); + assert_eq!(worst.slice, SliceId::PresenceStationary10m); + assert_eq!(worst.point(), Some(0.61)); + } + + #[test] + fn worst_domain_ranks_no_evidence_below_any_score() { + let mut sc = Scorecard::empty(); + sc.presence_room_known = scored(0.95, 500); + sc.presence_room_unseen = scored(0.10, 500); + // device-unseen and stationary remain NoEvidence — no coverage is worst. + let worst = sc.worst_domain(Task::Presence); + assert!(matches!(worst.cell, Cell::NoEvidence)); + assert_eq!(worst.point(), None); + // Canonical order breaks the NoEvidence tie deterministically. + assert_eq!(worst.slice, SliceId::PresenceDeviceUnseen); + } + + // ---- promotion gate --------------------------------------------------- + + #[test] + fn gate_fails_on_hidden_worst_domain_regression_while_pooled_improves() { + // Only two covered presence slices, so pooled == their mean. + // prev pooled = (0.80 + 0.75)/2 = 0.775 + let mut prev = Scorecard::empty(); + prev.presence_room_known = scored(0.80, 1000); + prev.presence_room_unseen = scored(0.75, 1000); + + // curr pooled = (0.95 + 0.65)/2 = 0.80 -> pooled IMPROVED + // but room-unseen (strict budget) dropped 0.75 -> 0.65 -> regression. + let mut curr = Scorecard::empty(); + curr.presence_room_known = scored(0.95, 1000); + curr.presence_room_unseen = scored(0.65, 1000); + + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(report.pooled_improved, "pooled should have improved"); + assert!(!report.passed, "gate must fail on the hidden regression"); + assert!(report.hidden_behind_pooled()); + assert_eq!(report.regressions.len(), 1); + assert_eq!(report.regressions[0].slice, SliceId::PresenceRoomUnseen); + assert_eq!(report.regressions[0].kind, RegressionKind::AccuracyDrop); + } + + #[test] + fn gate_passes_on_across_the_board_improvement() { + let mut prev = Scorecard::empty(); + prev.presence_room_known = scored(0.80, 1000); + prev.presence_room_unseen = scored(0.70, 1000); + prev.presence_device_unseen = scored(0.72, 1000); + prev.presence_stationary_10m = scored(0.55, 1000); + prev.pose_matched = scored(0.60, 1000); + prev.ood_rejection = scored(0.90, 1000); + prev.calibration_drift = scored(0.20, 1000); + + let mut curr = Scorecard::empty(); + curr.presence_room_known = scored(0.85, 1000); + curr.presence_room_unseen = scored(0.74, 1000); + curr.presence_device_unseen = scored(0.76, 1000); + curr.presence_stationary_10m = scored(0.60, 1000); + curr.pose_matched = scored(0.65, 1000); + curr.ood_rejection = scored(0.93, 1000); + curr.calibration_drift = scored(0.15, 1000); // drift down = better + + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(report.passed, "expected pass: {:?}", report.regressions); + assert!(report.regressions.is_empty()); + assert!(!report.hidden_behind_pooled()); + } + + #[test] + fn gate_fails_on_coverage_loss() { + let mut prev = Scorecard::empty(); + prev.presence_room_unseen = scored(0.75, 1000); + let curr = Scorecard::empty(); // lost the covered domain + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(!report.passed); + assert_eq!(report.regressions[0].kind, RegressionKind::CoverageLoss); + } + + #[test] + fn gate_fails_on_drift_increase() { + let mut prev = Scorecard::empty(); + prev.calibration_drift = scored(0.10, 1000); + let mut curr = Scorecard::empty(); + curr.calibration_drift = scored(0.30, 1000); // drift rose beyond budget + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(!report.passed); + assert_eq!(report.regressions[0].kind, RegressionKind::DriftIncrease); + } + + #[test] + fn small_move_within_tolerance_is_not_a_regression() { + let mut prev = Scorecard::empty(); + prev.presence_room_known = scored(0.80, 1000); // base budget 0.02 + let mut curr = Scorecard::empty(); + curr.presence_room_known = scored(0.79, 1000); // within budget + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(report.passed); + } + + // ---- determinism ------------------------------------------------------ + + fn sample_scorecard() -> Scorecard { + let mut sc = Scorecard::empty(); + sc.presence_room_known = scored(0.91, 800); + sc.presence_room_unseen = scored(0.68, 800); + sc.presence_stationary_10m = scored(0.52, 400); + sc.ood_rejection = scored(0.88, 300); + sc.calibration_drift = scored(0.12, 800); + sc.state_fractions = Some(StateFractions::new(0.7, 0.2, 0.1).unwrap()); + sc + } + + #[test] + fn render_and_gate_are_deterministic() { + // Rendering and the gate are pure: the same inputs yield the same + // output every time (no wall clock, no RNG). + let a = sample_scorecard(); + let b = sample_scorecard(); + assert_eq!(a.render(), a.render()); + // Two independent builds render identically; 4-decimal formatting is + // stable across any last-ULP difference the compiler may introduce by + // const-folding one build differently from the other. + assert_eq!(a.render(), b.render()); + assert_eq!( + promotion_gate(&a, &b, &GatePolicy::default()), + promotion_gate(&a, &b, &GatePolicy::default()) + ); + + // Serialisation preserves the scorecard to reporting precision: a + // JSON round-trip reproduces the same rendered scorecard. (Rendering + // fixes precision at 4 decimals; the ADR-149 hash binding hashes the + // reproducible reporting form, not raw f64 bits.) + let back: Scorecard = + serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); + assert_eq!(back.render(), a.render()); + } + + #[test] + fn render_reports_no_evidence_not_a_number() { + let sc = Scorecard::empty(); + let text = sc.render(); + assert!(text.contains("no-ev")); + assert!(text.contains("no coverage")); + assert!(text.contains("pooled accuracy = no-evidence")); + assert!(text.contains("domain state = untracked")); + } + + #[test] + fn render_flags_pooled_as_insufficient() { + let sc = sample_scorecard(); + let text = sc.render(); + assert!(text.contains("INSUFFICIENT ALONE")); + assert!(text.contains("worst presence")); + assert!(text.contains("domain state = Valid")); + } +} diff --git a/v2/crates/ruview-witness/Cargo.toml b/v2/crates/ruview-witness/Cargo.toml new file mode 100644 index 00000000..87be495a --- /dev/null +++ b/v2/crates/ruview-witness/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-witness/src/lib.rs b/v2/crates/ruview-witness/src/lib.rs new file mode 100644 index 00000000..e9c98e25 --- /dev/null +++ b/v2/crates/ruview-witness/src/lib.rs @@ -0,0 +1,1194 @@ +//! `ruview-witness` — the staged, append-only, hash-linked witness chain. +//! +//! This crate implements **ADR-316** (witness chain), primitive 19 of the +//! ADR-297 perception substrate. Instead of emitting a bare boolean +//! ("person present"), RuView emits a *chain* whose ordered stages record the +//! auditable reasoning behind an output: +//! +//! ```text +//! RF observation ▸ DSP evidence ▸ model inference ▸ independent corroboration +//! ▸ spatial state ▸ policy decision +//! ``` +//! +//! Each stage is a typed record carrying its own [`Confidence`] and its +//! provenance ([`EvidenceLevel`]). The chain is **hash-linked**: each stage +//! binds the hash of the prior stage ([`Stage::prior_hash`]), and the chain +//! carries the recomputed [`WitnessChain::head`] of its terminal stage, so an +//! in-place mutation, a reordering, or a broken link is detectable by +//! [`WitnessChain::verify`] without trusting the emitting host. +//! +//! ## Relationship to sibling ADRs +//! +//! - **ADR-302 ([`ruview_attest`])** roots the chain: the first stage is built +//! from an authenticated [`VerifiedMeasurement`] +//! ([`WitnessChain::from_measurement`]), so the whole chain descends from a +//! verified chain of custody. The stage hash reuses `ruview-attest`'s BLAKE3 +//! ([`ruview_attest::PayloadHash`]) — no separate hash primitive is added. +//! - **ADR-292** contributes [`SourceState`]: a `Synthetic` root can never +//! present as `LiveVerified`, and it caps the chain's effective evidence +//! level. +//! - **ADR-299** contributes [`DomainState`] (the `KNOWN → DEGRADED → UNKNOWN` +//! staleness guard): a low-confidence or out-of-distribution inference is +//! recorded as such, never silently promoted. +//! - **ADR-318** will attach the real terminal [`PolicyDecision`]; here it is a +//! faithfully-typed placeholder for the governed action taken (or withheld). +//! +//! ## Honesty discipline (ADR-297 rule 1 / CLAUDE.md) +//! +//! [`Confidence::Unknown`] is a first-class value, never an error. A missing +//! corroboration is recorded as [`Corroboration::None`], never fabricated. The +//! chain's *effective* evidence level is the **minimum** across its stages, so +//! the chain can never claim more than its weakest link. +//! +//! ### Evidence grade +//! +//! Like `ruview-attest`, any guarantee exercised by this crate's tests is +//! **SYNTHETIC / L0** — the fixtures are constructed in code, never captured +//! from silicon. Hash-linking here is tamper-*evident* against in-place +//! mutation; deployment-grade non-repudiation additionally requires the ADR-302 +//! per-stage RuField signatures, layered on top of this structure, plus +//! real-hardware evidence. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub use ruview_attest::{ + CalibrationRef, DeviceId, PayloadHash, SignedMeasurement, Timestamp, VerifiedMeasurement, +}; + +/// Width, in bytes, of a stage hash (BLAKE3, via [`ruview_attest::PayloadHash`]). +pub const HASH_LEN: usize = 32; + +/// Maximum accepted byte length of any free-text field carried in a stage. +/// Bounds allocation at the (untrusted) construction boundary. +pub const MAX_TEXT_LEN: usize = 256; + +/// Domain-separation prefix mixed into every stage's canonical bytes so a stage +/// hash can never collide with a hash computed for another purpose. +const DOMAIN: &[u8] = b"ruview-witness/v1\x00stage\x00"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Failure while constructing a stage value from untrusted input. +#[derive(Debug, Clone, PartialEq, Error)] +pub enum InputError { + /// A free-text field exceeded [`MAX_TEXT_LEN`]. + #[error("text field length {got} exceeds maximum {max}", max = MAX_TEXT_LEN)] + TextTooLong { + /// The offending length. + got: usize, + }, + /// A confidence value was not a finite number in `0.0..=1.0`. + #[error("confidence {0} is not a finite value in 0.0..=1.0")] + InvalidConfidence(f32), +} + +/// Reason a [`WitnessChain`] operation was rejected. Malformed structure is a +/// hard `Err`, never a panic and never a silently-accepted chain. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ChainError { + /// A stage whose evidence kind does not permit it here was appended: the + /// stage order must be strictly increasing (append-only, no reordering). + #[error("stage {next:?} cannot follow {last:?}: stage order must strictly increase")] + NotAppendable { + /// The current terminal stage kind. + last: StageKind, + /// The rejected stage kind. + next: StageKind, + }, + /// The chain was empty (a chain always roots in an RF observation). + #[error("chain is empty")] + Empty, + /// The root stage was not an RF observation. + #[error("chain root must be an RF observation, found {0:?}")] + RootNotObservation(StageKind), + /// A stage's recorded prior-stage hash did not match the recomputed hash of + /// its predecessor: a link is broken, missing, or a stage was reordered. + #[error("broken hash link at stage index {index}")] + BrokenLink { + /// Index of the stage whose `prior_hash` did not match. + index: usize, + }, + /// Stage order was not strictly increasing (a stage was reordered). + #[error("non-monotonic stage order at index {index}")] + NonMonotonicOrder { + /// Index of the out-of-order stage. + index: usize, + }, + /// The terminal stage's recomputed hash did not match the chain head: the + /// last stage was tampered with in place. + #[error("terminal stage does not match the recorded chain head (tamper)")] + TerminalTampered, +} + +// --------------------------------------------------------------------------- +// Provenance and confidence primitives +// --------------------------------------------------------------------------- + +/// The evidence level of a stage (ADR-282, L0–L5). The chain's effective level +/// is the **minimum** across stages, so the weakest link caps the whole chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum EvidenceLevel { + /// L0 — synthetic / self-referential; no external anchor. + L0 = 0, + /// L1. + L1 = 1, + /// L2. + L2 = 2, + /// L3. + L3 = 3, + /// L4. + L4 = 4, + /// L5 — strongest anchored evidence. + L5 = 5, +} + +/// The ADR-292 source state of an RF observation. `Unknown` is structurally +/// absent here: a stage that cannot assert a live state records `Synthetic` or +/// `Disconnected`, and a `Synthetic` root can never present as `LiveVerified`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum SourceState { + /// Synthetic input; caps the chain at [`EvidenceLevel::L0`]. + Synthetic = 0, + /// Live and cryptographically verified (ADR-302). + LiveVerified = 1, + /// Live but unverified. + LiveUnverified = 2, + /// Live source that has gone stale. + Stale = 3, + /// Source disconnected. + Disconnected = 4, +} + +impl SourceState { + /// Whether this state is the authenticated live state. A `Synthetic` root + /// answers `false`, upholding the ADR-292 invariant. + pub fn is_live_verified(&self) -> bool { + matches!(self, SourceState::LiveVerified) + } +} + +/// The ADR-299 domain-signature gate result for a model inference — the +/// `VALID → DEGRADED → UNKNOWN` staleness guard. Recorded faithfully so a +/// degraded or out-of-distribution inference is never silently promoted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum DomainState { + /// In-distribution; the certificate is valid. + Known = 0, + /// Drifting; the capability is degraded and recalibration is due. + Degraded = 1, + /// Out of distribution; the answer is UNKNOWN (never a confident class). + Unknown = 2, +} + +/// A stage's confidence. [`Confidence::Unknown`] is a first-class value +/// (ADR-297 rule 1), never an error and never coerced to a number. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Confidence { + /// A finite confidence in `0.0..=1.0`. + Known(f32), + /// The stage has no confidence to report. + Unknown, +} + +impl Confidence { + /// Construct a known confidence, rejecting non-finite or out-of-range input + /// at the boundary. + pub fn known(value: f32) -> Result { + if !value.is_finite() || value < 0.0 || value > 1.0 { + return Err(InputError::InvalidConfidence(value)); + } + Ok(Confidence::Known(value)) + } + + /// The unknown confidence. + pub const fn unknown() -> Self { + Confidence::Unknown + } +} + +// --------------------------------------------------------------------------- +// Stage kinds and typed per-stage evidence +// --------------------------------------------------------------------------- + +/// The ordered kinds of a witness stage. Ordering is the pipeline order; a +/// chain's stages must strictly increase, which is what makes reordering +/// detectable and bounds a chain to at most six stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum StageKind { + /// The authenticated RF frame envelope (root link). + RfObservation = 0, + /// Deterministic DSP features and ADR-137 quality signals. + DspEvidence = 1, + /// Model version, raw output, uncertainty, and the ADR-299 gate result. + ModelInference = 2, + /// The ADR-300 agreement link (phase 2); "no corroboration" in phase 1. + IndependentCorroboration = 3, + /// The ADR-303 ontology entity the inference updated. + SpatialState = 4, + /// The terminal governed action (ADR-318). + PolicyDecision = 5, +} + +/// The RF observation stage: the authenticated measurement lineage plus its +/// ADR-292 source state. This is the root link of every chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RfObservation { + /// The verified chain-of-custody record from `ruview-attest` (ADR-302). + pub measurement: VerifiedMeasurement, + /// The ADR-292 source state of the measurement. + pub source_state: SourceState, +} + +impl RfObservation { + /// Root an observation in a verified measurement and its source state. + pub fn from_verified(measurement: VerifiedMeasurement, source_state: SourceState) -> Self { + Self { + measurement, + source_state, + } + } +} + +/// The DSP evidence stage: a deterministic feature descriptor, an SNR-style +/// quality figure, and the ADR-137 quality-gate verdict. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DspEvidence { + descriptor: String, + /// Signal-quality figure (e.g. SNR in dB) supporting the features. + pub snr_db: f32, + /// Whether the ADR-137 quality gate passed. + pub quality_ok: bool, +} + +impl DspEvidence { + /// Construct DSP evidence, validating the descriptor length at the boundary. + pub fn new( + descriptor: impl Into, + snr_db: f32, + quality_ok: bool, + ) -> Result { + Ok(Self { + descriptor: checked_text(descriptor.into())?, + snr_db, + quality_ok, + }) + } + + /// The feature descriptor. + pub fn descriptor(&self) -> &str { + &self.descriptor + } +} + +/// The model inference stage: which model produced which raw output, with what +/// predictive uncertainty, under which ADR-299 domain gate. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModelInference { + model_version: String, + label: String, + /// Predictive uncertainty of the raw output. + pub uncertainty: f32, + /// The ADR-299 domain-gate result. `Unknown` records an OOD inference. + pub domain_state: DomainState, +} + +impl ModelInference { + /// Construct a model inference, validating text fields at the boundary. + pub fn new( + model_version: impl Into, + label: impl Into, + uncertainty: f32, + domain_state: DomainState, + ) -> Result { + Ok(Self { + model_version: checked_text(model_version.into())?, + label: checked_text(label.into())?, + uncertainty, + domain_state, + }) + } + + /// The model version string. + pub fn model_version(&self) -> &str { + &self.model_version + } + + /// The raw output label. + pub fn label(&self) -> &str { + &self.label + } +} + +/// The independent-corroboration stage (ADR-300). In phase 1 the honest value +/// is [`Corroboration::None`] — a missing corroboration is recorded, never +/// fabricated. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Corroboration { + /// No independent corroboration was available (phase-1 default). + None, + /// A second modality agreed, with an agreement score in `0.0..=1.0`. + Agreed { + /// The corroborating modality. + modality: String, + /// Agreement score. + score: f32, + }, + /// A second modality disagreed. + Disagreed { + /// The corroborating modality. + modality: String, + /// Agreement score. + score: f32, + }, +} + +impl Corroboration { + /// Construct an `Agreed` corroboration, validating the modality length. + pub fn agreed(modality: impl Into, score: f32) -> Result { + Ok(Corroboration::Agreed { + modality: checked_text(modality.into())?, + score, + }) + } + + /// Construct a `Disagreed` corroboration, validating the modality length. + pub fn disagreed(modality: impl Into, score: f32) -> Result { + Ok(Corroboration::Disagreed { + modality: checked_text(modality.into())?, + score, + }) + } +} + +/// The kind of ADR-303 ontology entity a stage updated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum SpatialEntity { + /// A single observation. + Observation = 0, + /// A track (linked observations over time). + Track = 1, + /// A discrete event. + Event = 2, +} + +/// The spatial-state stage: the ADR-303 ontology entity the inference updated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpatialState { + /// The kind of entity updated. + pub entity: SpatialEntity, + entity_id: String, +} + +impl SpatialState { + /// Construct a spatial-state record, validating the entity id length. + pub fn new(entity: SpatialEntity, entity_id: impl Into) -> Result { + Ok(Self { + entity, + entity_id: checked_text(entity_id.into())?, + }) + } + + /// The entity identifier. + pub fn entity_id(&self) -> &str { + &self.entity_id + } +} + +/// The governed action a policy took or withheld (ADR-318 owns the real one). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PolicyAction { + /// An action was taken. + Act { + /// The action identifier. + action: String, + }, + /// The action was withheld (e.g. on a degraded/unknown domain). + Withhold { + /// Why the action was withheld. + reason: String, + }, +} + +/// The terminal policy-decision stage: the governed action, with the ADR-315 +/// capability certificate it relied on (if any). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicyDecision { + /// The governed action taken or withheld. + pub action: PolicyAction, + certificate_ref: Option, +} + +impl PolicyDecision { + /// Record a taken action. + pub fn act( + action: impl Into, + certificate_ref: Option, + ) -> Result { + Self::new( + PolicyAction::Act { + action: checked_text(action.into())?, + }, + certificate_ref, + ) + } + + /// Record a withheld action. + pub fn withhold( + reason: impl Into, + certificate_ref: Option, + ) -> Result { + Self::new( + PolicyAction::Withhold { + reason: checked_text(reason.into())?, + }, + certificate_ref, + ) + } + + fn new(action: PolicyAction, certificate_ref: Option) -> Result { + let certificate_ref = match certificate_ref { + Some(c) => Some(checked_text(c)?), + None => None, + }; + Ok(Self { + action, + certificate_ref, + }) + } + + /// The relied-upon certificate reference, if any. + pub fn certificate_ref(&self) -> Option<&str> { + self.certificate_ref.as_deref() + } +} + +/// The typed evidence carried by a stage. Its variant fixes the stage kind, so +/// a stage can never carry evidence inconsistent with its position. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum StageEvidence { + /// RF observation (root). + RfObservation(RfObservation), + /// DSP evidence. + DspEvidence(DspEvidence), + /// Model inference. + ModelInference(ModelInference), + /// Independent corroboration. + IndependentCorroboration(Corroboration), + /// Spatial state. + SpatialState(SpatialState), + /// Policy decision (terminal). + PolicyDecision(PolicyDecision), +} + +impl StageEvidence { + /// The stage kind this evidence variant belongs to. + pub fn kind(&self) -> StageKind { + match self { + StageEvidence::RfObservation(_) => StageKind::RfObservation, + StageEvidence::DspEvidence(_) => StageKind::DspEvidence, + StageEvidence::ModelInference(_) => StageKind::ModelInference, + StageEvidence::IndependentCorroboration(_) => StageKind::IndependentCorroboration, + StageEvidence::SpatialState(_) => StageKind::SpatialState, + StageEvidence::PolicyDecision(_) => StageKind::PolicyDecision, + } + } + + fn write_canonical(&self, out: &mut Vec) { + match self { + StageEvidence::RfObservation(o) => { + out.push(0); + let m = &o.measurement; + push_field(out, m.device.as_str().as_bytes()); + out.extend_from_slice(&m.sequence.to_le_bytes()); + out.extend_from_slice(&m.timestamp.0.to_le_bytes()); + push_field(out, &m.payload_hash.0); + match &m.calibration_ref { + Some(c) => { + out.push(1); + push_field(out, c.as_str().as_bytes()); + } + None => out.push(0), + } + out.push(o.source_state as u8); + } + StageEvidence::DspEvidence(d) => { + out.push(1); + push_field(out, d.descriptor.as_bytes()); + out.extend_from_slice(&d.snr_db.to_le_bytes()); + out.push(d.quality_ok as u8); + } + StageEvidence::ModelInference(m) => { + out.push(2); + push_field(out, m.model_version.as_bytes()); + push_field(out, m.label.as_bytes()); + out.extend_from_slice(&m.uncertainty.to_le_bytes()); + out.push(m.domain_state as u8); + } + StageEvidence::IndependentCorroboration(c) => { + out.push(3); + match c { + Corroboration::None => out.push(0), + Corroboration::Agreed { modality, score } => { + out.push(1); + push_field(out, modality.as_bytes()); + out.extend_from_slice(&score.to_le_bytes()); + } + Corroboration::Disagreed { modality, score } => { + out.push(2); + push_field(out, modality.as_bytes()); + out.extend_from_slice(&score.to_le_bytes()); + } + } + } + StageEvidence::SpatialState(s) => { + out.push(4); + out.push(s.entity as u8); + push_field(out, s.entity_id.as_bytes()); + } + StageEvidence::PolicyDecision(p) => { + out.push(5); + match &p.action { + PolicyAction::Act { action } => { + out.push(0); + push_field(out, action.as_bytes()); + } + PolicyAction::Withhold { reason } => { + out.push(1); + push_field(out, reason.as_bytes()); + } + } + match &p.certificate_ref { + Some(c) => { + out.push(1); + push_field(out, c.as_bytes()); + } + None => out.push(0), + } + } + } + } +} + +// --------------------------------------------------------------------------- +// The hash-linked stage +// --------------------------------------------------------------------------- + +/// A stage hash: the BLAKE3 (via [`ruview_attest::PayloadHash`]) of a stage's +/// canonical bytes, which include the prior stage's hash. Fixed width so a +/// malformed wire value cannot force an unbounded allocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct StageHash(pub [u8; HASH_LEN]); + +/// One stage of a witness chain: a typed [`StageEvidence`] with its +/// [`Confidence`] and [`EvidenceLevel`], hash-linked to the prior stage. +/// +/// Fields are readable for inspection but a `Stage` inside a [`WitnessChain`] is +/// only reachable immutably ([`WitnessChain::stages`]); the chain never hands +/// out a mutable stage, upholding the append-only invariant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Stage { + /// The stage kind (equals `evidence.kind()`). + pub kind: StageKind, + /// The stage's confidence (may be [`Confidence::Unknown`]). + pub confidence: Confidence, + /// The stage's provenance / evidence level. + pub evidence_level: EvidenceLevel, + /// The hash of the prior stage, or `None` for the root. + pub prior_hash: Option, + /// The typed evidence. + pub evidence: StageEvidence, +} + +impl Stage { + fn new( + evidence: StageEvidence, + confidence: Confidence, + evidence_level: EvidenceLevel, + prior_hash: Option, + ) -> Self { + Self { + kind: evidence.kind(), + confidence, + evidence_level, + prior_hash, + evidence, + } + } + + /// The deterministic hash of this stage over its canonical, length-prefixed + /// bytes — including the prior-stage hash, which is what links the chain. + pub fn hash(&self) -> StageHash { + let mut b = Vec::with_capacity(DOMAIN.len() + 64); + b.extend_from_slice(DOMAIN); + b.push(self.kind as u8); + match self.confidence { + Confidence::Unknown => b.push(0), + Confidence::Known(v) => { + b.push(1); + b.extend_from_slice(&v.to_le_bytes()); + } + } + b.push(self.evidence_level as u8); + match &self.prior_hash { + Some(h) => { + b.push(1); + b.extend_from_slice(&h.0); + } + None => b.push(0), + } + self.evidence.write_canonical(&mut b); + StageHash(PayloadHash::of(&b).0) + } +} + +// --------------------------------------------------------------------------- +// The chain +// --------------------------------------------------------------------------- + +/// A verified summary of a chain, returned by [`WitnessChain::verify`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChainSummary { + /// Number of stages. + pub len: usize, + /// The effective evidence level: the **minimum** across all stages. + pub effective_level: EvidenceLevel, + /// The kind of the terminal stage. + pub terminal_kind: StageKind, + /// The verified head hash. + pub head: StageHash, + /// Whether the terminal stage is a [`StageKind::PolicyDecision`]. + pub finalized: bool, +} + +/// A staged, append-only, hash-linked witness chain (ADR-316). +/// +/// A chain always roots in an RF observation and grows by strictly-increasing +/// stage kind. Prior stages are immutable: [`WitnessChain::append`] only ever +/// pushes, and the stored [`WitnessChain::head`] binds the terminal stage so +/// even a last-stage in-place mutation is caught by [`WitnessChain::verify`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WitnessChain { + /// When this chain is an append-only correction of a prior chain, the head + /// hash of the chain it supersedes. + correction_of: Option, + stages: Vec, + head: StageHash, +} + +impl WitnessChain { + /// Root a chain in an RF observation. + pub fn root( + observation: RfObservation, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Self { + let stage = Stage::new( + StageEvidence::RfObservation(observation), + confidence, + evidence_level, + None, + ); + let head = stage.hash(); + Self { + correction_of: None, + stages: vec![stage], + head, + } + } + + /// Root a chain directly in an authenticated [`VerifiedMeasurement`] + /// (ADR-302), so the chain descends from a verified chain of custody. + pub fn from_measurement( + measurement: VerifiedMeasurement, + source_state: SourceState, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Self { + Self::root( + RfObservation::from_verified(measurement, source_state), + confidence, + evidence_level, + ) + } + + /// Begin an append-only *correction* of `prior`: a fresh chain that + /// references the superseded chain's head rather than editing it in place. + pub fn correcting( + prior: &WitnessChain, + observation: RfObservation, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Self { + let mut chain = Self::root(observation, confidence, evidence_level); + chain.correction_of = Some(prior.head); + chain + } + + /// Append a stage. The stage kind (derived from `evidence`) must be strictly + /// greater than the current terminal kind, so prior stages are never + /// mutated or reordered. The new stage binds the current head hash. + pub fn append( + &mut self, + evidence: StageEvidence, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Result<(), ChainError> { + let next = evidence.kind(); + let last = self.stages.last().map(|s| s.kind).ok_or(ChainError::Empty)?; + if (next as u8) <= (last as u8) { + return Err(ChainError::NotAppendable { last, next }); + } + let stage = Stage::new(evidence, confidence, evidence_level, Some(self.head)); + self.head = stage.hash(); + self.stages.push(stage); + Ok(()) + } + + /// The chain's stages, immutably. There is no mutable accessor: a chain is + /// append-only. + pub fn stages(&self) -> &[Stage] { + &self.stages + } + + /// The recorded head (terminal-stage) hash. + pub fn head(&self) -> StageHash { + self.head + } + + /// The head hash of a chain this one corrects, if any. + pub fn correction_of(&self) -> Option { + self.correction_of + } + + /// Verify the whole chain: the root is an RF observation, every stage binds + /// the recomputed hash of its predecessor, stage order strictly increases, + /// and the terminal stage matches the recorded head. Returns a + /// [`ChainSummary`] whose effective level is the minimum across stages. + pub fn verify(&self) -> Result { + let first = self.stages.first().ok_or(ChainError::Empty)?; + if first.kind != StageKind::RfObservation { + return Err(ChainError::RootNotObservation(first.kind)); + } + + let mut prev_hash: Option = None; + let mut prev_kind: Option = None; + let mut effective = EvidenceLevel::L5; + + for (index, stage) in self.stages.iter().enumerate() { + // Link integrity: the recorded prior hash must equal the recomputed + // hash of the predecessor (None for the root). + if stage.prior_hash != prev_hash { + return Err(ChainError::BrokenLink { index }); + } + // Monotonic stage order. + if let Some(pk) = prev_kind { + if (stage.kind as u8) <= (pk as u8) { + return Err(ChainError::NonMonotonicOrder { index }); + } + } + if stage.evidence_level < effective { + effective = stage.evidence_level; + } + prev_hash = Some(stage.hash()); + prev_kind = Some(stage.kind); + } + + // Terminal integrity: catches an in-place mutation of the last stage, + // which no successor's `prior_hash` binds. + let head = prev_hash.ok_or(ChainError::Empty)?; + if head != self.head { + return Err(ChainError::TerminalTampered); + } + + let terminal_kind = prev_kind.ok_or(ChainError::Empty)?; + Ok(ChainSummary { + len: self.stages.len(), + effective_level: effective, + terminal_kind, + head, + finalized: terminal_kind == StageKind::PolicyDecision, + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Validate a free-text field length at the boundary. +fn checked_text(text: String) -> Result { + if text.len() > MAX_TEXT_LEN { + return Err(InputError::TextTooLong { got: text.len() }); + } + Ok(text) +} + +/// Append a `u32` little-endian length prefix followed by the bytes, so the +/// canonical encoding is field-unambiguous and serde-format independent. +fn push_field(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(bytes); +} + +// --------------------------------------------------------------------------- +// Tests (SYNTHETIC / L0 fixtures — constructed in code, no wall clock, no RNG) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use ruview_attest::{ + AttestationVerifier, Blake3MacSigner, FreshnessPolicy, SignedMeasurement, TAG_LEN, + }; + + fn verified(seq: u64) -> VerifiedMeasurement { + VerifiedMeasurement { + device: DeviceId::new("esp32-node-01").unwrap(), + sequence: seq, + timestamp: Timestamp(1000), + payload_hash: PayloadHash::of(b"csi-frame"), + calibration_ref: Some(CalibrationRef::new("cal-cert-abc").unwrap()), + } + } + + /// Build a full six-stage chain from an L-labelled root. + fn full_chain(root_level: EvidenceLevel, source: SourceState) -> WitnessChain { + let mut chain = WitnessChain::from_measurement( + verified(1), + source, + Confidence::known(0.9).unwrap(), + root_level, + ); + chain + .append( + StageEvidence::DspEvidence( + DspEvidence::new("doppler-motion-band", 12.5, true).unwrap(), + ), + Confidence::known(0.8).unwrap(), + EvidenceLevel::L3, + ) + .unwrap(); + chain + .append( + StageEvidence::ModelInference( + ModelInference::new("presence-v3", "person", 0.15, DomainState::Known).unwrap(), + ), + Confidence::known(0.72).unwrap(), + EvidenceLevel::L2, + ) + .unwrap(); + chain + .append( + StageEvidence::IndependentCorroboration(Corroboration::None), + Confidence::Unknown, + EvidenceLevel::L1, + ) + .unwrap(); + chain + .append( + StageEvidence::SpatialState( + SpatialState::new(SpatialEntity::Track, "track-7").unwrap(), + ), + Confidence::known(0.7).unwrap(), + EvidenceLevel::L2, + ) + .unwrap(); + chain + .append( + StageEvidence::PolicyDecision( + PolicyDecision::act("dim-lights", Some("cap-cert-9".into())).unwrap(), + ), + Confidence::known(0.7).unwrap(), + EvidenceLevel::L2, + ) + .unwrap(); + chain + } + + #[test] + fn full_chain_verifies() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let summary = chain.verify().unwrap(); + assert_eq!(summary.len, 6); + assert_eq!(summary.terminal_kind, StageKind::PolicyDecision); + assert!(summary.finalized); + // Effective level is the minimum across stages (L1 from the + // no-corroboration stage), never the root's L3. + assert_eq!(summary.effective_level, EvidenceLevel::L1); + } + + #[test] + fn stages_are_in_pipeline_order() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let kinds: Vec = chain.stages().iter().map(|s| s.kind).collect(); + assert_eq!( + kinds, + vec![ + StageKind::RfObservation, + StageKind::DspEvidence, + StageKind::ModelInference, + StageKind::IndependentCorroboration, + StageKind::SpatialState, + StageKind::PolicyDecision, + ] + ); + } + + #[test] + fn root_from_authenticated_measurement() { + // Sign + verify with ruview-attest, then root the chain in the result. + let key = [7u8; TAG_LEN]; + let signer = Blake3MacSigner::new(key); + let device = DeviceId::new("esp32-node-01").unwrap(); + let mut verifier = AttestationVerifier::new(FreshnessPolicy::new(1_000_000_000, 100_000_000)); + verifier.enroll(device.clone(), signer.clone()); + let signed = + SignedMeasurement::sign(&signer, device, 1, Timestamp(1000), b"csi-frame", None); + let vm = verifier.verify(&signed, b"csi-frame", Timestamp(1000)).unwrap(); + + let chain = WitnessChain::from_measurement( + vm, + SourceState::LiveVerified, + Confidence::known(0.9).unwrap(), + EvidenceLevel::L4, + ); + assert!(chain.verify().is_ok()); + match &chain.stages()[0].evidence { + StageEvidence::RfObservation(o) => { + assert_eq!(o.measurement.sequence, 1); + assert!(o.source_state.is_live_verified()); + } + _ => panic!("root must be an RF observation"), + } + } + + #[test] + fn synthetic_root_caps_effective_level_at_l0() { + // A synthetic root labelled L0 caps the whole chain regardless of + // later, higher-labelled stages. + let chain = full_chain(EvidenceLevel::L0, SourceState::Synthetic); + let summary = chain.verify().unwrap(); + assert_eq!(summary.effective_level, EvidenceLevel::L0); + match &chain.stages()[0].evidence { + StageEvidence::RfObservation(o) => { + assert_eq!(o.source_state, SourceState::Synthetic); + assert!(!o.source_state.is_live_verified()); + } + _ => panic!(), + } + } + + #[test] + fn append_rejects_out_of_order_stage() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Terminal is PolicyDecision; nothing can follow. + let err = chain + .append( + StageEvidence::DspEvidence(DspEvidence::new("x", 1.0, true).unwrap()), + Confidence::Unknown, + EvidenceLevel::L1, + ) + .unwrap_err(); + assert!(matches!(err, ChainError::NotAppendable { .. })); + + // A second RF observation is also rejected (kind not strictly greater). + let mut c2 = WitnessChain::from_measurement( + verified(1), + SourceState::LiveVerified, + Confidence::Unknown, + EvidenceLevel::L2, + ); + assert!(matches!( + c2.append( + StageEvidence::RfObservation(RfObservation::from_verified( + verified(2), + SourceState::LiveVerified + )), + Confidence::Unknown, + EvidenceLevel::L2, + ), + Err(ChainError::NotAppendable { .. }) + )); + } + + #[test] + fn tampered_middle_stage_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Mutate a middle stage's confidence in place without re-linking. + chain.stages[2].confidence = Confidence::known(0.01).unwrap(); + assert!(matches!(chain.verify(), Err(ChainError::BrokenLink { index: 3 }))); + } + + #[test] + fn tampered_terminal_stage_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let last = chain.stages.len() - 1; + // No successor binds the terminal stage; the recorded head catches it. + chain.stages[last].evidence_level = EvidenceLevel::L5; + assert_eq!(chain.verify(), Err(ChainError::TerminalTampered)); + } + + #[test] + fn tampered_evidence_content_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Rewrite the model label on a middle stage. + chain.stages[2].evidence = StageEvidence::ModelInference( + ModelInference::new("presence-v3", "empty-room", 0.15, DomainState::Known).unwrap(), + ); + assert!(matches!(chain.verify(), Err(ChainError::BrokenLink { index: 3 }))); + } + + #[test] + fn reordered_stages_fail() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + chain.stages.swap(2, 3); + // The swap breaks both the hash link and the monotonic order; either + // way verification must reject it. + assert!(chain.verify().is_err()); + } + + #[test] + fn missing_link_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Drop a non-root stage's prior-hash link. + chain.stages[3].prior_hash = None; + assert!(matches!(chain.verify(), Err(ChainError::BrokenLink { index: 3 }))); + } + + #[test] + fn dropped_stage_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Remove a middle stage entirely: the follower's link no longer matches + // and the stored head no longer matches the recomputed terminal. + chain.stages.remove(3); + assert!(chain.verify().is_err()); + } + + #[test] + fn serde_round_trip_preserves_chain() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let json = serde_json::to_string(&chain).unwrap(); + let back: WitnessChain = serde_json::from_str(&json).unwrap(); + assert_eq!(chain, back); + // A deserialized chain still verifies end to end. + assert!(back.verify().is_ok()); + } + + #[test] + fn confidence_and_provenance_preserved() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let json = serde_json::to_string(&chain).unwrap(); + let back: WitnessChain = serde_json::from_str(&json).unwrap(); + for (a, b) in chain.stages().iter().zip(back.stages()) { + assert_eq!(a.confidence, b.confidence); + assert_eq!(a.evidence_level, b.evidence_level); + assert_eq!(a.kind, b.kind); + } + // The UNKNOWN corroboration confidence survives faithfully. + assert_eq!(back.stages()[3].confidence, Confidence::Unknown); + match &back.stages()[3].evidence { + StageEvidence::IndependentCorroboration(c) => assert_eq!(*c, Corroboration::None), + _ => panic!(), + } + } + + #[test] + fn unknown_gate_recorded_faithfully() { + // An OOD inference is carried as DomainState::Unknown with Unknown + // confidence — never promoted to a confident class. + let mut chain = WitnessChain::from_measurement( + verified(1), + SourceState::LiveVerified, + Confidence::known(0.9).unwrap(), + EvidenceLevel::L3, + ); + chain + .append( + StageEvidence::DspEvidence(DspEvidence::new("band", 3.0, false).unwrap()), + Confidence::Unknown, + EvidenceLevel::L1, + ) + .unwrap(); + chain + .append( + StageEvidence::ModelInference( + ModelInference::new("presence-v3", "unknown", 0.9, DomainState::Unknown) + .unwrap(), + ), + Confidence::Unknown, + EvidenceLevel::L0, + ) + .unwrap(); + let summary = chain.verify().unwrap(); + assert_eq!(summary.effective_level, EvidenceLevel::L0); + match &chain.stages()[2].evidence { + StageEvidence::ModelInference(m) => assert_eq!(m.domain_state, DomainState::Unknown), + _ => panic!(), + } + } + + #[test] + fn append_only_correction_references_prior() { + let prior = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let correction = WitnessChain::correcting( + &prior, + RfObservation::from_verified(verified(2), SourceState::LiveVerified), + Confidence::known(0.95).unwrap(), + EvidenceLevel::L3, + ); + // The correction is a new chain that references, not edits, the prior. + assert_eq!(correction.correction_of(), Some(prior.head())); + assert!(correction.verify().is_ok()); + assert!(prior.verify().is_ok()); + assert_ne!(correction.head(), prior.head()); + } + + #[test] + fn chain_building_is_deterministic() { + let a = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let b = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + assert_eq!(a, b); + assert_eq!(a.head(), b.head()); + assert_eq!( + serde_json::to_string(&a).unwrap(), + serde_json::to_string(&b).unwrap() + ); + } + + #[test] + fn confidence_boundary_validation() { + assert!(Confidence::known(f32::NAN).is_err()); + assert!(Confidence::known(-0.1).is_err()); + assert!(Confidence::known(1.1).is_err()); + assert!(Confidence::known(0.0).is_ok()); + assert!(Confidence::known(1.0).is_ok()); + } + + #[test] + fn text_boundary_validation() { + let long = "x".repeat(MAX_TEXT_LEN + 1); + assert!(matches!( + DspEvidence::new(long, 1.0, true), + Err(InputError::TextTooLong { .. }) + )); + assert!(DspEvidence::new("x".repeat(MAX_TEXT_LEN), 1.0, true).is_ok()); + } + + #[test] + fn empty_chain_verify_is_error_not_panic() { + // Constructed only via serde to reach the empty-stages guard. + let json = r#"{"correction_of":null,"stages":[],"head":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#; + let chain: WitnessChain = serde_json::from_str(json).unwrap(); + assert_eq!(chain.verify(), Err(ChainError::Empty)); + } +}