mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
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:
Generated
+29
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,977 @@
|
||||
//! # `ruview-evidence` — the append-only accuracy ledger (ADR-301, ADR-297 §4)
|
||||
//!
|
||||
//! "MLflow for physical sensing." Where an experiment tracker overwrites
|
||||
//! yesterday's number, this crate is an **append-only** record of how a model
|
||||
//! actually performs, keyed per deployment context
|
||||
//! `(room, device, subject-class, model-version)` and carrying, per record,
|
||||
//! the ADR-301 metrics (moving/stationary recall, false-positive rate, drift,
|
||||
//! predictive uncertainty, calibration age, sample count) plus exactly one
|
||||
//! [`EvidenceLevel`] (L0–L5, mirroring ADR-282 semantics).
|
||||
//!
|
||||
//! ## Leaf, deterministic, honest
|
||||
//!
|
||||
//! - **Leaf**: this crate depends only on `serde`/`thiserror`. The
|
||||
//! [`EvidenceLevel`] ladder mirrors ADR-282 (`frame::EvidenceLevel`) but is
|
||||
//! defined locally so the ledger never pulls in the frame crate.
|
||||
//! - **Deterministic**: no wall-clock and no randomness. Record time is
|
||||
//! injected by the caller; the ledger assigns a monotonic append sequence.
|
||||
//! - **Honest by construction**:
|
||||
//! - A record's [`EvidenceLevel`] is fixed by its *provenance* at write time
|
||||
//! ([`EvidenceRecord::synthetic`] is `L0` and cannot be raised — there is
|
||||
//! no `set_level`). This is the ADR-282/288/290 "no upgrade" rule.
|
||||
//! - Records are **append-only**: [`EvidenceLedger::append`] consumes a
|
||||
//! record by value and nothing hands back a mutable reference. A correction
|
||||
//! is a *new* record, never an in-place edit (ADR-301 §1).
|
||||
//! - Aggregation **never pools across contexts** (ADR-301 §2/§Consequences):
|
||||
//! an [`EvidenceSlice`] is minted by [`EvidenceLedger::query`] for exactly
|
||||
//! one context and there is no API that averages two contexts into one
|
||||
//! number. A summary's evidence level is the **floor** (minimum) of the
|
||||
//! levels present in the slice — a slice can never report a level above the
|
||||
//! weakest record it contains.
|
||||
//! - An empty context returns [`SummaryEvidence::NoEvidence`], distinct from a
|
||||
//! present-but-zero-accuracy summary — downstream (ADR-315) must treat
|
||||
//! "no evidence" as "no capability", not as a `0.0` score.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Maximum byte length accepted for any context identifier string. Bounds
|
||||
/// allocation at the untrusted-input boundary (CLAUDE.md).
|
||||
pub const MAX_ID_LEN: usize = 256;
|
||||
|
||||
/// Default upper bound on records held by a single ledger. Bounds allocation;
|
||||
/// [`EvidenceLedger::with_capacity`] can raise or lower it.
|
||||
pub const DEFAULT_MAX_RECORDS: usize = 1_000_000;
|
||||
|
||||
/// The ADR-282 evidence ladder, L0–L5, mirrored locally to keep this crate a
|
||||
/// leaf (no dependency on the frame crate). Exactly one level travels with each
|
||||
/// [`EvidenceRecord`]. Ordering is meaningful and load-bearing: the summary
|
||||
/// floor rule takes `min` over these, so `L0 < L1 < … < L5`.
|
||||
///
|
||||
/// Semantics mirror `frame::EvidenceLevel` (ADR-282 §4): L0 simulation-only,
|
||||
/// rising to L5 production/witnessed evidence. See ADR-282 for the canonical
|
||||
/// ladder; this enum is a faithful local copy, not an independent scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum EvidenceLevel {
|
||||
/// L0 — simulation / synthetic only, no signal evidence (ADR-282).
|
||||
L0,
|
||||
/// L1 — captured replay / heuristic evidence.
|
||||
L1,
|
||||
/// L2 — controlled single-surface signal evidence.
|
||||
L2,
|
||||
/// L3 — corroborated / held-out room-and-subject validation.
|
||||
L3,
|
||||
/// L4 — calibrated multi-site field evidence.
|
||||
L4,
|
||||
/// L5 — production, witnessed / certified (ADR-316).
|
||||
L5,
|
||||
}
|
||||
|
||||
/// Accuracy tag for a record (CLAUDE.md honesty rule). The class is fixed by
|
||||
/// the constructor used and cannot alias: synthetic input can never be minted
|
||||
/// as `Measured`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProvenanceClass {
|
||||
/// Produced by a simulator/generator — L0 by construction (ADR-276/301).
|
||||
Synthetic,
|
||||
/// Real inference but no ground-truth reference backs the accuracy.
|
||||
Claimed,
|
||||
/// Backed by an ADR-300 reference plus a reproducer handle.
|
||||
Measured,
|
||||
}
|
||||
|
||||
/// The deployment context a record is keyed by: `(room, device, subject-class,
|
||||
/// model-version)`. Identity is caller-supplied (ADR-303 space id, ADR-302
|
||||
/// signed device id); this crate treats the fields as opaque bounded handles
|
||||
/// and never invents them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct EvidenceContext {
|
||||
/// Space / room id (ADR-303).
|
||||
pub room: String,
|
||||
/// Signed device id (ADR-302).
|
||||
pub device: String,
|
||||
/// Subject class where consented/available; empty means "no subject"
|
||||
/// (ADR-301 §1 — subject id only where consented).
|
||||
pub subject_class: String,
|
||||
/// Model version that produced the inferences (ADR-136).
|
||||
pub model_version: String,
|
||||
}
|
||||
|
||||
impl EvidenceContext {
|
||||
/// Construct a context, validating every field at the boundary. `room`,
|
||||
/// `device`, and `model_version` must be non-empty; every field is bounded
|
||||
/// to [`MAX_ID_LEN`] bytes. `subject_class` may be empty (no consented
|
||||
/// subject) but is still length-bounded.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`EvidenceError::EmptyField`] for a missing required field and
|
||||
/// [`EvidenceError::IdTooLong`] for any over-length field.
|
||||
pub fn new(
|
||||
room: impl Into<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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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, L0–L5. Exactly one level travels with each
|
||||
/// fact. Ordering is meaningful: `L0 < L1 < … < L5`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum EvidenceLevel {
|
||||
/// L0 — declared/assumed, no signal evidence.
|
||||
L0,
|
||||
/// L1 — heuristic/synthetic.
|
||||
L1,
|
||||
/// L2 — single-surface signal evidence.
|
||||
L2,
|
||||
/// L3 — corroborated across surfaces.
|
||||
L3,
|
||||
/// L4 — calibrated and held-out validated.
|
||||
L4,
|
||||
/// L5 — witnessed / certified (ADR-316).
|
||||
L5,
|
||||
}
|
||||
|
||||
/// Mandatory provenance for every fact (mirrors the `worldgraph`
|
||||
/// `SemanticProvenance` house rule so the two can map losslessly). Every field
|
||||
/// is a bounded string handle, not embedded data.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SemanticProvenance {
|
||||
/// Evidence content-address handle(s) (ADR-137 `EvidenceRef`).
|
||||
#[serde(default)]
|
||||
pub evidence: Vec<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;
|
||||
|
||||
Reference in New Issue
Block a user