diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 64623879..82e25ee8 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7869,6 +7869,16 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c" +[[package]] +name = "ruview-attest" +version = "0.3.1" +dependencies = [ + "blake3", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ruview-auth" version = "0.1.0" @@ -7889,6 +7899,24 @@ dependencies = [ "url", ] +[[package]] +name = "ruview-evidence" +version = "0.3.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "ruview-ontology" +version = "0.3.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ruview-swarm" version = "0.1.0" @@ -11376,6 +11404,7 @@ dependencies = [ "num-complex", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.18", "uuid", "wifi-densepose-core", diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 9ca8bcbd..f0e3ca66 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -94,6 +94,10 @@ members = [ # hardware coupling, every number SYNTHETIC/L0 until real wideband RF # hardware exists. "crates/wifi-densepose-sar", + # ADR-297 phase 1 — perception substrate spine (new first-party crates): + "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-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-attest/Cargo.toml b/v2/crates/ruview-attest/Cargo.toml new file mode 100644 index 00000000..c16d785e --- /dev/null +++ b/v2/crates/ruview-attest/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ruview-attest" +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 } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-attest/src/lib.rs b/v2/crates/ruview-attest/src/lib.rs new file mode 100644 index 00000000..70db9f62 --- /dev/null +++ b/v2/crates/ruview-attest/src/lib.rs @@ -0,0 +1,705 @@ +//! `ruview-attest` — authenticated sensor identity and RF chain of custody. +//! +//! This crate implements **ADR-302** (authenticated sensor identity), phase 1 of +//! the ADR-297 perception substrate. It models the chain of custody link +//! `device → signed measurement → sequence → timestamp → payload hash → +//! calibration`, verified at the ingest boundary. +//! +//! ## Relationship to sibling ADRs +//! +//! - **ADR-293** shipped step one — a loopback-default UDP bind and an optional +//! source IP/CIDR allowlist — and explicitly deferred "per-device provisioned +//! keys, MAC/AEAD, device identifiers, monotonic sequence numbers, freshness +//! window, and replay rejection." **This crate is that step two.** An IP +//! allowlist does not stop on-subnet spoofing; a cryptographic device +//! identity bound into each measurement does. +//! - **ADR-316** (witness chain) consumes the [`VerifiedMeasurement`] lineage +//! produced here and serializes it for offline re-verification. +//! +//! ## Signer / Verifier abstraction and the SYNTHETIC reference +//! +//! Signing is expressed through the [`Signer`] and [`Verifier`] traits so a +//! production **Ed25519** asymmetric signer is a drop-in: implement the two +//! traits over a real keypair and the envelope, sequence, freshness, and tamper +//! logic here are unchanged. +//! +//! The bundled reference is [`Blake3MacSigner`], a keyed-BLAKE3 MAC. It is a +//! symmetric MAC, **not** an asymmetric signature: the verifier holds the same +//! secret the signer does, so it demonstrates the end-to-end custody logic but +//! confers no non-repudiation and no public-key trust boundary. Every accuracy +//! or spoof-resistance guarantee obtained with this reference signer is +//! **SYNTHETIC-grade** (CLAUDE.md evidence rule): a passing test suite exercises +//! the logic, never a fielded device. A deployment-grade claim requires an +//! Ed25519 signer plus real-silicon evidence. + +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Maximum accepted byte length of a [`DeviceId`]. Bounds allocation at the +/// untrusted ingest boundary. +pub const MAX_DEVICE_ID_LEN: usize = 128; + +/// Maximum accepted byte length of a [`CalibrationRef`]. +pub const MAX_CALIBRATION_REF_LEN: usize = 128; + +/// Width, in bytes, of a payload hash and of the reference MAC tag. +pub const TAG_LEN: usize = 32; + +/// Domain-separation prefix mixed into the canonical signing bytes so a tag +/// produced here can never be confused with a hash produced for another purpose. +const DOMAIN: &[u8] = b"ruview-attest/v1\x00signed-measurement\x00"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Failure while constructing a value from untrusted input. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum InputError { + /// A device identifier was empty. + #[error("device id must not be empty")] + EmptyDeviceId, + /// A device identifier exceeded [`MAX_DEVICE_ID_LEN`]. + #[error("device id length {0} exceeds maximum {max}", max = MAX_DEVICE_ID_LEN)] + DeviceIdTooLong(usize), + /// A calibration reference exceeded [`MAX_CALIBRATION_REF_LEN`]. + #[error("calibration ref length {0} exceeds maximum {max}", max = MAX_CALIBRATION_REF_LEN)] + CalibrationRefTooLong(usize), +} + +/// Reason a [`SignedMeasurement`] was rejected at the verification boundary. +/// +/// Every variant is a hard `Err`: a rejected frame is dropped and counted, +/// never a warning that proceeds (mirroring ADR-293's source-drop behaviour). +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum VerifyError { + /// The measurement's `DeviceId` is not enrolled. + #[error("device is not enrolled")] + UnknownDevice, + /// The signature/MAC did not verify over the canonical bytes. + #[error("signature verification failed")] + BadSignature, + /// The carried payload hash did not match the presented payload. + #[error("payload hash does not match presented payload (tamper)")] + Tampered, + /// The sequence number did not strictly increase for this device. + #[error("sequence {got} is not greater than last accepted {last} (replay)")] + Replay { + /// The last sequence number this device successfully advanced to. + last: u64, + /// The offending non-increasing sequence number. + got: u64, + }, + /// The timestamp is older than the freshness window allows. + #[error("timestamp is stale by {by_nanos} ns beyond the freshness window")] + Stale { + /// How far past the allowed age the timestamp fell, in nanoseconds. + by_nanos: i64, + }, + /// The timestamp is further in the future than the clock-skew budget allows. + #[error("timestamp is {by_nanos} ns further ahead than the skew budget")] + FutureDated { + /// How far past the allowed skew the timestamp fell, in nanoseconds. + by_nanos: i64, + }, +} + +// --------------------------------------------------------------------------- +// Core value types +// --------------------------------------------------------------------------- + +/// Authenticated device identity. Constructed only through [`DeviceId::new`], +/// which validates length at the boundary. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct DeviceId(String); + +impl DeviceId { + /// Validate and wrap a device identifier. Rejects empty or oversized ids. + pub fn new(id: impl Into) -> Result { + let id = id.into(); + if id.is_empty() { + return Err(InputError::EmptyDeviceId); + } + if id.len() > MAX_DEVICE_ID_LEN { + return Err(InputError::DeviceIdTooLong(id.len())); + } + Ok(Self(id)) + } + + /// Borrow the identifier string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Server-injected timestamp, nanoseconds since an agreed epoch. Time is always +/// injected (never read from a wall clock inside this crate) so verification is +/// deterministic and testable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Timestamp(pub i64); + +/// BLAKE3 hash of a measurement payload (CSI/CIR bytes). The payload itself is +/// *not* embedded in the envelope; only this hash is signed, so tampering is +/// detectable without carrying the payload twice. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayloadHash(pub [u8; TAG_LEN]); + +impl PayloadHash { + /// Compute the hash of a payload. + pub fn of(payload: &[u8]) -> Self { + Self(*blake3::hash(payload).as_bytes()) + } +} + +/// Optional reference to a calibration certificate (ADR-298) in effect for a +/// measurement. Validated length at the boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CalibrationRef(String); + +impl CalibrationRef { + /// Validate and wrap a calibration reference. + pub fn new(reference: impl Into) -> Result { + let reference = reference.into(); + if reference.len() > MAX_CALIBRATION_REF_LEN { + return Err(InputError::CalibrationRefTooLong(reference.len())); + } + Ok(Self(reference)) + } + + /// Borrow the reference string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A signature/MAC tag over the canonical measurement bytes. Fixed width so a +/// malformed wire value cannot force an unbounded allocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Signature(pub [u8; TAG_LEN]); + +// --------------------------------------------------------------------------- +// The signed envelope +// --------------------------------------------------------------------------- + +/// The unsigned content bound by a signature: everything a verifier must be able +/// to reconstruct byte-for-byte to check the tag. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MeasurementContent { + /// Authenticated origin device. + pub device: DeviceId, + /// Strictly monotonic per-device sequence number (replay defense). + pub sequence: u64, + /// Device-asserted capture timestamp, checked against the freshness window. + pub timestamp: Timestamp, + /// Hash of the measurement payload (tamper detection). + pub payload_hash: PayloadHash, + /// Optional calibration certificate reference in effect. + pub calibration_ref: Option, +} + +impl MeasurementContent { + /// 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. + pub fn canonical_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(DOMAIN.len() + 96 + self.device.0.len()); + out.extend_from_slice(DOMAIN); + push_field(&mut out, self.device.0.as_bytes()); + out.extend_from_slice(&self.sequence.to_le_bytes()); + out.extend_from_slice(&self.timestamp.0.to_le_bytes()); + push_field(&mut out, &self.payload_hash.0); + match &self.calibration_ref { + Some(c) => { + out.push(1); + push_field(&mut out, c.0.as_bytes()); + } + None => out.push(0), + } + out + } +} + +/// A [`MeasurementContent`] together with its signature. This is the object on +/// the wire and the unit the witness chain (ADR-316) serializes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedMeasurement { + /// The signed content. + pub content: MeasurementContent, + /// The tag over [`MeasurementContent::canonical_bytes`]. + pub signature: Signature, +} + +impl SignedMeasurement { + /// Build a signed measurement from its parts using `signer`. + pub fn sign( + signer: &S, + device: DeviceId, + sequence: u64, + timestamp: Timestamp, + payload: &[u8], + calibration_ref: Option, + ) -> Self { + let content = MeasurementContent { + device, + sequence, + timestamp, + payload_hash: PayloadHash::of(payload), + calibration_ref, + }; + let signature = signer.sign(&content.canonical_bytes()); + Self { content, signature } + } +} + +/// The trusted result of verification: proof that a measurement's origin, +/// sequence, freshness, and payload integrity were all checked. Carries the +/// verified chain-of-custody fields forward to calibration, inference, and the +/// witness chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifiedMeasurement { + /// Verified origin device. + pub device: DeviceId, + /// Verified sequence number (strictly greater than the previous accepted). + pub sequence: u64, + /// Verified timestamp (within the freshness window). + pub timestamp: Timestamp, + /// Verified payload hash (matched the presented payload). + pub payload_hash: PayloadHash, + /// Calibration reference in effect, if any. + pub calibration_ref: Option, +} + +// --------------------------------------------------------------------------- +// Signer / Verifier abstraction +// --------------------------------------------------------------------------- + +/// Produces a signature over canonical measurement bytes. A production Ed25519 +/// signer implements this over its private key. +pub trait Signer { + /// Sign `message`, returning a fixed-width tag. + fn sign(&self, message: &[u8]) -> Signature; +} + +/// Verifies a signature over canonical measurement bytes. A production Ed25519 +/// verifier implements this over the enrolled public key. +pub trait Verifier { + /// Return `true` iff `signature` is valid for `message` under this identity. + fn verify(&self, message: &[u8], signature: &Signature) -> bool; +} + +/// **SYNTHETIC-grade reference** signer/verifier: a keyed-BLAKE3 MAC. +/// +/// This is a symmetric MAC — the same secret signs and verifies — so it proves +/// the chain-of-custody logic but provides no non-repudiation. Do not read a +/// spoof-resistance guarantee from tests that use it (CLAUDE.md evidence rule). +/// Swap in an Ed25519 [`Signer`]/[`Verifier`] for a real asymmetric identity. +#[derive(Clone)] +pub struct Blake3MacSigner { + key: [u8; TAG_LEN], +} + +impl Blake3MacSigner { + /// Construct from a 32-byte secret key. + pub fn new(key: [u8; TAG_LEN]) -> Self { + Self { key } + } + + fn tag(&self, message: &[u8]) -> Signature { + Signature(*blake3::keyed_hash(&self.key, message).as_bytes()) + } +} + +impl Signer for Blake3MacSigner { + fn sign(&self, message: &[u8]) -> Signature { + self.tag(message) + } +} + +impl Verifier for Blake3MacSigner { + fn verify(&self, message: &[u8], signature: &Signature) -> bool { + constant_time_eq(&self.tag(message).0, &signature.0) + } +} + +// --------------------------------------------------------------------------- +// Freshness policy +// --------------------------------------------------------------------------- + +/// Bounds a measurement timestamp against the injected server clock. Rejects +/// frames older than `max_age_nanos` (stale) or more than `max_skew_ahead_nanos` +/// in the future (clock-skew budget). Reuses ADR-292's freshness notion rather +/// than inventing a parallel one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FreshnessPolicy { + /// Maximum accepted age (`now - timestamp`) in nanoseconds. + pub max_age_nanos: i64, + /// Maximum accepted lead (`timestamp - now`) in nanoseconds. + pub max_skew_ahead_nanos: i64, +} + +impl FreshnessPolicy { + /// A policy with the given symmetric window. + pub fn new(max_age_nanos: i64, max_skew_ahead_nanos: i64) -> Self { + Self { + max_age_nanos: max_age_nanos.max(0), + max_skew_ahead_nanos: max_skew_ahead_nanos.max(0), + } + } + + fn check(&self, timestamp: Timestamp, now: Timestamp) -> Result<(), VerifyError> { + let delta = now.0.saturating_sub(timestamp.0); // positive => in the past + if delta > self.max_age_nanos { + return Err(VerifyError::Stale { + by_nanos: delta - self.max_age_nanos, + }); + } + let ahead = timestamp.0.saturating_sub(now.0); // positive => in the future + if ahead > self.max_skew_ahead_nanos { + return Err(VerifyError::FutureDated { + by_nanos: ahead - self.max_skew_ahead_nanos, + }); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// The verifier: enrollment + per-device sequence state +// --------------------------------------------------------------------------- + +struct Enrolled { + verifier: V, + last_sequence: Option, +} + +/// The ingest-boundary verifier. Holds enrolled device identities (a device is +/// untrusted until an operator enrolls its verifier) and the last accepted +/// sequence per device, and applies signature + monotonic-sequence + freshness +/// + tamper checks. +pub struct AttestationVerifier { + enrolled: BTreeMap>, + freshness: FreshnessPolicy, +} + +impl AttestationVerifier { + /// Create an empty verifier with the given freshness policy. + pub fn new(freshness: FreshnessPolicy) -> Self { + Self { + enrolled: BTreeMap::new(), + freshness, + } + } + + /// Enroll (or re-enroll) a device with the verifier for its identity. This + /// is the explicit, authorized enrollment step from ADR-302; re-enrolling + /// resets the device's sequence state. + pub fn enroll(&mut self, device: DeviceId, verifier: V) { + self.enrolled.insert( + device, + Enrolled { + verifier, + last_sequence: None, + }, + ); + } + + /// Whether a device is enrolled. + pub fn is_enrolled(&self, device: &DeviceId) -> bool { + self.enrolled.contains_key(device) + } + + /// The last accepted sequence for a device, if any. + pub fn last_sequence(&self, device: &DeviceId) -> Option { + self.enrolled.get(device).and_then(|e| e.last_sequence) + } + + /// Verify a signed measurement against the presented `payload` at injected + /// time `now`. + /// + /// Checks, in order: device enrolled → signature → payload-hash (tamper) → + /// strictly-monotonic sequence (replay) → freshness. Per-device sequence + /// state advances **only** on full success, so a rejected frame never + /// consumes a sequence number. + pub fn verify( + &mut self, + measurement: &SignedMeasurement, + payload: &[u8], + now: Timestamp, + ) -> Result { + let content = &measurement.content; + + let entry = self + .enrolled + .get_mut(&content.device) + .ok_or(VerifyError::UnknownDevice)?; + + // Authenticate the envelope: the tag covers the payload *hash*, so a + // valid signature also authenticates the hash field itself. + if !entry + .verifier + .verify(&content.canonical_bytes(), &measurement.signature) + { + return Err(VerifyError::BadSignature); + } + + // Tamper detection: the presented payload must match the signed hash. + if PayloadHash::of(payload) != content.payload_hash { + return Err(VerifyError::Tampered); + } + + // Replay defense: strictly increasing sequence per device. + if let Some(last) = entry.last_sequence { + if content.sequence <= last { + return Err(VerifyError::Replay { + last, + got: content.sequence, + }); + } + } + + // Freshness window. + self.freshness.check(content.timestamp, now)?; + + // All checks passed: advance the accepted sequence and emit the + // verified custody record. + entry.last_sequence = Some(content.sequence); + Ok(VerifiedMeasurement { + device: content.device.clone(), + sequence: content.sequence, + timestamp: content.timestamp, + payload_hash: content.payload_hash, + calibration_ref: content.calibration_ref.clone(), + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Append a `u32` little-endian length prefix followed by the bytes. +fn push_field(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(bytes); +} + +/// Constant-time equality over equal-length byte arrays. +fn constant_time_eq(a: &[u8; TAG_LEN], b: &[u8; TAG_LEN]) -> bool { + let mut diff = 0u8; + for i in 0..TAG_LEN { + diff |= a[i] ^ b[i]; + } + diff == 0 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; TAG_LEN] = [7u8; TAG_LEN]; + + fn signer() -> Blake3MacSigner { + Blake3MacSigner::new(KEY) + } + + fn device() -> DeviceId { + DeviceId::new("esp32-node-01").unwrap() + } + + fn fresh_policy() -> FreshnessPolicy { + // 1 second age budget, 100 ms future skew budget. + FreshnessPolicy::new(1_000_000_000, 100_000_000) + } + + fn make_verifier() -> AttestationVerifier { + let mut v = AttestationVerifier::new(fresh_policy()); + v.enroll(device(), signer()); + v + } + + #[test] + fn valid_measurement_verifies() { + let mut v = make_verifier(); + let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(1000), b"csi-frame", None); + let out = v.verify(&m, b"csi-frame", Timestamp(1000)).unwrap(); + assert_eq!(out.device, device()); + assert_eq!(out.sequence, 1); + assert_eq!(out.timestamp, Timestamp(1000)); + assert_eq!(v.last_sequence(&device()), Some(1)); + } + + #[test] + fn valid_with_calibration_ref_verifies() { + let mut v = make_verifier(); + let cal = CalibrationRef::new("cal-cert-abc").unwrap(); + let m = SignedMeasurement::sign( + &signer(), + device(), + 5, + Timestamp(2000), + b"payload", + Some(cal.clone()), + ); + let out = v.verify(&m, b"payload", Timestamp(2000)).unwrap(); + assert_eq!(out.calibration_ref, Some(cal)); + } + + #[test] + fn replayed_or_old_sequence_rejected() { + let mut v = make_verifier(); + let now = Timestamp(5000); + let m3 = SignedMeasurement::sign(&signer(), device(), 3, now, b"p", None); + v.verify(&m3, b"p", now).unwrap(); + + // Exact replay of sequence 3. + assert_eq!(v.verify(&m3, b"p", now), Err(VerifyError::Replay { last: 3, got: 3 })); + + // Older sequence 2. + let m2 = SignedMeasurement::sign(&signer(), device(), 2, now, b"p", None); + assert_eq!(v.verify(&m2, b"p", now), Err(VerifyError::Replay { last: 3, got: 2 })); + + // A strictly greater sequence still works, and the rejected frames did + // not consume a sequence slot. + let m4 = SignedMeasurement::sign(&signer(), device(), 4, now, b"p", None); + assert!(v.verify(&m4, b"p", now).is_ok()); + assert_eq!(v.last_sequence(&device()), Some(4)); + } + + #[test] + fn stale_timestamp_rejected() { + let mut v = make_verifier(); + // Captured at t=0, verified at t=2s with a 1s age budget => 1s stale. + let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(0), b"p", None); + assert_eq!( + v.verify(&m, b"p", Timestamp(2_000_000_000)), + Err(VerifyError::Stale { by_nanos: 1_000_000_000 }) + ); + // Rejected frame did not advance sequence state. + assert_eq!(v.last_sequence(&device()), None); + } + + #[test] + fn future_dated_timestamp_rejected() { + let mut v = make_verifier(); + // Captured 500ms in the future with a 100ms skew budget => 400ms over. + let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(500_000_000), b"p", None); + assert_eq!( + v.verify(&m, b"p", Timestamp(0)), + Err(VerifyError::FutureDated { by_nanos: 400_000_000 }) + ); + } + + #[test] + fn tampered_payload_rejected() { + let mut v = make_verifier(); + let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(0), b"real-payload", None); + // Same envelope, but a different payload is presented at ingest. + assert_eq!(v.verify(&m, b"evil-payload", Timestamp(0)), Err(VerifyError::Tampered)); + assert_eq!(v.last_sequence(&device()), None); + } + + #[test] + fn tampered_envelope_field_fails_signature() { + let mut v = make_verifier(); + let mut m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(0), b"p", None); + // Flip the sequence without re-signing. + m.content.sequence = 999; + assert_eq!(v.verify(&m, b"p", Timestamp(0)), Err(VerifyError::BadSignature)); + } + + #[test] + fn wrong_key_fails_signature() { + let mut v = make_verifier(); + let attacker = Blake3MacSigner::new([9u8; TAG_LEN]); + let m = SignedMeasurement::sign(&attacker, device(), 1, Timestamp(0), b"p", None); + assert_eq!(v.verify(&m, b"p", Timestamp(0)), Err(VerifyError::BadSignature)); + } + + #[test] + fn unknown_device_rejected() { + let mut v = make_verifier(); + let stranger = DeviceId::new("rogue-node").unwrap(); + let m = SignedMeasurement::sign(&signer(), stranger, 1, Timestamp(0), b"p", None); + assert_eq!(v.verify(&m, b"p", Timestamp(0)), Err(VerifyError::UnknownDevice)); + } + + #[test] + fn signing_is_deterministic() { + let a = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(42), b"p", None); + let b = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(42), b"p", None); + assert_eq!(a, b); + assert_eq!(a.signature, b.signature); + } + + #[test] + fn canonical_bytes_are_field_unambiguous() { + // "ab" + "" must not collide with "a" + "b": length prefixes prevent it. + let mk = |d: &str, cal: Option<&str>| MeasurementContent { + device: DeviceId::new(d).unwrap(), + sequence: 1, + timestamp: Timestamp(0), + payload_hash: PayloadHash::of(b""), + calibration_ref: cal.map(|c| CalibrationRef::new(c).unwrap()), + }; + assert_ne!( + mk("ab", None).canonical_bytes(), + mk("a", Some("b")).canonical_bytes() + ); + } + + #[test] + fn envelope_round_trips_through_serde() { + let m = SignedMeasurement::sign( + &signer(), + device(), + 7, + Timestamp(123), + b"payload", + Some(CalibrationRef::new("cal").unwrap()), + ); + let json = serde_json::to_string(&m).unwrap(); + let back: SignedMeasurement = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back); + + // A deserialized envelope still verifies end-to-end. + let mut v = make_verifier(); + assert!(v.verify(&back, b"payload", Timestamp(123)).is_ok()); + } + + #[test] + fn device_id_boundary_validation() { + assert_eq!(DeviceId::new(""), Err(InputError::EmptyDeviceId)); + let long = "x".repeat(MAX_DEVICE_ID_LEN + 1); + assert_eq!( + DeviceId::new(long), + Err(InputError::DeviceIdTooLong(MAX_DEVICE_ID_LEN + 1)) + ); + assert!(DeviceId::new("x").is_ok()); + } + + #[test] + fn per_device_sequence_is_independent() { + let mut v = AttestationVerifier::new(fresh_policy()); + let d1 = DeviceId::new("node-1").unwrap(); + let d2 = DeviceId::new("node-2").unwrap(); + v.enroll(d1.clone(), signer()); + v.enroll(d2.clone(), signer()); + + let now = Timestamp(100); + let m1 = SignedMeasurement::sign(&signer(), d1.clone(), 10, now, b"p", None); + let m2 = SignedMeasurement::sign(&signer(), d2.clone(), 1, now, b"p", None); + // d1 at seq 10 does not block d2 at seq 1. + assert!(v.verify(&m1, b"p", now).is_ok()); + assert!(v.verify(&m2, b"p", now).is_ok()); + assert_eq!(v.last_sequence(&d1), Some(10)); + assert_eq!(v.last_sequence(&d2), Some(1)); + } +} diff --git a/v2/crates/ruview-evidence/Cargo.toml b/v2/crates/ruview-evidence/Cargo.toml new file mode 100644 index 00000000..26c8df77 --- /dev/null +++ b/v2/crates/ruview-evidence/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-evidence" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + + +[dependencies] +thiserror.workspace = true +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-evidence/src/lib.rs b/v2/crates/ruview-evidence/src/lib.rs new file mode 100644 index 00000000..73f30dc0 --- /dev/null +++ b/v2/crates/ruview-evidence/src/lib.rs @@ -0,0 +1,977 @@ +//! # `ruview-evidence` — the append-only accuracy ledger (ADR-301, ADR-297 §4) +//! +//! "MLflow for physical sensing." Where an experiment tracker overwrites +//! yesterday's number, this crate is an **append-only** record of how a model +//! actually performs, keyed per deployment context +//! `(room, device, subject-class, model-version)` and carrying, per record, +//! the ADR-301 metrics (moving/stationary recall, false-positive rate, drift, +//! predictive uncertainty, calibration age, sample count) plus exactly one +//! [`EvidenceLevel`] (L0–L5, mirroring ADR-282 semantics). +//! +//! ## Leaf, deterministic, honest +//! +//! - **Leaf**: this crate depends only on `serde`/`thiserror`. The +//! [`EvidenceLevel`] ladder mirrors ADR-282 (`frame::EvidenceLevel`) but is +//! defined locally so the ledger never pulls in the frame crate. +//! - **Deterministic**: no wall-clock and no randomness. Record time is +//! injected by the caller; the ledger assigns a monotonic append sequence. +//! - **Honest by construction**: +//! - A record's [`EvidenceLevel`] is fixed by its *provenance* at write time +//! ([`EvidenceRecord::synthetic`] is `L0` and cannot be raised — there is +//! no `set_level`). This is the ADR-282/288/290 "no upgrade" rule. +//! - Records are **append-only**: [`EvidenceLedger::append`] consumes a +//! record by value and nothing hands back a mutable reference. A correction +//! is a *new* record, never an in-place edit (ADR-301 §1). +//! - Aggregation **never pools across contexts** (ADR-301 §2/§Consequences): +//! an [`EvidenceSlice`] is minted by [`EvidenceLedger::query`] for exactly +//! one context and there is no API that averages two contexts into one +//! number. A summary's evidence level is the **floor** (minimum) of the +//! levels present in the slice — a slice can never report a level above the +//! weakest record it contains. +//! - An empty context returns [`SummaryEvidence::NoEvidence`], distinct from a +//! present-but-zero-accuracy summary — downstream (ADR-315) must treat +//! "no evidence" as "no capability", not as a `0.0` score. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// Maximum byte length accepted for any context identifier string. Bounds +/// allocation at the untrusted-input boundary (CLAUDE.md). +pub const MAX_ID_LEN: usize = 256; + +/// Default upper bound on records held by a single ledger. Bounds allocation; +/// [`EvidenceLedger::with_capacity`] can raise or lower it. +pub const DEFAULT_MAX_RECORDS: usize = 1_000_000; + +/// The ADR-282 evidence ladder, L0–L5, mirrored locally to keep this crate a +/// leaf (no dependency on the frame crate). Exactly one level travels with each +/// [`EvidenceRecord`]. Ordering is meaningful and load-bearing: the summary +/// floor rule takes `min` over these, so `L0 < L1 < … < L5`. +/// +/// Semantics mirror `frame::EvidenceLevel` (ADR-282 §4): L0 simulation-only, +/// rising to L5 production/witnessed evidence. See ADR-282 for the canonical +/// ladder; this enum is a faithful local copy, not an independent scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EvidenceLevel { + /// L0 — simulation / synthetic only, no signal evidence (ADR-282). + L0, + /// L1 — captured replay / heuristic evidence. + L1, + /// L2 — controlled single-surface signal evidence. + L2, + /// L3 — corroborated / held-out room-and-subject validation. + L3, + /// L4 — calibrated multi-site field evidence. + L4, + /// L5 — production, witnessed / certified (ADR-316). + L5, +} + +/// Accuracy tag for a record (CLAUDE.md honesty rule). The class is fixed by +/// the constructor used and cannot alias: synthetic input can never be minted +/// as `Measured`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProvenanceClass { + /// Produced by a simulator/generator — L0 by construction (ADR-276/301). + Synthetic, + /// Real inference but no ground-truth reference backs the accuracy. + Claimed, + /// Backed by an ADR-300 reference plus a reproducer handle. + Measured, +} + +/// The deployment context a record is keyed by: `(room, device, subject-class, +/// model-version)`. Identity is caller-supplied (ADR-303 space id, ADR-302 +/// signed device id); this crate treats the fields as opaque bounded handles +/// and never invents them. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EvidenceContext { + /// Space / room id (ADR-303). + pub room: String, + /// Signed device id (ADR-302). + pub device: String, + /// Subject class where consented/available; empty means "no subject" + /// (ADR-301 §1 — subject id only where consented). + pub subject_class: String, + /// Model version that produced the inferences (ADR-136). + pub model_version: String, +} + +impl EvidenceContext { + /// Construct a context, validating every field at the boundary. `room`, + /// `device`, and `model_version` must be non-empty; every field is bounded + /// to [`MAX_ID_LEN`] bytes. `subject_class` may be empty (no consented + /// subject) but is still length-bounded. + /// + /// # Errors + /// Returns [`EvidenceError::EmptyField`] for a missing required field and + /// [`EvidenceError::IdTooLong`] for any over-length field. + pub fn new( + room: impl Into, + device: impl Into, + subject_class: impl Into, + model_version: impl Into, + ) -> Result { + let room = room.into(); + let device = device.into(); + let subject_class = subject_class.into(); + let model_version = model_version.into(); + + check_bound("room", &room)?; + check_bound("device", &device)?; + check_bound("subject_class", &subject_class)?; + check_bound("model_version", &model_version)?; + check_nonempty("room", &room)?; + check_nonempty("device", &device)?; + check_nonempty("model_version", &model_version)?; + + Ok(Self { + room, + device, + subject_class, + model_version, + }) + } +} + +fn check_bound(field: &'static str, value: &str) -> Result<(), EvidenceError> { + if value.len() > MAX_ID_LEN { + return Err(EvidenceError::IdTooLong { + field, + len: value.len(), + max: MAX_ID_LEN, + }); + } + Ok(()) +} + +fn check_nonempty(field: &'static str, value: &str) -> Result<(), EvidenceError> { + if value.is_empty() { + return Err(EvidenceError::EmptyField { field }); + } + Ok(()) +} + +/// The per-inference-window accuracy metrics accumulated into a record +/// (ADR-301 §1). Rates are fractions in `[0, 1]`; `drift` and `uncertainty` +/// are non-negative finite magnitudes; `sample_count` is the number of +/// inferences the record summarizes and must be at least one. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct AccuracyMetrics { + /// Recall on moving subjects, `[0, 1]`. + pub moving_recall: f64, + /// Recall on stationary subjects, `[0, 1]`. + pub stationary_recall: f64, + /// False-positive rate, `[0, 1]`. + pub false_positive_rate: f64, + /// Drift magnitude — fingerprint distance from the calibration baseline + /// (ADR-298); non-negative. + pub drift: f64, + /// Predictive uncertainty; non-negative. + pub uncertainty: f64, + /// Age of the calibration certificate in effect, seconds (ADR-298). + pub calibration_age_secs: u64, + /// Number of inferences this record summarizes; at least one. + pub sample_count: u64, +} + +impl AccuracyMetrics { + /// Validate the metrics at the boundary. Rates must be finite and within + /// `[0, 1]`; `drift`/`uncertainty` must be finite and non-negative; + /// `sample_count` must be `>= 1` (a record represents at least one + /// inference, which also guarantees non-zero aggregation weight). + /// + /// # Errors + /// [`EvidenceError::RateOutOfRange`], [`EvidenceError::NegativeMagnitude`], + /// or [`EvidenceError::ZeroSamples`]. + pub fn validate(&self) -> Result<(), EvidenceError> { + check_rate("moving_recall", self.moving_recall)?; + check_rate("stationary_recall", self.stationary_recall)?; + check_rate("false_positive_rate", self.false_positive_rate)?; + check_magnitude("drift", self.drift)?; + check_magnitude("uncertainty", self.uncertainty)?; + if self.sample_count == 0 { + return Err(EvidenceError::ZeroSamples); + } + Ok(()) + } +} + +fn check_rate(field: &'static str, v: f64) -> Result<(), EvidenceError> { + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(EvidenceError::RateOutOfRange { field, value: v }); + } + Ok(()) +} + +fn check_magnitude(field: &'static str, v: f64) -> Result<(), EvidenceError> { + if !v.is_finite() || v < 0.0 { + return Err(EvidenceError::NegativeMagnitude { field, value: v }); + } + Ok(()) +} + +/// One immutable, append-only accuracy record (ADR-301 §1). All fields are +/// private: there is no setter and no `&mut` accessor, so a level can never be +/// upgraded and a record can never be edited in place — a correction is a new +/// record. Construct via [`EvidenceRecord::synthetic`], +/// [`EvidenceRecord::claimed`], or [`EvidenceRecord::measured`]; the sequence +/// number is assigned by the ledger on [`EvidenceLedger::append`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EvidenceRecord { + context: EvidenceContext, + metrics: AccuracyMetrics, + level: EvidenceLevel, + class: ProvenanceClass, + /// Reproducer handle for `Measured` records (ADR-300); empty otherwise. + reproducer: String, + /// Caller-injected record time, nanoseconds. Never read from a clock here. + timestamp_ns: u64, + /// Ledger-assigned monotonic append sequence; `None` until appended. + seq: Option, +} + +impl EvidenceRecord { + /// Mint a **synthetic** record. Class is [`ProvenanceClass::Synthetic`] and + /// the evidence level is forced to [`EvidenceLevel::L0`] — synthetic input + /// is L0 by construction (ADR-301 §3) and there is no way to raise it. + /// + /// # Errors + /// Propagates [`AccuracyMetrics::validate`] failures. + pub fn synthetic( + context: EvidenceContext, + metrics: AccuracyMetrics, + timestamp_ns: u64, + ) -> Result { + metrics.validate()?; + Ok(Self { + context, + metrics, + level: EvidenceLevel::L0, + class: ProvenanceClass::Synthetic, + reproducer: String::new(), + timestamp_ns, + seq: None, + }) + } + + /// Mint a **claimed** record: a real inference with no ADR-300 reference + /// backing its accuracy. The level is set by the caller's provenance at + /// write time and is never MEASURED. A claimed record may not be minted at + /// `L0`, which is reserved for synthetic input. + /// + /// # Errors + /// Propagates metric validation; [`EvidenceError::SyntheticOnlyL0`] if + /// `level` is `L0`. + pub fn claimed( + context: EvidenceContext, + metrics: AccuracyMetrics, + level: EvidenceLevel, + timestamp_ns: u64, + ) -> Result { + metrics.validate()?; + if level == EvidenceLevel::L0 { + return Err(EvidenceError::SyntheticOnlyL0); + } + Ok(Self { + context, + metrics, + level, + class: ProvenanceClass::Claimed, + reproducer: String::new(), + timestamp_ns, + seq: None, + }) + } + + /// Mint a **measured** record: accuracy backed by an ADR-300 reference and + /// a non-empty reproducer handle. The level is set by provenance and must + /// not be `L0`. + /// + /// # Errors + /// Propagates metric validation; [`EvidenceError::MissingReproducer`] if + /// the reproducer handle is empty or over-length; + /// [`EvidenceError::SyntheticOnlyL0`] if `level` is `L0`. + pub fn measured( + context: EvidenceContext, + metrics: AccuracyMetrics, + level: EvidenceLevel, + reproducer: impl Into, + timestamp_ns: u64, + ) -> Result { + metrics.validate()?; + if level == EvidenceLevel::L0 { + return Err(EvidenceError::SyntheticOnlyL0); + } + let reproducer = reproducer.into(); + check_bound("reproducer", &reproducer)?; + if reproducer.is_empty() { + return Err(EvidenceError::MissingReproducer); + } + Ok(Self { + context, + metrics, + level, + class: ProvenanceClass::Measured, + reproducer, + timestamp_ns, + seq: None, + }) + } + + /// The context this record is keyed by. + #[must_use] + pub fn context(&self) -> &EvidenceContext { + &self.context + } + + /// The record's metrics. + #[must_use] + pub fn metrics(&self) -> &AccuracyMetrics { + &self.metrics + } + + /// The record's evidence level, fixed at write time. + #[must_use] + pub fn level(&self) -> EvidenceLevel { + self.level + } + + /// The record's provenance class. + #[must_use] + pub fn class(&self) -> ProvenanceClass { + self.class + } + + /// The reproducer handle (empty unless [`ProvenanceClass::Measured`]). + #[must_use] + pub fn reproducer(&self) -> &str { + &self.reproducer + } + + /// Caller-injected record time in nanoseconds. + #[must_use] + pub fn timestamp_ns(&self) -> u64 { + self.timestamp_ns + } + + /// Ledger-assigned append sequence, or `None` before the record is + /// appended. + #[must_use] + pub fn seq(&self) -> Option { + self.seq + } +} + +/// The append-only evidence ledger (ADR-301). The record vector is private and +/// exposed only through read-only queries; nothing returns a mutable reference +/// to a stored record, so the append-only and no-upgrade invariants hold at the +/// type level. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct EvidenceLedger { + records: Vec, + next_seq: u64, + max_records: usize, +} + +impl EvidenceLedger { + /// A new empty ledger bounded to [`DEFAULT_MAX_RECORDS`] records. + #[must_use] + pub fn new() -> Self { + Self::with_capacity(DEFAULT_MAX_RECORDS) + } + + /// A new empty ledger bounded to `max_records`. + #[must_use] + pub fn with_capacity(max_records: usize) -> Self { + Self { + records: Vec::new(), + next_seq: 0, + max_records, + } + } + + /// Append a record. The ledger stamps it with the next monotonic sequence + /// and stores it; the record is consumed by value, so the caller cannot + /// retain a handle to mutate the stored copy. Returns the assigned + /// sequence. + /// + /// # Errors + /// [`EvidenceError::LedgerFull`] once the bounded capacity is reached, so + /// a malformed or runaway producer cannot exhaust memory. + pub fn append(&mut self, mut record: EvidenceRecord) -> Result { + if self.records.len() >= self.max_records { + return Err(EvidenceError::LedgerFull { + max: self.max_records, + }); + } + let seq = self.next_seq; + record.seq = Some(seq); + self.next_seq += 1; + self.records.push(record); + Ok(seq) + } + + /// Total number of records in the ledger. + #[must_use] + pub fn len(&self) -> usize { + self.records.len() + } + + /// Whether the ledger holds no records. + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + /// Every record, in append order (read-only). + #[must_use] + pub fn records(&self) -> &[EvidenceRecord] { + &self.records + } + + /// Query the records for exactly one context, in append order. The returned + /// [`EvidenceSlice`] carries only records whose context equals `context`, + /// so aggregation over it can never mix two contexts (ADR-301 §2 — no + /// pooling). + #[must_use] + pub fn query<'a>(&'a self, context: &EvidenceContext) -> EvidenceSlice<'a> { + let records: Vec<&'a EvidenceRecord> = self + .records + .iter() + .filter(|r| &r.context == context) + .collect(); + EvidenceSlice { + context: context.clone(), + records, + } + } + + /// The distinct contexts present in the ledger, in first-append order. + #[must_use] + pub fn contexts(&self) -> Vec { + let mut out: Vec = Vec::new(); + for r in &self.records { + if !out.contains(&r.context) { + out.push(r.context.clone()); + } + } + out + } + + /// Summarize **each** context independently and return one summary per + /// context — never a single pooled number across contexts (ADR-301 + /// §Consequences: "never paper over a thin context with a global average"). + #[must_use] + pub fn summarize(&self) -> Vec { + self.contexts() + .into_iter() + .map(|ctx| self.query(&ctx).summarize()) + .collect() + } +} + +/// A read-only view of the records for exactly one context. It can only be +/// minted by [`EvidenceLedger::query`], so a slice is always single-context — +/// there is no constructor that merges two contexts, which is what makes +/// pooling impossible through the API. +#[derive(Clone, Debug)] +pub struct EvidenceSlice<'a> { + context: EvidenceContext, + records: Vec<&'a EvidenceRecord>, +} + +impl<'a> EvidenceSlice<'a> { + /// The single context this slice covers. + #[must_use] + pub fn context(&self) -> &EvidenceContext { + &self.context + } + + /// The records in the slice, in append order (read-only). + #[must_use] + pub fn records(&self) -> &[&'a EvidenceRecord] { + &self.records + } + + /// Number of records in the slice. + #[must_use] + pub fn len(&self) -> usize { + self.records.len() + } + + /// Whether the slice has no records (the context has no evidence). + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + /// Aggregate the slice into a per-context summary. This is a **pure** + /// function of the records (deterministic; no clock, no randomness): + /// + /// - An empty slice yields [`SummaryEvidence::NoEvidence`] — distinct from + /// a zero-accuracy summary (ADR-301 §3). + /// - The summary's evidence level is the **floor** — the minimum level over + /// the records — so a slice can never report a level above its weakest + /// record (the "no upgrade" honesty rule). Synthetic (L0) records pin the + /// floor to L0. + /// - Rates and uncertainty are sample-count-weighted means; `drift` and + /// `calibration_age` report the latest (by append sequence) value with + /// the running maximum; `sample_count` is the sum. All within this one + /// context — nothing is pooled across contexts. + #[must_use] + pub fn summarize(&self) -> ContextSummary { + if self.records.is_empty() { + return ContextSummary { + context: self.context.clone(), + evidence: SummaryEvidence::NoEvidence, + }; + } + + // Floor over evidence levels — never an upgrade. Safe: non-empty. + let level = self + .records + .iter() + .map(|r| r.level) + .min() + .expect("slice is non-empty"); + + // The class is Measured only if *every* record is Measured; any weaker + // record downgrades the aggregate class (honesty, no upgrade). + let aggregate_class = self.aggregate_class(); + + let mut total_samples: u128 = 0; + let mut w_moving: f64 = 0.0; + let mut w_stationary: f64 = 0.0; + let mut w_fpr: f64 = 0.0; + let mut w_uncertainty: f64 = 0.0; + let mut max_drift: f64 = 0.0; + let mut max_calibration_age_secs: u64 = 0; + + // Latest by append sequence (deterministic, no clock). Records without + // a seq (never appended) sort before any appended record. + let latest = self + .records + .iter() + .max_by_key(|r| r.seq.unwrap_or(0)) + .expect("slice is non-empty"); + + for r in &self.records { + let m = &r.metrics; + let w = m.sample_count as f64; + total_samples += u128::from(m.sample_count); + w_moving += m.moving_recall * w; + w_stationary += m.stationary_recall * w; + w_fpr += m.false_positive_rate * w; + w_uncertainty += m.uncertainty * w; + if m.drift > max_drift { + max_drift = m.drift; + } + if m.calibration_age_secs > max_calibration_age_secs { + max_calibration_age_secs = m.calibration_age_secs; + } + } + + // Every record has sample_count >= 1, so the divisor is never zero. + let denom = total_samples as f64; + let agg = AggregateMetrics { + record_count: self.records.len(), + sample_count: total_samples, + moving_recall: w_moving / denom, + stationary_recall: w_stationary / denom, + false_positive_rate: w_fpr / denom, + uncertainty: w_uncertainty / denom, + latest_drift: latest.metrics.drift, + max_drift, + latest_calibration_age_secs: latest.metrics.calibration_age_secs, + max_calibration_age_secs, + }; + + ContextSummary { + context: self.context.clone(), + evidence: SummaryEvidence::Aggregated { + level, + class: aggregate_class, + metrics: agg, + }, + } + } + + fn aggregate_class(&self) -> ProvenanceClass { + let mut any_synthetic = false; + let mut all_measured = true; + for r in &self.records { + match r.class { + ProvenanceClass::Synthetic => any_synthetic = true, + ProvenanceClass::Claimed => all_measured = false, + ProvenanceClass::Measured => {} + } + } + if any_synthetic { + ProvenanceClass::Synthetic + } else if all_measured { + ProvenanceClass::Measured + } else { + ProvenanceClass::Claimed + } + } +} + +/// A per-context summary. Always carries the context it belongs to, so a +/// summary can never be mistaken for a global rollup. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContextSummary { + /// The context this summary covers. + pub context: EvidenceContext, + /// Either "no evidence" or the aggregated metrics for this one context. + pub evidence: SummaryEvidence, +} + +impl ContextSummary { + /// Whether this context has any evidence at all. + #[must_use] + pub fn has_evidence(&self) -> bool { + matches!(self.evidence, SummaryEvidence::Aggregated { .. }) + } +} + +/// The evidence outcome for a context: explicitly absent, or aggregated. +/// +/// [`SummaryEvidence::NoEvidence`] is deliberately **not** a zero-accuracy +/// summary: an empty context has *no capability*, which downstream (ADR-315) +/// must not read as a `0.0` score. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum SummaryEvidence { + /// The context has no records — no evidence, not zero accuracy. + NoEvidence, + /// Aggregated metrics for the one context. + Aggregated { + /// Floor evidence level (min over the slice) — never upgraded. + level: EvidenceLevel, + /// Aggregate provenance class (Measured only if all records are). + class: ProvenanceClass, + /// The aggregated metrics for this context. + metrics: AggregateMetrics, + }, +} + +/// Aggregated metrics for a single context. Every field is derived purely from +/// that context's records; nothing here is pooled across contexts. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct AggregateMetrics { + /// Number of records aggregated. + pub record_count: usize, + /// Sum of `sample_count` across records. + pub sample_count: u128, + /// Sample-weighted mean moving recall. + pub moving_recall: f64, + /// Sample-weighted mean stationary recall. + pub stationary_recall: f64, + /// Sample-weighted mean false-positive rate. + pub false_positive_rate: f64, + /// Sample-weighted mean predictive uncertainty. + pub uncertainty: f64, + /// Drift of the latest record (by append sequence) — trajectory endpoint. + pub latest_drift: f64, + /// Maximum drift observed in the context. + pub max_drift: f64, + /// Calibration age of the latest record, seconds. + pub latest_calibration_age_secs: u64, + /// Maximum calibration age observed, seconds. + pub max_calibration_age_secs: u64, +} + +/// Errors raised at the ledger's input boundaries. No variant panics; malformed +/// input is always a returned error (CLAUDE.md). +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum EvidenceError { + /// A required context field was empty. + #[error("context field `{field}` must not be empty")] + EmptyField { + /// The offending field name. + field: &'static str, + }, + /// A context/reproducer identifier exceeded [`MAX_ID_LEN`]. + #[error("identifier `{field}` is {len} bytes, exceeds max {max}")] + IdTooLong { + /// The offending field name. + field: &'static str, + /// Actual byte length. + len: usize, + /// Allowed maximum. + max: usize, + }, + /// A rate metric was outside `[0, 1]` or non-finite. + #[error("rate `{field}` = {value} is out of range [0, 1] or non-finite")] + RateOutOfRange { + /// The offending field name. + field: &'static str, + /// The rejected value. + value: f64, + }, + /// A magnitude metric was negative or non-finite. + #[error("magnitude `{field}` = {value} must be finite and non-negative")] + NegativeMagnitude { + /// The offending field name. + field: &'static str, + /// The rejected value. + value: f64, + }, + /// A record claimed zero samples. + #[error("sample_count must be at least 1")] + ZeroSamples, + /// A non-synthetic record was minted at L0, which is reserved for + /// synthetic input. + #[error("L0 is reserved for synthetic records")] + SyntheticOnlyL0, + /// A measured record was minted without a reproducer handle. + #[error("a measured record requires a non-empty reproducer handle")] + MissingReproducer, + /// The bounded ledger is full. + #[error("ledger is full ({max} records)")] + LedgerFull { + /// The capacity that was reached. + max: usize, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(room: &str, subject: &str) -> EvidenceContext { + EvidenceContext::new(room, "dev-esp32-A", subject, "model-v1").expect("valid context") + } + + fn metrics(sample_count: u64) -> AccuracyMetrics { + AccuracyMetrics { + moving_recall: 0.8, + stationary_recall: 0.6, + false_positive_rate: 0.05, + drift: 0.1, + uncertainty: 0.2, + calibration_age_secs: 3600, + sample_count, + } + } + + #[test] + fn append_assigns_monotonic_seq_and_query_filters_by_context() { + let mut ledger = EvidenceLedger::new(); + let kitchen = ctx("kitchen", "adult"); + let bedroom = ctx("bedroom", "adult"); + + let s0 = ledger + .append(EvidenceRecord::synthetic(kitchen.clone(), metrics(10), 1).unwrap()) + .unwrap(); + let s1 = ledger + .append(EvidenceRecord::synthetic(bedroom.clone(), metrics(20), 2).unwrap()) + .unwrap(); + let s2 = ledger + .append(EvidenceRecord::synthetic(kitchen.clone(), metrics(30), 3).unwrap()) + .unwrap(); + + assert_eq!((s0, s1, s2), (0, 1, 2)); + assert_eq!(ledger.len(), 3); + + let k = ledger.query(&kitchen); + assert_eq!(k.len(), 2); + assert!(k.records().iter().all(|r| r.context() == &kitchen)); + + let b = ledger.query(&bedroom); + assert_eq!(b.len(), 1); + assert_eq!(b.records()[0].metrics().sample_count, 20); + } + + #[test] + fn records_are_append_only_no_in_place_edit() { + // The only mutation is `append`, which consumes by value and stamps a + // seq. Corrections are new records; the original is unchanged. + let mut ledger = EvidenceLedger::new(); + let c = ctx("lab", "adult"); + + ledger + .append(EvidenceRecord::measured(c.clone(), metrics(100), EvidenceLevel::L3, "repro-1", 1).unwrap()) + .unwrap(); + // A "correction" is appended, not edited in place. + ledger + .append(EvidenceRecord::measured(c.clone(), metrics(50), EvidenceLevel::L3, "repro-2", 2).unwrap()) + .unwrap(); + + let slice = ledger.query(&c); + assert_eq!(slice.len(), 2); + // Original record still present and unmodified. + assert_eq!(slice.records()[0].metrics().sample_count, 100); + assert_eq!(slice.records()[0].reproducer(), "repro-1"); + assert_eq!(slice.records()[0].seq(), Some(0)); + // `records()` returns shared references — no path mutates a stored + // record. (If a `&mut` accessor existed this test would need to change; + // its absence is the invariant.) + } + + #[test] + fn no_pooling_across_contexts() { + // The API only ever summarizes one context at a time. `summarize()` + // returns one entry per context; there is no call that averages two + // contexts into a single number. + let mut ledger = EvidenceLedger::new(); + let kitchen = ctx("kitchen", "adult"); + let bedroom = ctx("bedroom", "adult"); + + // Kitchen: perfect. Bedroom: poor. A pooled average would hide the poor + // context; per-context summaries must not. + let good = AccuracyMetrics { moving_recall: 1.0, ..metrics(100) }; + let bad = AccuracyMetrics { moving_recall: 0.0, ..metrics(100) }; + ledger.append(EvidenceRecord::measured(kitchen.clone(), good, EvidenceLevel::L3, "r", 1).unwrap()).unwrap(); + ledger.append(EvidenceRecord::measured(bedroom.clone(), bad, EvidenceLevel::L3, "r", 2).unwrap()).unwrap(); + + let summaries = ledger.summarize(); + assert_eq!(summaries.len(), 2, "one summary per context, never pooled"); + + let k = ledger.query(&kitchen).summarize(); + let b = ledger.query(&bedroom).summarize(); + match (k.evidence, b.evidence) { + ( + SummaryEvidence::Aggregated { metrics: km, .. }, + SummaryEvidence::Aggregated { metrics: bm, .. }, + ) => { + assert_eq!(km.moving_recall, 1.0); + assert_eq!(bm.moving_recall, 0.0); + // No global average exists; if it did it would be 0.5 and hide + // the bad context. The API offers no such value. + } + _ => panic!("both contexts should have evidence"), + } + } + + #[test] + fn evidence_level_floor_is_the_minimum_never_an_upgrade() { + let mut ledger = EvidenceLedger::new(); + let c = ctx("lab", "adult"); + + // A strong measured record... + ledger.append(EvidenceRecord::measured(c.clone(), metrics(100), EvidenceLevel::L4, "repro", 1).unwrap()).unwrap(); + // ...alongside a synthetic (L0) record in the same context. + ledger.append(EvidenceRecord::synthetic(c.clone(), metrics(100), 2).unwrap()).unwrap(); + + let summary = ledger.query(&c).summarize(); + match summary.evidence { + SummaryEvidence::Aggregated { level, class, .. } => { + // Floor: the L0 synthetic record pins the level to L0 — the + // slice cannot report the higher L4. + assert_eq!(level, EvidenceLevel::L0); + // And the class downgrades to Synthetic (no upgrade). + assert_eq!(class, ProvenanceClass::Synthetic); + } + SummaryEvidence::NoEvidence => panic!("context has records"), + } + } + + #[test] + fn synthetic_is_forced_l0_and_cannot_be_upgraded() { + let c = ctx("sim", "adult"); + let rec = EvidenceRecord::synthetic(c, metrics(10), 1).unwrap(); + assert_eq!(rec.level(), EvidenceLevel::L0); + assert_eq!(rec.class(), ProvenanceClass::Synthetic); + // There is no setter to raise the level: the type has no `set_level`. + + // A non-synthetic record cannot occupy L0. + let c2 = ctx("sim", "adult"); + assert_eq!( + EvidenceRecord::claimed(c2, metrics(10), EvidenceLevel::L0, 1).unwrap_err(), + EvidenceError::SyntheticOnlyL0 + ); + } + + #[test] + fn empty_context_is_no_evidence_not_zero_accuracy() { + let ledger = EvidenceLedger::new(); + let never_seen = ctx("attic", "adult"); + + let slice = ledger.query(&never_seen); + assert!(slice.is_empty()); + + let summary = slice.summarize(); + assert!(!summary.has_evidence()); + assert_eq!(summary.evidence, SummaryEvidence::NoEvidence); + // Explicitly NOT a zero-accuracy Aggregated summary. + assert!(!matches!(summary.evidence, SummaryEvidence::Aggregated { .. })); + } + + #[test] + fn summarize_is_deterministic_and_serde_round_trips() { + let build = || { + let mut ledger = EvidenceLedger::new(); + let c = ctx("kitchen", "adult"); + ledger.append(EvidenceRecord::measured(c.clone(), metrics(100), EvidenceLevel::L3, "r1", 10).unwrap()).unwrap(); + ledger.append(EvidenceRecord::measured(c.clone(), metrics(300), EvidenceLevel::L4, "r2", 20).unwrap()).unwrap(); + ledger + }; + + let a = build().summarize(); + let b = build().summarize(); + assert_eq!(a, b, "aggregation is a pure function of the records"); + + // Sample-weighted mean check: same metrics, weights 100 and 300 → 0.8. + let c = ctx("kitchen", "adult"); + let s = build().query(&c).summarize(); + if let SummaryEvidence::Aggregated { level, metrics: m, .. } = &s.evidence { + assert_eq!(*level, EvidenceLevel::L3); // floor of L3 and L4 + assert_eq!(m.sample_count, 400); + assert!((m.moving_recall - 0.8).abs() < 1e-12); + assert_eq!(m.latest_calibration_age_secs, 3600); + } else { + panic!("expected aggregated evidence"); + } + + // Serde round-trip of a summary is stable. + let json = serde_json::to_string(&a).unwrap(); + let back: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(a, back); + } + + #[test] + fn boundary_validation_rejects_malformed_input_without_panicking() { + assert_eq!( + EvidenceContext::new("", "d", "s", "m").unwrap_err(), + EvidenceError::EmptyField { field: "room" } + ); + let long = "x".repeat(MAX_ID_LEN + 1); + assert!(matches!( + EvidenceContext::new(long, "d", "s", "m").unwrap_err(), + EvidenceError::IdTooLong { .. } + )); + + let bad_rate = AccuracyMetrics { moving_recall: 1.5, ..metrics(1) }; + assert!(matches!( + bad_rate.validate().unwrap_err(), + EvidenceError::RateOutOfRange { .. } + )); + let nan = AccuracyMetrics { uncertainty: f64::NAN, ..metrics(1) }; + assert!(matches!( + nan.validate().unwrap_err(), + EvidenceError::NegativeMagnitude { .. } + )); + let zero = AccuracyMetrics { sample_count: 0, ..metrics(1) }; + assert_eq!(zero.validate().unwrap_err(), EvidenceError::ZeroSamples); + + let c = ctx("lab", "adult"); + assert_eq!( + EvidenceRecord::measured(c, metrics(1), EvidenceLevel::L3, "", 1).unwrap_err(), + EvidenceError::MissingReproducer + ); + } + + #[test] + fn ledger_capacity_is_bounded() { + let mut ledger = EvidenceLedger::with_capacity(1); + let c = ctx("lab", "adult"); + ledger.append(EvidenceRecord::synthetic(c.clone(), metrics(1), 1).unwrap()).unwrap(); + assert_eq!( + ledger.append(EvidenceRecord::synthetic(c, metrics(1), 2).unwrap()).unwrap_err(), + EvidenceError::LedgerFull { max: 1 } + ); + } +} diff --git a/v2/crates/ruview-ontology/Cargo.toml b/v2/crates/ruview-ontology/Cargo.toml new file mode 100644 index 00000000..1446f951 --- /dev/null +++ b/v2/crates/ruview-ontology/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-ontology" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + + +[dependencies] +thiserror.workspace = true +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-ontology/src/entity.rs b/v2/crates/ruview-ontology/src/entity.rs new file mode 100644 index 00000000..88ff20f0 --- /dev/null +++ b/v2/crates/ruview-ontology/src/entity.rs @@ -0,0 +1,206 @@ +//! Canonical entity types: the `Site ▸ Building ▸ Floor ▸ Space ▸ Zone` +//! containment spine and the leaf entities located within it (ADR-303 §1). +//! +//! Containment is expressed by a typed `parent` field on each spine node and a +//! [`Container`] reference on each leaf. This is the pure-hierarchy analogue of +//! the `worldgraph` `PartOf`/`LocatedIn` edges: a `Zone` is part of exactly one +//! `Space`, a `Space` on exactly one `Floor`, and so on. The [`WorldGraph`] +//! registry enforces those single-parent invariants. +//! +//! [`WorldGraph`]: crate::WorldGraph + +use serde::{Deserialize, Serialize}; + +use crate::id::{ + BuildingId, EventId, FloorId, ObjectId, ObservationId, PersonId, SensorId, SiteId, SpaceId, + TrackId, ZoneId, +}; +use crate::provenance::{EvidenceLevel, SemanticProvenance}; + +/// The containment root. A site has no parent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Site { + /// Stable id. + pub id: SiteId, + /// Human-readable name. + pub name: String, +} + +/// A building within a [`Site`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Building { + /// Stable id. + pub id: BuildingId, + /// Containing site. + pub parent: SiteId, + /// Human-readable name. + pub name: String, +} + +/// A floor within a [`Building`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Floor { + /// Stable id. + pub id: FloorId, + /// Containing building. + pub parent: BuildingId, + /// Storey index (ground = 0, basements negative). + pub level: i16, + /// Human-readable name. + pub name: String, +} + +/// A bounded interior space within a [`Floor`] — the ADR-294 "room" and the +/// HomeCore `area_id` join point (ADR-127). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Space { + /// Stable id. + pub id: SpaceId, + /// Containing floor. + pub parent: FloorId, + /// HomeCore registry `area_id` — the external entity-linkage join key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub area_id: Option, + /// Human-readable name. + pub name: String, +} + +/// A sub-region of a [`Space`] targeted for sensing. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Zone { + /// Stable id. + pub id: ZoneId, + /// Containing space. + pub parent: SpaceId, + /// Human-readable name. + pub name: String, +} + +/// Where a leaf entity is located: directly in a [`Space`] or in a [`Zone`]. +/// A zone resolves upward to its containing space via the registry. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "container", rename_all = "snake_case")] +pub enum Container { + /// Located directly in a space. + Space { + /// The space id. + id: SpaceId, + }, + /// Located in a zone (which is itself part of a space). + Zone { + /// The zone id. + id: ZoneId, + }, +} + +/// A physical sensing device placement — the entity ADR-302 authenticates. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sensor { + /// Stable id. + pub id: SensorId, + /// ADR-302 authenticated device identity (HomeCore `device_id`). + pub device_id: String, + /// Where the sensor is placed. + pub located_in: Container, + /// Exactly one evidence level travels with this fact. + pub evidence_level: EvidenceLevel, + /// Mandatory provenance. + pub provenance: SemanticProvenance, +} + +/// A tracked or known person. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Person { + /// Stable id. + pub id: PersonId, + /// Where the person currently is. + pub located_in: Container, + /// Exactly one evidence level travels with this fact. + pub evidence_level: EvidenceLevel, + /// Mandatory provenance. + pub provenance: SemanticProvenance, +} + +/// A persistent physical object / static anchor. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Object { + /// Stable id. + pub id: ObjectId, + /// Where the object is. + pub located_in: Container, + /// Classification tag (e.g. `"furniture"`, `"reflector"`). + pub class: String, + /// Exactly one evidence level travels with this fact. + pub evidence_level: EvidenceLevel, + /// Mandatory provenance. + pub provenance: SemanticProvenance, +} + +/// A calibrated observation produced from an authenticated frame (ADR-298). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Observation { + /// Stable id. + pub id: ObservationId, + /// The sensor that produced it. + pub sensor: SensorId, + /// Where it was observed. + pub located_in: Container, + /// Producer-supplied capture timestamp (Unix ms). Injected, never sampled + /// from a clock inside this crate. + pub at_unix_ms: i64, + /// Exactly one evidence level travels with this fact. + pub evidence_level: EvidenceLevel, + /// Mandatory provenance. + pub provenance: SemanticProvenance, +} + +/// A persistent track (ADR-304), optionally resolved to a [`Person`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Track { + /// Stable id. + pub id: TrackId, + /// Resolved person identity, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub person: Option, + /// Where the track currently is. + pub located_in: Container, + /// Exactly one evidence level travels with this fact. + pub evidence_level: EvidenceLevel, + /// Mandatory provenance. + pub provenance: SemanticProvenance, +} + +/// A discrete governed event (ADR-315 certified, ADR-316 witnessed). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Event { + /// Stable id. + pub id: EventId, + /// Event type tag (e.g. `"fall"`, `"entry"`). + pub event_type: String, + /// Producer-supplied event timestamp (Unix ms). Injected. + pub at_unix_ms: i64, + /// Where the event occurred. + pub located_in: Container, + /// Exactly one evidence level travels with this fact. + pub evidence_level: EvidenceLevel, + /// Mandatory provenance. + pub provenance: SemanticProvenance, +} + +/// Shared accessor: the [`Container`] a leaf entity is located in. +pub trait Located { + /// Borrow this entity's container. + fn container(&self) -> &Container; +} + +macro_rules! impl_located { + ($($ty:ty),+ $(,)?) => { + $(impl Located for $ty { + fn container(&self) -> &Container { + &self.located_in + } + })+ + }; +} + +impl_located!(Sensor, Person, Object, Observation, Track, Event); diff --git a/v2/crates/ruview-ontology/src/graph.rs b/v2/crates/ruview-ontology/src/graph.rs new file mode 100644 index 00000000..51f62eeb --- /dev/null +++ b/v2/crates/ruview-ontology/src/graph.rs @@ -0,0 +1,381 @@ +//! [`WorldGraph`] — the canonical registry that holds the containment hierarchy +//! and resolves an entity's containing [`Space`]/[`Zone`] (ADR-303 §1). +//! +//! The registry is the sole insertion boundary: every `add_*` method rejects a +//! duplicate id and a dangling parent/container, so the single-parent +//! containment invariants of ADR-303 hold by construction. The graph is a pure +//! data structure — no I/O, no async, deterministic `BTreeMap` ordering for a +//! stable canonical serialization. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::entity::{ + Building, Container, Event, Floor, Object, Observation, Person, Sensor, Site, Space, Track, Zone, +}; +use crate::id::{ + BuildingId, EventId, FloorId, IdError, ObjectId, ObservationId, PersonId, SensorId, SiteId, + SpaceId, TrackId, ZoneId, +}; + +/// Errors returned when mutating the [`WorldGraph`]. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum OntologyError { + /// A raw id failed boundary validation. + #[error("invalid identifier: {0}")] + Id(#[from] IdError), + /// An entity with this id already exists. + #[error("duplicate {kind} id: {id}")] + Duplicate { + /// Entity kind tag. + kind: &'static str, + /// The conflicting id. + id: String, + }, + /// The referenced parent entity does not exist in the registry. + #[error("missing {parent_kind} parent '{parent_id}' for {child_kind} '{child_id}'")] + MissingParent { + /// Kind of the missing parent. + parent_kind: &'static str, + /// Id of the missing parent. + parent_id: String, + /// Kind of the child being inserted. + child_kind: &'static str, + /// Id of the child being inserted. + child_id: String, + }, + /// The [`Container`] a leaf references does not exist. + #[error("missing {container_kind} container '{container_id}' for {child_kind} '{child_id}'")] + MissingContainer { + /// `space` or `zone`. + container_kind: &'static str, + /// The missing container id. + container_id: String, + /// Kind of the leaf being inserted. + child_kind: &'static str, + /// Id of the leaf being inserted. + child_id: String, + }, +} + +/// The canonical world registry: the containment spine plus all leaf entities. +/// Serializes to one versioned canonical JSON document. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorldGraph { + /// Canonical schema version for the serialized form. + pub schema_version: u32, + /// Sites, keyed by id. + pub sites: BTreeMap, + /// Buildings, keyed by id. + pub buildings: BTreeMap, + /// Floors, keyed by id. + pub floors: BTreeMap, + /// Spaces, keyed by id. + pub spaces: BTreeMap, + /// Zones, keyed by id. + pub zones: BTreeMap, + /// Sensors, keyed by id. + pub sensors: BTreeMap, + /// Persons, keyed by id. + pub persons: BTreeMap, + /// Objects, keyed by id. + pub objects: BTreeMap, + /// Observations, keyed by id. + pub observations: BTreeMap, + /// Tracks, keyed by id. + pub tracks: BTreeMap, + /// Events, keyed by id. + pub events: BTreeMap, +} + +/// The canonical serialization version this build emits. +pub const SCHEMA_VERSION: u32 = 1; + +impl Default for WorldGraph { + fn default() -> Self { + Self { + schema_version: SCHEMA_VERSION, + sites: BTreeMap::new(), + buildings: BTreeMap::new(), + floors: BTreeMap::new(), + spaces: BTreeMap::new(), + zones: BTreeMap::new(), + sensors: BTreeMap::new(), + persons: BTreeMap::new(), + objects: BTreeMap::new(), + observations: BTreeMap::new(), + tracks: BTreeMap::new(), + events: BTreeMap::new(), + } + } +} + +impl WorldGraph { + /// A fresh, empty registry at the current [`SCHEMA_VERSION`]. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + // ---- containment spine -------------------------------------------------- + + /// Insert a site (spine root; no parent to validate). + pub fn add_site(&mut self, site: Site) -> Result<(), OntologyError> { + if self.sites.contains_key(&site.id) { + return Err(OntologyError::Duplicate { + kind: "site", + id: site.id.to_string(), + }); + } + self.sites.insert(site.id.clone(), site); + Ok(()) + } + + /// Insert a building; its parent site must already exist. + pub fn add_building(&mut self, building: Building) -> Result<(), OntologyError> { + if self.buildings.contains_key(&building.id) { + return Err(OntologyError::Duplicate { + kind: "building", + id: building.id.to_string(), + }); + } + if !self.sites.contains_key(&building.parent) { + return Err(OntologyError::MissingParent { + parent_kind: "site", + parent_id: building.parent.to_string(), + child_kind: "building", + child_id: building.id.to_string(), + }); + } + self.buildings.insert(building.id.clone(), building); + Ok(()) + } + + /// Insert a floor; its parent building must already exist. + pub fn add_floor(&mut self, floor: Floor) -> Result<(), OntologyError> { + if self.floors.contains_key(&floor.id) { + return Err(OntologyError::Duplicate { + kind: "floor", + id: floor.id.to_string(), + }); + } + if !self.buildings.contains_key(&floor.parent) { + return Err(OntologyError::MissingParent { + parent_kind: "building", + parent_id: floor.parent.to_string(), + child_kind: "floor", + child_id: floor.id.to_string(), + }); + } + self.floors.insert(floor.id.clone(), floor); + Ok(()) + } + + /// Insert a space; its parent floor must already exist. + pub fn add_space(&mut self, space: Space) -> Result<(), OntologyError> { + if self.spaces.contains_key(&space.id) { + return Err(OntologyError::Duplicate { + kind: "space", + id: space.id.to_string(), + }); + } + if !self.floors.contains_key(&space.parent) { + return Err(OntologyError::MissingParent { + parent_kind: "floor", + parent_id: space.parent.to_string(), + child_kind: "space", + child_id: space.id.to_string(), + }); + } + self.spaces.insert(space.id.clone(), space); + Ok(()) + } + + /// Insert a zone; its parent space must already exist. + pub fn add_zone(&mut self, zone: Zone) -> Result<(), OntologyError> { + if self.zones.contains_key(&zone.id) { + return Err(OntologyError::Duplicate { + kind: "zone", + id: zone.id.to_string(), + }); + } + if !self.spaces.contains_key(&zone.parent) { + return Err(OntologyError::MissingParent { + parent_kind: "space", + parent_id: zone.parent.to_string(), + child_kind: "zone", + child_id: zone.id.to_string(), + }); + } + self.zones.insert(zone.id.clone(), zone); + Ok(()) + } + + // ---- leaf entities ------------------------------------------------------ + + /// Validate that a [`Container`] resolves to an existing space or zone. + fn check_container( + &self, + container: &Container, + child_kind: &'static str, + child_id: String, + ) -> Result<(), OntologyError> { + match container { + Container::Space { id } => { + if self.spaces.contains_key(id) { + Ok(()) + } else { + Err(OntologyError::MissingContainer { + container_kind: "space", + container_id: id.to_string(), + child_kind, + child_id, + }) + } + } + Container::Zone { id } => { + if self.zones.contains_key(id) { + Ok(()) + } else { + Err(OntologyError::MissingContainer { + container_kind: "zone", + container_id: id.to_string(), + child_kind, + child_id, + }) + } + } + } + } + + /// Insert a sensor; its container must already exist. + pub fn add_sensor(&mut self, sensor: Sensor) -> Result<(), OntologyError> { + if self.sensors.contains_key(&sensor.id) { + return Err(OntologyError::Duplicate { + kind: "sensor", + id: sensor.id.to_string(), + }); + } + self.check_container(&sensor.located_in, "sensor", sensor.id.to_string())?; + self.sensors.insert(sensor.id.clone(), sensor); + Ok(()) + } + + /// Insert a person; its container must already exist. + pub fn add_person(&mut self, person: Person) -> Result<(), OntologyError> { + if self.persons.contains_key(&person.id) { + return Err(OntologyError::Duplicate { + kind: "person", + id: person.id.to_string(), + }); + } + self.check_container(&person.located_in, "person", person.id.to_string())?; + self.persons.insert(person.id.clone(), person); + Ok(()) + } + + /// Insert an object; its container must already exist. + pub fn add_object(&mut self, object: Object) -> Result<(), OntologyError> { + if self.objects.contains_key(&object.id) { + return Err(OntologyError::Duplicate { + kind: "object", + id: object.id.to_string(), + }); + } + self.check_container(&object.located_in, "object", object.id.to_string())?; + self.objects.insert(object.id.clone(), object); + Ok(()) + } + + /// Insert an observation; its sensor and container must already exist. + pub fn add_observation(&mut self, obs: Observation) -> Result<(), OntologyError> { + if self.observations.contains_key(&obs.id) { + return Err(OntologyError::Duplicate { + kind: "observation", + id: obs.id.to_string(), + }); + } + if !self.sensors.contains_key(&obs.sensor) { + return Err(OntologyError::MissingParent { + parent_kind: "sensor", + parent_id: obs.sensor.to_string(), + child_kind: "observation", + child_id: obs.id.to_string(), + }); + } + self.check_container(&obs.located_in, "observation", obs.id.to_string())?; + self.observations.insert(obs.id.clone(), obs); + Ok(()) + } + + /// Insert a track; its container (and resolved person, if any) must exist. + pub fn add_track(&mut self, track: Track) -> Result<(), OntologyError> { + if self.tracks.contains_key(&track.id) { + return Err(OntologyError::Duplicate { + kind: "track", + id: track.id.to_string(), + }); + } + if let Some(person) = &track.person { + if !self.persons.contains_key(person) { + return Err(OntologyError::MissingParent { + parent_kind: "person", + parent_id: person.to_string(), + child_kind: "track", + child_id: track.id.to_string(), + }); + } + } + self.check_container(&track.located_in, "track", track.id.to_string())?; + self.tracks.insert(track.id.clone(), track); + Ok(()) + } + + /// Insert an event; its container must already exist. + pub fn add_event(&mut self, event: Event) -> Result<(), OntologyError> { + if self.events.contains_key(&event.id) { + return Err(OntologyError::Duplicate { + kind: "event", + id: event.id.to_string(), + }); + } + self.check_container(&event.located_in, "event", event.id.to_string())?; + self.events.insert(event.id.clone(), event); + Ok(()) + } + + // ---- containment resolution --------------------------------------------- + + /// Resolve the [`Zone`] a container references, if it is a zone container. + /// A space container has no zone. + #[must_use] + pub fn zone_of(&self, container: &Container) -> Option<&Zone> { + match container { + Container::Zone { id } => self.zones.get(id), + Container::Space { .. } => None, + } + } + + /// Resolve the containing [`Space`] for any container, walking a zone up to + /// its parent space. Returns `None` if the container (or a zone's parent + /// space) is not registered. + #[must_use] + pub fn space_of(&self, container: &Container) -> Option<&Space> { + match container { + Container::Space { id } => self.spaces.get(id), + Container::Zone { id } => { + let zone = self.zones.get(id)?; + self.spaces.get(&zone.parent) + } + } + } + + /// Resolve the containing [`Floor`] for any container. + #[must_use] + pub fn floor_of(&self, container: &Container) -> Option<&Floor> { + let space = self.space_of(container)?; + self.floors.get(&space.parent) + } +} diff --git a/v2/crates/ruview-ontology/src/id.rs b/v2/crates/ruview-ontology/src/id.rs new file mode 100644 index 00000000..5bec0cd8 --- /dev/null +++ b/v2/crates/ruview-ontology/src/id.rs @@ -0,0 +1,161 @@ +//! Typed, deterministic identifier scheme (ADR-303 §1). +//! +//! Every ontology entity carries a stable, caller-provided string id wrapped in +//! a distinct newtype. Ids are *never* randomly generated here: the ontology is +//! a pure representation, so identity is supplied by the producing surface +//! (ADR-302 `DeviceId`, HomeCore `area_id`, tracker `track_id`, …) and only +//! validated at the crate boundary. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Maximum accepted id length, in bytes. Bounds allocation on untrusted input. +pub const MAX_ID_LEN: usize = 256; + +/// Reasons a raw id string is rejected at the boundary. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum IdError { + /// The id was empty after trimming was *not* applied (empty is invalid). + #[error("identifier must not be empty")] + Empty, + /// The id exceeded [`MAX_ID_LEN`] bytes. + #[error("identifier length {len} exceeds maximum {max}")] + TooLong { + /// Actual length in bytes. + len: usize, + /// The enforced maximum. + max: usize, + }, + /// The id contained an ASCII control character (newline, NUL, …). + #[error("identifier contains a control character at byte {pos}")] + ControlChar { + /// Byte offset of the offending control character. + pos: usize, + }, +} + +/// Validate a raw id string: non-empty, bounded length, no control characters. +pub(crate) fn validate_id(raw: &str) -> Result<(), IdError> { + if raw.is_empty() { + return Err(IdError::Empty); + } + if raw.len() > MAX_ID_LEN { + return Err(IdError::TooLong { + len: raw.len(), + max: MAX_ID_LEN, + }); + } + if let Some(pos) = raw.bytes().position(|b| b.is_ascii_control()) { + return Err(IdError::ControlChar { pos }); + } + Ok(()) +} + +macro_rules! typed_id { + ($(#[$meta:meta])* $name:ident, $kind:literal) => { + $(#[$meta])* + /// + /// A stable, caller-supplied identifier. Construct with [`Self::new`] to + /// validate untrusted input; serde round-trips it transparently as a + /// plain JSON string so it is usable as a canonical map key. + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + /// Construct a validated id, rejecting empty, over-long, or + /// control-character input at the boundary. + pub fn new(raw: impl Into) -> Result { + let s = raw.into(); + validate_id(&s)?; + Ok(Self(s)) + } + + /// Borrow the underlying id string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The stable type tag for this id kind (e.g. `"site"`). + #[must_use] + pub const fn kind() -> &'static str { + $kind + } + } + + impl core::fmt::Display for $name { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +typed_id!( + /// Identifier for a [`Site`](crate::Site) — the containment-spine root. + SiteId, "site" +); +typed_id!( + /// Identifier for a [`Building`](crate::Building). + BuildingId, "building" +); +typed_id!( + /// Identifier for a [`Floor`](crate::Floor). + FloorId, "floor" +); +typed_id!( + /// Identifier for a [`Space`](crate::Space) (ADR-294 room / HomeCore area). + SpaceId, "space" +); +typed_id!( + /// Identifier for a [`Zone`](crate::Zone) — a sub-region of a space. + ZoneId, "zone" +); +typed_id!( + /// Identifier for a [`Sensor`](crate::Sensor) (ADR-302 authenticated device). + SensorId, "sensor" +); +typed_id!( + /// Identifier for a [`Person`](crate::Person). + PersonId, "person" +); +typed_id!( + /// Identifier for an [`Object`](crate::Object). + ObjectId, "object" +); +typed_id!( + /// Identifier for an [`Observation`](crate::Observation). + ObservationId, "observation" +); +typed_id!( + /// Identifier for a [`Track`](crate::Track) (ADR-304 persistent track). + TrackId, "track" +); +typed_id!( + /// Identifier for an [`Event`](crate::Event) (ADR-315/ADR-316 governed output). + EventId, "event" +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty_and_overlong_and_control() { + assert_eq!(SiteId::new(""), Err(IdError::Empty)); + let long = "x".repeat(MAX_ID_LEN + 1); + assert!(matches!(SiteId::new(long), Err(IdError::TooLong { .. }))); + assert!(matches!( + SiteId::new("a\nb"), + Err(IdError::ControlChar { pos: 1 }) + )); + } + + #[test] + fn kind_tags_are_stable() { + assert_eq!(SiteId::kind(), "site"); + assert_eq!(ZoneId::kind(), "zone"); + assert_eq!(EventId::kind(), "event"); + } +} diff --git a/v2/crates/ruview-ontology/src/lib.rs b/v2/crates/ruview-ontology/src/lib.rs new file mode 100644 index 00000000..18f4b49f --- /dev/null +++ b/v2/crates/ruview-ontology/src/lib.rs @@ -0,0 +1,349 @@ +//! # `ruview-ontology` — the canonical spatial ontology (ADR-303, ADR-297 §6) +//! +//! One `Site ▸ Building ▸ Floor ▸ Space ▸ Zone` containment model, plus the +//! leaf entities `Sensor`, `Person`, `Object`, `Observation`, `Track`, and +//! `Event`, that **every** RuView surface reads from and writes to. The same +//! physical fact — "a person is in the kitchen" — is encoded *once* here and +//! every surface (MQTT/Home-Assistant, REST, WebSocket, RuField, Matter, agent +//! queries) is a *projection* of this model rather than an independent schema. +//! +//! This crate is a **pure data / relationship representation**: no I/O, no +//! async, no inference. It says nothing about *how* a `Track` or `Event` is +//! produced (that is owned by ADR-298/ADR-304/ADR-299) and makes no accuracy +//! claim. Identity is caller-supplied and deterministic — ids are never +//! randomly generated here. +//! +//! ## Model at a glance +//! +//! ```text +//! Site ▸ Building ▸ Floor ▸ Space ▸ Zone +//! └▸ { Sensor, Person, Object, +//! Observation, Track, Event } +//! ``` +//! +//! - The spine is enforced single-parent by the [`WorldGraph`] registry: a +//! `Zone` is part of exactly one `Space`, a `Space` on exactly one `Floor`, +//! and so on. Inserting a child whose parent is absent is rejected with +//! [`OntologyError::MissingParent`]. +//! - Each leaf carries a [`Container`] (a `Space` or `Zone`); the registry +//! resolves it upward with [`WorldGraph::space_of`] / [`WorldGraph::zone_of`]. +//! - Every leaf carries exactly one [`EvidenceLevel`] and a +//! [`SemanticProvenance`] record, so lineage and evidence level travel *with* +//! the fact across every projection and cannot be silently dropped. +//! +//! ## Example +//! +//! ``` +//! use ruview_ontology::*; +//! +//! let mut g = WorldGraph::new(); +//! g.add_site(Site { id: SiteId::new("home")?, name: "Home".into() })?; +//! g.add_building(Building { +//! id: BuildingId::new("b1")?, parent: SiteId::new("home")?, name: "House".into(), +//! })?; +//! g.add_floor(Floor { +//! id: FloorId::new("f1")?, parent: BuildingId::new("b1")?, level: 0, name: "Ground".into(), +//! })?; +//! g.add_space(Space { +//! id: SpaceId::new("kitchen")?, parent: FloorId::new("f1")?, +//! area_id: Some("area-42".into()), name: "Kitchen".into(), +//! })?; +//! +//! let here = Container::Space { id: SpaceId::new("kitchen")? }; +//! g.add_person(Person { +//! id: PersonId::new("p1")?, located_in: here.clone(), +//! evidence_level: EvidenceLevel::L2, +//! provenance: SemanticProvenance::declared("fusion@1"), +//! })?; +//! +//! assert_eq!(g.space_of(&here).unwrap().name, "Kitchen"); +//! # Ok::<(), Box>(()) +//! ``` +//! +//! ## Migration path from existing per-surface shapes (docs only) +//! +//! ADR-303 §3 requires a documented, tested bidirectional mapping from each +//! existing per-surface schema onto these canonical types. This crate does not +//! edit those surfaces; the mappings below are the contract each surface's +//! projection implements when it is cut over (one surface at a time). Until a +//! surface is cut over, its mapping layer is authoritative and round-tripped so +//! no fact is lost. +//! +//! | Legacy shape | Source | Canonical target | +//! |---|---|---| +//! | `NodeInference` | ADR-294 MQTT/HA mapper | `Sensor` + an `Observation` whose `sensor` is that node; node-vs-room separation is preserved because the observation is sensor-scoped, not space-scoped. | +//! | `RoomInference` | ADR-294 MQTT/HA mapper | The `Space`-level fused inference: a `Person`/`Track` (or `Event`) whose `located_in` is `Container::Space`. `RoomInference.area_id` ↦ [`Space::area_id`]. | +//! | `WorldNode::Room { area_id, name, floor }` | `worldgraph` | [`Space`] (`area_id`, `name` retained; `floor` index ↦ the parent [`Floor::level`]). | +//! | `WorldNode::Zone { parent_room }` | `worldgraph` | [`Zone`] (`parent_room` ↦ [`Zone::parent`]). | +//! | `WorldNode::Sensor { device_id, modality }` | `worldgraph` | [`Sensor`] (`device_id` retained; placement ↦ its [`Container`]). | +//! | `WorldNode::PersonTrack { track_id }` | `worldgraph` | [`Track`] (`track_id` ↦ [`TrackId`]) optionally resolved to a [`Person`]. | +//! | `WorldNode::Event { event_type, at_unix_ms, located_in }` | `worldgraph` | [`Event`] (fields map 1:1; `located_in` ↦ [`Container`]). | +//! | `SemanticProvenance` | `worldgraph` / RuField `SemanticProvenance` | [`SemanticProvenance`] (`evidence`, `model_version`, `calibration_version`, `privacy_decision` map 1:1). | +//! | MQTT topic `...//` payload | MQTT/HA surface | `area` ↦ [`Space::area_id`], `sensor` ↦ [`Sensor::device_id`]; the payload's belief becomes a `Person`/`Event` under the resolved `Container`. | +//! | REST `GET /spaces/{id}` / `/events` | REST surface | Direct projection of [`Space`] / [`Event`] JSON produced by this crate's canonical serializer. | +//! | RuField observation + `SemanticProvenance` | RuField | [`Observation`] carrying the same [`SemanticProvenance`] and [`EvidenceLevel`]. | +//! | Matter/HomeKit area model | Matter surface | Matter "area" ↦ [`Space`] via the HomeCore `area_id` (ADR-127) join key. | +//! +//! The HomeCore `area_id` linkage (ADR-127) remains the join key between a +//! canonical [`Space`] and external area registries. New surfaces (ROS 2, +//! OpenUSD, OPC UA) plug in as additional projections — the translation matrix +//! stays O(surfaces), not O(surfaces²). + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod entity; +mod graph; +mod id; +mod provenance; + +pub use entity::{ + Building, Container, Event, Floor, Located, Object, Observation, Person, Sensor, Site, Space, + Track, Zone, +}; +pub use graph::{OntologyError, WorldGraph, SCHEMA_VERSION}; +pub use id::{ + BuildingId, EventId, FloorId, IdError, ObjectId, ObservationId, PersonId, SensorId, SiteId, + SpaceId, TrackId, ZoneId, MAX_ID_LEN, +}; +pub use provenance::{EvidenceLevel, SemanticProvenance}; + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a small but complete two-level hierarchy for reuse in tests. + fn fixture() -> WorldGraph { + let mut g = WorldGraph::new(); + g.add_site(Site { + id: SiteId::new("home").unwrap(), + name: "Home".into(), + }) + .unwrap(); + g.add_building(Building { + id: BuildingId::new("b1").unwrap(), + parent: SiteId::new("home").unwrap(), + name: "House".into(), + }) + .unwrap(); + g.add_floor(Floor { + id: FloorId::new("f1").unwrap(), + parent: BuildingId::new("b1").unwrap(), + level: 0, + name: "Ground".into(), + }) + .unwrap(); + g.add_space(Space { + id: SpaceId::new("kitchen").unwrap(), + parent: FloorId::new("f1").unwrap(), + area_id: Some("area-42".into()), + name: "Kitchen".into(), + }) + .unwrap(); + g.add_zone(Zone { + id: ZoneId::new("stove-zone").unwrap(), + parent: SpaceId::new("kitchen").unwrap(), + name: "Stove".into(), + }) + .unwrap(); + g + } + + fn prov() -> SemanticProvenance { + SemanticProvenance::declared("fusion@1") + } + + #[test] + fn construction_builds_full_spine() { + let g = fixture(); + assert_eq!(g.sites.len(), 1); + assert_eq!(g.buildings.len(), 1); + assert_eq!(g.floors.len(), 1); + assert_eq!(g.spaces.len(), 1); + assert_eq!(g.zones.len(), 1); + assert_eq!(g.schema_version, SCHEMA_VERSION); + } + + #[test] + fn containment_resolution_walks_zone_to_space_to_floor() { + let mut g = fixture(); + let in_zone = Container::Zone { + id: ZoneId::new("stove-zone").unwrap(), + }; + // A sensor placed in the stove zone resolves up to the kitchen space + // and the ground floor. + g.add_sensor(Sensor { + id: SensorId::new("s1").unwrap(), + device_id: "dev-aa".into(), + located_in: in_zone.clone(), + evidence_level: EvidenceLevel::L3, + provenance: prov(), + }) + .unwrap(); + + let sensor = g.sensors.get(&SensorId::new("s1").unwrap()).unwrap(); + let container = sensor.located_in.clone(); + assert_eq!(g.zone_of(&container).unwrap().name, "Stove"); + assert_eq!(g.space_of(&container).unwrap().name, "Kitchen"); + assert_eq!(g.space_of(&container).unwrap().area_id.as_deref(), Some("area-42")); + assert_eq!(g.floor_of(&container).unwrap().level, 0); + + // A person placed directly in the space has no zone but the same space. + let in_space = Container::Space { + id: SpaceId::new("kitchen").unwrap(), + }; + assert!(g.zone_of(&in_space).is_none()); + assert_eq!(g.space_of(&in_space).unwrap().name, "Kitchen"); + } + + #[test] + fn json_round_trip_is_lossless() { + let mut g = fixture(); + g.add_person(Person { + id: PersonId::new("p1").unwrap(), + located_in: Container::Space { + id: SpaceId::new("kitchen").unwrap(), + }, + evidence_level: EvidenceLevel::L2, + provenance: prov(), + }) + .unwrap(); + g.add_sensor(Sensor { + id: SensorId::new("s1").unwrap(), + device_id: "dev-aa".into(), + located_in: Container::Zone { + id: ZoneId::new("stove-zone").unwrap(), + }, + evidence_level: EvidenceLevel::L4, + provenance: prov(), + }) + .unwrap(); + g.add_observation(Observation { + id: ObservationId::new("o1").unwrap(), + sensor: SensorId::new("s1").unwrap(), + located_in: Container::Zone { + id: ZoneId::new("stove-zone").unwrap(), + }, + at_unix_ms: 1_700_000_000_000, + evidence_level: EvidenceLevel::L3, + provenance: prov(), + }) + .unwrap(); + g.add_track(Track { + id: TrackId::new("t1").unwrap(), + person: Some(PersonId::new("p1").unwrap()), + located_in: Container::Space { + id: SpaceId::new("kitchen").unwrap(), + }, + evidence_level: EvidenceLevel::L3, + provenance: prov(), + }) + .unwrap(); + g.add_event(Event { + id: EventId::new("e1").unwrap(), + event_type: "entry".into(), + at_unix_ms: 1_700_000_000_500, + located_in: Container::Space { + id: SpaceId::new("kitchen").unwrap(), + }, + evidence_level: EvidenceLevel::L5, + provenance: prov(), + }) + .unwrap(); + g.add_object(Object { + id: ObjectId::new("obj1").unwrap(), + located_in: Container::Space { + id: SpaceId::new("kitchen").unwrap(), + }, + class: "reflector".into(), + evidence_level: EvidenceLevel::L1, + provenance: prov(), + }) + .unwrap(); + + let json = serde_json::to_string_pretty(&g).unwrap(); + let back: WorldGraph = serde_json::from_str(&json).unwrap(); + assert_eq!(g, back); + + // Canonical serialization uses stable string keys (typed ids) and a + // versioned envelope. + assert!(json.contains("\"schema_version\": 1")); + assert!(json.contains("\"container\": \"space\"")); + assert!(json.contains("\"evidence_level\": \"L5\"")); + } + + #[test] + fn invalid_parent_is_rejected() { + let mut g = WorldGraph::new(); + // Building without its site. + let err = g + .add_building(Building { + id: BuildingId::new("b1").unwrap(), + parent: SiteId::new("ghost").unwrap(), + name: "Orphan".into(), + }) + .unwrap_err(); + assert!(matches!( + err, + OntologyError::MissingParent { + parent_kind: "site", + .. + } + )); + + // Leaf into a non-existent container. + let mut g = fixture(); + let err = g + .add_person(Person { + id: PersonId::new("p1").unwrap(), + located_in: Container::Zone { + id: ZoneId::new("nope").unwrap(), + }, + evidence_level: EvidenceLevel::L0, + provenance: prov(), + }) + .unwrap_err(); + assert!(matches!( + err, + OntologyError::MissingContainer { + container_kind: "zone", + .. + } + )); + + // Observation referencing an unknown sensor. + let err = g + .add_observation(Observation { + id: ObservationId::new("o1").unwrap(), + sensor: SensorId::new("ghost-sensor").unwrap(), + located_in: Container::Space { + id: SpaceId::new("kitchen").unwrap(), + }, + at_unix_ms: 0, + evidence_level: EvidenceLevel::L2, + provenance: prov(), + }) + .unwrap_err(); + assert!(matches!( + err, + OntologyError::MissingParent { + parent_kind: "sensor", + .. + } + )); + } + + #[test] + fn duplicate_id_is_rejected() { + let mut g = fixture(); + let err = g + .add_space(Space { + id: SpaceId::new("kitchen").unwrap(), + parent: FloorId::new("f1").unwrap(), + area_id: None, + name: "Dup".into(), + }) + .unwrap_err(); + assert!(matches!(err, OntologyError::Duplicate { kind: "space", .. })); + } +} diff --git a/v2/crates/ruview-ontology/src/provenance.rs b/v2/crates/ruview-ontology/src/provenance.rs new file mode 100644 index 00000000..f20657bd --- /dev/null +++ b/v2/crates/ruview-ontology/src/provenance.rs @@ -0,0 +1,68 @@ +//! Evidence ladder and provenance carried by every fact (ADR-303 §2, ADR-282). +//! +//! The ontology mandates that a fact cannot cross a surface boundary and lose +//! its lineage: every leaf entity carries exactly one [`EvidenceLevel`] plus a +//! [`SemanticProvenance`] record, so no projection can silently upgrade or drop +//! the evidence level. + +use serde::{Deserialize, Serialize}; + +/// The ADR-282 evidence ladder, L0–L5. Exactly one level travels with each +/// fact. Ordering is meaningful: `L0 < L1 < … < L5`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EvidenceLevel { + /// L0 — declared/assumed, no signal evidence. + L0, + /// L1 — heuristic/synthetic. + L1, + /// L2 — single-surface signal evidence. + L2, + /// L3 — corroborated across surfaces. + L3, + /// L4 — calibrated and held-out validated. + L4, + /// L5 — witnessed / certified (ADR-316). + L5, +} + +/// Mandatory provenance for every fact (mirrors the `worldgraph` +/// `SemanticProvenance` house rule so the two can map losslessly). Every field +/// is a bounded string handle, not embedded data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SemanticProvenance { + /// Evidence content-address handle(s) (ADR-137 `EvidenceRef`). + #[serde(default)] + pub evidence: Vec, + /// Model version that produced the fact (ADR-136). + pub model_version: String, + /// Calibration baseline in effect (ADR-135/ADR-298). + pub calibration_version: String, + /// Privacy decision the fact was derived under (ADR-141). + pub privacy_decision: String, +} + +impl SemanticProvenance { + /// A minimal declared-provenance record for L0/L1 structural facts that + /// have no signal evidence yet. Deterministic; no I/O. + #[must_use] + pub fn declared(model_version: impl Into) -> Self { + Self { + evidence: Vec::new(), + model_version: model_version.into(), + calibration_version: "none".to_string(), + privacy_decision: "none".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evidence_level_orders_ascending() { + assert!(EvidenceLevel::L0 < EvidenceLevel::L5); + assert!(EvidenceLevel::L3 > EvidenceLevel::L2); + } +} diff --git a/v2/crates/wifi-densepose-calibration/Cargo.toml b/v2/crates/wifi-densepose-calibration/Cargo.toml index 80832f58..1df2f644 100644 --- a/v2/crates/wifi-densepose-calibration/Cargo.toml +++ b/v2/crates/wifi-densepose-calibration/Cargo.toml @@ -13,6 +13,7 @@ wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", serde = { workspace = true } serde_json = "1.0" +sha2 = { workspace = true } thiserror = { workspace = true } uuid = { version = "1.6", features = ["v4", "serde"] } diff --git a/v2/crates/wifi-densepose-calibration/src/certificate.rs b/v2/crates/wifi-densepose-calibration/src/certificate.rs new file mode 100644 index 00000000..df9beb12 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/certificate.rs @@ -0,0 +1,1135 @@ +//! Signed, versioned, invalidatable room-fingerprint certificate (ADR-298). +//! +//! ADR-298 is primitive 1 of the perception-substrate program (ADR-297) and the +//! first brick of its "certificate spine". This module implements the +//! **certificate portion**: a portable, signed, expiring artifact that says +//! *"this is the room, here is when it was measured, and here is the evidence +//! that it is still the same room."* +//! +//! Nothing here re-derives room state. The [`RoomFingerprint`] is a bounded, +//! fixed-length statistical summary *reused* from the existing calibration types +//! — the [`SpecialistBank`](crate::bank::SpecialistBank)'s empty-vs-occupied +//! presence separation (ADR-135 baseline / ADR-151) and its transceiver +//! [`GeometryEmbedding`](crate::geometry_embedding::GeometryEmbedding) +//! (ADR-152). The [`CalibrationCertificate`] binds that fingerprint to a space, +//! a signing sensor identity, a monotonic version, a capture time, an expiry, +//! an evidence level (ADR-282), and a content hash suitable for signing. +//! +//! ## Honesty discipline (ADR-298 §"Provenance and honesty") +//! +//! A certificate produced from generated CSI is [`EvidenceLevel::L0Synthetic`] +//! by construction; [`CalibrationCertificate::mint`] **rejects** labelling a +//! synthetic characterization as measured, and rejects an automatic +//! ([`CalibrationTier::Auto`]) characterization claiming more than L2. +//! +//! ## Invalidation is explicit, not a silent `STALE` flag +//! +//! [`CalibrationCertificate::status`] returns a typed [`CertificateStatus`]: +//! valid, past-expiry, tampered signature, or drifted beyond the +//! [`CompatibilityEnvelope`]. Small drift stays inside the envelope and is +//! absorbed; drift beyond it invalidates the certificate and forces +//! re-characterization. Compensation never rewrites a signed certificate in +//! place — [`CalibrationCertificate::renew`] mints a *new* version, preserving +//! an append-only history. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::bank::SpecialistBank; +use crate::error::{CalibrationError, Result}; +use crate::geometry_embedding::GeometryEmbedding; + +/// Schema version for the [`RoomFingerprint`] wire format. Bumped when the +/// fingerprint's field set changes (ADR-298 §1: "its schema is versioned"). +pub const FINGERPRINT_SCHEMA_VERSION: u32 = 1; + +/// Schema version for the [`CalibrationCertificate`] wire format. +pub const CERTIFICATE_SCHEMA_VERSION: u32 = 1; + +// Fixed, data-independent normalization scales for the fingerprint distance. +// Data-independent so `distance` is strictly monotonic under a single-field +// perturbation (a data-dependent denominator would grow with the perturbation +// and could mask it) — see `distance_is_monotonic` in the tests. +const MEAN_SCALE: f32 = 1.0; +const VAR_SCALE: f32 = 10.0; +const GEOM_SCALE: f32 = 1.0; + +// --------------------------------------------------------------------------- +// Evidence level, tier, characterization source +// --------------------------------------------------------------------------- + +/// Evidence ladder (ADR-282 L0–L5). An automatic characterization on real +/// captured CSI is at most L1/L2 and is labelled as such, never L3+ (ADR-298 +/// §3). L0 is reserved for synthetic/generated input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum EvidenceLevel { + /// Generated / synthetic CSI — no measured evidence (ADR-279 invariant 6). + L0Synthetic, + /// Weakest measured evidence (automatic observe-only, unverified). + L1, + /// Automatic characterization with a passing quality gate. + L2, + /// Guided enrollment or better (not reachable from `autocal`). + L3, + /// Cross-validated against a held-out split. + L4, + /// Independently reproduced on real silicon. + L5, +} + +impl EvidenceLevel { + /// `true` for any measured level (L1+); L0 is synthetic. + pub fn is_measured(self) -> bool { + self != EvidenceLevel::L0Synthetic + } + + /// Stable tag for canonical hashing. + fn tag(self) -> u8 { + match self { + EvidenceLevel::L0Synthetic => 0, + EvidenceLevel::L1 => 1, + EvidenceLevel::L2 => 2, + EvidenceLevel::L3 => 3, + EvidenceLevel::L4 => 4, + EvidenceLevel::L5 => 5, + } + } +} + +/// How the fingerprint was characterized (ADR-298 §"Provenance and honesty"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CharacterizationSource { + /// Generated / simulated CSI. Forces [`EvidenceLevel::L0Synthetic`]. + Synthetic, + /// Real captured CSI from a sensor. + MeasuredCsi, +} + +impl CharacterizationSource { + fn tag(self) -> u8 { + match self { + CharacterizationSource::Synthetic => 0, + CharacterizationSource::MeasuredCsi => 1, + } + } +} + +/// Calibration tier (ADR-298 §1/§3): the automatic observe-only path yields a +/// weaker evidence level than guided enrollment; the certificate states which +/// path produced it so consumers can weight it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CalibrationTier { + /// Automatic observe-only characterization (`autocal`). Capped at L2. + Auto, + /// Guided human enrollment (existing anchor ritual). + Guided, +} + +impl CalibrationTier { + fn tag(self) -> u8 { + match self { + CalibrationTier::Auto => 0, + CalibrationTier::Guided => 1, + } + } + + /// The strongest evidence level this tier may honestly claim. + fn max_measured_evidence(self) -> EvidenceLevel { + match self { + CalibrationTier::Auto => EvidenceLevel::L2, + CalibrationTier::Guided => EvidenceLevel::L5, + } + } +} + +// --------------------------------------------------------------------------- +// Room fingerprint (reused summary of the existing calibration state) +// --------------------------------------------------------------------------- + +/// A bounded, fixed-length statistical summary of a room's CSI distribution — +/// the distance-comparable object ADR-299 measures against. +/// +/// It is *derived* from the existing calibration state, not a new measurement: +/// the empty-vs-occupied separation comes from the bank's +/// [`PresenceSpecialist`](crate::specialist::PresenceSpecialist) (ADR-135 +/// baseline / ADR-151), and the geometry conditioning comes from the bank's +/// [`GeometryEmbedding`](crate::geometry_embedding::GeometryEmbedding) +/// (ADR-152). Both the **empty** distribution and the **occupied** distribution +/// are stored so downstream OOD gating can distinguish "the empty room changed" +/// (furniture/geometry drift) from "occupancy statistics changed". +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoomFingerprint { + /// Schema version ([`FINGERPRINT_SCHEMA_VERSION`]). + pub schema_version: u32, + /// Empty-room scalar mean (static multipath load), from the presence gate's + /// `empty_mean` reference. `0.0` when the bank learned no presence gate. + pub empty_mean: f32, + /// Empty-room band energy (variance), reconstructed from the presence gate's + /// finite decision boundary; `0.0` when unavailable. + pub empty_variance: f32, + /// Occupied-room band energy (mean occupied-anchor variance). + pub occupied_variance: f32, + /// Learned variance decision boundary. Finite-guarded: an "inert" (issue + /// #1440, `+inf`) boundary is stored as `0.0` so distance math stays finite. + pub presence_threshold: f32, + /// Empty→occupied mean separation (occupancy signal strength). + pub occupancy_mean_shift: f32, + /// Transceiver-geometry conditioning (ADR-152); all-zero when no geometry + /// was recorded. + pub geometry: GeometryEmbedding, +} + +impl RoomFingerprint { + /// Derive the fingerprint from a trained [`SpecialistBank`] — a pure + /// function of the bank's existing state (no new capture). + pub fn from_bank(bank: &SpecialistBank) -> Self { + let (empty_mean, empty_variance, occupied_variance, presence_threshold, mean_shift) = + match bank.presence.as_ref() { + Some(p) => { + let threshold = if p.threshold.is_finite() { + p.threshold + } else { + 0.0 + }; + // threshold == 0.5 * (empty_var + occupied_var) when finite, so + // empty_var reconstructs as 2*threshold - occupied_var (>= 0). + let empty_var = if p.threshold.is_finite() { + (2.0 * p.threshold - p.occupied_var).max(0.0) + } else { + 0.0 + }; + // presence threshold == 0.5 * mean_dist ⇒ mean_dist == 2*threshold. + let mean_shift = p.mean_dist_threshold.map(|t| 2.0 * t).unwrap_or(0.0); + ( + p.empty_mean, + empty_var, + p.occupied_var, + threshold, + mean_shift, + ) + } + None => (0.0, 0.0, 0.0, 0.0, 0.0), + }; + + Self { + schema_version: FINGERPRINT_SCHEMA_VERSION, + empty_mean, + empty_variance, + occupied_variance, + presence_threshold, + occupancy_mean_shift: mean_shift, + geometry: bank.geometry_embedding(), + } + } + + /// Bounded fingerprint distance to another fingerprint — the primitive + /// ADR-299 uses to gate KNOWN → DEGRADED → UNKNOWN. + /// + /// Splits drift into an **empty-room** component (static multipath + physical + /// geometry) and an **occupancy** component (dynamics), so a consumer can + /// tell furniture/geometry drift from a different subject. The `total` is + /// squashed into `[0, 1)` and is monotonic in any single-field perturbation. + pub fn distance(&self, other: &RoomFingerprint) -> FingerprintDistance { + let dmean = (self.empty_mean - other.empty_mean) / MEAN_SCALE; + let dempty_var = (self.empty_variance - other.empty_variance) / VAR_SCALE; + let geom_sq = geometry_l2_sq(&self.geometry, &other.geometry) / (GEOM_SCALE * GEOM_SCALE); + let baseline_raw = (dmean * dmean + dempty_var * dempty_var + geom_sq).sqrt(); + + let docc_var = (self.occupied_variance - other.occupied_variance) / VAR_SCALE; + let dshift = (self.occupancy_mean_shift - other.occupancy_mean_shift) / MEAN_SCALE; + let dthr = (self.presence_threshold - other.presence_threshold) / VAR_SCALE; + let occupancy_raw = (docc_var * docc_var + dshift * dshift + dthr * dthr).sqrt(); + + let raw = baseline_raw + occupancy_raw; + FingerprintDistance { + baseline_drift: baseline_raw, + occupancy_drift: occupancy_raw, + total: raw / (1.0 + raw), + } + } +} + +fn geometry_l2_sq(a: &GeometryEmbedding, b: &GeometryEmbedding) -> f32 { + a.as_slice() + .iter() + .zip(b.as_slice().iter()) + .map(|(x, y)| { + let d = x - y; + d * d + }) + .sum() +} + +/// A drift/distance summary between two [`RoomFingerprint`]s. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct FingerprintDistance { + /// Empty-room drift: static multipath + transceiver geometry ("did the room + /// itself change"). + pub baseline_drift: f32, + /// Occupancy drift: how differently occupants perturb the field. + pub occupancy_drift: f32, + /// Total drift, squashed into `[0, 1)`. Monotonic in the underlying raw + /// distance, so it is directly comparable against a [`CompatibilityEnvelope`]. + pub total: f32, +} + +impl FingerprintDistance { + /// `true` when total drift stays within the envelope (small drift absorbed). + pub fn within_envelope(&self, envelope: &CompatibilityEnvelope) -> bool { + self.total <= envelope.max_total_drift + } +} + +/// The compatibility envelope for continuous drift compensation (ADR-298 §4). +/// Drift within the envelope is absorbed and logged; drift beyond it invalidates +/// the certificate. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CompatibilityEnvelope { + /// Maximum tolerated total fingerprint drift in `[0, 1)` before the + /// certificate is invalidated and re-characterization is forced. + pub max_total_drift: f32, +} + +impl Default for CompatibilityEnvelope { + fn default() -> Self { + // A conservative default: modest drift is absorbed, a clearly different + // room is rejected. Consumers (ADR-299) may tighten this per space. + Self { + max_total_drift: 0.15, + } + } +} + +impl CompatibilityEnvelope { + /// Validated constructor. Rejects a non-finite or out-of-`[0, 1)` envelope + /// (bounded-input discipline at the config boundary). + pub fn new(max_total_drift: f32) -> Result { + if !max_total_drift.is_finite() || !(0.0..1.0).contains(&max_total_drift) { + return Err(CalibrationError::InvalidCertificate(format!( + "compatibility envelope must be finite in [0, 1), got {max_total_drift}" + ))); + } + Ok(Self { max_total_drift }) + } +} + +// --------------------------------------------------------------------------- +// Signing abstraction (kept behind a trait, consistent with the crate's style) +// --------------------------------------------------------------------------- + +/// Signs a certificate content hash. Kept behind a trait so the RuField +/// provenance/signature backend (ADR-260/262/277/279) can be substituted +/// without changing the certificate types. A signature is mandatory: an +/// unsigned certificate is not a valid certificate (ADR-298 §3). +pub trait CertificateSigner { + /// Identity of the signing key (bound into the certificate as the signer). + fn key_id(&self) -> &str; + /// Produce a detached signature over the 32-byte content hash. + fn sign(&self, content_hash: &[u8; 32]) -> Vec; +} + +/// Verifies a detached signature over a certificate content hash. +pub trait CertificateVerifier { + /// `true` iff `signature` is a valid signature by `key_id` over `content_hash`. + fn verify(&self, key_id: &str, content_hash: &[u8; 32], signature: &[u8]) -> bool; +} + +/// A deterministic keyed-hash signer/verifier (`SHA-256(secret‖hash‖secret)`). +/// +/// This is a self-contained, dependency-free stand-in for the RuField signature +/// backend so the certificate machinery is testable today. It is a *keyed MAC*, +/// not asymmetric provenance — it is honest about being a placeholder and is +/// never labelled as the production RuField signature. Determinism makes signing +/// reproducible in tests; secrecy of `secret` gives tamper detection. +#[derive(Debug, Clone)] +pub struct KeyedHashSigner { + key_id: String, + secret: Vec, +} + +impl KeyedHashSigner { + /// Construct from a key identity and secret bytes. + pub fn new(key_id: impl Into, secret: impl Into>) -> Self { + Self { + key_id: key_id.into(), + secret: secret.into(), + } + } + + fn mac(&self, content_hash: &[u8; 32]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(&self.secret); + h.update(content_hash); + h.update(&self.secret); + h.finalize().into() + } +} + +impl CertificateSigner for KeyedHashSigner { + fn key_id(&self) -> &str { + &self.key_id + } + fn sign(&self, content_hash: &[u8; 32]) -> Vec { + self.mac(content_hash).to_vec() + } +} + +impl CertificateVerifier for KeyedHashSigner { + fn verify(&self, key_id: &str, content_hash: &[u8; 32], signature: &[u8]) -> bool { + // Constant-time-ish comparison is out of scope for a placeholder; the + // production RuField verifier owns that. Bind the key identity too. + key_id == self.key_id && signature == self.mac(content_hash).as_slice() + } +} + +/// A detached signature bound to a content hash and a signing-key identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CertificateSignature { + /// Identity of the signing key (ADR-302 sensor identity binding). + pub key_id: String, + /// Lowercase-hex SHA-256 of the certificate's canonical signable bytes. + pub content_hash_hex: String, + /// Lowercase-hex detached signature over the content hash. + pub signature_hex: String, +} + +// --------------------------------------------------------------------------- +// Certificate +// --------------------------------------------------------------------------- + +/// Parameters for [`CalibrationCertificate::mint`]. Keeping them in one struct +/// avoids a long positional argument list and documents each binding. +#[derive(Debug, Clone)] +pub struct MintParams { + /// Canonical space identifier (ADR-303 ontology) — *which* space. + pub space_id: String, + /// Signing sensor identity (ADR-302) — *which signed device* produced it. + /// Must equal the signer's `key_id`. + pub sensor_id: String, + /// Capture time (unix seconds). Injected, never read from the wall clock. + pub captured_at_unix_s: i64, + /// Validity window in seconds; `expires_at = captured_at + validity_secs`. + pub validity_secs: i64, + /// Monotonic version (start at 1; [`CalibrationCertificate::renew`] increments). + pub version: u64, + /// Which calibration path produced this (caps the evidence level). + pub tier: CalibrationTier, + /// Evidence level claimed (ADR-282). Validated against `tier`/`source`. + pub evidence: EvidenceLevel, + /// How the fingerprint was characterized (synthetic vs measured). + pub source: CharacterizationSource, + /// Drift envelope governing invalidation. + pub envelope: CompatibilityEnvelope, +} + +/// A signed, versioned, comparable, invalidatable room-fingerprint certificate. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CalibrationCertificate { + /// Certificate schema version ([`CERTIFICATE_SCHEMA_VERSION`]). + pub schema_version: u32, + /// Canonical space identifier (ADR-303). + pub space_id: String, + /// Room scope carried through from the calibration state. + pub room_id: String, + /// ADR-135 baseline id the fingerprint was derived against. + pub baseline_id: String, + /// Signing sensor identity (ADR-302). + pub sensor_id: String, + /// Monotonic version (append-only history; renewal increments). + pub version: u64, + /// Capture time (unix seconds). + pub captured_at_unix_s: i64, + /// Expiry time (unix seconds); `captured_at + validity_secs`. + pub expires_at_unix_s: i64, + /// Calibration tier. + pub tier: CalibrationTier, + /// Evidence level (honesty-checked at mint). + pub evidence: EvidenceLevel, + /// Characterization source. + pub source: CharacterizationSource, + /// The room fingerprint this certificate attests. + pub fingerprint: RoomFingerprint, + /// Drift envelope governing invalidation. + pub envelope: CompatibilityEnvelope, + /// Mandatory signature over the canonical signable bytes. + pub signature: CertificateSignature, +} + +impl CalibrationCertificate { + /// Mint a certificate from existing calibration state — a pure function over + /// the [`SpecialistBank`] and [`MintParams`], plus a signer. + /// + /// Enforces the honesty discipline before signing: + /// - a [`CharacterizationSource::Synthetic`] fingerprint must be labelled + /// [`EvidenceLevel::L0Synthetic`] (never measured); + /// - a [`CharacterizationSource::MeasuredCsi`] fingerprint must be L1+; + /// - the claimed evidence may not exceed what the `tier` can honestly bear + /// (an [`CalibrationTier::Auto`] characterization is capped at L2); + /// - the `sensor_id` must match the signer's `key_id`; + /// - `version` must be ≥ 1 and `validity_secs` ≥ 0. + pub fn mint( + params: MintParams, + bank: &SpecialistBank, + signer: &S, + ) -> Result { + Self::validate_mint(¶ms, signer)?; + + let fingerprint = RoomFingerprint::from_bank(bank); + let expires_at_unix_s = params.captured_at_unix_s.saturating_add(params.validity_secs); + + // Build the unsigned certificate, then sign its canonical bytes. + let unsigned = UnsignedCertificate { + schema_version: CERTIFICATE_SCHEMA_VERSION, + space_id: ¶ms.space_id, + room_id: &bank.room_id, + baseline_id: &bank.baseline_id, + sensor_id: ¶ms.sensor_id, + version: params.version, + captured_at_unix_s: params.captured_at_unix_s, + expires_at_unix_s, + tier: params.tier, + evidence: params.evidence, + source: params.source, + fingerprint: &fingerprint, + envelope: params.envelope, + }; + let content_hash = unsigned.content_hash(); + let signature = CertificateSignature { + key_id: signer.key_id().to_string(), + content_hash_hex: hex_lower(&content_hash), + signature_hex: hex_lower(&signer.sign(&content_hash)), + }; + + Ok(Self { + schema_version: CERTIFICATE_SCHEMA_VERSION, + space_id: params.space_id, + room_id: bank.room_id.clone(), + baseline_id: bank.baseline_id.clone(), + sensor_id: params.sensor_id, + version: params.version, + captured_at_unix_s: params.captured_at_unix_s, + expires_at_unix_s, + tier: params.tier, + evidence: params.evidence, + source: params.source, + fingerprint, + envelope: params.envelope, + signature, + }) + } + + fn validate_mint(params: &MintParams, signer: &S) -> Result<()> { + if params.version == 0 { + return Err(CalibrationError::InvalidCertificate( + "certificate version must start at 1 (monotonic)".into(), + )); + } + if params.validity_secs < 0 { + return Err(CalibrationError::InvalidCertificate( + "validity_secs must be non-negative".into(), + )); + } + if params.sensor_id != signer.key_id() { + return Err(CalibrationError::InvalidCertificate(format!( + "sensor_id '{}' does not match signing key '{}'", + params.sensor_id, + signer.key_id() + ))); + } + match params.source { + CharacterizationSource::Synthetic => { + if params.evidence.is_measured() { + return Err(CalibrationError::SyntheticMislabel { + claimed: format!("{:?}", params.evidence), + }); + } + } + CharacterizationSource::MeasuredCsi => { + if !params.evidence.is_measured() { + return Err(CalibrationError::InvalidCertificate( + "measured CSI cannot be labelled L0Synthetic".into(), + )); + } + if params.evidence > params.tier.max_measured_evidence() { + return Err(CalibrationError::InvalidCertificate(format!( + "{:?} tier may claim at most {:?}, got {:?}", + params.tier, + params.tier.max_measured_evidence(), + params.evidence + ))); + } + } + } + Ok(()) + } + + /// Re-characterize into the **next** version, preserving the append-only + /// history (ADR-298 §4). Same space/sensor/tier/evidence/source/envelope, + /// `version + 1`, re-signed over the fresh fingerprint and capture time. + /// + /// `source`/`evidence` are inherited so a renewal cannot silently upgrade a + /// synthetic or auto certificate past its honesty cap. + pub fn renew( + &self, + captured_at_unix_s: i64, + validity_secs: i64, + bank: &SpecialistBank, + signer: &S, + ) -> Result { + let params = MintParams { + space_id: self.space_id.clone(), + sensor_id: self.sensor_id.clone(), + captured_at_unix_s, + validity_secs, + version: self.version.saturating_add(1), + tier: self.tier, + evidence: self.evidence, + source: self.source, + envelope: self.envelope, + }; + Self::mint(params, bank, signer) + } + + /// The 32-byte content hash over this certificate's canonical signable bytes + /// — the object the signature covers and a witness-chain anchor (ADR-316). + pub fn content_hash(&self) -> [u8; 32] { + self.as_unsigned().content_hash() + } + + fn as_unsigned(&self) -> UnsignedCertificate<'_> { + UnsignedCertificate { + schema_version: self.schema_version, + space_id: &self.space_id, + room_id: &self.room_id, + baseline_id: &self.baseline_id, + sensor_id: &self.sensor_id, + version: self.version, + captured_at_unix_s: self.captured_at_unix_s, + expires_at_unix_s: self.expires_at_unix_s, + tier: self.tier, + evidence: self.evidence, + source: self.source, + fingerprint: &self.fingerprint, + envelope: self.envelope, + } + } + + /// `true` iff the signature verifies against `verifier` and the recorded + /// content hash matches the recomputed one (tamper rejection). + pub fn verify_signature(&self, verifier: &V) -> bool { + let content_hash = self.content_hash(); + if self.signature.content_hash_hex != hex_lower(&content_hash) { + return false; + } + let Some(sig) = hex_decode(&self.signature.signature_hex) else { + return false; + }; + verifier.verify(&self.signature.key_id, &content_hash, &sig) + } + + /// Distance between this certificate's fingerprint and another's — two + /// certificates for the same space are comparable (ADR-298 §3). + pub fn distance(&self, other: &CalibrationCertificate) -> FingerprintDistance { + self.fingerprint.distance(&other.fingerprint) + } + + /// Evaluate validity against live room state and a signature verifier. + /// + /// Invalidation is an explicit, typed transition (ADR-298 §4), never a + /// silent flag. Order of precedence: tampered signature → expired → drift + /// beyond the envelope → valid. `now_unix_s` is injected (no wall clock). + pub fn status( + &self, + current: &RoomFingerprint, + now_unix_s: i64, + verifier: &V, + ) -> CertificateStatus { + if !self.verify_signature(verifier) { + return CertificateStatus::TamperedSignature; + } + if now_unix_s >= self.expires_at_unix_s { + return CertificateStatus::Expired { + now_unix_s, + expires_at_unix_s: self.expires_at_unix_s, + }; + } + let distance = self.fingerprint.distance(current); + if !distance.within_envelope(&self.envelope) { + return CertificateStatus::Drifted { + distance, + envelope: self.envelope, + }; + } + CertificateStatus::Valid { distance } + } + + /// Convenience: `true` iff [`Self::status`] is [`CertificateStatus::Valid`]. + pub fn is_valid( + &self, + current: &RoomFingerprint, + now_unix_s: i64, + verifier: &V, + ) -> bool { + matches!( + self.status(current, now_unix_s, verifier), + CertificateStatus::Valid { .. } + ) + } + + /// Serialize to pretty JSON (matches [`SpecialistBank`]'s persistence style). + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| CalibrationError::Serde(e.to_string())) + } + + /// Deserialize from JSON, validating the schema version at the boundary. + pub fn from_json(s: &str) -> Result { + let cert: Self = + serde_json::from_str(s).map_err(|e| CalibrationError::Serde(e.to_string()))?; + if cert.schema_version != CERTIFICATE_SCHEMA_VERSION { + return Err(CalibrationError::InvalidCertificate(format!( + "unsupported certificate schema version {} (expected {})", + cert.schema_version, CERTIFICATE_SCHEMA_VERSION + ))); + } + Ok(cert) + } +} + +/// The typed result of a certificate validity check (ADR-298 §4). +#[derive(Debug, Clone, PartialEq)] +pub enum CertificateStatus { + /// Still valid; carries the (in-envelope) drift for logging/compensation. + Valid { + /// Measured drift vs the live fingerprint (within the envelope). + distance: FingerprintDistance, + }, + /// Past its expiry (`now_unix_s >= expires_at_unix_s`). + Expired { + /// The injected evaluation time. + now_unix_s: i64, + /// The certificate's recorded expiry. + expires_at_unix_s: i64, + }, + /// Drift beyond the compatibility envelope — re-characterization required. + Drifted { + /// The measured drift that breached the envelope. + distance: FingerprintDistance, + /// The envelope it breached. + envelope: CompatibilityEnvelope, + }, + /// Signature did not verify (content hash mismatch or bad signature). + TamperedSignature, +} + +impl CertificateStatus { + /// `true` only for [`CertificateStatus::Valid`]. + pub fn is_valid(&self) -> bool { + matches!(self, CertificateStatus::Valid { .. }) + } +} + +// --------------------------------------------------------------------------- +// Canonical signable encoding +// --------------------------------------------------------------------------- + +/// A borrowed view of the signable fields, in a fixed order, used to compute the +/// content hash. Excludes the signature itself (which covers this hash). +struct UnsignedCertificate<'a> { + schema_version: u32, + space_id: &'a str, + room_id: &'a str, + baseline_id: &'a str, + sensor_id: &'a str, + version: u64, + captured_at_unix_s: i64, + expires_at_unix_s: i64, + tier: CalibrationTier, + evidence: EvidenceLevel, + source: CharacterizationSource, + fingerprint: &'a RoomFingerprint, + envelope: CompatibilityEnvelope, +} + +impl UnsignedCertificate<'_> { + /// SHA-256 over a deterministic, architecture-independent byte encoding. + /// + /// Fields are hashed in a fixed order: strings length-prefixed, integers and + /// floats as little-endian, enums as a stable one-byte tag. No text + /// formatting of floats (raw IEEE-754 LE), matching the ADR-136 + /// `CanonicalFrame` precedent, so the hash is stable across runs and + /// architectures. + fn content_hash(&self) -> [u8; 32] { + let mut h = Sha256::new(); + // Domain separation so this hash can never collide with another artifact. + h.update(b"ruview.adr298.calibration-certificate.v1"); + h.update(self.schema_version.to_le_bytes()); + hash_str(&mut h, self.space_id); + hash_str(&mut h, self.room_id); + hash_str(&mut h, self.baseline_id); + hash_str(&mut h, self.sensor_id); + h.update(self.version.to_le_bytes()); + h.update(self.captured_at_unix_s.to_le_bytes()); + h.update(self.expires_at_unix_s.to_le_bytes()); + h.update([self.tier.tag()]); + h.update([self.evidence.tag()]); + h.update([self.source.tag()]); + hash_fingerprint(&mut h, self.fingerprint); + h.update(self.envelope.max_total_drift.to_le_bytes()); + h.finalize().into() + } +} + +fn hash_str(h: &mut Sha256, s: &str) { + h.update((s.len() as u64).to_le_bytes()); + h.update(s.as_bytes()); +} + +fn hash_fingerprint(h: &mut Sha256, fp: &RoomFingerprint) { + h.update(fp.schema_version.to_le_bytes()); + h.update(fp.empty_mean.to_le_bytes()); + h.update(fp.empty_variance.to_le_bytes()); + h.update(fp.occupied_variance.to_le_bytes()); + h.update(fp.presence_threshold.to_le_bytes()); + h.update(fp.occupancy_mean_shift.to_le_bytes()); + h.update((GeometryEmbedding::DIM as u64).to_le_bytes()); + for v in fp.geometry.as_slice() { + h.update(v.to_le_bytes()); + } +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(HEX[(b >> 4) as usize] as char); + s.push(HEX[(b & 0x0f) as usize] as char); + } + s +} + +/// Decode lowercase/uppercase hex. Returns `None` on malformed input (odd length +/// or non-hex digit) — no panics at the deserialization boundary. +fn hex_decode(s: &str) -> Option> { + if s.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let hi = hex_val(bytes[i])?; + let lo = hex_val(bytes[i + 1])?; + out.push((hi << 4) | lo); + i += 2; + } + Some(out) +} + +fn hex_val(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::anchor::AnchorLabel; + use crate::extract::{AnchorFeature, Features}; + use crate::geometry::NodeGeometry; + + fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature { + af_mean(label, 1.0, variance, motion) + } + + fn af_mean(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 anchors() -> Vec { + vec![ + af_mean(AnchorLabel::Empty, 1.0, 1.0, 0.1), + af_mean(AnchorLabel::StandStill, 3.0, 10.0, 0.2), + af(AnchorLabel::Sit, 6.0, 0.2), + af(AnchorLabel::LieDown, 3.0, 0.2), + af(AnchorLabel::SmallMove, 4.0, 1.2), + af(AnchorLabel::SleepPosture, 3.0, 0.1), + ] + } + + fn bank_with_geometry() -> SpecialistBank { + let geometry = vec![ + NodeGeometry::new(1, "tape-measure").with_position(0.0, 0.0, 1.0), + NodeGeometry::new(2, "tape-measure").with_position(3.0, 0.0, 1.0), + ]; + SpecialistBank::train("living-room", "base-1", &anchors(), 1000) + .unwrap() + .with_geometry(geometry) + } + + fn signer() -> KeyedHashSigner { + KeyedHashSigner::new("sensor-42", b"top-secret".to_vec()) + } + + fn measured_params(version: u64) -> MintParams { + MintParams { + space_id: "home/living-room".into(), + sensor_id: "sensor-42".into(), + captured_at_unix_s: 1_000_000, + validity_secs: 3600, + version, + tier: CalibrationTier::Auto, + evidence: EvidenceLevel::L2, + source: CharacterizationSource::MeasuredCsi, + envelope: CompatibilityEnvelope::default(), + } + } + + #[test] + fn mint_is_deterministic() { + let bank = bank_with_geometry(); + let s = signer(); + let a = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let b = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + assert_eq!(a, b, "same inputs → identical certificate"); + assert_eq!(a.content_hash(), b.content_hash()); + assert_eq!(a.signature, b.signature); + } + + #[test] + fn signature_round_trips_and_rejects_tampering() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + assert!(cert.verify_signature(&s), "freshly minted cert verifies"); + + // Tamper with a signable field: the recorded content hash no longer matches. + let mut tampered = cert.clone(); + tampered.expires_at_unix_s += 10_000; + assert!(!tampered.verify_signature(&s), "expiry tamper is rejected"); + + // Tamper with the fingerprint payload. + let mut tampered2 = cert.clone(); + tampered2.fingerprint.empty_mean += 5.0; + assert!( + !tampered2.verify_signature(&s), + "fingerprint tamper is rejected" + ); + + // Wrong key does not verify. + let other = KeyedHashSigner::new("sensor-42", b"different-secret".to_vec()); + assert!(!cert.verify_signature(&other), "wrong secret is rejected"); + } + + #[test] + fn version_is_monotonic_across_renewals() { + let bank = bank_with_geometry(); + let s = signer(); + let v1 = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let v2 = v1.renew(2_000_000, 3600, &bank, &s).unwrap(); + let v3 = v2.renew(3_000_000, 3600, &bank, &s).unwrap(); + assert_eq!(v1.version, 1); + assert_eq!(v2.version, 2); + assert_eq!(v3.version, 3); + assert!(v1.version < v2.version && v2.version < v3.version); + // Renewal preserves identity but is a distinct, freshly signed artifact. + assert_eq!(v2.space_id, v1.space_id); + assert_eq!(v2.sensor_id, v1.sensor_id); + assert_ne!(v2.content_hash(), v1.content_hash()); + assert!(v2.verify_signature(&s)); + } + + #[test] + fn version_zero_is_rejected() { + let bank = bank_with_geometry(); + let s = signer(); + assert!(CalibrationCertificate::mint(measured_params(0), &bank, &s).is_err()); + } + + #[test] + fn compare_identical_vs_drifted() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + + // Identical fingerprint → zero drift. + let same = cert.fingerprint.clone(); + let d0 = cert.fingerprint.distance(&same); + assert_eq!(d0.total, 0.0); + assert_eq!(d0.baseline_drift, 0.0); + assert_eq!(d0.occupancy_drift, 0.0); + + // A drifted room (empty-room mean moved) → positive, larger drift. + let mut drifted = cert.fingerprint.clone(); + drifted.empty_mean += 4.0; + let d1 = cert.fingerprint.distance(&drifted); + assert!(d1.total > d0.total); + assert!(d1.baseline_drift > 0.0); + assert!(d1.total < 1.0, "total is bounded in [0, 1)"); + } + + #[test] + fn distance_is_monotonic() { + let base = RoomFingerprint { + schema_version: FINGERPRINT_SCHEMA_VERSION, + empty_mean: 1.0, + empty_variance: 5.0, + occupied_variance: 10.0, + presence_threshold: 5.5, + occupancy_mean_shift: 2.0, + geometry: GeometryEmbedding::default(), + }; + let mut last = -1.0; + for step in 0..8 { + let mut perturbed = base.clone(); + perturbed.empty_mean = base.empty_mean + step as f32; + let d = base.distance(&perturbed).total; + assert!( + d > last, + "distance must increase with perturbation (step {step}: {d} <= {last})" + ); + last = d; + } + } + + #[test] + fn expiry_invalidates() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let current = cert.fingerprint.clone(); + + // Before expiry, same room → valid. + assert!(cert.is_valid(¤t, 1_000_500, &s)); + assert!(matches!( + cert.status(¤t, 1_000_500, &s), + CertificateStatus::Valid { .. } + )); + + // At/after expiry → Expired. + assert!(!cert.is_valid(¤t, cert.expires_at_unix_s, &s)); + assert!(matches!( + cert.status(¤t, cert.expires_at_unix_s + 1, &s), + CertificateStatus::Expired { .. } + )); + } + + #[test] + fn drift_beyond_envelope_invalidates() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.envelope = CompatibilityEnvelope::new(0.05).unwrap(); + let cert = CalibrationCertificate::mint(params, &bank, &s).unwrap(); + + // Small drift stays inside the envelope → valid. + let mut small = cert.fingerprint.clone(); + small.empty_mean += 0.01; + assert!(cert.is_valid(&small, 1_000_500, &s)); + + // Large drift breaches the envelope → Drifted (explicit invalidation). + let mut large = cert.fingerprint.clone(); + large.empty_mean += 10.0; + match cert.status(&large, 1_000_500, &s) { + CertificateStatus::Drifted { distance, envelope } => { + assert!(distance.total > envelope.max_total_drift); + } + other => panic!("expected Drifted, got {other:?}"), + } + } + + #[test] + fn tampered_signature_takes_precedence() { + let bank = bank_with_geometry(); + let s = signer(); + let mut cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + cert.fingerprint.empty_mean += 1.0; // invalidate the signature + let current = cert.fingerprint.clone(); + assert!(matches!( + cert.status(¤t, 1_000_500, &s), + CertificateStatus::TamperedSignature + )); + } + + #[test] + fn json_round_trip() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let json = cert.to_json().unwrap(); + let back = CalibrationCertificate::from_json(&json).unwrap(); + assert_eq!(cert, back); + // Signature still verifies after a serialization round-trip. + assert!(back.verify_signature(&s)); + } + + #[test] + fn synthetic_cannot_be_labelled_measured() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.source = CharacterizationSource::Synthetic; + params.evidence = EvidenceLevel::L2; // synthetic claiming measured + let err = CalibrationCertificate::mint(params, &bank, &s).unwrap_err(); + assert!(matches!(err, CalibrationError::SyntheticMislabel { .. })); + + // Correctly labelled synthetic (L0) is accepted. + let mut ok = measured_params(1); + ok.source = CharacterizationSource::Synthetic; + ok.evidence = EvidenceLevel::L0Synthetic; + assert!(CalibrationCertificate::mint(ok, &bank, &s).is_ok()); + } + + #[test] + fn auto_tier_cannot_over_claim_evidence() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.tier = CalibrationTier::Auto; + params.evidence = EvidenceLevel::L3; // Auto is capped at L2 + assert!(CalibrationCertificate::mint(params, &bank, &s).is_err()); + } + + #[test] + fn sensor_identity_must_match_signer() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.sensor_id = "some-other-device".into(); + assert!(CalibrationCertificate::mint(params, &bank, &s).is_err()); + } + + #[test] + fn fingerprint_from_bank_reuses_presence_separation() { + let bank = bank_with_geometry(); + let fp = RoomFingerprint::from_bank(&bank); + let presence = bank.presence.as_ref().unwrap(); + assert_eq!(fp.empty_mean, presence.empty_mean); + assert_eq!(fp.occupied_variance, presence.occupied_var); + assert_eq!(fp.geometry, bank.geometry_embedding()); + assert!(fp.geometry.as_slice().iter().any(|&x| x != 0.0)); + } + + #[test] + fn invalid_envelope_is_rejected() { + assert!(CompatibilityEnvelope::new(-0.1).is_err()); + assert!(CompatibilityEnvelope::new(1.0).is_err()); + assert!(CompatibilityEnvelope::new(f32::NAN).is_err()); + assert!(CompatibilityEnvelope::new(0.2).is_ok()); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/error.rs b/v2/crates/wifi-densepose-calibration/src/error.rs index 197b9d76..bf17629d 100644 --- a/v2/crates/wifi-densepose-calibration/src/error.rs +++ b/v2/crates/wifi-densepose-calibration/src/error.rs @@ -35,6 +35,18 @@ pub enum CalibrationError { #[error("serialization error: {0}")] Serde(String), + /// A calibration certificate failed validation at construction (ADR-298). + #[error("invalid calibration certificate: {0}")] + InvalidCertificate(String), + + /// A synthetic characterization was labelled as measured evidence — rejected + /// by the honesty discipline (ADR-279 invariant 6, ADR-282 ladder, ADR-298). + #[error("synthetic characterization cannot be labelled measured (claimed {claimed})")] + SyntheticMislabel { + /// The measured evidence level that was wrongly claimed for synthetic input. + claimed: String, + }, + /// The specialist bank was trained against a different baseline and is stale. #[error("bank is STALE: trained against baseline {trained}, current is {current}")] StaleBaseline { diff --git a/v2/crates/wifi-densepose-calibration/src/lib.rs b/v2/crates/wifi-densepose-calibration/src/lib.rs index db407cf7..278f7dff 100644 --- a/v2/crates/wifi-densepose-calibration/src/lib.rs +++ b/v2/crates/wifi-densepose-calibration/src/lib.rs @@ -23,6 +23,7 @@ pub mod anchor; pub mod bank; +pub mod certificate; pub mod enrollment; pub mod error; pub mod extract; @@ -34,6 +35,11 @@ pub mod specialist; pub use anchor::{Anchor, AnchorLabel, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture}; pub use bank::SpecialistBank; +pub use certificate::{ + CalibrationCertificate, CalibrationTier, CertificateSignature, CertificateSigner, + CertificateStatus, CertificateVerifier, CharacterizationSource, CompatibilityEnvelope, + EvidenceLevel, FingerprintDistance, KeyedHashSigner, MintParams, RoomFingerprint, +}; pub use enrollment::{AnchorQualityGate, AnchorRecorder}; pub use error::{CalibrationError, Result}; pub use extract::AnchorFeature;