feat: implement ADR-297 phase-1 spine roots — ontology, attest, evidence, calibration cert

Four foundational perception-substrate crates (dependency roots of the
certificate spine), each pure/leaf and deterministically tested:

ruview-ontology (ADR-303): canonical Site>Building>Floor>Space>Zone +
Sensor/Person/Object/Observation/Track/Event, typed serde-transparent ids,
WorldGraph registry with dangling-parent/duplicate rejection and containment
resolution, EvidenceLevel L0-L5, and a docs-only migration table for the
per-surface shapes. 8 tests + doctest.

ruview-attest (ADR-302): DeviceId + SignedMeasurement envelope binding
{device, monotonic sequence, timestamp, payload hash, calibration ref};
AttestationVerifier enforces unknown-device, signature, tamper, strict
per-device sequence (replay), and freshness; Signer/Verifier traits with a
blake3-keyed SYNTHETIC-grade reference MAC (Ed25519 is a drop-in). 14 tests.

ruview-evidence (ADR-301): append-only per-(room,device,subject,model) ledger;
immutable records with provenance-gated constructors (synthetic forces L0,
measured needs a reproducer); summarize() never pools across contexts; a slice
reports the floor evidence level, never above its weakest record. 8 tests.

wifi-densepose-calibration (ADR-298): CalibrationCertificate — signed, versioned
room fingerprint minted from existing calibration/bank state, compare/drift, and
invalidate-on-drift/expiry. 78 tests green.

Registers the three new crates as workspace members. All four verified green
independently. SYNTHETIC/L0 reference crypto; no hardware claims.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
This commit is contained in:
Claude
2026-08-11 00:50:28 +00:00
parent 34c9804002
commit 6506438b83
16 changed files with 4080 additions and 0 deletions
Generated
+29
View File
@@ -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",
+4
View File
@@ -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`.
+16
View File
@@ -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
+705
View File
@@ -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<String>) -> Result<Self, InputError> {
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<String>) -> Result<Self, InputError> {
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<CalibrationRef>,
}
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<u8> {
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<S: Signer + ?Sized>(
signer: &S,
device: DeviceId,
sequence: u64,
timestamp: Timestamp,
payload: &[u8],
calibration_ref: Option<CalibrationRef>,
) -> 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<CalibrationRef>,
}
// ---------------------------------------------------------------------------
// 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<V: Verifier> {
verifier: V,
last_sequence: Option<u64>,
}
/// 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<V: Verifier> {
enrolled: BTreeMap<DeviceId, Enrolled<V>>,
freshness: FreshnessPolicy,
}
impl<V: Verifier> AttestationVerifier<V> {
/// 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<u64> {
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<VerifiedMeasurement, VerifyError> {
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<u8>, 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<Blake3MacSigner> {
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));
}
}
+15
View File
@@ -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
+977
View File
@@ -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`] (L0L5, 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, L0L5, 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<String>,
device: impl Into<String>,
subject_class: impl Into<String>,
model_version: impl Into<String>,
) -> Result<Self, EvidenceError> {
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<u64>,
}
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<Self, EvidenceError> {
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<Self, EvidenceError> {
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<String>,
timestamp_ns: u64,
) -> Result<Self, EvidenceError> {
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<u64> {
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<EvidenceRecord>,
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<u64, EvidenceError> {
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<EvidenceContext> {
let mut out: Vec<EvidenceContext> = 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<ContextSummary> {
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<ContextSummary> = 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 }
);
}
}
+15
View File
@@ -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
+206
View File
@@ -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<String>,
/// 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<PersonId>,
/// 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);
+381
View File
@@ -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<SiteId, Site>,
/// Buildings, keyed by id.
pub buildings: BTreeMap<BuildingId, Building>,
/// Floors, keyed by id.
pub floors: BTreeMap<FloorId, Floor>,
/// Spaces, keyed by id.
pub spaces: BTreeMap<SpaceId, Space>,
/// Zones, keyed by id.
pub zones: BTreeMap<ZoneId, Zone>,
/// Sensors, keyed by id.
pub sensors: BTreeMap<SensorId, Sensor>,
/// Persons, keyed by id.
pub persons: BTreeMap<PersonId, Person>,
/// Objects, keyed by id.
pub objects: BTreeMap<ObjectId, Object>,
/// Observations, keyed by id.
pub observations: BTreeMap<ObservationId, Observation>,
/// Tracks, keyed by id.
pub tracks: BTreeMap<TrackId, Track>,
/// Events, keyed by id.
pub events: BTreeMap<EventId, Event>,
}
/// 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)
}
}
+161
View File
@@ -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<String>) -> Result<Self, IdError> {
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");
}
}
+349
View File
@@ -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<dyn std::error::Error>>(())
//! ```
//!
//! ## 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 `.../<area>/<sensor>` 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", .. }));
}
}
@@ -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, L0L5. 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<String>,
/// 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<String>) -> 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);
}
}
@@ -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"] }
File diff suppressed because it is too large Load Diff
@@ -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 {
@@ -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;